@ultimat3/admin 23.0.0 → 25.0.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 (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
@@ -34,8 +34,9 @@ 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
+ import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
39
40
  import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
40
41
  import {
41
42
  type AdminRouteRequest,
@@ -126,7 +127,7 @@ export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen
126
127
  // one, and one read per resource the page references — never one per row.
127
128
  const [counts, relations] = await Promise.all([
128
129
  scopeCounts(resource, request.ctx.actor),
129
- relationsFor(
130
+ relationDataFor(
130
131
  app.resources,
131
132
  request.ctx,
132
133
  relationNeeds(result.page.rows, resource.listFields, resource.filters),
@@ -171,7 +172,7 @@ const relationsOf = (
171
172
  row: AdminRow,
172
173
  picking: boolean,
173
174
  ): Promise<WidgetContext> =>
174
- relationsFor(
175
+ relationDataFor(
175
176
  app.resources,
176
177
  request.ctx,
177
178
  relationNeeds([row], resource.fields, picking ? resource.formFields : []),
@@ -250,12 +251,15 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
250
251
  entityId: id,
251
252
  changes: true,
252
253
  limit: HISTORY_PAGE,
254
+ // The actor's tenant's entries only, as on the audit screen — a row two orgs may open
255
+ // does not show one org who in the other changed it.
256
+ ...(request.ctx.actor.orgId === undefined ? {} : { orgId: request.ctx.actor.orgId }),
253
257
  ...(before === undefined ? {} : { before }),
254
258
  }),
255
259
  ]);
256
260
  // ONE label read per referenced target for the whole page — this row's references and every
257
261
  // related row's together.
258
- const relations = await relationsFor(
262
+ const relations = await relationDataFor(
259
263
  app.resources,
260
264
  request.ctx,
261
265
  mergedNeeds([
@@ -310,6 +314,7 @@ async function form(
310
314
  mode: 'create' | 'edit',
311
315
  values: Readonly<Record<string, unknown>>,
312
316
  issues: readonly ValidationIssue[],
317
+ version: string | null,
313
318
  ): Promise<JSX.Element> {
314
319
  const list = listHref(app.basePath, resource);
315
320
  return (
@@ -323,10 +328,18 @@ async function form(
323
328
  ctx={await relationsOf(app, resource, request, values, true)}
324
329
  action={new URL(request.url).pathname}
325
330
  cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
331
+ version={version}
326
332
  />
327
333
  );
328
334
  }
329
335
 
336
+ /** The form-level issue a write against a stale version is re-rendered with. */
337
+ const ROW_CHANGED_ISSUE: ValidationIssue = {
338
+ path: VERSION_FIELD,
339
+ message: 'the row was changed after this form was opened',
340
+ messageKey: ROW_CHANGED_REASON,
341
+ };
342
+
330
343
  export function createScreen(
331
344
  app: AdminApp,
332
345
  route: AdminRoute,
@@ -340,7 +353,7 @@ export function createScreen(
340
353
  app,
341
354
  request,
342
355
  resource.titleKey,
343
- await form(app, resource, request, 'create', {}, []),
356
+ await form(app, resource, request, 'create', {}, [], null),
344
357
  );
345
358
  }
346
359
  const input = decodeForm(resource, request.form ?? {});
@@ -350,13 +363,14 @@ export function createScreen(
350
363
  return { kind: 'redirect', location: rowHref(app, resource, id) };
351
364
  }
352
365
  if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
353
- if (result.kind === 'missing') return notFound(app, resource, request);
366
+ // A create has no row to have gone missing or stale: neither is an answer it can give.
367
+ if (result.kind !== 'invalid') return notFound(app, resource, request);
354
368
  // 422, with what was typed: a refused form that came back empty is a form filled in twice.
355
369
  return framed(
356
370
  app,
357
371
  request,
358
372
  resource.titleKey,
359
- await form(app, resource, request, 'create', input, result.issues),
373
+ await form(app, resource, request, 'create', input, result.issues, null),
360
374
  422,
361
375
  );
362
376
  };
@@ -365,8 +379,8 @@ export function createScreen(
365
379
  export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
366
380
  return async (request) => {
367
381
  const id = idOf(request);
382
+ const row = await findRow(resource, request.ctx.actor, id);
368
383
  if (request.method === 'GET') {
369
- const row = await findRow(resource, request.ctx.actor, id);
370
384
  const decision = decideOperation(resource, 'update', request.ctx, id, row);
371
385
  if (!decision.allowed) return refusedPage(app, request, route, decision);
372
386
  if (row === null) return notFound(app, resource, request);
@@ -374,19 +388,53 @@ export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminReso
374
388
  app,
375
389
  request,
376
390
  resource.titleKey,
377
- await form(app, resource, request, 'edit', row, []),
391
+ await form(app, resource, request, 'edit', row, [], rowVersion(row)),
378
392
  );
379
393
  }
380
- const input = decodeForm(resource, request.form ?? {});
381
- const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input);
394
+ const submitted = request.form ?? {};
395
+ // Decoded against the row as it is: an instant posted back as the minute it was drawn at is
396
+ // left out, not rewritten to that minute. A row that moved on since the form was rendered is
397
+ // refused below on its version, so "as it is" and "as it was drawn" are the same row here.
398
+ const input = decodeForm(resource, submitted, row ?? undefined);
399
+ // A post with no version is not a form this admin rendered: it is refused as stale, never run
400
+ // as an unversioned write — the version is how the form says which row it was looking at.
401
+ const version = posted(submitted, VERSION_FIELD) ?? '';
402
+ const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input, { version });
382
403
  if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
383
404
  if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
384
405
  if (result.kind === 'missing') return notFound(app, resource, request);
406
+ if (result.kind === 'stale') {
407
+ // What they typed, over the row as it is NOW, under its current version: saving again is a
408
+ // decision taken with the other edit on screen, not an accident.
409
+ return framed(
410
+ app,
411
+ request,
412
+ resource.titleKey,
413
+ await form(
414
+ app,
415
+ resource,
416
+ request,
417
+ 'edit',
418
+ { ...result.row, ...input },
419
+ [ROW_CHANGED_ISSUE],
420
+ result.version,
421
+ ),
422
+ 409,
423
+ );
424
+ }
385
425
  return framed(
386
426
  app,
387
427
  request,
388
428
  resource.titleKey,
389
- await form(app, resource, request, 'edit', input, result.issues),
429
+ await form(
430
+ app,
431
+ resource,
432
+ request,
433
+ 'edit',
434
+ { ...(row ?? {}), ...input },
435
+ result.issues,
436
+ version,
437
+ ),
390
438
  422,
391
439
  );
392
440
  };
@@ -14,8 +14,14 @@ import { adminSearch } from './search';
14
14
  const AUDIT_PAGE = 100;
15
15
 
16
16
  export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
17
- return guardedScreen(app, route, async () => {
18
- const entries = await app.audit.entries({ limit: AUDIT_PAGE });
17
+ return guardedScreen(app, route, async (request) => {
18
+ // The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
19
+ // their ids or what they touched. An actor with no org — the platform — reads every tenant's.
20
+ const orgId = request.ctx.actor.orgId;
21
+ const entries = await app.audit.entries({
22
+ limit: AUDIT_PAGE,
23
+ ...(orgId === undefined ? {} : { orgId }),
24
+ });
19
25
  return (
20
26
  <Card>
21
27
  {entries.length === 0 ? (
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';
@@ -105,6 +105,7 @@ async function searchResource(
105
105
  }
106
106
 
107
107
  const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
108
+ const SKIPPED_FAILED = 'admin.search.skipped.failed';
108
109
 
109
110
  export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
110
111
  const { ctx } = input;
@@ -144,7 +145,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
144
145
  entityId: null,
145
146
  // A resource that does not OFFER search was refused by the registry, not by a policy,
146
147
  // and the decision object would otherwise read `allow` on a denied entry.
147
- decision: offered ? decision : denied(decision.permission, SKIPPED_FORBIDDEN),
148
+ decision: offered ? decision : adminDenied(decision.permission, SKIPPED_FORBIDDEN),
148
149
  }),
149
150
  ),
150
151
  );
@@ -160,7 +161,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
160
161
  skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
161
162
  continue;
162
163
  }
163
- searched.push(resource.name);
164
164
  // Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
165
165
  // asked for — `audit.ts`: if it isn't logged, it didn't happen.
166
166
  audit.push(
@@ -176,7 +176,15 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
176
176
  reason: decision.reason,
177
177
  }),
178
178
  );
179
- hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
179
+ try {
180
+ hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
181
+ searched.push(resource.name);
182
+ } catch {
183
+ // One resource whose repo fails is a resource this search could not read — listed, so the
184
+ // operator is never silently shown a subset — never a 500 for every other one. Nothing is
185
+ // read off the thrown value: a reason is a key.
186
+ skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
187
+ }
180
188
  }
181
189
 
182
190
  return { term, hits, searched, skipped, audit };
package/src/widgets.tsx CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  } from '@ultimat3/ui';
18
18
  import type { JSX } from 'solid-js';
19
19
  import type { AdminField } from './fields';
20
- import { currencyFieldOf } from './form-decode';
20
+ import { currencyFieldOf, datetimeInputValue } from './form-decode';
21
21
  import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
22
22
 
23
23
  export interface WidgetInput {
@@ -70,10 +70,6 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
70
70
  dateStyle: 'medium',
71
71
  }).format(at);
72
72
 
73
- /** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
74
- const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
75
- iso.slice(0, precision === 'date' ? 10 : 16);
76
-
77
73
  /**
78
74
  * An enum value's label: its translation when the catalog has one, the value itself when it does
79
75
  * not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
@@ -238,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
238
234
  {...shared}
239
235
  name={props.field}
240
236
  type={props.precision === 'date' ? 'date' : 'datetime-local'}
241
- value={props.value === null ? '' : inputValueFor(props.value, props.precision)}
237
+ value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
242
238
  suffix={props.precision === 'date' ? undefined : 'UTC'}
243
239
  disabled={disabled}
244
240
  onInput={(event) => emit(input, event.currentTarget.value)}
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
- }