@ultimat3/testing 29.4.0 → 30.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 (93) hide show
  1. package/CLAUDE.md +56 -63
  2. package/README.md +281 -6
  3. package/package.json +27 -16
  4. package/src/ax-node.ts +96 -0
  5. package/src/cassette-errors.ts +91 -0
  6. package/src/cassette-format.ts +175 -0
  7. package/src/cassette-record.ts +120 -0
  8. package/src/cassette-secret-scan.ts +77 -0
  9. package/src/cassette.ts +166 -0
  10. package/src/cdp-browser.ts +5 -3
  11. package/src/cdp-e2e-page.ts +91 -3
  12. package/src/cdp-e2e-session.ts +18 -1
  13. package/src/define-exercise.ts +92 -0
  14. package/src/drill-budgets.ts +54 -0
  15. package/src/drill-errors.ts +45 -0
  16. package/src/drill-fleet.ts +251 -0
  17. package/src/drill-jobs.ts +79 -0
  18. package/src/drill-load.ts +166 -0
  19. package/src/drill-probe.ts +68 -0
  20. package/src/drill-run.ts +144 -0
  21. package/src/drill-services.ts +70 -0
  22. package/src/drill-sync.ts +108 -0
  23. package/src/drill-verdict.ts +256 -0
  24. package/src/drill-writes.ts +123 -0
  25. package/src/drills.ts +109 -0
  26. package/src/e2e-a11y-run.ts +189 -0
  27. package/src/e2e-a11y-tabs.ts +113 -0
  28. package/src/e2e-a11y.ts +153 -0
  29. package/src/e2e-driver.ts +46 -10
  30. package/src/e2e-page.ts +28 -3
  31. package/src/e2e-run.ts +1 -0
  32. package/src/e2e-spawn.ts +3 -2
  33. package/src/errors.ts +43 -0
  34. package/src/exercise-actor.ts +124 -0
  35. package/src/exercise-crash.ts +46 -0
  36. package/src/exercise-input.ts +182 -0
  37. package/src/exercise-override-input.ts +34 -0
  38. package/src/exercise-report.ts +282 -0
  39. package/src/exercise-request.ts +38 -0
  40. package/src/exercise-sweeps.ts +100 -0
  41. package/src/exercise.ts +451 -0
  42. package/src/factories.ts +10 -7
  43. package/src/factory-registry.ts +49 -15
  44. package/src/fake-identity-provider.ts +191 -0
  45. package/src/fault-proxy.ts +342 -0
  46. package/src/file-boundary.ts +4 -1
  47. package/src/fixture-jobs.ts +3 -1
  48. package/src/fixture-statements.ts +4 -1
  49. package/src/free-port.ts +59 -0
  50. package/src/index.ts +148 -3
  51. package/src/island-dom.ts +5 -0
  52. package/src/isolated-plugins.ts +1 -1
  53. package/src/job-crash-proof.ts +249 -0
  54. package/src/job-sim-driver.ts +164 -0
  55. package/src/job-sim-effects.ts +142 -0
  56. package/src/job-sim-errors.ts +72 -0
  57. package/src/job-sim-schedule.ts +154 -0
  58. package/src/job-sim-world.ts +198 -0
  59. package/src/job-simulate.ts +179 -0
  60. package/src/mock-route.ts +81 -0
  61. package/src/module-mock-boundary.ts +85 -0
  62. package/src/module-mock-error.ts +28 -0
  63. package/src/plan-oracle.ts +164 -0
  64. package/src/plan-sweep-fix.ts +123 -0
  65. package/src/plan-sweep-report.ts +84 -0
  66. package/src/plan-sweep.ts +148 -0
  67. package/src/preload.ts +24 -4
  68. package/src/registry-snapshot.ts +1 -1
  69. package/src/repro-stand-in.ts +67 -0
  70. package/src/repro.ts +158 -0
  71. package/src/request-log.ts +64 -0
  72. package/src/reusable-database.ts +1 -1
  73. package/src/scale-sweep-graph.ts +82 -0
  74. package/src/scale-sweep-run.ts +134 -0
  75. package/src/scale-sweep.ts +74 -0
  76. package/src/sealed-network.ts +63 -46
  77. package/src/seed-graph-error.ts +50 -0
  78. package/src/seed-graph-order.ts +98 -0
  79. package/src/seed-graph-pass.ts +85 -0
  80. package/src/seed-graph-plan.ts +81 -0
  81. package/src/seed-graph-row.ts +134 -0
  82. package/src/seed-graph-shape.ts +104 -0
  83. package/src/seed-graph-values.ts +135 -0
  84. package/src/seed-graph.ts +250 -0
  85. package/src/seed-writer.ts +180 -0
  86. package/src/subject-proof-error.ts +23 -0
  87. package/src/subject-proof.ts +180 -0
  88. package/src/template-db-roles.ts +51 -0
  89. package/src/template-db.ts +8 -2
  90. package/src/tenant-canary.ts +120 -0
  91. package/src/tenant-proof-report.ts +62 -0
  92. package/src/tenant-proof.ts +177 -0
  93. package/src/test-types.ts +12 -3
package/src/e2e-driver.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  // made, which is what keeps a CI box with no Chrome answering `hasE2eDriver() === false`.
4
4
 
5
5
  import { test as bunTest } from 'bun:test';
6
+ import type { A11yOptions, A11yRun } from './e2e-a11y-run';
7
+ import { checkAllow, startA11yRun } from './e2e-a11y-run';
6
8
  import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
7
9
  import { e2ePage } from './e2e-page';
8
10
  import { FixtureUnavailableError } from './errors';
@@ -75,6 +77,44 @@ export const e2eFixtures = (
75
77
  ),
76
78
  });
77
79
 
80
+ /** One suite's page and the way it runs an `e2eTest` body — audited, with the app's log on failure. */
81
+ export interface E2eRunner {
82
+ readonly page: PageLike;
83
+ run(body: E2eBody, a11y?: A11yOptions): Promise<void>;
84
+ }
85
+
86
+ /**
87
+ * What `installE2eDriver` registers, without the registering: the page, and one body's run.
88
+ *
89
+ * The audit runs after every settled navigation and once more after the body — so a dialog the
90
+ * body opened counts — and fails the test only then, with every finding at once. A body that threw
91
+ * fails on its own error: the page it left is not the page it meant to show. `reported` is the
92
+ * suite's, so a finding one test failed on is not told again by the next (`e2e-a11y-run.ts`).
93
+ */
94
+ export function e2eRunner(options: E2eDriverOptions): E2eRunner {
95
+ // The running test's audit, which every settled navigation feeds; none outside a test body.
96
+ let audit: A11yRun | undefined;
97
+ const page = e2ePage({ ...options, afterNavigate: () => audit?.check() ?? Promise.resolve() });
98
+ const reported = new Set<string>();
99
+ return {
100
+ page,
101
+ async run(body, a11y) {
102
+ // Through `withFailureContext`, as `fixtureTest` is: the failing test carries the app's log.
103
+ try {
104
+ const current = startA11yRun(options.page, checkAllow(a11y), reported);
105
+ audit = current;
106
+ await body(e2eFixtures(page, options.page, options.newBuild));
107
+ await current.check();
108
+ current.verdict();
109
+ } catch (error) {
110
+ throw withFailureContext(error);
111
+ } finally {
112
+ audit = undefined;
113
+ }
114
+ },
115
+ };
116
+ }
117
+
78
118
  /**
79
119
  * Install the browser-backed driver for this process.
80
120
  *
@@ -93,22 +133,18 @@ export const e2eFixtures = (
93
133
  * every later file in the run.
94
134
  */
95
135
  export function installE2eDriver(options: E2eDriverOptions): () => void {
96
- const page = e2ePage(options);
136
+ const { page, run } = e2eRunner(options);
97
137
  const newBuild = options.newBuild;
98
138
  defineFixtures({
99
139
  page: () => page,
100
140
  ...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
101
141
  });
102
142
  useE2eDriver((name, body: E2eBody, test) => {
103
- // Through `withFailureContext`, as `fixtureTest` is: the failing test carries the app's log.
104
- const run = async (): Promise<void> => {
105
- try {
106
- await body(e2eFixtures(page, options.page, newBuild));
107
- } catch (error) {
108
- throw withFailureContext(error);
109
- }
110
- };
111
- bunTest(name, run, test?.timeoutMs === undefined ? undefined : paced(test.timeoutMs));
143
+ bunTest(
144
+ name,
145
+ () => run(body, test?.a11y),
146
+ test?.timeoutMs === undefined ? undefined : paced(test.timeoutMs),
147
+ );
112
148
  });
113
149
  return () => {
114
150
  // Both halves, because both were installed. Putting the DECLARATION back — rather than
package/src/e2e-page.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // may import the other — so the join is here, in the one package allowed to know about both.
4
4
 
5
5
  import { finiteCount } from '@ultimat3/core';
6
+ import type { AxTreeNode } from './ax-node';
6
7
  import { evaluateClosure } from './e2e-evaluate';
7
8
  import { e2eLocator } from './e2e-locator';
8
9
  import type { E2eSelection } from './e2e-selection';
@@ -31,6 +32,20 @@ export interface E2eBrowserPage {
31
32
  * app's ONLINE page pass an offline test.
32
33
  */
33
34
  offline?(enabled: boolean): Promise<void>;
35
+ /**
36
+ * The document's full accessibility tree, as the browser computed it for a screen reader. A node
37
+ * `request.describe` picks is answered with its bounded `outerHTML` and which of
38
+ * `request.selectors` it matches — one round trip per such node, never per node of the tree.
39
+ * OPTIONAL as `offline` is, and for its reason; absent, every `e2eTest` refuses by name
40
+ * (`e2e-a11y-run.ts`) rather than passing a page nothing audited.
41
+ */
42
+ accessibilityTree?(request: AxTreeRequest): Promise<readonly AxTreeNode[]>;
43
+ }
44
+
45
+ /** What the audit asks of one tree read: which nodes to describe, and the selectors to test. */
46
+ export interface AxTreeRequest {
47
+ readonly describe: (node: AxTreeNode) => boolean;
48
+ readonly selectors: readonly string[];
34
49
  }
35
50
 
36
51
  export interface E2ePageOptions {
@@ -44,6 +59,11 @@ export interface E2ePageOptions {
44
59
  * IN THE PAGE. One test raises its own with `waitForServiceWorker({ timeoutMs })`.
45
60
  */
46
61
  readonly serviceWorkerTimeoutMs?: number | undefined;
62
+ /**
63
+ * Run after every navigation this adapter makes has settled — `goto`, `reload`, `gotoStreamed`.
64
+ * The e2e driver's accessibility audit (`e2e-driver.ts`); a rejection fails the navigation.
65
+ */
66
+ readonly afterNavigate?: (() => Promise<void>) | undefined;
47
67
  }
48
68
 
49
69
  /** Named once, so both refusals below name the call an app's test preload actually makes. */
@@ -137,20 +157,25 @@ export function e2ePage(options: E2ePageOptions): PageLike {
137
157
  );
138
158
  const absolute = (url: string): string => new URL(url, options.baseUrl).toString();
139
159
  const locate = (selection: E2eSelection): LocatorLike => e2eLocator(page, selection);
160
+ const settled = async (navigation: Promise<unknown>): Promise<unknown> => {
161
+ const answer = await navigation;
162
+ await options.afterNavigate?.();
163
+ return answer;
164
+ };
140
165
 
141
166
  return {
142
167
  url: () => page.url(),
143
- goto: (url) => page.goto(absolute(url), { timeout }),
168
+ goto: (url) => settled(page.goto(absolute(url), { timeout })),
144
169
  // The page's OWN url, re-read at call time: a reload of the url the adapter was built with
145
170
  // would navigate away from wherever the test had got to.
146
- reload: () => page.goto(page.url(), { timeout }),
171
+ reload: () => settled(page.goto(page.url(), { timeout })),
147
172
  title: async () => {
148
173
  const title = readField(await page.evaluate(TITLE), 'title');
149
174
  return typeof title === 'string' ? title : '';
150
175
  },
151
176
  gotoStreamed: async (url) => {
152
177
  const target = absolute(url);
153
- await page.goto(target, { timeout });
178
+ await settled(page.goto(target, { timeout }));
154
179
  const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
155
180
  return { html: typeof html === 'string' ? html : '' };
156
181
  },
package/src/e2e-run.ts CHANGED
@@ -70,6 +70,7 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
70
70
  evaluate: (expression) => browser.page.evaluate(expression),
71
71
  click: (selector) => browser.page.click(selector),
72
72
  offline: (enabled) => browser.page.offline(enabled),
73
+ accessibilityTree: (request) => browser.page.accessibilityTree(request),
73
74
  };
74
75
  const uninstall = deps.install({ page: current, baseUrl: app.base, newBuild });
75
76
 
package/src/e2e-spawn.ts CHANGED
@@ -10,7 +10,7 @@ import { get } from 'node:http';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { assert, DRAIN_DEADLINE_DEFAULT_MS, READINESS_GRACE_DEFAULT_MS } from '@ultimat3/core';
12
12
  import { E2eAppFailedError } from './e2e-errors';
13
- import { freePort, freePortApartFrom } from './free-port';
13
+ import { freePortApartFrom, freePortPair } from './free-port';
14
14
 
15
15
  /** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
16
16
  export type E2eAppMode = 'dev' | 'serve';
@@ -113,7 +113,8 @@ export const refuse = (step: string, output: string): E2eAppFailedError =>
113
113
  /** Spawn the app and answer once `/readyz` does; refuses with the app's own output tail. */
114
114
  export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE2eApp> {
115
115
  const deadline = options.readyTimeoutMs;
116
- const port = freePort();
116
+ // A PAIR: `x dev` binds its sync node on `port + 1`, and a held neighbour exited the app at boot.
117
+ const port = freePortPair();
117
118
  // Its own scrape port too: every role opens one, the default is a fixed 9090, and a second app —
118
119
  // or a developer's `x dev` — already holding it is an app that dies at boot with X_PORT_IN_USE.
119
120
  // Apart from the web port AND its neighbour, which `x dev` binds the sync node on: the OS may hand
package/src/errors.ts CHANGED
@@ -49,6 +49,26 @@ export const TESTING_ERROR_CODES = [
49
49
  'X_TEST_ISLAND_STATE_UNKNOWN',
50
50
  'X_TEST_APP_NOT_BOOTED',
51
51
  'X_TEST_PACE_INVALID',
52
+ 'X_SEED_GRAPH_UNSATISFIED',
53
+ 'X_E2E_A11Y_VIOLATION',
54
+ 'X_SEED_GRAPH_ENTITY_UNKNOWN',
55
+ 'X_TENANT_LEAK',
56
+ 'X_TENANT_WRITE_CROSSED',
57
+ 'X_JOB_CRASH_DIVERGED',
58
+ 'X_JOB_SIM_INVARIANT',
59
+ 'X_JOB_SIM_NONDETERMINISTIC',
60
+ 'X_DRILL_FAILED',
61
+ 'X_DRILL_SERVICE_MISSING',
62
+ 'X_SUBJECT_ERASE_INCOMPLETE',
63
+ 'X_QUERY_UNINDEXED',
64
+ 'X_QUERY_SORT_UNSERVED',
65
+ 'X_QUERY_SCALES_WITH_ROWS',
66
+ 'X_QUERY_PLAN_UNAVAILABLE',
67
+ 'X_CASSETTE_MISSING',
68
+ 'X_CASSETTE_UNUSED',
69
+ 'X_CASSETTE_SECRET',
70
+ 'X_CASSETTE_RECORD_REFUSED',
71
+ 'X_TEST_MODULE_MOCK_UNRESTORABLE',
52
72
  ] as const;
53
73
 
54
74
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -90,6 +110,29 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
90
110
  X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
91
111
  X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
92
112
  X_TEST_PACE_INVALID: 'ULTIMATE_TEST_PACE is not a number at or above 1',
113
+ X_SEED_GRAPH_UNSATISFIED: 'no generated row satisfies a table',
114
+ X_E2E_A11Y_VIOLATION: 'a page fails the accessibility audit',
115
+ X_SEED_GRAPH_ENTITY_UNKNOWN: 'a seed plan names an entity its writer was not given',
116
+ X_TENANT_LEAK: "another tenant's row reached a call's output or effect",
117
+ X_TENANT_WRITE_CROSSED: "a call by one tenant changed another tenant's row",
118
+ X_JOB_CRASH_DIVERGED:
119
+ 'a job run killed at a step boundary made an effect a different number of times than a clean run',
120
+ X_JOB_SIM_INVARIANT: 'a simulateJobs seed broke the invariant the test stated',
121
+ X_JOB_SIM_NONDETERMINISTIC: 'one simulateJobs seed produced two different traces',
122
+ X_DRILL_FAILED: 'a fault drill property did not hold',
123
+ X_DRILL_SERVICE_MISSING: 'a fault drill needs a service that is not there',
124
+ X_SUBJECT_ERASE_INCOMPLETE: 'a value of the subject survived subjectErase',
125
+ X_QUERY_UNINDEXED: 'a statement no index can serve: the planner can only scan the table',
126
+ X_QUERY_SORT_UNSERVED: 'a limited read sorts its rows: no index serves the order',
127
+ X_QUERY_SCALES_WITH_ROWS:
128
+ 'a statement shape runs once per row: its count grows with the rows seeded',
129
+ X_QUERY_PLAN_UNAVAILABLE:
130
+ 'the database could not answer a generic plan, so no statement was judged',
131
+ X_CASSETTE_MISSING: 'a cassette replayed with no recording on disk',
132
+ X_CASSETTE_UNUSED: 'a cassette entry was never replayed',
133
+ X_CASSETTE_SECRET: 'a cassette about to be written holds a secret value',
134
+ X_CASSETTE_RECORD_REFUSED: 'a cassette recording was refused',
135
+ X_TEST_MODULE_MOCK_UNRESTORABLE: 'a mocked module could not be put back at the file boundary',
93
136
  };
94
137
 
95
138
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -0,0 +1,124 @@
1
+ // Who a primitive runs as in the exercise pass: the first actor `x policy explain` evaluates that
2
+ // the policy allows, minted in the primitive's own tenant. The actors are INJECTED (`RoleGrants`)
3
+ // because the set — anonymous plus one per declared role — is `@ultimat3/cli`'s `devActors()`,
4
+ // and a second "every role" builder here would drift from the one the policy panel shows.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import type { NamedActor, Policy } from '@ultimat3/policy';
8
+ import { policyMatrix, rolesGranting } from '@ultimat3/policy';
9
+ import type { ExerciseOverride } from './define-exercise';
10
+
11
+ /**
12
+ * The actors `x policy explain` evaluates, each a member of `tenant`: what `devActors()` answers,
13
+ * with the org set. Injected by the caller (`x exercise` passes cli's own set).
14
+ */
15
+ export type RoleGrants = (tenant: string) => readonly NamedActor[];
16
+
17
+ export type ActorChoice =
18
+ | { readonly kind: 'actor'; readonly actor: Actor | null; readonly name: string }
19
+ /** No actor the policy is evaluated for is allowed — refused by design, not by a guess. */
20
+ | { readonly kind: 'refused'; readonly reason: string };
21
+
22
+ /**
23
+ * An override's actor, moved into the primitive's tenant: every primitive gets a fresh pair of
24
+ * tenants, so an actor declared once in `exercise.ts` cannot know the org it will act in.
25
+ */
26
+ export const inTenant = (actor: Actor | null, tenant: string): Actor | null =>
27
+ actor === null ? null : Object.freeze({ ...actor, orgId: tenant });
28
+
29
+ type Verdict = 'allowed' | 'denied' | 'undecidable';
30
+
31
+ /**
32
+ * `policyMatrix` with no row and an empty input — the evaluation `x policy explain` prints. A
33
+ * predicate that reads the input throws there; that actor is undecidable, decided again at run.
34
+ */
35
+ const verdictFor = (policy: Policy, candidate: NamedActor): Verdict => {
36
+ try {
37
+ const row = policyMatrix(policy, { actors: [candidate], input: {} }).rows[0];
38
+ return row?.allowed === true ? 'allowed' : 'denied';
39
+ } catch {
40
+ return 'undecidable';
41
+ }
42
+ };
43
+
44
+ /** Whether `actor` holds every permission, directly or through a role — the grant, not the rule. */
45
+ const holds = (actor: Actor | null, permissions: readonly string[]): boolean =>
46
+ actor !== null &&
47
+ permissions.every(
48
+ (permission) =>
49
+ (actor.permissions ?? []).includes(permission) ||
50
+ rolesGranting(permission).some((role) => (actor.roles ?? []).includes(role)),
51
+ );
52
+
53
+ /**
54
+ * The actor for one primitive. An override's actor wins; otherwise a signed-in actor before the
55
+ * anonymous one (a tenant's rows are only reachable signed in). Allowed outright first; then one
56
+ * that HOLDS every permission the policy names, whose predicate an empty input could not satisfy
57
+ * (`x policy explain` evaluates with no row, and a row rule decides again on the real call); then
58
+ * one the empty input could not even evaluate. None of the three is refused by design.
59
+ */
60
+ export function chooseActor(
61
+ policy: Policy | undefined,
62
+ permissions: readonly string[],
63
+ grants: RoleGrants,
64
+ tenant: string,
65
+ override: ExerciseOverride | undefined,
66
+ ): ActorChoice {
67
+ if (override !== undefined && 'actor' in override) {
68
+ return { kind: 'actor', actor: inTenant(override.actor, tenant), name: 'override' };
69
+ }
70
+ const candidates = grants(tenant);
71
+ const ordered = [
72
+ ...candidates.filter((c) => c.actor !== null),
73
+ ...candidates.filter((c) => c.actor === null),
74
+ ];
75
+ // A job has no policy, and a served worker acts as nobody: the job's declared tenant is the
76
+ // only org its body runs under, so the pass drains exactly as production does.
77
+ if (policy === undefined) return { kind: 'actor', actor: null, name: 'worker' };
78
+ const verdicts = ordered.map((candidate) => [candidate, verdictFor(policy, candidate)] as const);
79
+ const pick =
80
+ verdicts.find(([, verdict]) => verdict === 'allowed') ??
81
+ verdicts.find(
82
+ ([candidate, verdict]) =>
83
+ verdict === 'denied' && permissions.length > 0 && holds(candidate.actor, permissions),
84
+ ) ??
85
+ verdicts.find(([, verdict]) => verdict === 'undecidable');
86
+ if (pick === undefined) {
87
+ return {
88
+ kind: 'refused',
89
+ reason:
90
+ ordered.length === 0
91
+ ? `no actor to evaluate ${policy.label} for: the role grants answered none`
92
+ : `none of ${ordered.map((c) => c.name).join(', ')} is allowed by ${policy.label} or holds ${permissions.join(', ') || 'its grant'}`,
93
+ };
94
+ }
95
+ return { kind: 'actor', actor: pick[0].actor, name: pick[0].name };
96
+ }
97
+
98
+ /** The seeded rows an actor can be one of: the pass's graph, read for one entity. */
99
+ export interface ActorRows {
100
+ readonly entity: { readonly $name: string; readonly $primaryKey: readonly string[] };
101
+ readonly rows: readonly {
102
+ readonly tenant: string | null;
103
+ readonly row: Readonly<Record<string, unknown>>;
104
+ }[];
105
+ }
106
+
107
+ /**
108
+ * The actor as a ROW of the entity the app named (`defineExercise(…, { actor: members })`): its
109
+ * id becomes the key of a seeded row in its tenant, the one carrying one of its roles when any
110
+ * does. Without it an actor's id is a role name, and every handler that writes `ctx.actor.id`
111
+ * into a foreign key — `authorId` — is refused by the database.
112
+ */
113
+ export function asSeededRow(actor: Actor | null, tenant: string, rows: ActorRows): Actor | null {
114
+ const key = rows.entity.$primaryKey[0];
115
+ if (actor === null || key === undefined || rows.entity.$primaryKey.length !== 1) return actor;
116
+ const own = rows.rows.filter((row) => row.tenant === tenant || row.tenant === null);
117
+ const roles = actor.roles ?? [];
118
+ const row =
119
+ own.find((candidate) =>
120
+ Object.values(candidate.row).some((v) => roles.includes(v as string)),
121
+ ) ?? own[0];
122
+ const id = row?.row[key];
123
+ return typeof id === 'string' ? Object.freeze({ ...actor, id }) : actor;
124
+ }
@@ -0,0 +1,46 @@
1
+ // The crash proof, as the exercise pass runs it: a job its control reached is killed at every step
2
+ // boundary with the control's own input and actor (`crashProof`), and the answer is a plain record
3
+ // `.x/exercise.json` can hold. Run after the control, so its runs meet the rows the control left.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import { ctxOf, isUltimateError, renderThrowable } from '@ultimat3/core';
7
+ import type { AnyJobHandle } from '@ultimat3/jobs';
8
+ import type { CrashFinding, CrashProof } from './exercise-report';
9
+ import type { CrashDivergence } from './job-crash-proof';
10
+ import { crashProof } from './job-crash-proof';
11
+
12
+ const findingOf = (divergence: CrashDivergence): CrashFinding => {
13
+ const after = divergence.error.meta?.['after'];
14
+ return {
15
+ boundary: divergence.boundary,
16
+ mode: divergence.mode,
17
+ effect: divergence.effect,
18
+ clean: divergence.clean,
19
+ killed: divergence.killed,
20
+ ...(typeof after === 'string' ? { after } : {}),
21
+ };
22
+ };
23
+
24
+ /**
25
+ * `diverged` is red; `unproven` — the proof itself could not finish — is reported, never red, as
26
+ * the tenant proof's is: a question the pass could not ask is not an answer.
27
+ */
28
+ export async function proveCrash(
29
+ handle: AnyJobHandle,
30
+ input: unknown,
31
+ actor: Actor | null,
32
+ ): Promise<CrashProof> {
33
+ const worker = ctxOf(actor === null ? { role: 'worker' } : { role: 'worker', actor });
34
+ try {
35
+ const report = await crashProof(handle, input, { context: () => worker });
36
+ const counted = { boundaries: report.boundaries.length, reruns: report.reruns.length };
37
+ return report.divergences.length === 0
38
+ ? { verdict: 'held', ...counted }
39
+ : { verdict: 'diverged', ...counted, findings: report.divergences.map(findingOf) };
40
+ } catch (error) {
41
+ return {
42
+ verdict: 'unproven',
43
+ reason: isUltimateError(error) ? `${error.code}: ${error.cause}` : renderThrowable(error),
44
+ };
45
+ }
46
+ }
@@ -0,0 +1,182 @@
1
+ // The input one exercise run sends: 39's minimal draw (`@ultimat3/schema/sample`, the one schema
2
+ // walker), then every id field whose stem names a seeded entity bound to a row of the actor's own
3
+ // tenant (`postId` -> `posts`), then the app's override. A wrong guess is visible as `unreached`.
4
+
5
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
6
+ import type { SeedGraph } from './seed-graph-plan';
7
+ import type { SeedEntity } from './seed-graph-shape';
8
+
9
+ /** The two draws 39 ships: the minimal value, and a seeded one for every later run. */
10
+ export interface Sampler {
11
+ sampleInput(schema: StandardSchemaV1): unknown;
12
+ arbitrary(schema: StandardSchemaV1, options: { seed: number; run: number }): unknown;
13
+ }
14
+
15
+ /** On demand: a `packages/core` test that never exercises anything must not load the walker. */
16
+ export const loadSampler = async (): Promise<Sampler> => import('@ultimat3/schema/sample');
17
+
18
+ /** Run 0 is the minimal draw; a run an override asked for beyond it is 39's seeded draw. */
19
+ export const drawInput = (sampler: Sampler, schema: StandardSchemaV1, seed: number, run: number) =>
20
+ run === 0 ? sampler.sampleInput(schema) : sampler.arbitrary(schema, { seed, run });
21
+
22
+ /** `postId`, `post_id` -> `post`; `id` alone names no entity. */
23
+ export function idStem(key: string): string | undefined {
24
+ const match = /^(.+?)_?(?:Id|_id|ID)$/.exec(key);
25
+ return match?.[1] === undefined || match[1].length === 0 ? undefined : match[1];
26
+ }
27
+
28
+ /** Case and separators dropped: `blogPost` and `blog_posts` meet at `blogpost`/`blogposts`. */
29
+ const folded = (text: string): string => text.toLowerCase().replace(/[^a-z0-9]/g, '');
30
+
31
+ const pluralsOf = (stem: string): readonly string[] => {
32
+ const base = folded(stem);
33
+ return [base, `${base}s`, `${base}es`, base.endsWith('y') ? `${base.slice(0, -1)}ies` : base];
34
+ };
35
+
36
+ /**
37
+ * The entity a stem names, by its declared name or its table, singular or plural — exactly, or
38
+ * else as the one entity whose name ENDS with it (`endpointId` -> `webhook_endpoints`). Two
39
+ * suffix matches name nothing: a guess between two tables is not a binding.
40
+ */
41
+ export function entityForStem(
42
+ stem: string,
43
+ entities: readonly SeedEntity[],
44
+ ): SeedEntity | undefined {
45
+ const wanted = pluralsOf(stem);
46
+ const names = (entity: SeedEntity): readonly string[] => [
47
+ folded(entity.$name),
48
+ folded(entity.$table),
49
+ ];
50
+ const exact = entities.find((entity) => names(entity).some((name) => wanted.includes(name)));
51
+ if (exact !== undefined) return exact;
52
+ const suffixed = entities.filter((entity) =>
53
+ names(entity).some((name) => wanted.some((form) => name.endsWith(form))),
54
+ );
55
+ return suffixed.length === 1 ? suffixed[0] : undefined;
56
+ }
57
+
58
+ /**
59
+ * The id an `<stem>Id` field takes: the key of a seeded row of that entity the tenant sees — one
60
+ * that references the actor when there is one (`authorId` = the actor), since ownership is the
61
+ * rule a handler most often checks; else one that is not the actor's own row.
62
+ */
63
+ export type IdLookup = (
64
+ key: string,
65
+ ) => { readonly found: true; readonly value: unknown } | undefined;
66
+
67
+ const singularOf = (name: string): string =>
68
+ name.endsWith('ies') ? `${name.slice(0, -3)}y` : name.endsWith('s') ? name.slice(0, -1) : name;
69
+
70
+ /**
71
+ * A bare `id` names the entity the PRIMITIVE is named after (`movePostStatus` -> `posts`): the
72
+ * longest singular entity name inside the primitive's own name. A tie is no binding.
73
+ */
74
+ export function entityForSubject(
75
+ subject: string,
76
+ entities: readonly SeedEntity[],
77
+ ): SeedEntity | undefined {
78
+ const name = folded(subject);
79
+ const named = entities
80
+ .map((entity) => [entity, singularOf(folded(entity.$name))] as const)
81
+ .filter(([, singular]) => singular.length > 0 && name.includes(singular))
82
+ .sort((a, b) => b[1].length - a[1].length);
83
+ const [first, second] = named;
84
+ return first === undefined || second?.[1].length === first[1].length ? undefined : first[0];
85
+ }
86
+
87
+ export interface IdScope {
88
+ readonly tenant: string;
89
+ /** The acting row's id: a row referencing it is preferred. */
90
+ readonly actorId?: string | undefined;
91
+ /** The primitive's name, for a bare `id`. */
92
+ readonly subject: string;
93
+ /** The entity whose rows are the actors (`defineExercise(…, { actor })`). */
94
+ readonly actorEntity?: string | undefined;
95
+ /**
96
+ * For a field naming the actor entity itself: the actor's OWN row (`memberId` on a preference)
97
+ * or somebody else's (`userId` on a block). Both are legitimate, so the pass tries each.
98
+ */
99
+ readonly self?: boolean;
100
+ }
101
+
102
+ /** The entity an input key names, if any: `<stem>Id`, or a bare `id` by the primitive's name. */
103
+ const entityForField = (
104
+ key: string,
105
+ entities: readonly SeedEntity[],
106
+ subject: string,
107
+ ): SeedEntity | undefined => {
108
+ if (key === 'id') return entityForSubject(subject, entities);
109
+ const stem = idStem(key);
110
+ return stem === undefined ? undefined : entityForStem(stem, entities);
111
+ };
112
+
113
+ /** Whether any string field of the drawn value names the actor entity — a two-run input. */
114
+ export function namesActorEntity(
115
+ drawn: unknown,
116
+ entities: readonly SeedEntity[],
117
+ scope: Pick<IdScope, 'subject' | 'actorEntity'>,
118
+ ): boolean {
119
+ if (scope.actorEntity === undefined || !isPlainObject(drawn)) return false;
120
+ return Object.keys(drawn).some((key) =>
121
+ typeof drawn[key] === 'string'
122
+ ? entityForField(key, entities, scope.subject)?.$name === scope.actorEntity
123
+ : namesActorEntity(drawn[key], entities, scope),
124
+ );
125
+ }
126
+
127
+ export function seededIds(
128
+ graph: SeedGraph,
129
+ entities: readonly SeedEntity[],
130
+ scope: IdScope,
131
+ ): IdLookup {
132
+ const { tenant, actorId } = scope;
133
+ // A tenant column's own field (`orgId`) is the tenant the actor stands in, whatever entity it
134
+ // happens to name: a policy comparing it with `actor.orgId` refuses any other id.
135
+ const tenantKeys = new Set(entities.map((entity) => entity.$tenantColumn));
136
+ return (key) => {
137
+ if (tenantKeys.has(key)) return { found: true, value: tenant };
138
+ const entity = entityForField(key, entities, scope.subject);
139
+ if (entity === undefined || entity.$primaryKey.length !== 1) return undefined;
140
+ const property = entity.$primaryKey[0] as string;
141
+ const seen = graph.inserts.filter(
142
+ (insert) =>
143
+ insert.entity === entity.$name && (insert.tenant === null || insert.tenant === tenant),
144
+ );
145
+ const own = (insert: (typeof seen)[number]) => insert.row[property] === actorId;
146
+ const references = (insert: (typeof seen)[number]) =>
147
+ !own(insert) && Object.values(insert.row).includes(actorId);
148
+ const other = seen.find((insert) => !own(insert));
149
+ // The actor entity itself: the actor's own row on the `self` run, somebody else's otherwise.
150
+ // Any other entity: a row the actor owns (`authorId`) first, then anyone but the actor.
151
+ const row =
152
+ actorId === undefined
153
+ ? seen[0]
154
+ : entity.$name === scope.actorEntity
155
+ ? scope.self === true
156
+ ? (seen.find(own) ?? seen[0])
157
+ : (other ?? seen[0])
158
+ : (seen.find(references) ?? other ?? seen[0]);
159
+ return row === undefined ? undefined : { found: true, value: row.row[property] };
160
+ };
161
+ }
162
+
163
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
164
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
165
+ const prototype: unknown = Object.getPrototypeOf(value);
166
+ return prototype === null || prototype === Object.prototype;
167
+ };
168
+
169
+ /**
170
+ * The drawn value with every id field bound, at any depth of plain objects. A walk of the VALUE,
171
+ * not the schema: the draw already decided which keys exist. Own keys only, into a fresh object.
172
+ */
173
+ export function bindIds(drawn: unknown, lookup: IdLookup): unknown {
174
+ if (Array.isArray(drawn)) return drawn.map((item) => bindIds(item, lookup));
175
+ if (!isPlainObject(drawn)) return drawn;
176
+ const bound: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
177
+ for (const key of Object.keys(drawn)) {
178
+ const hit = typeof drawn[key] === 'string' ? lookup(key) : undefined;
179
+ bound[key] = hit === undefined ? bindIds(drawn[key], lookup) : hit.value;
180
+ }
181
+ return bound;
182
+ }
@@ -0,0 +1,34 @@
1
+ // An override's `input`, as the exercise pass applies it: AWAITED, and run inside the request the
2
+ // call itself runs in — so `useContext().actor` is the seated actor that call acts as. That is how
3
+ // an input the draw cannot forge (a signed passkey answer, a challenge issued to THIS actor) is
4
+ // computed from the pass's own state, by one mechanism and no per-primitive case.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { bindDrawn } from '@ultimat3/core';
8
+ import type { ExerciseOverride } from './define-exercise';
9
+ import { withExerciseRequest } from './exercise-request';
10
+
11
+ /** `(drawn, actor) => input`, or `undefined` when the override binds nothing. */
12
+ export type OverrideInput = (drawn: unknown, actor: Actor | null) => Promise<unknown>;
13
+
14
+ export function overrideInput(
15
+ name: string,
16
+ override: ExerciseOverride | undefined,
17
+ ): OverrideInput | undefined {
18
+ const bind = bindDrawn(
19
+ override !== undefined && 'actor' in override ? override.input : undefined,
20
+ );
21
+ if (bind === undefined) return undefined;
22
+ return (drawn, actor) => withExerciseRequest(name, actor, async () => bind(drawn));
23
+ }
24
+
25
+ /**
26
+ * Whether an override's input is declared `async` — state the pass computes, which a load that
27
+ * draws with no pass behind it (`x drill`) cannot reproduce. The function's own tag, never a call:
28
+ * a plain function RETURNING a promise passes it, and `drill-writes.ts` catches that one at its draw.
29
+ */
30
+ export const isAsyncInput = (override: ExerciseOverride | undefined): boolean =>
31
+ override !== undefined &&
32
+ 'actor' in override &&
33
+ override.input !== undefined &&
34
+ Object.prototype.toString.call(override.input) === '[object AsyncFunction]';