@ultimat3/testing 29.3.2 → 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.
- package/CLAUDE.md +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +82 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +448 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +68 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// A drill's fleet: the app's `web` and `worker` roles as separate processes (`apps/web/server.ts`
|
|
2
|
+
// under `ROLE`), on a scratch database it creates and drops, each dialling Postgres, Redis and
|
|
3
|
+
// NATS through a fault proxy of its own. The proxies are what the drill switches; the roles are
|
|
4
|
+
// what it signals. Nothing here judges — `drill-run.ts` does.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no temp-directory primitive or recursive remove; `tmpdir()` is node:os's alone.
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun exposes no tmpdir() — only node:os answers the platform temp root.
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
// why: Bun exposes no path API — the state dir is joined, not concatenated.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { probeStatus } from './drill-probe';
|
|
13
|
+
import type { DrillRole, DrillService } from './drills';
|
|
14
|
+
import { E2eAppFailedError } from './e2e-errors';
|
|
15
|
+
import type { FaultProxy } from './fault-proxy';
|
|
16
|
+
import { faultProxy, faultTarget, throughFaultProxy } from './fault-proxy';
|
|
17
|
+
import { freePortApartFrom } from './free-port';
|
|
18
|
+
import { dropSql, urlFor } from './template-db';
|
|
19
|
+
import { testSealKeyEnv } from './test-seal-key';
|
|
20
|
+
|
|
21
|
+
export interface DrillServiceUrls {
|
|
22
|
+
/** A Postgres URL with rights to CREATE DATABASE: the drill's scratch database is made there. */
|
|
23
|
+
readonly postgres: string;
|
|
24
|
+
readonly redis?: string | undefined;
|
|
25
|
+
/** NATS, and the variable the app's `realtime.urlEnv` reads it from. */
|
|
26
|
+
readonly nats?: { readonly url: string; readonly env: string } | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DrillFleetOptions {
|
|
30
|
+
readonly root: string;
|
|
31
|
+
readonly services: DrillServiceUrls;
|
|
32
|
+
readonly roles: readonly DrillRole[];
|
|
33
|
+
readonly readyTimeoutMs: number;
|
|
34
|
+
/** Extra environment for every role. */
|
|
35
|
+
readonly env?: Readonly<Record<string, string>> | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DrillRoleProcess {
|
|
39
|
+
readonly role: DrillRole;
|
|
40
|
+
/** `http://127.0.0.1:<PORT>`: the port the role was handed (`sync` binds its socket on PORT + 1). */
|
|
41
|
+
readonly origin: string;
|
|
42
|
+
/** `/readyz` on this role's own listener: the web port for `web`, the scrape port otherwise. */
|
|
43
|
+
readonly readyz: string;
|
|
44
|
+
signal(signal: 'SIGKILL' | 'SIGTERM'): void;
|
|
45
|
+
/** The exit code, once the current process ends. */
|
|
46
|
+
exited(): Promise<number>;
|
|
47
|
+
/** A fresh process for the role on the same ports — what a supervisor does after a kill. */
|
|
48
|
+
respawn(): Promise<void>;
|
|
49
|
+
log(): string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DrillFleet {
|
|
53
|
+
readonly base: string;
|
|
54
|
+
/** The scratch database, dialled directly: the evidence's line, never the faulted one. */
|
|
55
|
+
readonly databaseUrl: string;
|
|
56
|
+
readonly proxies: ReadonlyMap<DrillService, FaultProxy>;
|
|
57
|
+
readonly roles: ReadonlyMap<DrillRole, DrillRoleProcess>;
|
|
58
|
+
stop(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SERVER = 'apps/web/server.ts';
|
|
62
|
+
const TAIL_CHARS = 8_000;
|
|
63
|
+
const KILL_GRACE_MS = 5_000;
|
|
64
|
+
|
|
65
|
+
/** This process's environment minus every binding the fleet sets itself, and minus `NODE_ENV`. */
|
|
66
|
+
const inheritedEnv = (): Record<string, string | undefined> => {
|
|
67
|
+
const {
|
|
68
|
+
NODE_ENV: _node,
|
|
69
|
+
DATABASE_URL: _db,
|
|
70
|
+
REDIS_URL: _redis,
|
|
71
|
+
NATS_URL: _nats,
|
|
72
|
+
...rest
|
|
73
|
+
} = Bun.env;
|
|
74
|
+
return rest;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function tailOf(child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>): () => string {
|
|
78
|
+
let text = '';
|
|
79
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
80
|
+
void (async () => {
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
for await (const chunk of stream) {
|
|
83
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-TAIL_CHARS);
|
|
84
|
+
}
|
|
85
|
+
})().catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
return () => text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const refuse = (step: string, output: string): E2eAppFailedError =>
|
|
91
|
+
new E2eAppFailedError({
|
|
92
|
+
step,
|
|
93
|
+
output,
|
|
94
|
+
fix: 'ROLE=web bun apps/web/server.ts # in the app root, with the same DATABASE_URL: read why the role will not start',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Create the scratch database on the admin URL; answers its own URL and its drop. */
|
|
98
|
+
async function scratchDatabase(adminUrl: string) {
|
|
99
|
+
const name = `x_drill_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
|
|
100
|
+
const admin = new Bun.SQL(adminUrl);
|
|
101
|
+
try {
|
|
102
|
+
await admin.unsafe(`create database ${name}`);
|
|
103
|
+
} finally {
|
|
104
|
+
await admin.close();
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
url: urlFor(adminUrl, name),
|
|
108
|
+
async drop(): Promise<void> {
|
|
109
|
+
const again = new Bun.SQL(adminUrl);
|
|
110
|
+
try {
|
|
111
|
+
await again.unsafe(dropSql(name));
|
|
112
|
+
} finally {
|
|
113
|
+
await again.close();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function waitReady(url: string, deadlineMs: number, alive: () => boolean): Promise<boolean> {
|
|
120
|
+
const until = performance.now() + deadlineMs;
|
|
121
|
+
while (performance.now() < until && alive()) {
|
|
122
|
+
if ((await probeStatus(url, 1_000)) === 200) return true;
|
|
123
|
+
await Bun.sleep(250);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The fleet, ready: database created and migrated, every proxy listening, every role answering. */
|
|
129
|
+
export async function startDrillFleet(options: DrillFleetOptions): Promise<DrillFleet> {
|
|
130
|
+
const database = await scratchDatabase(options.services.postgres);
|
|
131
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-drill-'));
|
|
132
|
+
const proxies = new Map<DrillService, FaultProxy>();
|
|
133
|
+
const roles = new Map<DrillRole, DrillRoleProcess>();
|
|
134
|
+
const children = new Set<Bun.Subprocess>();
|
|
135
|
+
const stop = async (): Promise<void> => {
|
|
136
|
+
for (const child of children) child.kill('SIGKILL');
|
|
137
|
+
await Promise.all([...children].map((child) => child.exited));
|
|
138
|
+
for (const proxy of proxies.values()) proxy.close();
|
|
139
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
140
|
+
await database.drop();
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
proxies.set('postgres', await faultProxy({ target: faultTarget(database.url) }));
|
|
144
|
+
const redis = options.services.redis;
|
|
145
|
+
if (redis !== undefined) proxies.set('redis', await faultProxy({ target: faultTarget(redis) }));
|
|
146
|
+
const nats = options.services.nats;
|
|
147
|
+
if (nats !== undefined)
|
|
148
|
+
proxies.set('nats', await faultProxy({ target: faultTarget(nats.url) }));
|
|
149
|
+
const via = (service: DrillService, url: string): string =>
|
|
150
|
+
throughFaultProxy(url, proxies.get(service)?.port ?? 0);
|
|
151
|
+
const base: Record<string, string> = {
|
|
152
|
+
...testSealKeyEnv({ root: options.root, env: { ...Bun.env, NODE_ENV: 'test' } }),
|
|
153
|
+
// `test`, never `development`: a local environment (the throwaway seal key, no deploy-secret
|
|
154
|
+
// refusal), without the development-only seams an app installs — a demo viewer answering
|
|
155
|
+
// every request as ONE actor puts the whole load in one rate-limit bucket.
|
|
156
|
+
ULTIMATE_ENV: 'test',
|
|
157
|
+
// The load names a client per request in X-Forwarded-For, as an ingress does: without one
|
|
158
|
+
// trusted hop every request is ONE client, and the rate limiter's 429 drowns the drill.
|
|
159
|
+
TRUSTED_PROXY_HOPS: '1',
|
|
160
|
+
ULTIMATE_STATE_DIR: stateDir,
|
|
161
|
+
DATABASE_URL: via('postgres', database.url),
|
|
162
|
+
...(redis === undefined ? {} : { REDIS_URL: via('redis', redis) }),
|
|
163
|
+
...(nats === undefined ? {} : { [nats.env]: via('nats', nats.url) }),
|
|
164
|
+
...options.env,
|
|
165
|
+
};
|
|
166
|
+
const spawnRole = (env: Record<string, string>) => {
|
|
167
|
+
const child = Bun.spawn(['bun', SERVER], {
|
|
168
|
+
cwd: options.root,
|
|
169
|
+
env: { ...inheritedEnv(), ...base, ...env },
|
|
170
|
+
stdin: 'ignore',
|
|
171
|
+
stdout: 'pipe',
|
|
172
|
+
stderr: 'pipe',
|
|
173
|
+
});
|
|
174
|
+
children.add(child);
|
|
175
|
+
return child;
|
|
176
|
+
};
|
|
177
|
+
// The release phase on the DIRECT url: migrations are not what any drill faults.
|
|
178
|
+
const migrate = spawnRole({ ROLE: 'migrate', DATABASE_URL: database.url });
|
|
179
|
+
const migrateLog = tailOf(migrate);
|
|
180
|
+
// Bounded like a role's readiness: a wedged migration is a refusal, and the `catch` below
|
|
181
|
+
// SIGKILLs it — it is still in `children`.
|
|
182
|
+
const migrated = await Promise.race([
|
|
183
|
+
migrate.exited,
|
|
184
|
+
Bun.sleep(options.readyTimeoutMs).then(() => null),
|
|
185
|
+
]);
|
|
186
|
+
if (migrated !== 0) throw refuse('ROLE=migrate', migrateLog());
|
|
187
|
+
children.delete(migrate);
|
|
188
|
+
|
|
189
|
+
const taken: number[] = [...[...proxies.values()].map((proxy) => proxy.port)];
|
|
190
|
+
const take = (): number => {
|
|
191
|
+
const port = freePortApartFrom(taken);
|
|
192
|
+
taken.push(port);
|
|
193
|
+
return port;
|
|
194
|
+
};
|
|
195
|
+
let webBase = '';
|
|
196
|
+
for (const role of options.roles) {
|
|
197
|
+
const port = take();
|
|
198
|
+
// The sync node listens on PORT + 1 (`syncPortFor`): held too, so no other role is handed it.
|
|
199
|
+
if (role === 'sync') taken.push(port + 1);
|
|
200
|
+
const metricsPort = take();
|
|
201
|
+
const origin = `http://127.0.0.1:${String(port)}`;
|
|
202
|
+
if (role === 'web') webBase = origin;
|
|
203
|
+
const env = {
|
|
204
|
+
ROLE: role,
|
|
205
|
+
PORT: String(port),
|
|
206
|
+
METRICS_PORT: String(metricsPort),
|
|
207
|
+
APP_URL: origin,
|
|
208
|
+
};
|
|
209
|
+
const readyz =
|
|
210
|
+
role === 'web' ? `${origin}/readyz` : `http://127.0.0.1:${String(metricsPort)}/readyz`;
|
|
211
|
+
let child = spawnRole(env);
|
|
212
|
+
let log = tailOf(child);
|
|
213
|
+
const boot = async (): Promise<void> => {
|
|
214
|
+
const ready = await waitReady(
|
|
215
|
+
readyz,
|
|
216
|
+
options.readyTimeoutMs,
|
|
217
|
+
() => child.exitCode === null,
|
|
218
|
+
);
|
|
219
|
+
if (!ready) throw refuse(`ROLE=${role} never answered ${readyz}`, log());
|
|
220
|
+
};
|
|
221
|
+
await boot();
|
|
222
|
+
roles.set(role, {
|
|
223
|
+
role,
|
|
224
|
+
origin,
|
|
225
|
+
readyz,
|
|
226
|
+
signal: (signal) => child.kill(signal),
|
|
227
|
+
exited: () => child.exited,
|
|
228
|
+
async respawn() {
|
|
229
|
+
children.delete(child);
|
|
230
|
+
child = spawnRole(env);
|
|
231
|
+
log = tailOf(child);
|
|
232
|
+
await boot();
|
|
233
|
+
},
|
|
234
|
+
log: () => log(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { base: webBase, databaseUrl: database.url, proxies, roles, stop };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await stop();
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A role's exit, or `null` past `withinMs`: SIGKILLed then, so a wedged drain ends the drill. */
|
|
245
|
+
export async function exitWithin(role: DrillRoleProcess, withinMs: number): Promise<number | null> {
|
|
246
|
+
const exited = await Promise.race([role.exited(), Bun.sleep(withinMs).then(() => null)]);
|
|
247
|
+
if (exited !== null) return exited;
|
|
248
|
+
role.signal('SIGKILL');
|
|
249
|
+
await Promise.race([role.exited(), Bun.sleep(KILL_GRACE_MS)]);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// What the jobs tables say after a drill: how many jobs the drill window created, how many are not
|
|
2
|
+
// terminal once the fleet had its chance to reclaim them, and how many idempotency keys completed
|
|
3
|
+
// twice. Read straight off `x_jobs` on a direct connection — never through the faulted proxy.
|
|
4
|
+
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** The one query surface this file needs; `Bun.SQL` satisfies it, a test hands in rows. */
|
|
8
|
+
export interface DrillSql {
|
|
9
|
+
rows(text: string, params: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DrillJobCounts {
|
|
13
|
+
readonly ran: number;
|
|
14
|
+
readonly stuck: number;
|
|
15
|
+
readonly doubled: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Not terminal and due: claimable and unclaimed, or running past nothing reclaiming it. */
|
|
19
|
+
export const STUCK_SQL = `select count(*)::int as n from x_jobs
|
|
20
|
+
where created_at >= $1 and state in ('ready', 'delayed', 'running') and run_at <= now()`;
|
|
21
|
+
|
|
22
|
+
export const RAN_SQL = 'select count(*)::int as n from x_jobs where created_at >= $1';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A key that ran twice AT ONCE: a second done row created while the first was still live (before
|
|
26
|
+
* its `updated_at`, the instant it settled). The live-row dedupe only holds while the first is
|
|
27
|
+
* live, so the same key enqueued again after it finished is a new job by design, never a double.
|
|
28
|
+
*/
|
|
29
|
+
export const DOUBLED_SQL = `select count(distinct (a.name, coalesce(a.tenant_id, ''), a.idempotency_key))::int as n
|
|
30
|
+
from x_jobs a join x_jobs b
|
|
31
|
+
on b.name = a.name and coalesce(b.tenant_id, '') = coalesce(a.tenant_id, '')
|
|
32
|
+
and b.idempotency_key = a.idempotency_key and b.id <> a.id
|
|
33
|
+
where a.created_at >= $1 and b.created_at >= $1 and a.state = 'done' and b.state = 'done'
|
|
34
|
+
and b.created_at >= a.created_at and b.created_at < a.updated_at`;
|
|
35
|
+
|
|
36
|
+
const count = async (sql: DrillSql, text: string, since: Date): Promise<number> => {
|
|
37
|
+
const [row] = await sql.rows(text, [since]);
|
|
38
|
+
const value = row?.['n'];
|
|
39
|
+
return typeof value === 'number' ? value : Number(value ?? 0);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface DrillJobsOptions {
|
|
43
|
+
readonly sql: DrillSql;
|
|
44
|
+
readonly since: Date;
|
|
45
|
+
/** How long a killed worker's run may take to be reclaimed and finished. */
|
|
46
|
+
readonly settleMs: number;
|
|
47
|
+
readonly pollMs?: number | undefined;
|
|
48
|
+
readonly now?: (() => number) | undefined;
|
|
49
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Counted once nothing is stuck, or once `settleMs` has passed — whichever is first. */
|
|
53
|
+
export async function drillJobCounts(options: DrillJobsOptions): Promise<DrillJobCounts> {
|
|
54
|
+
const settleMs = finiteCount('drillJobCounts', 'settleMs', options.settleMs);
|
|
55
|
+
const pollMs = finiteCount('drillJobCounts', 'pollMs', options.pollMs ?? 250, 1);
|
|
56
|
+
const now = options.now ?? (() => performance.now());
|
|
57
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
58
|
+
const until = now() + settleMs;
|
|
59
|
+
let stuck = await count(options.sql, STUCK_SQL, options.since);
|
|
60
|
+
while (stuck > 0 && now() < until) {
|
|
61
|
+
await sleep(pollMs);
|
|
62
|
+
stuck = await count(options.sql, STUCK_SQL, options.since);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ran: await count(options.sql, RAN_SQL, options.since),
|
|
66
|
+
stuck,
|
|
67
|
+
doubled: await count(options.sql, DOUBLED_SQL, options.since),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `Bun.SQL` behind `DrillSql`: the drill's direct line to the scratch database. */
|
|
72
|
+
export function bunDrillSql(url: string): DrillSql & { close(): Promise<void> } {
|
|
73
|
+
const client = new Bun.SQL(url);
|
|
74
|
+
return {
|
|
75
|
+
rows: async (text, params) =>
|
|
76
|
+
(await client.unsafe(text, [...params])) as readonly Record<string, unknown>[],
|
|
77
|
+
close: () => client.close(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// A drill's load: lanes of requests against the web role, every answer recorded with its timing
|
|
2
|
+
// and its `X_*` code, then folded into the evidence the verdict reads. Each POST carries its own
|
|
3
|
+
// idempotency key, so a retried write is the framework's to dedupe and a doubled one is a finding.
|
|
4
|
+
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { DrillRequestEvidence } from './drill-verdict';
|
|
7
|
+
|
|
8
|
+
export interface DrillTarget {
|
|
9
|
+
readonly method: 'GET' | 'POST';
|
|
10
|
+
/** Path from the web role's origin, query included. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
/** A POST's JSON body. */
|
|
13
|
+
readonly body?: unknown;
|
|
14
|
+
/**
|
|
15
|
+
* A POST's body per request, given the load's request number (from 1); wins over `body`. A write
|
|
16
|
+
* that enqueues a job needs a distinct input each time, or its key dedupes every request into one.
|
|
17
|
+
*/
|
|
18
|
+
readonly draw?: ((run: number) => unknown) | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One request, as the load saw it. Times are ms on the load's own monotonic clock. */
|
|
22
|
+
export interface DrillSample {
|
|
23
|
+
readonly target: string;
|
|
24
|
+
readonly startedMs: number;
|
|
25
|
+
readonly endedMs: number;
|
|
26
|
+
/** `null`: nothing answered — refused, reset, or past the harness's own bound. */
|
|
27
|
+
readonly status: number | null;
|
|
28
|
+
/** The problem document's `code`, when the answer carried one. */
|
|
29
|
+
readonly code: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DrillLoadOptions {
|
|
33
|
+
readonly base: string;
|
|
34
|
+
readonly targets: readonly DrillTarget[];
|
|
35
|
+
readonly lanes?: number | undefined;
|
|
36
|
+
/** The harness's own bound on one request: past it the request is `unanswered`. */
|
|
37
|
+
readonly requestBoundMs: number;
|
|
38
|
+
/** Between two requests of one lane, so a lane measures rather than floods. */
|
|
39
|
+
readonly pauseMs?: number | undefined;
|
|
40
|
+
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
41
|
+
readonly now?: (() => number) | undefined;
|
|
42
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DrillLoad {
|
|
46
|
+
/** Every sample so far, in completion order. */
|
|
47
|
+
samples(): readonly DrillSample[];
|
|
48
|
+
/** Stop every lane and wait for its in-flight request to settle. */
|
|
49
|
+
stop(): Promise<readonly DrillSample[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CODE = /^X_[A-Z0-9_]+$/;
|
|
53
|
+
|
|
54
|
+
/** The `code` of a problem document, or `undefined` for any other body. */
|
|
55
|
+
export function problemCode(contentType: string | null, body: string): string | undefined {
|
|
56
|
+
if (contentType === null || !contentType.includes('json')) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(body);
|
|
59
|
+
const code =
|
|
60
|
+
typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'code') : undefined;
|
|
61
|
+
return typeof code === 'string' && CODE.test(code) ? code : undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const bodyOf = (target: DrillTarget, run: number): unknown =>
|
|
68
|
+
target.draw === undefined ? (target.body ?? {}) : target.draw(run);
|
|
69
|
+
|
|
70
|
+
/** Lanes over `targets`, round-robin, until `stop()`. */
|
|
71
|
+
export function startDrillLoad(options: DrillLoadOptions): DrillLoad {
|
|
72
|
+
const lanes = finiteCount('startDrillLoad', 'lanes', options.lanes ?? 4, 1);
|
|
73
|
+
const pauseMs = finiteCount('startDrillLoad', 'pauseMs', options.pauseMs ?? 50);
|
|
74
|
+
const boundMs = finiteCount('startDrillLoad', 'requestBoundMs', options.requestBoundMs, 1);
|
|
75
|
+
const send = options.fetch ?? globalThis.fetch;
|
|
76
|
+
const now = options.now ?? (() => performance.now());
|
|
77
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
78
|
+
const samples: DrillSample[] = [];
|
|
79
|
+
const stopping = new AbortController();
|
|
80
|
+
let sequence = 0;
|
|
81
|
+
|
|
82
|
+
const once = async (target: DrillTarget): Promise<void> => {
|
|
83
|
+
sequence += 1;
|
|
84
|
+
const run = sequence;
|
|
85
|
+
const label = `${target.method} ${target.path}`;
|
|
86
|
+
const headers: Record<string, string> = {
|
|
87
|
+
'x-forwarded-for': `10.${String((sequence >> 16) & 255)}.${String((sequence >> 8) & 255)}.${String(sequence & 255)}`,
|
|
88
|
+
};
|
|
89
|
+
if (target.method === 'POST') {
|
|
90
|
+
headers['content-type'] = 'application/json';
|
|
91
|
+
headers['idempotency-key'] = `drill-${String(sequence)}`;
|
|
92
|
+
}
|
|
93
|
+
const startedMs = now();
|
|
94
|
+
try {
|
|
95
|
+
const response = await send(`${options.base}${target.path}`, {
|
|
96
|
+
method: target.method,
|
|
97
|
+
headers,
|
|
98
|
+
...(target.method === 'POST' ? { body: JSON.stringify(bodyOf(target, run)) } : {}),
|
|
99
|
+
signal: AbortSignal.timeout(boundMs),
|
|
100
|
+
});
|
|
101
|
+
const body = await response.text();
|
|
102
|
+
samples.push({
|
|
103
|
+
target: label,
|
|
104
|
+
startedMs,
|
|
105
|
+
endedMs: now(),
|
|
106
|
+
status: response.status,
|
|
107
|
+
code: problemCode(response.headers.get('content-type'), body),
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
samples.push({ target: label, startedMs, endedMs: now(), status: null, code: undefined });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const lane = async (index: number): Promise<void> => {
|
|
115
|
+
for (let turn = index; !stopping.signal.aborted; turn += 1) {
|
|
116
|
+
const target = options.targets[turn % options.targets.length];
|
|
117
|
+
if (target === undefined) return;
|
|
118
|
+
await once(target);
|
|
119
|
+
if (!stopping.signal.aborted) await sleep(pauseMs);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const running = Array.from({ length: lanes }, (_, index) => lane(index));
|
|
124
|
+
return {
|
|
125
|
+
samples: () => samples,
|
|
126
|
+
async stop() {
|
|
127
|
+
stopping.abort();
|
|
128
|
+
await Promise.all(running);
|
|
129
|
+
return samples;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `X_INTERNAL` is the code of a throw nothing classified: a failure, but not a coded one. */
|
|
135
|
+
const UNCLASSIFIED = 'X_INTERNAL';
|
|
136
|
+
|
|
137
|
+
/** The samples as the verdict reads them. */
|
|
138
|
+
export function requestEvidence(samples: readonly DrillSample[]): DrillRequestEvidence {
|
|
139
|
+
const failedByCode = new Map<string, number>();
|
|
140
|
+
let ok = 0;
|
|
141
|
+
let uncoded = 0;
|
|
142
|
+
let unanswered = 0;
|
|
143
|
+
let maxMs = 0;
|
|
144
|
+
for (const sample of samples) {
|
|
145
|
+
if (sample.status === null) {
|
|
146
|
+
unanswered += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
maxMs = Math.max(maxMs, Math.round(sample.endedMs - sample.startedMs));
|
|
150
|
+
if (sample.status >= 200 && sample.status < 300) ok += 1;
|
|
151
|
+
else if (sample.code === undefined || sample.code === UNCLASSIFIED) uncoded += 1;
|
|
152
|
+
else failedByCode.set(sample.code, (failedByCode.get(sample.code) ?? 0) + 1);
|
|
153
|
+
}
|
|
154
|
+
return { ok, failedByCode, uncoded, unanswered, maxMs };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** ms from `clearedMs` to the end of the first 2xx that STARTED after it; `null` for none. */
|
|
158
|
+
export function recoveryAfter(samples: readonly DrillSample[], clearedMs: number): number | null {
|
|
159
|
+
let first: number | null = null;
|
|
160
|
+
for (const sample of samples) {
|
|
161
|
+
const answered = sample.status !== null && sample.status >= 200 && sample.status < 300;
|
|
162
|
+
if (!answered || sample.startedMs < clearedMs) continue;
|
|
163
|
+
if (first === null || sample.endedMs < first) first = sample.endedMs;
|
|
164
|
+
}
|
|
165
|
+
return first === null ? null : Math.round(first - clearedMs);
|
|
166
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// One status probe and a sampler over it: how a drill asks a role's `/readyz` what it says while a
|
|
2
|
+
// dependency fails. Through `node:http`, never `fetch`, so the probe answers the same inside
|
|
3
|
+
// `bun test` — where the preload seals `fetch` — as under `x drill`.
|
|
4
|
+
|
|
5
|
+
// why: `globalThis.fetch` is sealed inside `bun test`; `node:http` is not, and loopback is all it reaches.
|
|
6
|
+
import { get } from 'node:http';
|
|
7
|
+
import { finiteCount } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/** One GET: the status, or `null` when nothing answered within `boundMs`. Never a throw. */
|
|
10
|
+
export function probeStatus(url: string, boundMs: number): Promise<number | null> {
|
|
11
|
+
const bound = finiteCount('probeStatus', 'boundMs', boundMs, 1);
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const request = get(url, (response) => {
|
|
14
|
+
response.resume();
|
|
15
|
+
resolve(response.statusCode ?? null);
|
|
16
|
+
});
|
|
17
|
+
request.on('error', () => resolve(null));
|
|
18
|
+
request.setTimeout(bound, () => {
|
|
19
|
+
request.destroy();
|
|
20
|
+
resolve(null);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StatusSample {
|
|
26
|
+
/** ms since the sampler's `zero`. */
|
|
27
|
+
readonly atMs: number;
|
|
28
|
+
readonly status: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatusSampler {
|
|
32
|
+
stop(): Promise<readonly StatusSample[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StatusSamplerOptions {
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly everyMs: number;
|
|
38
|
+
readonly boundMs: number;
|
|
39
|
+
/** The instant `atMs` counts from — the fault's start. */
|
|
40
|
+
readonly zero: number;
|
|
41
|
+
readonly now?: (() => number) | undefined;
|
|
42
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
43
|
+
readonly probe?: ((url: string, boundMs: number) => Promise<number | null>) | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Probe `url` every `everyMs` until `stop()`; one probe at a time, so a hung one is one sample. */
|
|
47
|
+
export function sampleStatus(options: StatusSamplerOptions): StatusSampler {
|
|
48
|
+
const everyMs = finiteCount('sampleStatus', 'everyMs', options.everyMs, 1);
|
|
49
|
+
const now = options.now ?? (() => performance.now());
|
|
50
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
51
|
+
const probe = options.probe ?? probeStatus;
|
|
52
|
+
const samples: StatusSample[] = [];
|
|
53
|
+
let stopped = false;
|
|
54
|
+
const loop = (async () => {
|
|
55
|
+
while (!stopped) {
|
|
56
|
+
const atMs = Math.round(now() - options.zero);
|
|
57
|
+
samples.push({ atMs, status: await probe(options.url, options.boundMs) });
|
|
58
|
+
if (!stopped) await sleep(everyMs);
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return {
|
|
62
|
+
async stop() {
|
|
63
|
+
stopped = true;
|
|
64
|
+
await loop;
|
|
65
|
+
return samples;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|