@ultimat3/testing 29.4.0 → 30.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 (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 +82 -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 +448 -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 +68 -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/index.ts CHANGED
@@ -27,8 +27,24 @@ export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bu
27
27
  // `@ultimat3/http` is imported inside the function.
28
28
  export type { AuthRequestInit } from './auth-request';
29
29
  export { authRequest } from './auth-request';
30
+ // The accessibility audit every `e2eTest` runs (plan 101 / 39): the one reader of the browser's
31
+ // tree, its rules, and the reasoned per-test `allow`.
32
+ export type { AxNode, AxTreeNode } from './ax-node';
33
+ export { toAxNode, toAxTreeNodes } from './ax-node';
30
34
  export type { BudgetStoreLike } from './budget-store-conformance';
31
35
  export { budgetStoreConformance } from './budget-store-conformance';
36
+ // Recorded vendor answers, replayed through `mockFetch` with the network sealed; recorded only by
37
+ // hand (`ULTIMATE_CASSETTE=record`, never under CI), with the written bytes scanned for secrets.
38
+ export type { Cassette, CassetteOptions } from './cassette';
39
+ export { CASSETTE_ENV, cassette } from './cassette';
40
+ export type { RecordRefusal, UnusedEntry } from './cassette-errors';
41
+ export {
42
+ CassetteMissingError,
43
+ CassetteRecordRefusedError,
44
+ CassetteSecretError,
45
+ CassetteUnusedError,
46
+ } from './cassette-errors';
47
+ export type { CassetteEntry, CassetteFile } from './cassette-format';
32
48
  // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
33
49
  // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
34
50
  // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
@@ -82,6 +98,10 @@ export {
82
98
  launchChrome,
83
99
  launchFoundChrome,
84
100
  } from './cdp-launch';
101
+ // The exercise pass (plan 101 / 40, phase 2): an app's per-primitive overrides — 39's
102
+ // `.contract({ exercise })` option, one shape — and the pass over every action, query and job.
103
+ export type { ExerciseAppOptions, ExerciseOverride, ExerciseOverrides } from './define-exercise';
104
+ export { defineExercise } from './define-exercise';
85
105
  // The island-state vocabulary. Pure data by design: a `*.island.states.ts` file is read by the
86
106
  // command that photographs the states, by the harness page and by a guard test — none of which has
87
107
  // a bundler, and only one of which has a browser.
@@ -106,6 +126,35 @@ export {
106
126
  setFrozenClock,
107
127
  withFrozenClock,
108
128
  } from './determinism';
129
+ // `x drill` (plan 101 / 40): the eight fault drills, the fleet they run on, the verdict over evidence.
130
+ export type { DrillConfig, DrillTiming } from './drill-budgets';
131
+ export { drillBudgets } from './drill-budgets';
132
+ export { DrillFailedError, DrillServiceMissingError } from './drill-errors';
133
+ export type { DrillFleet, DrillRoleProcess, DrillServiceUrls } from './drill-fleet';
134
+ export { startDrillFleet } from './drill-fleet';
135
+ export { bunDrillSql } from './drill-jobs';
136
+ export type { DrillTarget } from './drill-load';
137
+ export type { DrillReport } from './drill-run';
138
+ export { runDrill } from './drill-run';
139
+ export type { DrillDeclarations } from './drill-services';
140
+ export { DRILL_SERVICE_ENV, drillServices } from './drill-services';
141
+ export type { DrillSyncEvidence, DrillSyncLane, DrillSyncOptions } from './drill-sync';
142
+ export { startDrillSync } from './drill-sync';
143
+ export type { DrillBudgets, DrillEvidence, DrillFailure, DrillProperty } from './drill-verdict';
144
+ export { judgeDrill } from './drill-verdict';
145
+ export type { DrillWriteCandidate, DrillWrites } from './drill-writes';
146
+ export { DRILL_DRAW_SEED, drillWriteTargets } from './drill-writes';
147
+ export type { Drill, DrillName, DrillRole, DrillService } from './drills';
148
+ export { DRILL_NAMES, DRILLS, drillNamed, drillRoles } from './drills';
149
+ export type { A11yFinding, A11yRule } from './e2e-a11y';
150
+ export { auditTree, NAMED_ROLES } from './e2e-a11y';
151
+ export type { A11yAllow, A11yOptions } from './e2e-a11y-run';
152
+ export {
153
+ E2eA11yAllowUnreasonedError,
154
+ E2eA11yUnavailableError,
155
+ E2eA11yViolationError,
156
+ } from './e2e-a11y-run';
157
+ export { allowA11y } from './e2e-a11y-tabs';
109
158
  export type { E2eApp, E2eAppMode, StartE2eAppOptions } from './e2e-app';
110
159
  export { E2E_APP_START_MS, E2E_APP_STOP_MS, startE2eApp } from './e2e-app';
111
160
  export { E2E_ROOT_ENV, e2eApp, e2eBaseUrl, e2eBrowser } from './e2e-browser-handle';
@@ -126,7 +175,7 @@ export type { EvaluablePage } from './e2e-evaluate';
126
175
  export { closureSource, evaluateClosure, evaluateExpression } from './e2e-evaluate';
127
176
  export type { LocatablePage } from './e2e-locator';
128
177
  export { e2eLocator, resetLocatorMarks } from './e2e-locator';
129
- export type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
178
+ export type { AxTreeRequest, E2eBrowserPage, E2ePageOptions } from './e2e-page';
130
179
  export { DEFAULT_E2E_TIMEOUT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT_MS, e2ePage } from './e2e-page';
131
180
  export type { E2eResolution, E2eSelection } from './e2e-selection';
132
181
  export {
@@ -148,6 +197,21 @@ export {
148
197
  TESTING_ERROR_TITLES,
149
198
  TestDatabaseUnavailableError,
150
199
  } from './errors';
200
+ export type { ExercisePassOptions } from './exercise';
201
+ export { runExercisePass } from './exercise';
202
+ export type { ActorChoice, RoleGrants } from './exercise-actor';
203
+ export type {
204
+ CrashFinding,
205
+ CrashProof,
206
+ EffectPayload,
207
+ ExerciseEffect,
208
+ ExerciseOutcome,
209
+ ExerciseRecord,
210
+ TenantFinding,
211
+ TenantLeg,
212
+ TenantProof,
213
+ } from './exercise-report';
214
+ export { overrideLine } from './exercise-report';
151
215
  export type {
152
216
  Association,
153
217
  AssociationMap,
@@ -163,6 +227,15 @@ export type { Persister } from './factory-persist';
163
227
  export { clearPersister, persisterInstalled, usePersister } from './factory-persist';
164
228
  export type { EntityRegistry, FactoryRegistry } from './factory-registry';
165
229
  export { factoriesFor } from './factory-registry';
230
+ export type {
231
+ FakeIdentityProvider,
232
+ FakeIdentityProviderInput,
233
+ FakeIdpTamper,
234
+ FakeIdpUser,
235
+ } from './fake-identity-provider';
236
+ export { fakeIdentityProvider } from './fake-identity-provider';
237
+ export type { FaultMode, FaultProxy, FaultProxyCounts } from './fault-proxy';
238
+ export { faultProxy, throughFaultProxy } from './fault-proxy';
166
239
  export type { TestClock, TestDuration } from './fixture-clock';
167
240
  export { testClock } from './fixture-clock';
168
241
  export type {
@@ -291,7 +364,31 @@ export {
291
364
  normalizeIslandName,
292
365
  } from './island-states-resolve';
293
366
  export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
367
+ export type {
368
+ CrashBoundary,
369
+ CrashDivergence,
370
+ CrashProofOptions,
371
+ CrashReport,
372
+ CrashRerun,
373
+ } from './job-crash-proof';
374
+ export { crashProof } from './job-crash-proof';
294
375
  export { jobDriverConformance } from './job-driver-conformance';
376
+ export type { SimEffect } from './job-sim-effects';
377
+ export {
378
+ JobCrashDivergedError,
379
+ JobSimInvariantError,
380
+ JobSimNondeterministicError,
381
+ } from './job-sim-errors';
382
+ export type { JobFaultKind } from './job-sim-schedule';
383
+ export { ALL_FAULTS, JOB_FAULT_KINDS } from './job-sim-schedule';
384
+ export type {
385
+ SimulatedJob,
386
+ SimulatedRun,
387
+ SimulateJobsOptions,
388
+ SimulationReport,
389
+ SimulationState,
390
+ } from './job-simulate';
391
+ export { JOB_SIM_SEED_ENV, simulateJobs } from './job-simulate';
295
392
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
296
393
  export { liveNode } from './live-node';
297
394
  // The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
@@ -305,6 +402,17 @@ export { memoryLocalTx } from './local-tx';
305
402
  export type { VisibleOptions } from './matcher-visible';
306
403
  export type { MatcherResult } from './matchers';
307
404
  export { matchersInstalled, recordSteps } from './matchers';
405
+ export type { MockHandler, MockRoute } from './mock-route';
406
+ // The plan and scale sweeps (plan 101 / 40-next-query-plans): every statement the pass sent planned
407
+ // with no index but the declared ones, every reached primitive again at 2 and 8 rows a table.
408
+ export type { PlanTableColumns, PlanVerdict, PlanVerdictCode } from './plan-oracle';
409
+ export { judgePlan, planRoot } from './plan-oracle';
410
+ export type { PlanExempt, PlanProof, SentStatement } from './plan-sweep';
411
+ export { QueryPlanUnavailableError, sweepPlans } from './plan-sweep';
412
+ export type { PlanFinding } from './plan-sweep-fix';
413
+ export { planFindings } from './plan-sweep-fix';
414
+ export type { SweepFinding } from './plan-sweep-report';
415
+ export { planSweepFindings, planSweepLines } from './plan-sweep-report';
308
416
  // `isolateEntityRegistry` is deliberately NOT here — it is `@ultimat3/testing/registry-isolation`.
309
417
  // This barrel is what a `packages/core` test imports for `expect` alone, and a static re-export
310
418
  // evaluates the module it names: that one would load `@ultimat3/entity` and its process-global
@@ -319,9 +427,15 @@ export { captureProcessRegistries, restoreProcessRegistries } from './registry-s
319
427
  // functions, so a tier-0 test importing this barrel for `expect` still loads no renderer.
320
428
  export type { RenderedRoute, RenderedView, RouteModule, RouteRequest } from './render-view';
321
429
  export { renderRoute, renderView } from './render-view';
430
+ // What a test `x repro --write` generated calls: a captured request or a dead job, replayed.
431
+ export type { JobFailure, ReproduceOptions, ReproOutcome } from './repro';
432
+ export { reproduce, reproJob } from './repro';
433
+ export type { LoggedRequest } from './request-log';
322
434
  export type { DatabaseSnapshot } from './reusable-database';
323
435
  export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
324
- export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
436
+ export type { ScaleProof, ScaleShape, StatementTally } from './scale-sweep';
437
+ export { judgeScale, SCALE_SIZES } from './scale-sweep';
438
+ export type { NetworkSnapshot, NetworkState } from './sealed-network';
325
439
  // `setNetworkState` is deliberately not here: it is the offline gate's one writer, and a test that
326
440
  // called it directly would bypass the `network` fixture's disposal and leave the whole process
327
441
  // offline for every file after it. The fixture is the way to go offline — there is no second one.
@@ -332,14 +446,38 @@ export {
332
446
  mockFetch,
333
447
  mockJson,
334
448
  networkState,
335
- requestedUrls,
449
+ requestLog,
336
450
  resetNetwork,
337
451
  restoreCapturedNetwork,
338
452
  sealNetwork,
339
453
  unsealNetwork,
340
454
  } from './sealed-network';
455
+ // Rows for every table with no factory written (plan 101 / 40, phase 1): parents first, same-tenant
456
+ // keys, values off each column's declaration. Types only from `@ultimat3/entity`, so no registry.
457
+ export { seedGraph } from './seed-graph';
458
+ export type { SeedGraphUnsatisfiedInput } from './seed-graph-error';
459
+ // A seed graph written through each entity's own repository — a pinned client (Postgres, PGlite) or
460
+ // the app's driver (memory, `testDriver()`). Loads `@ultimat3/entity` on its first write, not here.
461
+ export { SeedGraphEntityUnknownError, SeedGraphUnsatisfiedError } from './seed-graph-error';
462
+ export type {
463
+ SeedCanary,
464
+ SeedFactory,
465
+ SeedGraph,
466
+ SeedGraphOptions,
467
+ SeedRow,
468
+ SeedUpdate,
469
+ SeedWriter,
470
+ } from './seed-graph-plan';
471
+ export { insertSeedGraph } from './seed-graph-plan';
472
+ export type { SeedEntity } from './seed-graph-shape';
473
+ export type { SeedColumn } from './seed-graph-values';
474
+ export type { EntitySeedWriterOptions, SeedWriterTarget } from './seed-writer';
475
+ export { entitySeedWriter } from './seed-writer';
341
476
  export type { SharedExamples } from './shared-examples';
342
477
  export { behavesLike, sharedExamples } from './shared-examples';
478
+ export type { SubjectProofOptions, SubjectProofResult } from './subject-proof';
479
+ export { subjectProof } from './subject-proof';
480
+ export { SubjectEraseIncompleteError } from './subject-proof-error';
343
481
  export type {
344
482
  DisposableWorkerDatabase,
345
483
  SqlRunner,
@@ -358,11 +496,18 @@ export {
358
496
  urlFor,
359
497
  workerId,
360
498
  } from './template-db';
499
+ // The tenant proof's dynamic legs (plan 101 / 40): B with A's ids, A, B again. The report builders
500
+ // are what `x exercise` prints; `tenancy:cross` is `@ultimat3/entity`'s `CROSS_TENANT_SCOPE`.
501
+ export type { TenantProofFinding } from './tenant-proof-report';
502
+ export { tenantFindingLines, tenantProofFindings } from './tenant-proof-report';
361
503
  export { paced, TEST_PACE_ENV, TestPaceInvalidError, testPace } from './test-pace';
504
+ // `x exercise` is a test process too: it hands itself the same key, judged by the same rule.
505
+ export { testSealKeyEnv } from './test-seal-key';
362
506
  export type {
363
507
  E2eBody,
364
508
  E2eDriver,
365
509
  E2eFixtures,
510
+ E2eTestOptions,
366
511
  EvalCase,
367
512
  EvalOptions,
368
513
  LocatorLike,
package/src/island-dom.ts CHANGED
@@ -224,6 +224,11 @@ export class FakeElement extends FakeNode {
224
224
  /** Properties, not attributes: Solid assigns both of these straight onto the node. */
225
225
  value = '';
226
226
  checked = false;
227
+ /** The constraint API's message: `@ultimat3/ui`'s SearchableSelect refuses a required submit. */
228
+ validationMessage = '';
229
+ setCustomValidity(message: string): void {
230
+ this.validationMessage = String(message);
231
+ }
227
232
  constructor(readonly tagName: string) {
228
233
  super();
229
234
  }
@@ -13,7 +13,7 @@ import { afterAll } from 'bun:test';
13
13
  export const ISOLATED_ENV = 'ULTIMATE_TEST_ISOLATED';
14
14
 
15
15
  /**
16
- * `Bun.plugin.clearAll()` after each file, isolated runs only. Measured on notificado.co's unit
16
+ * `Bun.plugin.clearAll()` after each file, isolated runs only. Measured on one adopter's unit
17
17
  * suite (652 files, 18 workers): per-worker peak 2.1–2.3 GB → 1.0–1.5 GB, total ~30 GB → 18 GB. The
18
18
  * next file's preload registers the plugins again, so every file still gets them.
19
19
  *
@@ -0,0 +1,249 @@
1
+ // `crashProof(handle, input)`: one clean run, then one run killed at each boundary the clean run
2
+ // crossed — every step write landing, every settle — and one more where that write's answer is
3
+ // lost. Judged by the effect log: a killed run that makes an effect more often than the clean one
4
+ // is `X_JOB_CRASH_DIVERGED`; a lost answer rerunning a step is at-least-once, reported, never red.
5
+
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import { finiteCount } from '@ultimat3/core';
8
+ import type { JobHandle } from '@ultimat3/jobs';
9
+ import { COMPENSATE_PREFIX, UNWINDING_STEP } from '@ultimat3/jobs';
10
+ import { advanceClock, frozenNow } from './determinism';
11
+ import type { SimDecision, SimOp } from './job-sim-driver';
12
+ import { SEND_AND_ANSWER } from './job-sim-driver';
13
+ import type { SimEffect } from './job-sim-effects';
14
+ import { effectCounts } from './job-sim-effects';
15
+ import { JobCrashDivergedError } from './job-sim-errors';
16
+ import type { SimWorld } from './job-sim-world';
17
+ import { openSimWorld } from './job-sim-world';
18
+
19
+ export interface CrashBoundary {
20
+ /** `charge`, `charge (started)`, `sleep:1h (sleeping)`, `unwinding`, `compensate:charge`, `ack`. */
21
+ readonly at: string;
22
+ /** `forced-fail`: the run of a `compensates: true` job made to fail for good, then unwound. */
23
+ readonly mode: 'clean' | 'forced-fail';
24
+ /** The row's state once the killed run settled: `done`, `dead`, … */
25
+ readonly state: string;
26
+ /** The `X_*` code the row ended on, when it ended on one — `X_STEP_OUTCOME_UNKNOWN` after a kill inside `once`. */
27
+ readonly code?: string;
28
+ }
29
+
30
+ export interface CrashDivergence {
31
+ readonly boundary: string;
32
+ readonly mode: CrashBoundary['mode'];
33
+ readonly effect: string;
34
+ readonly clean: number;
35
+ readonly killed: number;
36
+ readonly error: JobCrashDivergedError;
37
+ }
38
+
39
+ /** A lost answer that made something run again: expected, since no framework can prevent it. */
40
+ export interface CrashRerun {
41
+ readonly boundary: string;
42
+ readonly effect: string;
43
+ readonly clean: number;
44
+ readonly rerun: number;
45
+ }
46
+
47
+ export interface CrashReport {
48
+ readonly boundaries: readonly CrashBoundary[];
49
+ readonly divergences: readonly CrashDivergence[];
50
+ readonly reruns: readonly CrashRerun[];
51
+ }
52
+
53
+ export interface CrashProofOptions {
54
+ /** What the workers run bodies under. Default: an anonymous worker. */
55
+ readonly context?: () => Ctx;
56
+ /** Seeds `Math.random` for every run. Default 1. */
57
+ readonly seed?: number;
58
+ }
59
+
60
+ /** A drain that has not settled in this many passes is a job that never settles (a long wait). */
61
+ const MAX_ROUNDS = 100;
62
+
63
+ /** What one run is told to do at the n-th boundary event of the job under proof. */
64
+ interface Plan {
65
+ readonly at?: number;
66
+ readonly fault?: 'kill' | 'lose';
67
+ /** Every forward ack dies: the run fails for good on its final attempt and unwinds. */
68
+ readonly forceFail: boolean;
69
+ }
70
+
71
+ interface Ran {
72
+ readonly boundaries: readonly string[];
73
+ readonly effects: readonly SimEffect[];
74
+ readonly compensated: readonly string[];
75
+ /** Forward steps that completed: what a compensator may undo. */
76
+ readonly forward: ReadonlySet<string>;
77
+ /** The row's state once the run settled. */
78
+ readonly settled: string;
79
+ readonly code?: string;
80
+ }
81
+
82
+ const labelOf = (op: SimOp): string =>
83
+ op.call !== 'put'
84
+ ? op.call
85
+ : op.status === 'completed'
86
+ ? (op.step ?? 'put')
87
+ : `${op.step ?? 'put'} (${op.status ?? '?'})`;
88
+
89
+ async function drive(world: SimWorld): Promise<void> {
90
+ let n = 1;
91
+ for (let round = 0; round < MAX_ROUNDS; round += 1) {
92
+ const worker = `w${n}`;
93
+ const ran = await world.tick(worker);
94
+ // A dead process is replaced; the lease it held lapses below, and the next worker claims it.
95
+ if (world.sim.isDead(worker)) n += 1;
96
+ if (ran.length > 0) continue;
97
+ const due = await world.nextDue();
98
+ if (due === undefined) return;
99
+ advanceClock(Math.max(1, due - frozenNow().getTime()));
100
+ }
101
+ }
102
+
103
+ async function runOnce<I>(
104
+ handle: JobHandle<I>,
105
+ input: I,
106
+ plan: Plan,
107
+ options: CrashProofOptions,
108
+ ): Promise<Ran> {
109
+ const ids = { job: '', run: '' };
110
+ const boundaries: string[] = [];
111
+ const compensated: string[] = [];
112
+ const forward = new Set<string>();
113
+ const isOwn = (op: SimOp): boolean =>
114
+ op.call === 'put'
115
+ ? op.runId === ids.run
116
+ : (op.call === 'ack' || op.call === 'nack') && op.jobId === ids.job;
117
+ const decide = (op: SimOp): SimDecision => {
118
+ if (!isOwn(op)) return SEND_AND_ANSWER;
119
+ if (plan.forceFail && op.call === 'ack') return { before: 'kill', after: 'answer' };
120
+ boundaries.push(labelOf(op));
121
+ if (boundaries.length !== plan.at || plan.fault === undefined) return SEND_AND_ANSWER;
122
+ // A step write lands and then the worker dies; a settle dies before it is sent.
123
+ if (plan.fault === 'lose') return { before: 'send', after: 'lose' };
124
+ return op.call === 'put'
125
+ ? { before: 'send', after: 'kill' }
126
+ : { before: 'kill', after: 'answer' };
127
+ };
128
+ const landed = (op: SimOp): void => {
129
+ if (!isOwn(op) || op.call !== 'put' || op.status !== 'completed' || op.step === undefined) {
130
+ return;
131
+ }
132
+ if (op.step.startsWith(COMPENSATE_PREFIX)) compensated.push(op.step);
133
+ else if (op.step !== UNWINDING_STEP) forward.add(op.step);
134
+ };
135
+ await using world = await openSimWorld({
136
+ seed: finiteCount('crashProof', 'seed', options.seed ?? 1),
137
+ decide,
138
+ landed,
139
+ ...(options.context === undefined ? {} : { context: options.context }),
140
+ });
141
+ const queued = await world.enqueue(handle, input);
142
+ ids.job = queued.id;
143
+ ids.run = queued.runId;
144
+ await drive(world);
145
+ const row = (await world.rows()).find((one) => one.id === queued.id);
146
+ const code = /\b(X_[A-Z0-9_]+)\b/.exec(row?.lastError ?? '')?.[1];
147
+ return {
148
+ boundaries,
149
+ effects: world.effects(),
150
+ compensated,
151
+ forward,
152
+ settled: row?.state ?? 'gone',
153
+ ...(code === undefined ? {} : { code }),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Effects the killed run made more often than the reference — or less often, when both ended alike
159
+ * and neither unwound. An unwound run is also held to the reference's compensators: each once, last
160
+ * in first out, for exactly the forward steps that completed in it.
161
+ */
162
+ function compare(reference: Ran, killed: Ran): readonly [string, number, number][] {
163
+ const before = effectCounts(reference.effects);
164
+ const after = effectCounts(killed.effects);
165
+ const unwound = reference.compensated.length > 0 || killed.compensated.length > 0;
166
+ const alike = killed.settled === reference.settled && !unwound;
167
+ const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
168
+ const out: [string, number, number][] = [];
169
+ for (const key of keys) {
170
+ const c = before.get(key) ?? 0;
171
+ const k = after.get(key) ?? 0;
172
+ if (k > c || (k < c && alike)) out.push([key, c, k]);
173
+ }
174
+ if (unwound) {
175
+ const expected = reference.compensated.filter((name) =>
176
+ killed.forward.has(name.slice(COMPENSATE_PREFIX.length)),
177
+ );
178
+ const order = (list: readonly string[]): string => list.join(' → ') || 'none';
179
+ if (order(killed.compensated) !== order(expected)) {
180
+ out.push([`compensators ${order(killed.compensated)}, not ${order(expected)}`, 0, 1]);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export async function crashProof<I>(
187
+ handle: JobHandle<I>,
188
+ input: I,
189
+ options: CrashProofOptions = {},
190
+ ): Promise<CrashReport> {
191
+ const boundaries: CrashBoundary[] = [];
192
+ const divergences: CrashDivergence[] = [];
193
+ const reruns: CrashRerun[] = [];
194
+ const modes: readonly CrashBoundary['mode'][] = handle.compensates
195
+ ? ['clean', 'forced-fail']
196
+ : ['clean'];
197
+ // A kill on the final attempt of a compensating job unwinds it: that run is held to the run
198
+ // forced to fail for good, never to the clean one it no longer resembles.
199
+ const failed = handle.compensates
200
+ ? await runOnce(handle, input, { forceFail: true }, options)
201
+ : undefined;
202
+ for (const mode of modes) {
203
+ const forceFail = mode === 'forced-fail';
204
+ const clean =
205
+ forceFail && failed !== undefined
206
+ ? failed
207
+ : await runOnce(handle, input, { forceFail }, options);
208
+ const afterOf = (key: string): string | undefined =>
209
+ clean.effects.find((effect) => effect.key === key)?.after;
210
+ for (let at = 1; at <= clean.boundaries.length; at += 1) {
211
+ const label = clean.boundaries[at - 1] ?? '?';
212
+ const killed = await runOnce(handle, input, { at, fault: 'kill', forceFail }, options);
213
+ boundaries.push({
214
+ at: label,
215
+ mode,
216
+ state: killed.settled,
217
+ ...(killed.code === undefined ? {} : { code: killed.code }),
218
+ });
219
+ const reference =
220
+ failed !== undefined && killed.compensated.length > 0 && clean.compensated.length === 0
221
+ ? failed
222
+ : clean;
223
+ for (const [effect, c, k] of compare(reference, killed)) {
224
+ const after = afterOf(effect);
225
+ divergences.push({
226
+ boundary: label,
227
+ mode,
228
+ effect,
229
+ clean: c,
230
+ killed: k,
231
+ error: new JobCrashDivergedError({
232
+ job: handle.name,
233
+ boundary: label,
234
+ effect,
235
+ clean: c,
236
+ killed: k,
237
+ ...(after === undefined ? {} : { after }),
238
+ }),
239
+ });
240
+ }
241
+ if (forceFail) continue;
242
+ const lost = await runOnce(handle, input, { at, fault: 'lose', forceFail }, options);
243
+ for (const [effect, c, k] of compare(clean, lost)) {
244
+ if (k > c) reruns.push({ boundary: label, effect, clean: c, rerun: k });
245
+ }
246
+ }
247
+ }
248
+ return { boundaries, divergences, reruns };
249
+ }
@@ -0,0 +1,164 @@
1
+ // One real `memoryJobDriver`, seen by each simulated worker through its own wrapper: every queue
2
+ // and step-store call waits at a yield point first, and a fault decided there lands. After a kill
3
+ // every call from that worker rejects and lands nothing; an answer lost has landed and still
4
+ // rejects. The rejection is the one a real driver raises for a dead connection (`X_DB_UNAVAILABLE`).
5
+
6
+ import { dbUnavailable } from '@ultimat3/db';
7
+ import type {
8
+ ClaimedJob,
9
+ EnqueueRequest,
10
+ EnqueueResult,
11
+ JobDriver,
12
+ MemoryJobDriver,
13
+ StepRecord,
14
+ StepStore,
15
+ } from '@ultimat3/jobs';
16
+
17
+ /** Every call a worker makes through its view. `put` is the step store's write. */
18
+ export type SimCall =
19
+ | 'claim'
20
+ | 'ack'
21
+ | 'nack'
22
+ | 'heartbeat'
23
+ | 'enqueue'
24
+ | 'enqueueMany'
25
+ | 'put'
26
+ | 'get'
27
+ | 'list'
28
+ | 'del'
29
+ | 'clear';
30
+
31
+ export interface SimOp {
32
+ readonly worker: string;
33
+ readonly call: SimCall;
34
+ /** The row the call names: absent on `claim` and on an enqueue. */
35
+ readonly jobId?: string;
36
+ readonly runId?: string;
37
+ /** A step-store call's step, and on a `put` the status it writes. */
38
+ readonly step?: string;
39
+ readonly status?: StepRecord['status'];
40
+ /** An enqueue's job names, one per request. */
41
+ readonly names?: readonly string[];
42
+ }
43
+
44
+ /** What happens to one call: `before` decides if it is sent, `after` what its caller hears. */
45
+ export interface SimDecision {
46
+ readonly before: 'send' | 'kill';
47
+ readonly after: 'answer' | 'kill' | 'lose';
48
+ }
49
+
50
+ export const SEND_AND_ANSWER: SimDecision = { before: 'send', after: 'answer' };
51
+
52
+ export interface SimHooks {
53
+ /** Awaited before every call: the scheduler's yield point and the fault schedule's. */
54
+ decide(op: SimOp): SimDecision | Promise<SimDecision>;
55
+ /** A landed call, before its caller hears — what the effect log and the trace read. */
56
+ landed?(op: SimOp, answer: unknown): void;
57
+ }
58
+
59
+ export interface SimDriver {
60
+ readonly base: MemoryJobDriver;
61
+ /** The driver one worker holds. Same object for the same id. */
62
+ viewFor(worker: string): JobDriver;
63
+ kill(worker: string): void;
64
+ isDead(worker: string): boolean;
65
+ }
66
+
67
+ /** Every rejection this file made, so a caller can tell a planted fault from a real failure. */
68
+ const planted = new WeakSet<object>();
69
+
70
+ const fault = (detail: string) => {
71
+ const error = dbUnavailable(detail);
72
+ planted.add(error);
73
+ return error;
74
+ };
75
+
76
+ /** The value was raised by a simulated fault, never by the code under test. */
77
+ export const isSimFault = (value: unknown): boolean =>
78
+ typeof value === 'object' && value !== null && planted.has(value);
79
+
80
+ const dead = (worker: string) =>
81
+ fault(`the simulated worker "${worker}" died: its connection to the queue is gone`);
82
+
83
+ const lost = (op: SimOp) =>
84
+ fault(`the answer to ${op.call} from simulated worker "${op.worker}" was lost after it landed`);
85
+
86
+ export function simDriver(base: MemoryJobDriver, hooks: SimHooks): SimDriver {
87
+ const killed = new Set<string>();
88
+ const views = new Map<string, JobDriver>();
89
+
90
+ /** One call, through the yield point, the death check and the decided fault. */
91
+ const call = async <T>(op: SimOp, send: () => Promise<T>): Promise<T> => {
92
+ if (killed.has(op.worker)) throw dead(op.worker);
93
+ const decision = await hooks.decide(op);
94
+ // Killed while parked at the yield: a dead process sends nothing it had not yet sent.
95
+ if (killed.has(op.worker) || decision.before === 'kill') {
96
+ killed.add(op.worker);
97
+ throw dead(op.worker);
98
+ }
99
+ const answer = await send();
100
+ hooks.landed?.(op, answer);
101
+ if (decision.after === 'kill') {
102
+ killed.add(op.worker);
103
+ throw dead(op.worker);
104
+ }
105
+ if (decision.after === 'lose') throw lost(op);
106
+ return answer;
107
+ };
108
+
109
+ const stepsFor = (worker: string): StepStore => ({
110
+ get: (runId, name) =>
111
+ call({ worker, call: 'get', runId, step: name }, () => base.steps.get(runId, name)),
112
+ put: (record, by) =>
113
+ call(
114
+ {
115
+ worker,
116
+ call: 'put',
117
+ runId: record.runId,
118
+ step: record.name,
119
+ status: record.status,
120
+ ...(by === undefined ? {} : { jobId: by.jobId }),
121
+ },
122
+ () => base.steps.put(record, by),
123
+ ),
124
+ list: (runId) => call({ worker, call: 'list', runId }, () => base.steps.list(runId)),
125
+ del: (runId, name) =>
126
+ call({ worker, call: 'del', runId, step: name }, () => base.steps.del(runId, name)),
127
+ clear: (runId) => call({ worker, call: 'clear', runId }, () => base.steps.clear(runId)),
128
+ });
129
+
130
+ const viewOf = (worker: string): JobDriver => ({
131
+ ...base,
132
+ name: `sim:${worker}`,
133
+ steps: stepsFor(worker),
134
+ enqueue: (request: EnqueueRequest): Promise<EnqueueResult> =>
135
+ call({ worker, call: 'enqueue', names: [request.name] }, () => base.enqueue(request)),
136
+ enqueueMany: (requests) =>
137
+ call({ worker, call: 'enqueueMany', names: requests.map((one) => one.name) }, () =>
138
+ base.enqueueMany(requests),
139
+ ),
140
+ claim: (options): Promise<readonly ClaimedJob[]> =>
141
+ call({ worker, call: 'claim' }, () => base.claim({ ...options, workerId: worker })),
142
+ ack: (jobId, by) => call({ worker, call: 'ack', jobId }, () => base.ack(jobId, by)),
143
+ nack: (jobId, options) =>
144
+ call({ worker, call: 'nack', jobId }, () => base.nack(jobId, options)),
145
+ heartbeat: (jobId, options) =>
146
+ call({ worker, call: 'heartbeat', jobId }, () => base.heartbeat(jobId, options)),
147
+ // Every worker's teardown would close the ONE shared driver; the world closes it once.
148
+ close: () => Promise.resolve(),
149
+ });
150
+
151
+ return {
152
+ base,
153
+ viewFor(worker) {
154
+ let view = views.get(worker);
155
+ if (view === undefined) {
156
+ view = viewOf(worker);
157
+ views.set(worker, view);
158
+ }
159
+ return view;
160
+ },
161
+ kill: (worker) => killed.add(worker),
162
+ isDead: (worker) => killed.has(worker),
163
+ };
164
+ }