@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
@@ -0,0 +1,144 @@
1
+ // One drill, played: load on the web role, a `/readyz` sampler, the fault — proxy phases or a
2
+ // signal — then recovery, then the evidence folded and judged. The fleet is a parameter, so the
3
+ // sequence is proven against an in-process stand-in and `x drill` hands in the real one.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import type { DrillFleet } from './drill-fleet';
7
+ import { exitWithin } from './drill-fleet';
8
+ import type { DrillJobCounts, DrillSql } from './drill-jobs';
9
+ import { drillJobCounts } from './drill-jobs';
10
+ import type { DrillSample, DrillTarget } from './drill-load';
11
+ import { recoveryAfter, requestEvidence, startDrillLoad } from './drill-load';
12
+ import { sampleStatus } from './drill-probe';
13
+ import type { DrillSyncLane } from './drill-sync';
14
+ import type { DrillBudgets, DrillEvidence, DrillVerdict } from './drill-verdict';
15
+ import { judgeDrill } from './drill-verdict';
16
+ import type { Drill } from './drills';
17
+
18
+ export interface DrillRunOptions {
19
+ readonly drill: Drill;
20
+ readonly fleet: DrillFleet;
21
+ readonly budgets: DrillBudgets & { readonly phaseMs: number };
22
+ readonly targets: readonly DrillTarget[];
23
+ /** The direct line to the scratch database, for the jobs evidence. */
24
+ readonly sql: DrillSql;
25
+ /** Load before the fault, so a drill that never had a 2xx says so rather than blaming the fault. */
26
+ readonly baselineMs?: number | undefined;
27
+ readonly sampleEveryMs?: number | undefined;
28
+ readonly lanes?: number | undefined;
29
+ /**
30
+ * The sync-client lane, started with the fault and stopped after recovery — what `sync-resumes`
31
+ * reads. Absent, that property is red on no evidence, never green on none.
32
+ */
33
+ readonly sync?: (() => DrillSyncLane) | undefined;
34
+ readonly now?: (() => number) | undefined;
35
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
36
+ }
37
+
38
+ export interface DrillReport {
39
+ readonly name: string;
40
+ readonly verdict: DrillVerdict;
41
+ readonly evidence: DrillEvidence;
42
+ readonly budgets: DrillBudgets;
43
+ /** Answers before the fault began: a drill whose baseline failed is reporting the app, not it. */
44
+ readonly baseline: { readonly ok: number; readonly failed: number };
45
+ }
46
+
47
+ interface Played {
48
+ readonly clearedMs: number | undefined;
49
+ readonly exit: DrillEvidence['exit'];
50
+ }
51
+
52
+ async function play(
53
+ options: DrillRunOptions,
54
+ now: () => number,
55
+ sleep: (ms: number) => Promise<void>,
56
+ ): Promise<Played> {
57
+ const { drill, fleet, budgets } = options;
58
+ const fault = drill.fault;
59
+ if (fault.kind === 'service') {
60
+ const proxy = fleet.proxies.get(fault.service);
61
+ if (proxy === undefined) return { clearedMs: undefined, exit: undefined };
62
+ for (const mode of fault.phases(budgets)) {
63
+ await proxy.set(mode);
64
+ await sleep(budgets.phaseMs);
65
+ }
66
+ await proxy.set({ kind: 'pass' });
67
+ return { clearedMs: now(), exit: undefined };
68
+ }
69
+ const role = fleet.roles.get(fault.role);
70
+ if (role === undefined) return { clearedMs: undefined, exit: undefined };
71
+ const signalled = now();
72
+ role.signal(fault.signal);
73
+ const code = await exitWithin(role, budgets.drainMs);
74
+ const exit = { role: fault.role, afterMs: code === null ? null : Math.round(now() - signalled) };
75
+ // A worker comes back, as its supervisor would bring it: the run it held is reclaimed by it.
76
+ if (fault.role === 'web') return { clearedMs: undefined, exit };
77
+ await role.respawn();
78
+ return { clearedMs: now(), exit };
79
+ }
80
+
81
+ const failedOf = (samples: readonly DrillSample[]): number =>
82
+ samples.filter((sample) => sample.status === null || sample.status >= 300).length;
83
+
84
+ /** Play `drill` on `fleet` and judge it. Never throws for a property that did not hold. */
85
+ export async function runDrill(options: DrillRunOptions): Promise<DrillReport> {
86
+ const now = options.now ?? (() => performance.now());
87
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
88
+ const { budgets, fleet, drill } = options;
89
+ const [started] = await options.sql.rows('select now() as t', []);
90
+ const since = started?.['t'];
91
+ const load = startDrillLoad({
92
+ base: fleet.base,
93
+ targets: options.targets,
94
+ lanes: options.lanes,
95
+ requestBoundMs: budgets.deadlineMs * 2,
96
+ now,
97
+ sleep,
98
+ });
99
+ await sleep(finiteCount('runDrill', 'baselineMs', options.baselineMs ?? budgets.promptMs * 2));
100
+ const baselineSamples = [...load.samples()];
101
+ const zero = now();
102
+ const web = fleet.roles.get('web');
103
+ const sampler =
104
+ web === undefined
105
+ ? undefined
106
+ : sampleStatus({
107
+ url: web.readyz,
108
+ everyMs: finiteCount('runDrill', 'sampleEveryMs', options.sampleEveryMs ?? 250, 1),
109
+ boundMs: budgets.promptMs,
110
+ zero,
111
+ now,
112
+ sleep,
113
+ });
114
+ const syncLane = options.sync?.();
115
+ const played = await play(options, now, sleep);
116
+ // Recovery is given its whole budget to show, and the load keeps measuring through it.
117
+ if (played.clearedMs !== undefined) await sleep(budgets.recoveryMs);
118
+ const samples = await load.stop();
119
+ const readyz = await sampler?.stop();
120
+ const sync = await syncLane?.stop();
121
+ const jobs: DrillJobCounts | undefined =
122
+ since instanceof Date
123
+ ? await drillJobCounts({ sql: options.sql, since, settleMs: budgets.drainMs, now, sleep })
124
+ : undefined;
125
+ const evidence: DrillEvidence = {
126
+ requests: requestEvidence(samples),
127
+ readyz,
128
+ recoveryMs:
129
+ played.clearedMs === undefined ? undefined : recoveryAfter(samples, played.clearedMs),
130
+ jobs,
131
+ exit: played.exit,
132
+ sync,
133
+ };
134
+ return {
135
+ name: drill.name,
136
+ verdict: judgeDrill(drill.holds, evidence, budgets),
137
+ evidence,
138
+ budgets,
139
+ baseline: {
140
+ ok: baselineSamples.length - failedOf(baselineSamples),
141
+ failed: failedOf(baselineSamples),
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,70 @@
1
+ // Which real services a drill runs against, read from the `TEST_*` URLs `docker/test-services.env`
2
+ // writes and from what the app declares. A service the app uses must be there for EVERY drill — the
3
+ // role would refuse to boot without it — and the faulted one must be one the app actually uses.
4
+
5
+ import { DrillServiceMissingError } from './drill-errors';
6
+ import type { DrillServiceUrls } from './drill-fleet';
7
+ import type { Drill } from './drills';
8
+ import { drillService } from './drills';
9
+
10
+ /** The variables the test services are published under (`docker/test-services.env`). */
11
+ export const DRILL_SERVICE_ENV = {
12
+ postgres: 'TEST_DATABASE_URL',
13
+ redis: 'TEST_REDIS_URL',
14
+ nats: 'TEST_NATS_URL',
15
+ } as const;
16
+
17
+ /** What the app's config says it uses, beyond the Postgres every drill needs. */
18
+ export interface DrillDeclarations {
19
+ /** `cache.tiers` names `redis`. */
20
+ readonly redis: boolean;
21
+ /** `realtime.transport` is `nats`, read from the variable `realtime.urlEnv` names. */
22
+ readonly nats: { readonly env: string } | undefined;
23
+ }
24
+
25
+ type Env = Readonly<Record<string, string | undefined>>;
26
+
27
+ const read = (env: Env, name: string, drill: Drill): string => {
28
+ const value = env[name]?.trim();
29
+ if (value === undefined || value === '') {
30
+ throw new DrillServiceMissingError({
31
+ drill: drill.name,
32
+ gap: { kind: 'unset', variable: name },
33
+ });
34
+ }
35
+ return value;
36
+ };
37
+
38
+ /** The URLs `drill` runs on, or `X_DRILL_SERVICE_MISSING` naming the one gap. */
39
+ export function drillServices(
40
+ drill: Drill,
41
+ env: Env,
42
+ declared: DrillDeclarations,
43
+ ): DrillServiceUrls {
44
+ const faulted = drillService(drill);
45
+ if (faulted === 'redis' && !declared.redis) {
46
+ throw new DrillServiceMissingError({
47
+ drill: drill.name,
48
+ gap: {
49
+ kind: 'undeclared',
50
+ declaration: "cache: { tiers: ['request-memo', 'lru', 'redis'] }",
51
+ },
52
+ });
53
+ }
54
+ if (faulted === 'nats' && declared.nats === undefined) {
55
+ throw new DrillServiceMissingError({
56
+ drill: drill.name,
57
+ gap: {
58
+ kind: 'undeclared',
59
+ declaration: "realtime: { transport: 'nats', urlEnv: 'NATS_URL' }",
60
+ },
61
+ });
62
+ }
63
+ const postgres = read(env, DRILL_SERVICE_ENV.postgres, drill);
64
+ const redis = declared.redis ? read(env, DRILL_SERVICE_ENV.redis, drill) : undefined;
65
+ const nats =
66
+ declared.nats === undefined
67
+ ? undefined
68
+ : { url: read(env, DRILL_SERVICE_ENV.nats, drill), env: declared.nats.env };
69
+ return { postgres, redis, nats };
70
+ }
@@ -0,0 +1,108 @@
1
+ // A drill's sync-client lane: one socket on the sync node, opened with the protocol's `hello`,
2
+ // holding the subscriptions it is handed and re-dialling whenever it drops, as a page's socket
3
+ // does. It counts what `sync-resumes` judges — whether the client is back once the fault cleared,
4
+ // and every `replay-gap` the node reported — and never decides anything itself.
5
+
6
+ import { finiteCount } from '@ultimat3/core';
7
+ import type { DrillEvidence } from './drill-verdict';
8
+
9
+ export type DrillSyncEvidence = NonNullable<DrillEvidence['sync']>;
10
+
11
+ /** The part of a `WebSocket` the lane uses; the real one satisfies it, a test hands in its own. */
12
+ export interface DrillSyncSocket {
13
+ send(data: string): void;
14
+ close(): void;
15
+ addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;
16
+ addEventListener(type: 'message', listener: (event: { readonly data: unknown }) => void): void;
17
+ }
18
+
19
+ export interface DrillSyncOptions {
20
+ /** `ws://…/_x/sync` on the sync role. */
21
+ readonly url: string;
22
+ /** `@ultimat3/realtime`'s `PROTOCOL_VERSION`, handed in: this file loads no realtime module. */
23
+ readonly protocolVersion: number;
24
+ /** Each a `subscribe` frame's `target`, re-sent on every `hello`, as a page resubscribes. */
25
+ readonly subscriptions?: readonly unknown[] | undefined;
26
+ /** Between a drop and the next dial. */
27
+ readonly redialMs?: number | undefined;
28
+ readonly connect?: ((url: string) => DrillSyncSocket) | undefined;
29
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
30
+ }
31
+
32
+ export interface DrillSyncLane {
33
+ /** Stop re-dialling, close the socket, and answer what the lane saw. */
34
+ stop(): Promise<DrillSyncEvidence>;
35
+ }
36
+
37
+ const typeOf = (data: unknown): string | undefined => {
38
+ if (typeof data !== 'string') return undefined;
39
+ try {
40
+ const frame: unknown = JSON.parse(data);
41
+ const type =
42
+ typeof frame === 'object' && frame !== null ? Reflect.get(frame, 'type') : undefined;
43
+ return typeof type === 'string' ? type : undefined;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ };
48
+
49
+ /** Dial `url` and keep a socket on it until `stop()`. */
50
+ export function startDrillSync(options: DrillSyncOptions): DrillSyncLane {
51
+ const redialMs = finiteCount('startDrillSync', 'redialMs', options.redialMs ?? 250);
52
+ const connect = options.connect ?? ((url: string): DrillSyncSocket => new WebSocket(url));
53
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
54
+ const v = finiteCount('startDrillSync', 'protocolVersion', options.protocolVersion, 1);
55
+ let stopping = false;
56
+ let gaps = 0;
57
+ /** The current socket has had the node's `hello`: the client is connected and subscribed. */
58
+ let live = false;
59
+ let socket: DrillSyncSocket | undefined;
60
+
61
+ const dial = (): void => {
62
+ if (stopping) return;
63
+ live = false;
64
+ const current = connect(options.url);
65
+ socket = current;
66
+ current.addEventListener('open', () => {
67
+ current.send(
68
+ JSON.stringify({ type: 'hello', v, buildId: '', sessionId: null, actorId: null }),
69
+ );
70
+ });
71
+ current.addEventListener('message', (event) => {
72
+ const type = typeOf(event.data);
73
+ if (type === 'replay-gap') gaps += 1;
74
+ if (type !== 'hello') return;
75
+ live = true;
76
+ for (const [index, target] of (options.subscriptions ?? []).entries()) {
77
+ current.send(
78
+ JSON.stringify({
79
+ type: 'subscribe',
80
+ v,
81
+ op: 'add',
82
+ sid: `drill-${String(index)}`,
83
+ target,
84
+ }),
85
+ );
86
+ }
87
+ });
88
+ let gone = false;
89
+ const drop = (): void => {
90
+ if (gone || socket !== current) return;
91
+ gone = true;
92
+ live = false;
93
+ if (!stopping) void sleep(redialMs).then(dial);
94
+ };
95
+ current.addEventListener('close', drop);
96
+ current.addEventListener('error', drop);
97
+ };
98
+
99
+ dial();
100
+ return {
101
+ async stop() {
102
+ stopping = true;
103
+ const reconnected = live;
104
+ socket?.close();
105
+ return { reconnected, gaps };
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,256 @@
1
+ // A drill's verdict, over evidence only: what the load saw, what `/readyz` answered, what the jobs
2
+ // tables hold, how long recovery took. Pure, so every property is proven red on evidence that
3
+ // violates it without a service running. A property with no evidence is red too: a drill that
4
+ // measured nothing is the vacuous pass this file exists to refuse.
5
+
6
+ /** Every property a drill may state. The table (`drills.ts`) picks; this file judges. */
7
+ export type DrillProperty =
8
+ | 'requests-coded'
9
+ | 'requests-bounded'
10
+ | 'requests-prompt'
11
+ | 'requests-ok'
12
+ | 'recovers'
13
+ | 'readyz-flips'
14
+ | 'jobs-terminal'
15
+ | 'jobs-once'
16
+ | 'role-exits'
17
+ | 'sync-resumes';
18
+
19
+ /** The thresholds, each derived from the app's config (`drillBudgets`), never typed per drill. */
20
+ export interface DrillBudgets {
21
+ /** The request deadline (`requestTimeoutMs`), or `--budget-ms`. A request past it is red. */
22
+ readonly deadlineMs: number;
23
+ /** One bounded delay: a degraded dependency may cost a read this much, and no more. */
24
+ readonly promptMs: number;
25
+ /** From the fault cleared to the first answered request, with no restart. */
26
+ readonly recoveryMs: number;
27
+ /** How long `/readyz` may take to say 503 once a dependency is gone. */
28
+ readonly readinessMs: number;
29
+ /** `drain.deadlineMs` plus the readiness grace: a signalled role is gone inside it. */
30
+ readonly drainMs: number;
31
+ /** `health.readiness`: `'process'` keeps `/readyz` 200 through a dependency outage, by design. */
32
+ readonly readiness: 'dependencies' | 'process';
33
+ /** `--budget-ms`, when given: printed in the verdict, because it replaced the derived deadline. */
34
+ readonly override?: number | undefined;
35
+ }
36
+
37
+ export interface DrillRequestEvidence {
38
+ readonly ok: number;
39
+ /** Non-2xx answers carrying a framework `X_*` code, by code. */
40
+ readonly failedByCode: ReadonlyMap<string, number>;
41
+ /** Non-2xx answers with no code, or `X_INTERNAL` — a failure the framework did not classify. */
42
+ readonly uncoded: number;
43
+ /** Requests nothing answered: the connection refused, reset or the harness's own bound hit. */
44
+ readonly unanswered: number;
45
+ /** The slowest answered request, in ms. */
46
+ readonly maxMs: number;
47
+ }
48
+
49
+ export interface DrillEvidence {
50
+ readonly requests: DrillRequestEvidence | undefined;
51
+ /** Each `/readyz` sample: ms since the fault began, and the status (`null`: nothing answered). */
52
+ readonly readyz: readonly { readonly atMs: number; readonly status: number | null }[] | undefined;
53
+ /** From the fault cleared to the first 2xx; `null` when none came. */
54
+ readonly recoveryMs: number | null | undefined;
55
+ readonly jobs:
56
+ | { readonly ran: number; readonly stuck: number; readonly doubled: number }
57
+ | undefined;
58
+ /** The signalled role: ms from the signal to its exit, `null` when it never exited. */
59
+ readonly exit: { readonly role: string; readonly afterMs: number | null } | undefined;
60
+ readonly sync: { readonly reconnected: boolean; readonly gaps: number } | undefined;
61
+ }
62
+
63
+ export interface DrillFailure {
64
+ readonly property: DrillProperty;
65
+ readonly expected: string;
66
+ readonly observed: string;
67
+ }
68
+
69
+ export interface DrillVerdict {
70
+ readonly ok: boolean;
71
+ readonly failures: readonly DrillFailure[];
72
+ }
73
+
74
+ type Judge = (evidence: DrillEvidence, budgets: DrillBudgets) => DrillFailure | undefined;
75
+
76
+ const NO_EVIDENCE = 'no evidence: the drill did not measure it';
77
+
78
+ /**
79
+ * How late past the deadline an answer may reach the load and still be one the deadline gave: the
80
+ * load times from before `fetch`, so the 504 a deadline fires arrives a beat after it (measured:
81
+ * 30,002 ms on a 30,000 ms deadline). A request nothing bounded runs to the load's own bound.
82
+ */
83
+ export const DRILL_ANSWER_SLACK_MS = 500;
84
+
85
+ const fail = (property: DrillProperty, expected: string, observed: string): DrillFailure => ({
86
+ property,
87
+ expected,
88
+ observed,
89
+ });
90
+
91
+ const codes = (requests: DrillRequestEvidence): string =>
92
+ [...requests.failedByCode].map(([code, count]) => `${code}×${String(count)}`).join(', ') ||
93
+ 'none';
94
+
95
+ const failedCount = (requests: DrillRequestEvidence): number =>
96
+ requests.uncoded + [...requests.failedByCode.values()].reduce((sum, count) => sum + count, 0);
97
+
98
+ const JUDGES: ReadonlyMap<DrillProperty, Judge> = new Map<DrillProperty, Judge>([
99
+ [
100
+ 'requests-coded',
101
+ ({ requests }) => {
102
+ const expected = 'every failed request answered with an X_* code';
103
+ if (requests === undefined) return fail('requests-coded', expected, NO_EVIDENCE);
104
+ if (requests.ok + requests.unanswered + failedCount(requests) === 0) {
105
+ return fail('requests-coded', expected, 'no request was sent');
106
+ }
107
+ return requests.uncoded === 0
108
+ ? undefined
109
+ : fail('requests-coded', expected, `${String(requests.uncoded)} uncoded 5xx or X_INTERNAL`);
110
+ },
111
+ ],
112
+ [
113
+ 'requests-bounded',
114
+ ({ requests }, budgets) => {
115
+ const expected = `every request answered within the ${String(budgets.deadlineMs)} ms deadline`;
116
+ if (requests === undefined) return fail('requests-bounded', expected, NO_EVIDENCE);
117
+ if (requests.unanswered > 0) {
118
+ return fail('requests-bounded', expected, `${String(requests.unanswered)} never answered`);
119
+ }
120
+ return requests.maxMs <= budgets.deadlineMs + DRILL_ANSWER_SLACK_MS
121
+ ? undefined
122
+ : fail('requests-bounded', expected, `slowest ${String(requests.maxMs)} ms`);
123
+ },
124
+ ],
125
+ [
126
+ 'requests-prompt',
127
+ ({ requests }, budgets) => {
128
+ const expected = `no request held past one bounded delay, ${String(budgets.promptMs)} ms`;
129
+ if (requests === undefined) return fail('requests-prompt', expected, NO_EVIDENCE);
130
+ return requests.maxMs <= budgets.promptMs && requests.unanswered === 0
131
+ ? undefined
132
+ : fail(
133
+ 'requests-prompt',
134
+ expected,
135
+ `slowest ${String(requests.maxMs)} ms, ${String(requests.unanswered)} unanswered`,
136
+ );
137
+ },
138
+ ],
139
+ [
140
+ 'requests-ok',
141
+ ({ requests }) => {
142
+ const expected = 'every request accepted was answered 2xx';
143
+ if (requests === undefined) return fail('requests-ok', expected, NO_EVIDENCE);
144
+ const failed = failedCount(requests);
145
+ if (requests.ok === 0) return fail('requests-ok', expected, 'no request was answered');
146
+ return failed === 0
147
+ ? undefined
148
+ : fail('requests-ok', expected, `${String(failed)} non-2xx: ${codes(requests)}`);
149
+ },
150
+ ],
151
+ [
152
+ 'recovers',
153
+ ({ recoveryMs }, budgets) => {
154
+ const expected = `a 2xx within ${String(budgets.recoveryMs)} ms of the fault clearing, no restart`;
155
+ if (recoveryMs === undefined) return fail('recovers', expected, NO_EVIDENCE);
156
+ if (recoveryMs === null) return fail('recovers', expected, 'no 2xx after the fault cleared');
157
+ return recoveryMs <= budgets.recoveryMs
158
+ ? undefined
159
+ : fail('recovers', expected, `first 2xx after ${String(recoveryMs)} ms`);
160
+ },
161
+ ],
162
+ ['readyz-flips', judgeReadyz],
163
+ [
164
+ 'jobs-terminal',
165
+ ({ jobs }) => {
166
+ const expected = 'every job the drill saw reached a terminal state';
167
+ if (jobs === undefined) return fail('jobs-terminal', expected, NO_EVIDENCE);
168
+ if (jobs.ran === 0) return fail('jobs-terminal', expected, 'no job ran during the drill');
169
+ return jobs.stuck === 0
170
+ ? undefined
171
+ : fail('jobs-terminal', expected, `${String(jobs.stuck)} of ${String(jobs.ran)} stuck`);
172
+ },
173
+ ],
174
+ [
175
+ 'jobs-once',
176
+ ({ jobs }) => {
177
+ const expected = 'no job run twice for one idempotency key';
178
+ if (jobs === undefined) return fail('jobs-once', expected, NO_EVIDENCE);
179
+ return jobs.doubled === 0
180
+ ? undefined
181
+ : fail('jobs-once', expected, `${String(jobs.doubled)} key(s) ran twice`);
182
+ },
183
+ ],
184
+ [
185
+ 'role-exits',
186
+ ({ exit }, budgets) => {
187
+ const expected = `the signalled role exits within its drain, ${String(budgets.drainMs)} ms`;
188
+ if (exit === undefined) return fail('role-exits', expected, NO_EVIDENCE);
189
+ if (exit.afterMs === null) return fail('role-exits', expected, `${exit.role} never exited`);
190
+ return exit.afterMs <= budgets.drainMs
191
+ ? undefined
192
+ : fail('role-exits', expected, `${exit.role} exited after ${String(exit.afterMs)} ms`);
193
+ },
194
+ ],
195
+ [
196
+ 'sync-resumes',
197
+ ({ sync }) => {
198
+ const expected = 'sync clients reconnected and resynced with no sequence gap';
199
+ if (sync === undefined) return fail('sync-resumes', expected, NO_EVIDENCE);
200
+ if (!sync.reconnected) return fail('sync-resumes', expected, 'a client never reconnected');
201
+ return sync.gaps === 0
202
+ ? undefined
203
+ : fail('sync-resumes', expected, `${String(sync.gaps)} sequence gap(s)`);
204
+ },
205
+ ],
206
+ ]);
207
+
208
+ /**
209
+ * `dependencies`: 503 within `readinessMs` of the fault, and 200 again by the last sample.
210
+ * `process`: 200 throughout — the app declared that one dependency must not empty the fleet.
211
+ */
212
+ function judgeReadyz(evidence: DrillEvidence, budgets: DrillBudgets): DrillFailure | undefined {
213
+ const samples = evidence.readyz;
214
+ const flips = budgets.readiness === 'dependencies';
215
+ const expected = flips
216
+ ? `/readyz 503 within ${String(budgets.readinessMs)} ms of the fault, then 200 again`
217
+ : '/readyz 200 throughout (health.readiness is process)';
218
+ if (samples === undefined || samples.length === 0) {
219
+ return fail('readyz-flips', expected, NO_EVIDENCE);
220
+ }
221
+ const last = samples[samples.length - 1];
222
+ const statuses = samples.map((sample) => String(sample.status ?? 'none')).join(',');
223
+ if (!flips) {
224
+ return samples.every((sample) => sample.status === 200)
225
+ ? undefined
226
+ : fail('readyz-flips', expected, `statuses ${statuses}`);
227
+ }
228
+ const down = samples.find((sample) => sample.status === 503);
229
+ if (down === undefined || down.atMs > budgets.readinessMs) {
230
+ return fail(
231
+ 'readyz-flips',
232
+ expected,
233
+ `first 503 at ${down === undefined ? 'never' : `${String(down.atMs)} ms`}`,
234
+ );
235
+ }
236
+ return last?.status === 200
237
+ ? undefined
238
+ : fail('readyz-flips', expected, `last sample ${String(last?.status ?? 'none')}`);
239
+ }
240
+
241
+ /** Every stated property, judged; `ok` only when none failed and at least one was stated. */
242
+ export function judgeDrill(
243
+ holds: readonly DrillProperty[],
244
+ evidence: DrillEvidence,
245
+ budgets: DrillBudgets,
246
+ ): DrillVerdict {
247
+ const failures: DrillFailure[] = [];
248
+ for (const property of holds) {
249
+ const failure = JUDGES.get(property)?.(evidence, budgets);
250
+ if (failure !== undefined) failures.push(failure);
251
+ }
252
+ return { ok: holds.length > 0 && failures.length === 0, failures };
253
+ }
254
+
255
+ /** The properties this file knows how to judge — what the drill table is held to. */
256
+ export const DRILL_PROPERTIES: readonly DrillProperty[] = [...JUDGES.keys()];