@ultimat3/admin 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CLAUDE.md +14 -11
  2. package/README.md +39 -8
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +18 -1
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +3 -3
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch.ts +3 -8
  10. package/src/crud-input.ts +2 -1
  11. package/src/describe.ts +6 -0
  12. package/src/dev/data.ts +2 -2
  13. package/src/dev/dev-t.ts +9 -0
  14. package/src/dev/jobs-state-chart.tsx +38 -0
  15. package/src/dev/panel-jobs.ts +10 -5
  16. package/src/dev/panel-timeline.ts +9 -1
  17. package/src/dev/panel.ts +6 -3
  18. package/src/dev/server.ts +42 -13
  19. package/src/dev/timeline-style.ts +47 -0
  20. package/src/dev/timeline-waterfall.ts +96 -0
  21. package/src/form-decode.ts +13 -4
  22. package/src/index.ts +8 -7
  23. package/src/jobs/operator.ts +2 -2
  24. package/src/jobs/overview-tab.tsx +2 -2
  25. package/src/layout.tsx +34 -8
  26. package/src/list.tsx +1 -1
  27. package/src/mcp-scopes.ts +36 -0
  28. package/src/mcp-tools.ts +16 -2
  29. package/src/mcp.ts +23 -11
  30. package/src/pagination.ts +22 -18
  31. package/src/policy-bridge.ts +6 -6
  32. package/src/registry.ts +7 -0
  33. package/src/related.ts +2 -2
  34. package/src/relations.ts +1 -1
  35. package/src/resource.ts +10 -0
  36. package/src/screen-home-access.tsx +117 -0
  37. package/src/screen-home-counts.ts +42 -0
  38. package/src/screen-home-kpis.tsx +64 -0
  39. package/src/screen-home.module.scss +169 -0
  40. package/src/screen-home.tsx +21 -82
  41. package/src/screen-lookup.tsx +1 -1
  42. package/src/screen-resource.tsx +4 -4
  43. package/src/search.ts +2 -2
  44. package/src/inert-jsx.ts +0 -182
@@ -0,0 +1,169 @@
1
+ // The `/admin` home's own layer: the KPI band and the access grid under it. Tiles wrap to two
2
+ // across a phone and as many as fit beside the ring on a monitor; the matrix cards pair up only
3
+ // once a card is wide enough that its permission column does not wrap mid-name.
4
+
5
+ @use '@ultimat3/ui/tokens' as t;
6
+
7
+ .band {
8
+ display: grid;
9
+ gap: t.space(4);
10
+ min-inline-size: 0;
11
+
12
+ @include t.respond-to('lg') {
13
+ grid-template-columns: minmax(0, 1fr) minmax(t.rem(320), t.rem(400));
14
+ align-items: start;
15
+ }
16
+ }
17
+
18
+ .heading {
19
+ @include t.label-caps;
20
+
21
+ grid-column: 1 / -1;
22
+ margin: 0;
23
+ color: t.role('fg-muted');
24
+ }
25
+
26
+ .kpis {
27
+ display: grid;
28
+ grid-template-columns: repeat(auto-fill, minmax(t.rem(150), 1fr));
29
+ gap: t.space(3);
30
+ padding: 0;
31
+ margin: 0;
32
+ list-style: none;
33
+ }
34
+
35
+ // The whole tile is the link: one target for a pointer and for a keyboard alike, never smaller
36
+ // than the touch target.
37
+ .kpi {
38
+ @include t.focus-ring;
39
+
40
+ display: block;
41
+ block-size: 100%;
42
+ min-block-size: t.touch(target);
43
+ border-radius: t.radius('lg');
44
+ color: inherit;
45
+ text-decoration: none;
46
+
47
+ > * {
48
+ block-size: 100%;
49
+ }
50
+
51
+ &:hover > * {
52
+ border-color: t.role('accent', 0.6);
53
+ }
54
+ }
55
+
56
+ .share {
57
+ min-inline-size: 0;
58
+ }
59
+
60
+ .access {
61
+ display: grid;
62
+ gap: t.space(4);
63
+ min-inline-size: 0;
64
+
65
+ @include t.respond-to('xl') {
66
+ grid-template-columns: repeat(2, minmax(0, 1fr));
67
+ align-items: start;
68
+ }
69
+ }
70
+
71
+ // One resource's matrix, closed by default: a native disclosure, so it opens with no script and its
72
+ // summary row is one touch target with the name and the allowed-count badge on a line.
73
+ .resource {
74
+ min-inline-size: 0;
75
+ border: t.stroke(hairline) solid t.role('line');
76
+ border-radius: t.radius('lg');
77
+ background: t.role('surface');
78
+ }
79
+
80
+ .summary {
81
+ @include t.focus-ring;
82
+
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ gap: t.space(3);
87
+ min-block-size: t.touch(target);
88
+ padding: t.space(2) t.space(4);
89
+ cursor: pointer;
90
+ list-style: none;
91
+
92
+ .resource[open] > & {
93
+ border-block-end: t.stroke(hairline) solid t.role('line');
94
+ }
95
+ }
96
+
97
+ // `display: flex` drops the native marker, so the disclosure draws its own chevron: pointing along
98
+ // the reading direction while closed, down once open.
99
+ .summary::-webkit-details-marker {
100
+ display: none;
101
+ }
102
+
103
+ .summary::before {
104
+ flex: none;
105
+ inline-size: t.rem(8);
106
+ block-size: t.rem(8);
107
+ border-inline-end: t.stroke(thick) solid t.role('fg-muted');
108
+ border-block-end: t.stroke(thick) solid t.role('fg-muted');
109
+ content: '';
110
+ transform: rotate(-45deg);
111
+
112
+ @media (prefers-reduced-motion: no-preference) {
113
+ transition: transform t.duration(fast) t.easing(out);
114
+ }
115
+
116
+ [dir='rtl'] & {
117
+ transform: rotate(135deg);
118
+ }
119
+
120
+ .resource[open] > & {
121
+ transform: rotate(45deg);
122
+ }
123
+ }
124
+
125
+ .name {
126
+ flex: 1;
127
+ min-inline-size: 0;
128
+ font-weight: t.weight('semibold');
129
+ color: t.role('fg-strong');
130
+ }
131
+
132
+ .body {
133
+ display: grid;
134
+ gap: t.space(3);
135
+ padding: t.space(3) t.space(4) t.space(4);
136
+ }
137
+
138
+ // A matrix is three columns of short words and one of permission pairs. On a phone the pair takes
139
+ // a line of its own under the operation and its verdict, so no column is cut at the card's edge;
140
+ // the header row goes to assistive tech only, since each cell already reads in place.
141
+ .matrixScroll {
142
+ min-inline-size: 0;
143
+
144
+ @include t.respond-down('sm') {
145
+ thead {
146
+ @include t.visually-hidden;
147
+ }
148
+
149
+ tr {
150
+ display: grid;
151
+ grid-template-columns: minmax(0, 1fr) auto;
152
+ column-gap: t.space(3);
153
+ padding-block: t.space(2);
154
+ border-block-end: t.stroke(hairline) solid t.role('line');
155
+ }
156
+
157
+ td {
158
+ padding: 0;
159
+ border: 0;
160
+ }
161
+
162
+ td:nth-child(2) {
163
+ grid-column: 1 / -1;
164
+ grid-row: 2;
165
+ overflow-wrap: anywhere;
166
+ color: t.role('fg-muted');
167
+ }
168
+ }
169
+ }
@@ -1,10 +1,8 @@
1
- // `/admin` — the dashboard: every resource this actor may open, the decision behind each of its
2
- // operations, and the app-wide actions. No row is read here: a dashboard that listed five rows of
3
- // every resource would be one query per entity on every visit to the front page.
1
+ // `/admin` — the dashboard: how many rows of each resource this actor may open, the decision behind
2
+ // each of its operations, and the app-wide actions. No row is READ here: a figure is one
3
+ // `count()` per resource (`screen-home-counts.ts`), never a page of rows per entity per visit.
4
4
 
5
5
  import { t } from '@ultimat3/i18n';
6
- import { Card } from '@ultimat3/ui';
7
- import type { JSX } from 'solid-js';
8
6
  import { AdminActionForm } from './action-form';
9
7
  import { actionButtons, invokeAdminAction } from './action-gate';
10
8
  import { decodeActionInput, postedActionInput } from './action-input';
@@ -12,12 +10,10 @@ import { ACTION_OPERATION, ACTION_PARAM, AdminActions, OPERATION_FIELD } from '.
12
10
  import type { AdminApp, AdminRoute } from './admin';
13
11
  import styles from './admin.module.scss';
14
12
  import { decideAll } from './authz';
15
- import type { CrudCtx } from './crud';
16
- import { canOperate, decideOperation, permissionsForOperation } from './crud';
13
+ import { canOperate } from './crud';
17
14
  import { posted } from './form-decode';
18
- import { ADMIN_OPERATIONS, type AdminOperation, confirmationToken } from './permissions';
15
+ import { confirmationToken } from './permissions';
19
16
  import type { AdminAction } from './registry';
20
- import type { AdminResource } from './resource';
21
17
  import {
22
18
  type AdminRouteRequest,
23
19
  type AdminRouteResponse,
@@ -26,70 +22,12 @@ import {
26
22
  refused,
27
23
  refusedPage,
28
24
  } from './screen-frame';
25
+ import { HomeAccess, operationMatrix } from './screen-home-access';
26
+ import { resourceCounts } from './screen-home-counts';
27
+ import { HomeKpis } from './screen-home-kpis';
29
28
  import type { ValidationIssue } from './validate';
30
29
 
31
- /** One row of an operation matrix: the decision the dashboard renders AND the call obeys. */
32
- export interface OperationDecision {
33
- readonly operation: AdminOperation;
34
- readonly allowed: boolean;
35
- readonly permissions: readonly string[];
36
- readonly reason: string;
37
- }
38
-
39
- /**
40
- * Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
41
- * rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
42
- * the operator should ask for, when no grant opens it.
43
- */
44
- export const operationMatrix = (
45
- resource: AdminResource,
46
- ctx: CrudCtx,
47
- ): readonly OperationDecision[] =>
48
- ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
49
- (operation) => {
50
- const decision = decideOperation(resource, operation, ctx);
51
- return {
52
- operation,
53
- allowed: decision.allowed,
54
- permissions: permissionsForOperation(resource.permission, operation),
55
- reason: decision.reason,
56
- };
57
- },
58
- );
59
-
60
- /**
61
- * The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
62
- * refused and WHICH permission refused them — the same decision the call obeys, not a note about
63
- * a button that was left out.
64
- */
65
- export function OperationMatrix(props: {
66
- readonly rows: readonly OperationDecision[];
67
- }): JSX.Element {
68
- return (
69
- <table class={styles['matrix']}>
70
- <thead>
71
- <tr>
72
- <th>{t('admin.matrix.operation')}</th>
73
- <th>{t('admin.matrix.permissions')}</th>
74
- <th>{t('admin.matrix.verdict')}</th>
75
- </tr>
76
- </thead>
77
- <tbody>
78
- {props.rows.map((row) => (
79
- <tr class={row.allowed ? styles['allowed'] : styles['denied']}>
80
- <td>{t(`admin.operation.${row.operation}`)}</td>
81
- <td class={styles['mono']}>{row.permissions.join(' + ')}</td>
82
- <td>
83
- {row.allowed
84
- ? t('admin.matrix.allowed')
85
- : `${t('admin.matrix.deniedBy')} ${row.reason}`}
86
- </td>
87
- </tr>
88
- ))}
89
- </tbody>
90
- </table>
91
- );
92
- }
30
+ export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home-access';
93
31
 
94
32
  /** An app-wide action's POST: the same gate, with no row for a subject. */
95
33
  async function globalWrite(
@@ -172,6 +110,8 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
172
110
  }
173
111
 
174
112
  const open = app.resources.filter((resource) => canOperate(resource, 'list', request.ctx));
113
+ // Counted only over what the actor may list: the size of a table is itself a fact to gate.
114
+ const counts = await resourceCounts(open, request.ctx.actor);
175
115
  return framed(
176
116
  app,
177
117
  request,
@@ -184,17 +124,16 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
184
124
  href={app.basePath}
185
125
  />
186
126
  {open.length === 0 ? <p class={styles['note']}>{t('admin.actions.none')}</p> : null}
187
- {open.map((resource) => (
188
- <Card
189
- header={
190
- <h2>
191
- <a href={`${app.basePath}${resource.path}`}>{t(resource.titleKey)}</a>
192
- </h2>
193
- }
194
- >
195
- <OperationMatrix rows={operationMatrix(resource, request.ctx)} />
196
- </Card>
197
- ))}
127
+ {counts.length === 0 ? null : <HomeKpis basePath={app.basePath} counts={counts} />}
128
+ {open.length === 0 ? null : (
129
+ <HomeAccess
130
+ basePath={app.basePath}
131
+ entries={open.map((resource) => ({
132
+ resource,
133
+ rows: operationMatrix(resource, request.ctx),
134
+ }))}
135
+ />
136
+ )}
198
137
  </div>,
199
138
  );
200
139
  };
@@ -147,7 +147,7 @@ export function lookupScreen(app: AdminApp, resource: AdminResource): AdminScree
147
147
  </ul>
148
148
  )}
149
149
  <Pagination
150
- nextCursor={page.hasMore ? (page.nextCursor ?? undefined) : undefined}
150
+ nextCursor={page.hasMore ? page.nextCursor : undefined}
151
151
  prevCursor={page.prevCursor ?? undefined}
152
152
  hrefFor={(cursor) => state(cursor)}
153
153
  />
@@ -34,7 +34,7 @@ import { type ListLocation, listHref, pageRequestOf } from './list-request';
34
34
  import { type AdminListRequest, findRow, scopeCounts } from './list-scope';
35
35
  import type { AdminRow } from './registry';
36
36
  import { relatedLists, relatedOf } from './related';
37
- import { type RelationNeed, relationNeeds, relationsFor } from './relations';
37
+ import { type RelationNeed, relationDataFor, relationNeeds } from './relations';
38
38
  import type { AdminResource } from './resource';
39
39
  import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
40
40
  import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
@@ -127,7 +127,7 @@ export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen
127
127
  // one, and one read per resource the page references — never one per row.
128
128
  const [counts, relations] = await Promise.all([
129
129
  scopeCounts(resource, request.ctx.actor),
130
- relationsFor(
130
+ relationDataFor(
131
131
  app.resources,
132
132
  request.ctx,
133
133
  relationNeeds(result.page.rows, resource.listFields, resource.filters),
@@ -172,7 +172,7 @@ const relationsOf = (
172
172
  row: AdminRow,
173
173
  picking: boolean,
174
174
  ): Promise<WidgetContext> =>
175
- relationsFor(
175
+ relationDataFor(
176
176
  app.resources,
177
177
  request.ctx,
178
178
  relationNeeds([row], resource.fields, picking ? resource.formFields : []),
@@ -259,7 +259,7 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
259
259
  ]);
260
260
  // ONE label read per referenced target for the whole page — this row's references and every
261
261
  // related row's together.
262
- const relations = await relationsFor(
262
+ const relations = await relationDataFor(
263
263
  app.resources,
264
264
  request.ctx,
265
265
  mergedNeeds([
package/src/search.ts CHANGED
@@ -6,7 +6,7 @@ import { finiteCount } from '@ultimat3/core';
6
6
  import { expectedQueryLoop } from '@ultimat3/db';
7
7
  import { type AuditEntry, deniedDraft } from './audit';
8
8
  import type { AdminActor } from './authz';
9
- import { denied } from './authz';
9
+ import { adminDenied } from './authz';
10
10
  import type { CrudCtx } from './crud';
11
11
  import { decideOperation } from './crud';
12
12
  import { rowWhere } from './list-scope';
@@ -145,7 +145,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
145
145
  entityId: null,
146
146
  // A resource that does not OFFER search was refused by the registry, not by a policy,
147
147
  // and the decision object would otherwise read `allow` on a denied entry.
148
- decision: offered ? decision : denied(decision.permission, SKIPPED_FORBIDDEN),
148
+ decision: offered ? decision : adminDenied(decision.permission, SKIPPED_FORBIDDEN),
149
149
  }),
150
150
  ),
151
151
  );
package/src/inert-jsx.ts DELETED
@@ -1,182 +0,0 @@
1
- // TEST-ONLY. Calls an admin `.tsx` view as the plain function it is and walks what came back, so
2
- // a test can assert on the tree a screen produced rather than on a value it also constructed.
3
- //
4
- // Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
5
- // installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import (`routes.ts` imports it,
6
- // so any sibling suite can win the race) and `bun test` is one process. Both factories build the
7
- // same shape — a `type`, a `props`, children under `props.children` — so this walker recognises
8
- // both and the run's file order stops deciding. Recognising neither is what falls through to
9
- // `String(value)` and asserts against `"[object Object]"`.
10
-
11
- // The subpath, never the barrel: this is a test harness and the design system's `index.ts` is its
12
- // semver'd contract (`packages/ui/CLAUDE.md`). It is imported for `globalThis.React`'s ONE counter.
13
- import { probe, unprobe } from '@ultimat3/ui/jsx-probe';
14
-
15
- /** `@ultimat3/render`'s node brand, read off the GLOBAL symbol registry rather than imported. */
16
- const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
17
-
18
- export type InertComponent = (props: Record<string, unknown>) => unknown;
19
-
20
- export interface InertNode {
21
- readonly type: string | InertComponent;
22
- readonly props: Record<string, unknown>;
23
- }
24
-
25
- export function isInertNode(value: unknown): value is InertNode {
26
- if (typeof value !== 'object' || value === null) return false;
27
- return 'inert' in value || RENDER_NODE in value;
28
- }
29
-
30
- /**
31
- * Build a node by hand, the shape the installed factory builds. NOT the installed factory — that is
32
- * `@ultimat3/ui`'s, so there is one owner of `globalThis.React` (see `installFactory` below). This
33
- * is what a test uses to construct a tree it did not render.
34
- */
35
- export function h(
36
- type: string | InertComponent,
37
- props: Record<string, unknown> | null,
38
- ...children: readonly unknown[]
39
- ): InertNode {
40
- const base = { ...(props ?? {}) };
41
- if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
42
- return { inert: true, type, props: base } as unknown as InertNode;
43
- }
44
-
45
- /**
46
- * Install the classic-factory global, through `@ultimat3/ui`'s ONE counter over `globalThis.React`.
47
- *
48
- * This package kept its own `depth`/`saved` pair until 2026-08-22, and two counters over one
49
- * property restore in the wrong order: admin installs (saving the real binding), ui installs
50
- * (saving ADMIN's factory), admin restores (its counter hits 0 → the real binding is back), ui
51
- * restores (its counter hits 0 → admin's factory goes back over it). `bun test` is one process and
52
- * `packages/ui` and `packages/admin` are one run, so the interleave is reachable, and the global
53
- * was left holding a harness the run had already torn down. `admin` is tier 5 and `ui` is tier 4,
54
- * so importing down is the fix; the two factories build the same `{ inert, type, props }` shape,
55
- * which is what `isInertNode` recognises.
56
- */
57
- export function installFactory(): void {
58
- probe();
59
- }
60
-
61
- /** Undo the matching `installFactory()`. Unbalanced calls are inert. */
62
- export function restoreFactory(): void {
63
- unprobe();
64
- }
65
-
66
- /**
67
- * Every node in the tree, depth first, with nested components CALLED and thunks invoked — a
68
- * component that reads a prop inside one renders nothing until something asks.
69
- *
70
- * Component nodes are KEPT as well as expanded, so a test can assert on the props a view handed
71
- * `<Money>` — the admin's contract with the design system — and not only on the markup ui chose
72
- * to emit for them, which is ui's business and changes without this package changing.
73
- */
74
- export function nodesOf(value: unknown): InertNode[] {
75
- if (value === null || value === undefined || typeof value === 'boolean') return [];
76
- if (typeof value === 'string' || typeof value === 'number') return [];
77
- if (Array.isArray(value)) return value.flatMap(nodesOf);
78
- if (isInertNode(value)) {
79
- if (typeof value.type === 'function') return [value, ...nodesOf(value.type(value.props))];
80
- return [value, ...nodesOf(value.props['children'])];
81
- }
82
- if (typeof value === 'function') return nodesOf((value as () => unknown)());
83
- return [];
84
- }
85
-
86
- /**
87
- * The tree this package itself built: component nodes are kept but NOT called, so the walk stops
88
- * at the design-system boundary. Use this when a design-system component's own internals would
89
- * otherwise answer a query about the admin's markup — `<Dialog>` renders a close `<button>`, and a
90
- * test asking "which buttons did the action bar render" must not be handed ui's.
91
- */
92
- export function shallowNodesOf(value: unknown): InertNode[] {
93
- if (value === null || value === undefined || typeof value === 'boolean') return [];
94
- if (typeof value === 'string' || typeof value === 'number') return [];
95
- if (Array.isArray(value)) return value.flatMap(shallowNodesOf);
96
- if (isInertNode(value)) return [value, ...shallowNodesOf(value.props['children'])];
97
- if (typeof value === 'function') return shallowNodesOf((value as () => unknown)());
98
- return [];
99
- }
100
-
101
- /** Nodes rendered by the named component — `byComponent(nodes, 'Money')`. */
102
- export function byComponent(nodes: readonly InertNode[], name: string): InertNode[] {
103
- return nodes.filter((node) => typeof node.type === 'function' && node.type.name === name);
104
- }
105
-
106
- const SKIPPED_PROPS = new Set(['children', 'ref', 'innerHTML']);
107
-
108
- function attributes(props: Record<string, unknown>): string {
109
- return Object.entries(props)
110
- .filter(([name, value]) => {
111
- if (SKIPPED_PROPS.has(name) || name.startsWith('on')) return false;
112
- return value !== undefined && value !== null && value !== false;
113
- })
114
- .map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${String(value)}"`))
115
- .join('');
116
- }
117
-
118
- /** The tree as markup. Unknown values are `String()`d, which the premise test refuses to allow. */
119
- export function renderHtml(value: unknown): string {
120
- if (value === null || value === undefined || typeof value === 'boolean') return '';
121
- if (typeof value === 'string' || typeof value === 'number') return String(value);
122
- if (Array.isArray(value)) return value.map(renderHtml).join('');
123
- if (isInertNode(value)) {
124
- if (typeof value.type === 'function') return renderHtml(value.type(value.props));
125
- const inner = renderHtml(value.props['children']);
126
- return `<${value.type}${attributes(value.props)}>${inner}</${value.type}>`;
127
- }
128
- if (typeof value === 'function') return renderHtml((value as () => unknown)());
129
- return String(value);
130
- }
131
-
132
- /** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
133
- export function renderNodes(component: unknown, props: Record<string, unknown> = {}): InertNode[] {
134
- return nodesOf((component as InertComponent)(props));
135
- }
136
-
137
- /**
138
- * `renderNodes`, stopping at the design-system boundary — the `shallowNodesOf` sibling. The
139
- * `unknown` parameter is what makes it usable: a screen's props are typed and generic, so a test
140
- * calling one through `as (props: Record<string, unknown>) => unknown` is a conversion TypeScript
141
- * refuses outright. One widening here beats one per call site.
142
- */
143
- export function renderShallowNodes(
144
- component: unknown,
145
- props: Record<string, unknown> = {},
146
- ): InertNode[] {
147
- return shallowNodesOf((component as InertComponent)(props));
148
- }
149
-
150
- /** Render a component to markup. */
151
- export function renderComponent(component: unknown, props: Record<string, unknown> = {}): string {
152
- return renderHtml((component as InertComponent)(props));
153
- }
154
-
155
- export function byTag(nodes: readonly InertNode[], tag: string): InertNode[] {
156
- return nodes.filter((node) => node.type === tag);
157
- }
158
-
159
- /** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
160
- export function withAttr(nodes: readonly InertNode[], name: string, value?: unknown): InertNode[] {
161
- return nodes.filter((node) =>
162
- value === undefined ? node.props[name] !== undefined : node.props[name] === value,
163
- );
164
- }
165
-
166
- /** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
167
- export function one(nodes: readonly InertNode[], what: string): InertNode {
168
- const node = nodes[0];
169
- if (node === undefined || nodes.length !== 1) {
170
- throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
171
- }
172
- return node;
173
- }
174
-
175
- /** Call an element's event handler prop, e.g. `fire(button, 'onClick', {})`. */
176
- export function fire(node: InertNode, handler: string, event: unknown): void {
177
- const fn = node.props[handler];
178
- if (typeof fn !== 'function') {
179
- throw new Error(`node <${String(node.type)}> carries no ${handler}`);
180
- }
181
- (fn as (e: unknown) => void)(event);
182
- }