@ultimat3/testing 22.15.0 → 24.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 +37 -44
- package/README.md +297 -13
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser.ts +5 -10
- package/src/cdp-connection.ts +23 -6
- package/src/cdp-e2e-session.ts +10 -1
- package/src/cdp-errors.ts +113 -8
- package/src/cdp-launch-attempt.ts +192 -0
- package/src/cdp-launch.ts +49 -122
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/determinism.ts +53 -4
- package/src/e2e-app.ts +17 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +7 -2
- package/src/e2e-spawn.ts +4 -8
- package/src/errors.ts +16 -1
- package/src/factories.ts +4 -6
- package/src/failure-context.ts +42 -0
- package/src/file-boundary.ts +12 -7
- package/src/fixture-island.ts +18 -45
- package/src/fixture-jobs.ts +117 -28
- package/src/fixtures.ts +9 -4
- package/src/free-port.ts +46 -0
- package/src/frozen-scheduler.ts +49 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +41 -1
- package/src/island-dom.ts +8 -4
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +41 -0
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +2 -0
- package/src/local-tx.ts +87 -0
- package/src/matcher-row.ts +93 -0
- package/src/matcher-surface.ts +7 -0
- package/src/matchers.ts +6 -0
- package/src/per-test-reset.ts +43 -0
- package/src/preload.ts +14 -7
- package/src/quiet-logs.ts +56 -0
- package/src/registry-leak-guard.ts +3 -1
- package/src/registry-snapshot.ts +22 -1
- package/src/render-view.ts +118 -0
- package/src/sealed-network.ts +5 -1
- package/src/template-db.ts +6 -3
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- package/src/e2e-dom-fixture.ts +0 -122
package/src/factories.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// caller composes, an association is a column whose value comes from another factory built with
|
|
7
7
|
// the SAME strategy — `build()` leaves the parent in memory, `create()` writes it.
|
|
8
8
|
|
|
9
|
+
import { fnv1a } from '@ultimat3/core';
|
|
9
10
|
import { seededRandom, seededUuid } from './determinism';
|
|
10
11
|
import { FactoryTraitUnknownError } from './errors';
|
|
11
12
|
import { persistRow } from './factory-persist';
|
|
@@ -91,15 +92,12 @@ export interface Factory<TRow, TTrait extends string = string> {
|
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Two factories with the same seed emit the same uuids, so a default of `1` everywhere gave a user
|
|
94
|
-
* and a post the same id — rows that only look related. FNV-1a over the table name keeps
|
|
95
|
-
* table on its own stream while staying a pure function of the schema, so the ids are still
|
|
95
|
+
* and a post the same id — rows that only look related. Core's FNV-1a over the table name keeps
|
|
96
|
+
* every table on its own stream while staying a pure function of the schema, so the ids are still
|
|
96
97
|
* identical run to run and machine to machine.
|
|
97
98
|
*/
|
|
98
99
|
export function seedFor(table: string): number {
|
|
99
|
-
|
|
100
|
-
for (let i = 0; i < table.length; i += 1) {
|
|
101
|
-
hash = Math.imul(hash ^ table.charCodeAt(i), 0x01000193) >>> 0;
|
|
102
|
-
}
|
|
100
|
+
const hash = fnv1a(table);
|
|
103
101
|
return hash === 0 ? 1 : hash;
|
|
104
102
|
}
|
|
105
103
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// What a FAILED test carries beside its own message: under e2e, the spawned app's last log lines.
|
|
2
|
+
// The app's log was shown only when it failed to boot, so a live-path bug — a fanout that dropped a
|
|
3
|
+
// change, a 500 behind a page — failed with a browser assertion and nothing from the server.
|
|
4
|
+
// Held on `globalThis` under one `Symbol.for` key: the e2e preload and a test file may each hold
|
|
5
|
+
// their own copy of this module, as `e2e-browser-handle.ts` already explains.
|
|
6
|
+
|
|
7
|
+
const KEY = Symbol.for('ultimate.test.failure-context');
|
|
8
|
+
|
|
9
|
+
/** Bounded twice: a log line can be a megabyte of JSON, and an app can talk for minutes. */
|
|
10
|
+
const TAIL_LINES = 40;
|
|
11
|
+
const TAIL_CHARS = 4_000;
|
|
12
|
+
|
|
13
|
+
/** Install (or with `undefined`, remove) the source of a failure's context. */
|
|
14
|
+
export function setFailureContext(source: (() => string) | undefined): void {
|
|
15
|
+
if (source === undefined) Reflect.deleteProperty(globalThis, KEY);
|
|
16
|
+
else Object.defineProperty(globalThis, KEY, { value: source, configurable: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The last `TAIL_LINES` lines of `log`, and never more than `TAIL_CHARS` of them. */
|
|
20
|
+
export function appLogTail(log: string): string {
|
|
21
|
+
const lines = log.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
|
|
22
|
+
return lines.length <= TAIL_CHARS ? lines : lines.slice(-TAIL_CHARS);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `error` with the app's log tail appended to its message, when a source is installed and has
|
|
27
|
+
* anything to say. Only an `Error`'s message is extended — that is what the runner prints under
|
|
28
|
+
* the failing test — and a thrown non-Error is handed back exactly as it was.
|
|
29
|
+
*/
|
|
30
|
+
export function withFailureContext(error: unknown): unknown {
|
|
31
|
+
const source = Reflect.get(globalThis, KEY) as (() => string) | undefined;
|
|
32
|
+
if (source === undefined || !(error instanceof Error)) return error;
|
|
33
|
+
const tail = appLogTail(source());
|
|
34
|
+
if (tail === '') return error;
|
|
35
|
+
const count = tail.split('\n').length;
|
|
36
|
+
Object.defineProperty(error, 'message', {
|
|
37
|
+
value: `${error.message}\n\n── the e2e app logged, last ${String(count)} line(s): ──\n${tail}`,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
});
|
|
41
|
+
return error;
|
|
42
|
+
}
|
package/src/file-boundary.ts
CHANGED
|
@@ -119,13 +119,8 @@ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): r
|
|
|
119
119
|
return touched;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
export function
|
|
124
|
-
if (baseline === undefined) {
|
|
125
|
-
baseline = captureGlobals();
|
|
126
|
-
envBaseline = { ...process.env };
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
122
|
+
/** Every `onFileBoundary` hook, in order — also run once after the run's last file. */
|
|
123
|
+
export function runBoundaryHooks(): void {
|
|
129
124
|
for (const hook of hooks) {
|
|
130
125
|
try {
|
|
131
126
|
hook();
|
|
@@ -133,6 +128,16 @@ export function runFileBoundary(): void {
|
|
|
133
128
|
// A hook that throws (an island's own disposer) must not stop the rest of the reset.
|
|
134
129
|
}
|
|
135
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Called by the leak guard as each test file loads. The first call only takes the baseline. */
|
|
134
|
+
export function runFileBoundary(): void {
|
|
135
|
+
if (baseline === undefined) {
|
|
136
|
+
baseline = captureGlobals();
|
|
137
|
+
envBaseline = { ...process.env };
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
runBoundaryHooks();
|
|
136
141
|
restoreGlobals(baseline);
|
|
137
142
|
if (envBaseline !== undefined) restoreEnv(envBaseline);
|
|
138
143
|
}
|
package/src/fixture-island.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing`
|
|
4
4
|
// edge. A structural seam keeps the direction honest and survives the bundler changing underneath.
|
|
5
5
|
|
|
6
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
6
|
import { basename, join } from 'node:path';
|
|
9
7
|
import { islandMountMissing, islandNotBuilt } from './errors';
|
|
8
|
+
import { onFileBoundary } from './file-boundary';
|
|
10
9
|
import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
|
|
11
10
|
import type { ResizeInput } from './island-observers';
|
|
12
11
|
import { deliverResize } from './island-observers';
|
|
12
|
+
import { createScratchDir, removeScratchDir } from './island-scratch';
|
|
13
13
|
|
|
14
14
|
/** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
|
|
15
15
|
* artifact, a source map, a dev/production flag — is invisible here on purpose. */
|
|
@@ -139,49 +139,13 @@ function entryOf(module: unknown, file: string): IslandEntry {
|
|
|
139
139
|
return { mount: mount as IslandEntry['mount'] };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
let moduleDir: string | undefined;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* One directory per process, outside the app under test — so no test leaves a `.mjs` behind. Two
|
|
146
|
-
* properties the module-scope `mkdtempSync` it replaces had neither of:
|
|
147
|
-
*
|
|
148
|
-
* LAZY. This module is on the `.` barrel, so importing `@ultimat3/testing` for `expect` alone
|
|
149
|
-
* created a directory — in every test process in the repo, whether or not it ever mounts anything.
|
|
150
|
-
*
|
|
151
|
-
* REMOVED. `exit` and `rmSync`, because an exit handler runs synchronously and nothing else covers
|
|
152
|
-
* every path: `mountIsland` restores and rethrows on a failed mount, so that run never reaches the
|
|
153
|
-
* `Disposable`, and the directory is per PROCESS while the disposable is per mount.
|
|
154
|
-
*/
|
|
155
|
-
function moduleDirPath(): string {
|
|
156
|
-
const existing = moduleDir;
|
|
157
|
-
if (existing !== undefined) return existing;
|
|
158
|
-
const created = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
|
|
159
|
-
process.on('exit', () => {
|
|
160
|
-
rmSync(created, { recursive: true, force: true });
|
|
161
|
-
});
|
|
162
|
-
moduleDir = created;
|
|
163
|
-
return created;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The scratch root, or `undefined` when nothing has been mounted. Read by the leak test, which
|
|
168
|
-
* asserts both halves of the above from a CHILD process — the only place where "this process
|
|
169
|
-
* created no directory" and "the directory is gone afterwards" are both observable.
|
|
170
|
-
*/
|
|
171
|
-
export const islandModuleDir = (): string | undefined => moduleDir;
|
|
172
|
-
|
|
173
142
|
/**
|
|
174
|
-
* A temp
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* island chunk is 12-55 kB. Measured on Bun 1.4.0, threshold at a URL length of 4032; without
|
|
178
|
-
* `--coverage` the same import is fine, which is why only the per-package CI job ever saw it.
|
|
179
|
-
*
|
|
180
|
-
* The property the URL form was chosen for survives: the name is derived from the bytes, so an
|
|
181
|
-
* edited island is a different module rather than a cache hit on the same path.
|
|
143
|
+
* A temp FILE named by the chunk's own hash, NOT a `data:` URL: `bun test --coverage` panics on
|
|
144
|
+
* `import()` of a `data:` module past ~4 kB (Bun 1.4.0) and an island chunk is 12-55 kB. The file
|
|
145
|
+
* sits in the mount's OWN directory (`island-scratch.ts`), so a mount is a fresh module instance.
|
|
182
146
|
*/
|
|
183
|
-
const modulePathFor = (code: string): string =>
|
|
184
|
-
join(
|
|
147
|
+
const modulePathFor = (dir: string, code: string): string =>
|
|
148
|
+
join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
|
|
185
149
|
|
|
186
150
|
/**
|
|
187
151
|
* DESCRIPTORS, not values, and all-or-nothing.
|
|
@@ -249,6 +213,10 @@ export function disposeLiveIslands(): number {
|
|
|
249
213
|
return pending.length;
|
|
250
214
|
}
|
|
251
215
|
|
|
216
|
+
// Registered here, not by each preload: the repo's own preload and an app's both install the leak
|
|
217
|
+
// guard that runs the boundary, and a mount a file forgot is one only this module can see.
|
|
218
|
+
onFileBoundary(disposeLiveIslands);
|
|
219
|
+
|
|
252
220
|
export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
|
|
253
221
|
// `only`, because this fixture has always KNOWN which island it wants and asked for all of them
|
|
254
222
|
// anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a
|
|
@@ -275,14 +243,17 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
275
243
|
|
|
276
244
|
const { documentElement, globals, resizeObservers } = createIslandDocument();
|
|
277
245
|
const restore = installGlobals({ ...globals, ...options.globals });
|
|
246
|
+
let dir: string | undefined;
|
|
278
247
|
try {
|
|
248
|
+
dir = createScratchDir();
|
|
249
|
+
const scratch = dir;
|
|
279
250
|
// Beside the entry, under the name its bytes import them by: the same relative resolution a
|
|
280
251
|
// browser does under `/islands/`. Named by their content-addressed URL, so a rewrite is the same
|
|
281
252
|
// bytes at the same name.
|
|
282
253
|
for (const shared of bundle.shared ?? []) {
|
|
283
|
-
await Bun.write(join(
|
|
254
|
+
await Bun.write(join(scratch, basename(shared.url)), shared.code);
|
|
284
255
|
}
|
|
285
|
-
const path = modulePathFor(chunk.code);
|
|
256
|
+
const path = modulePathFor(scratch, chunk.code);
|
|
286
257
|
await Bun.write(path, chunk.code);
|
|
287
258
|
const entry = entryOf(await import(path), chunk.file);
|
|
288
259
|
const el = new FakeElement('div');
|
|
@@ -310,6 +281,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
310
281
|
// Whatever the disposer did — including throw, which is the test's to see — the process
|
|
311
282
|
// gets its globals back, or the fake `document` reaches every later file in the run.
|
|
312
283
|
restore();
|
|
284
|
+
removeScratchDir(scratch);
|
|
313
285
|
}
|
|
314
286
|
};
|
|
315
287
|
liveMounts.add(dispose);
|
|
@@ -349,6 +321,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
349
321
|
// A mount that throws restores the process before it rethrows: the alternative leaves every
|
|
350
322
|
// later file in the run holding a fake `document`, which fails somewhere with no thread back.
|
|
351
323
|
restore();
|
|
324
|
+
if (dir !== undefined) removeScratchDir(dir);
|
|
352
325
|
throw error;
|
|
353
326
|
}
|
|
354
327
|
}
|
package/src/fixture-jobs.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// holding a connection — so the trace this returns is keyed by step name and counts executions.
|
|
6
6
|
// Nothing here polls or sleeps: a job becomes due only because `clock.advance()` said so.
|
|
7
7
|
|
|
8
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
9
|
import { assert, createContext } from '@ultimat3/core';
|
|
9
10
|
import type {
|
|
10
11
|
AnyJobHandle,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
StepStatus,
|
|
17
18
|
} from '@ultimat3/jobs';
|
|
18
19
|
import { frozenNow } from './determinism';
|
|
20
|
+
import { frozenScheduler } from './frozen-scheduler';
|
|
19
21
|
|
|
20
22
|
export interface StepTally {
|
|
21
23
|
/** Times the step body actually ran. A replay from storage does not count. */
|
|
@@ -30,18 +32,43 @@ export interface StepTally {
|
|
|
30
32
|
* about the whole run — a per-drain trace could not express it.
|
|
31
33
|
*/
|
|
32
34
|
export interface JobRunTrace {
|
|
35
|
+
/**
|
|
36
|
+
* Each run as the worker settled it. `executions[n].result` is what the BODY returned, on a
|
|
37
|
+
* completed run — `{ skipped: true }` and "did the work" are otherwise one trace.
|
|
38
|
+
*/
|
|
33
39
|
readonly executions: readonly JobExecution[];
|
|
34
40
|
/** `trace.steps['welcome-email'].executions` — the assertion a job test is written around. */
|
|
35
41
|
readonly steps: Readonly<Record<string, StepTally>>;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** How a test files the row: the half of `handle.as(actor, input)` that reaches the queue. */
|
|
45
|
+
export interface JobEnqueueAs {
|
|
46
|
+
/**
|
|
47
|
+
* The ENQUEUER's tenant on the row — the limiter's bucket and the idempotency namespace, so one
|
|
48
|
+
* key in two tenants is two jobs. Never the tenant the body runs under: that is the job's own
|
|
49
|
+
* declaration (`tenant: (input) => input.orgId`), read off the input.
|
|
50
|
+
*/
|
|
51
|
+
readonly tenantId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Who the worker is: what an app wires through `WorkerOptions.context()`. */
|
|
55
|
+
export interface JobDrainAs {
|
|
56
|
+
/**
|
|
57
|
+
* The identity the body runs as. Its ORG is replaced by the job's declared tenant, exactly as a
|
|
58
|
+
* real worker's is (`jobRunActor`) — `tenant: 'none'` strips it. Absent is core's anonymous actor.
|
|
59
|
+
*/
|
|
60
|
+
readonly actor?: Actor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunJobsOptions extends JobEnqueueAs, JobDrainAs {}
|
|
64
|
+
|
|
38
65
|
/** `AsyncDisposable`: the fixture installs the ambient job driver and restores it after the test. */
|
|
39
66
|
export interface RunJobs extends AsyncDisposable {
|
|
40
67
|
/** Enqueue and drain in one call — the common case. */
|
|
41
|
-
<I>(handle: JobHandle<I>, input: I): Promise<JobRunTrace>;
|
|
42
|
-
enqueue<I>(handle: JobHandle<I>, input: I): Promise<EnqueueResult>;
|
|
68
|
+
<I>(handle: JobHandle<I>, input: I, options?: RunJobsOptions): Promise<JobRunTrace>;
|
|
69
|
+
enqueue<I>(handle: JobHandle<I>, input: I, options?: JobEnqueueAs): Promise<EnqueueResult>;
|
|
43
70
|
/** Claim and execute everything due at the current instant, until nothing is. */
|
|
44
|
-
drain(): Promise<JobRunTrace>;
|
|
71
|
+
drain(options?: JobDrainAs): Promise<JobRunTrace>;
|
|
45
72
|
/** Live jobs — ready, delayed, running or suspended — optionally for one job only. */
|
|
46
73
|
depth(handle?: AnyJobHandle): Promise<number>;
|
|
47
74
|
/** Live jobs claimable right now. `clock.advance()` is what turns delayed into due. */
|
|
@@ -52,6 +79,14 @@ export interface RunJobs extends AsyncDisposable {
|
|
|
52
79
|
const WORKER_ID = 'test-worker';
|
|
53
80
|
const VISIBILITY_TIMEOUT_MS = 30_000;
|
|
54
81
|
const CLAIM_LIMIT = 64;
|
|
82
|
+
/**
|
|
83
|
+
* Lease and slot renewals, every millisecond of TEST time — on the frozen clock, through
|
|
84
|
+
* `frozenScheduler`, so any `clock.advance()` renews every running job once and the wall clock
|
|
85
|
+
* renews nothing. A renewal is how a running body learns its row was cancelled (`x jobs cancel`,
|
|
86
|
+
* an app's own cancel action): cancel, advance, and the drain settles. The LAPSE side of the lease
|
|
87
|
+
* reads the same clock, so a renewal can never report a lease lost that `clock.advance()` did not.
|
|
88
|
+
*/
|
|
89
|
+
const RENEW_MS = 1;
|
|
55
90
|
/** A drain that has not settled in this many rounds is a runaway, not a slow queue. */
|
|
56
91
|
const MAX_ROUNDS = 100;
|
|
57
92
|
const LIVE_STATES: ReadonlySet<string> = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
@@ -80,7 +115,13 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
80
115
|
// next file to call `send()` enqueues into this test's dead queue instead of sending inline.
|
|
81
116
|
const previous = jobs.jobDriver();
|
|
82
117
|
jobs.setJobDriver(driver);
|
|
83
|
-
|
|
118
|
+
// The same for the event bus `step.waitForEvent` reads: it is ambient and it STORES, so an
|
|
119
|
+
// answer one test published resumed the next test's waiting run. A fresh bus per fixture, and
|
|
120
|
+
// the process's own handed back with the driver.
|
|
121
|
+
const previousBus = jobs.eventBus();
|
|
122
|
+
jobs.setEventBus(jobs.createMemoryEventBus());
|
|
123
|
+
const anonymousWorker = createContext({ role: 'worker' });
|
|
124
|
+
const renewals = frozenScheduler();
|
|
84
125
|
|
|
85
126
|
const introspect = (): NonNullable<JobDriver['introspect']> => {
|
|
86
127
|
const found = driver.introspect;
|
|
@@ -92,8 +133,22 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
92
133
|
return found;
|
|
93
134
|
};
|
|
94
135
|
|
|
136
|
+
// Walked by cursor: one `list()` answers a PAGE (`MAX_JOB_PAGE`), and a test that queued more
|
|
137
|
+
// than a page of jobs must still see every one of them.
|
|
95
138
|
const live = async (name?: string): Promise<readonly JobRecord[]> => {
|
|
96
|
-
const rows
|
|
139
|
+
const rows: JobRecord[] = [];
|
|
140
|
+
let after: string | undefined;
|
|
141
|
+
for (;;) {
|
|
142
|
+
const page = await introspect().list({
|
|
143
|
+
limit: jobs.MAX_JOB_PAGE,
|
|
144
|
+
...(name === undefined ? {} : { name }),
|
|
145
|
+
...(after === undefined ? {} : { after }),
|
|
146
|
+
});
|
|
147
|
+
rows.push(...page);
|
|
148
|
+
const last = page[page.length - 1];
|
|
149
|
+
if (last === undefined || page.length < jobs.MAX_JOB_PAGE) break;
|
|
150
|
+
after = jobs.jobCursor(last);
|
|
151
|
+
}
|
|
97
152
|
return rows.filter((record) => LIVE_STATES.has(record.state));
|
|
98
153
|
};
|
|
99
154
|
|
|
@@ -101,32 +156,51 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
101
156
|
...new Set([jobs.DEFAULT_QUEUE, ...jobs.registeredJobs().map((handle) => handle.queue)]),
|
|
102
157
|
];
|
|
103
158
|
|
|
104
|
-
|
|
105
|
-
|
|
159
|
+
/**
|
|
160
|
+
* One pass, through a REAL worker's `tick()`: claim, admission — the in-process limiter and the
|
|
161
|
+
* fleet slot `job.concurrency` holds, keyed or not — then the run. Calling `executeJob` on what
|
|
162
|
+
* `claim()` answered skipped admission entirely, so `whenBusy: 'fail'` never refused and
|
|
163
|
+
* `concurrency: 1` never waited under this fixture. Never `start()`ed: no timer, no registry
|
|
164
|
+
* row, no shutdown hook — the frozen clock and the test decide when a pass happens.
|
|
165
|
+
*/
|
|
166
|
+
const round = async (ctx: Ctx): Promise<readonly JobExecution[]> => {
|
|
167
|
+
const worker = jobs.createWorker({
|
|
168
|
+
driver,
|
|
169
|
+
workerId: WORKER_ID,
|
|
106
170
|
queues: queues(),
|
|
107
|
-
limit:
|
|
171
|
+
// A whole batch per pass, as `claim({ limit })` was: two runs of one key are in flight
|
|
172
|
+
// together only if one pass claims both.
|
|
173
|
+
concurrency: CLAIM_LIMIT,
|
|
108
174
|
visibilityTimeoutMs: VISIBILITY_TIMEOUT_MS,
|
|
109
|
-
|
|
175
|
+
heartbeatIntervalMs: RENEW_MS,
|
|
176
|
+
schedule: renewals.schedule,
|
|
177
|
+
// A shed run is claimable on the next pass, not after a wait this clock would never reach.
|
|
178
|
+
pollIntervalMs: 0,
|
|
179
|
+
drainOnShutdown: false,
|
|
180
|
+
context: () => ctx,
|
|
110
181
|
});
|
|
111
|
-
const executions
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
}
|
|
182
|
+
const executions = await worker.tick();
|
|
183
|
+
// The worker PARKS a job no module registered — right for a fleet mid-deploy, where the pod
|
|
184
|
+
// beside it may know the name, and silence in a test that forgot an import.
|
|
185
|
+
const orphan = executions.find((run) => run.error === `no job registered as "${run.job}"`);
|
|
186
|
+
assert(
|
|
187
|
+
orphan === undefined,
|
|
188
|
+
`queue holds job "${orphan?.job ?? ''}" but nothing registered it`,
|
|
189
|
+
`import the module that declares job("${orphan?.job ?? ''}") from the test file — the registry is populated by the import, not by the queue`,
|
|
190
|
+
);
|
|
121
191
|
return executions;
|
|
122
192
|
};
|
|
123
193
|
|
|
124
194
|
/** Every execution this fixture has driven, because the trace is cumulative. */
|
|
125
195
|
const history: JobExecution[] = [];
|
|
126
196
|
|
|
127
|
-
const drain = async (): Promise<JobRunTrace> => {
|
|
197
|
+
const drain = async (options: JobDrainAs = {}): Promise<JobRunTrace> => {
|
|
198
|
+
const ctx =
|
|
199
|
+
options.actor === undefined
|
|
200
|
+
? anonymousWorker
|
|
201
|
+
: createContext({ role: 'worker', actor: options.actor });
|
|
128
202
|
for (let rounds = 0; rounds < MAX_ROUNDS; rounds += 1) {
|
|
129
|
-
const batch = await round();
|
|
203
|
+
const batch = await round(ctx);
|
|
130
204
|
if (batch.length === 0) return { executions: [...history], steps: tallyOf(history) };
|
|
131
205
|
history.push(...batch);
|
|
132
206
|
}
|
|
@@ -137,18 +211,27 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
137
211
|
);
|
|
138
212
|
};
|
|
139
213
|
|
|
140
|
-
const enqueue = async <I>(
|
|
214
|
+
const enqueue = async <I>(
|
|
215
|
+
handle: JobHandle<I>,
|
|
216
|
+
input: I,
|
|
217
|
+
options: JobEnqueueAs = {},
|
|
218
|
+
): Promise<EnqueueResult> =>
|
|
141
219
|
driver.enqueue({
|
|
142
220
|
name: handle.name,
|
|
143
221
|
queue: handle.queue,
|
|
144
222
|
input,
|
|
145
223
|
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
146
224
|
maxAttempts: handle.retry.attempts,
|
|
225
|
+
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
147
226
|
});
|
|
148
227
|
|
|
149
|
-
const enqueueThenDrain = async <I>(
|
|
150
|
-
|
|
151
|
-
|
|
228
|
+
const enqueueThenDrain = async <I>(
|
|
229
|
+
handle: JobHandle<I>,
|
|
230
|
+
input: I,
|
|
231
|
+
options: RunJobsOptions = {},
|
|
232
|
+
): Promise<JobRunTrace> => {
|
|
233
|
+
await enqueue(handle, input, options);
|
|
234
|
+
return drain(options);
|
|
152
235
|
};
|
|
153
236
|
|
|
154
237
|
return Object.assign(enqueueThenDrain, {
|
|
@@ -161,9 +244,15 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
161
244
|
).length,
|
|
162
245
|
inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
|
|
163
246
|
[Symbol.asyncDispose]: async (): Promise<void> => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
247
|
+
// A `finally`: a close that throws must still hand every later file its bus and its driver.
|
|
248
|
+
try {
|
|
249
|
+
await driver.close?.();
|
|
250
|
+
} finally {
|
|
251
|
+
renewals[Symbol.dispose]();
|
|
252
|
+
jobs.setEventBus(previousBus);
|
|
253
|
+
if (previous === undefined) jobs.resetJobDriver();
|
|
254
|
+
else jobs.setJobDriver(previous);
|
|
255
|
+
}
|
|
167
256
|
},
|
|
168
257
|
});
|
|
169
258
|
}
|
package/src/fixtures.ts
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import { test as bunTest } from 'bun:test';
|
|
12
12
|
import { fixtureUnknown } from './errors';
|
|
13
|
+
import { withFailureContext } from './failure-context';
|
|
13
14
|
import type { TestClock } from './fixture-clock';
|
|
14
15
|
import type { SignIn, Subscribe, TestBudget, TestDeploy } from './fixture-drivers';
|
|
15
16
|
import type { RunJobs } from './fixture-jobs';
|
|
16
17
|
import type { TestMail } from './fixture-mail';
|
|
17
18
|
import type { TestNetwork } from './fixture-network';
|
|
18
19
|
import type { TestStatements } from './fixture-statements';
|
|
19
|
-
import type { PageLike } from './test-types';
|
|
20
|
+
import type { PageLike, TestOptions } from './test-types';
|
|
20
21
|
|
|
21
22
|
/** Built once per test, on first use. */
|
|
22
23
|
export type FixtureFactory<T = unknown> = () => T | Promise<T>;
|
|
@@ -222,7 +223,8 @@ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
|
|
|
222
223
|
}
|
|
223
224
|
await body(bag as FixtureBag);
|
|
224
225
|
} catch (error) {
|
|
225
|
-
failure
|
|
226
|
+
// The body's own failure, with the e2e app's log tail when one is running (`failure-context.ts`).
|
|
227
|
+
failure = { error: withFailureContext(error) };
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
// Every disposer runs even when an earlier one throws: a fixture that cannot clean up must
|
|
@@ -244,7 +246,10 @@ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
|
|
|
244
246
|
*
|
|
245
247
|
* Teardown runs in reverse build order whether the body passed or threw: a failing assertion
|
|
246
248
|
* must not be the reason the next file inherits a queue.
|
|
249
|
+
*
|
|
250
|
+
* `options.timeoutMs` is the test's own deadline, the same key the six typed helpers take: a
|
|
251
|
+
* fixture that builds something slow (an island, a browser) is the test that needs one.
|
|
247
252
|
*/
|
|
248
|
-
export function fixtureTest(name: string, body: FixtureBody): void {
|
|
249
|
-
bunTest(name, () => runWithFixtures(body));
|
|
253
|
+
export function fixtureTest(name: string, body: FixtureBody, options?: TestOptions): void {
|
|
254
|
+
bunTest(name, () => runWithFixtures(body), options?.timeoutMs);
|
|
250
255
|
}
|
package/src/free-port.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One responsibility: a port the e2e app can listen on AND a browser will open. The OS hands out
|
|
2
|
+
// whatever is free in its ephemeral range, and a box whose range starts at 1024 hands out ports
|
|
3
|
+
// Chrome refuses outright — every navigation of the run then fails `net::ERR_UNSAFE_PORT`.
|
|
4
|
+
|
|
5
|
+
/** What `freePort` needs of a listener: the port the OS gave it, and a way to give it back. */
|
|
6
|
+
export interface PortProbe {
|
|
7
|
+
readonly port: number;
|
|
8
|
+
stop(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Chromium's restricted ports (`net/base/port_util.cc`, `kRestrictedPorts`) from 1024 up — the
|
|
13
|
+
* ones an unprivileged listener can be handed. Below 1024 the OS never answers a `port: 0` ask.
|
|
14
|
+
*/
|
|
15
|
+
export const BROWSER_REFUSED_PORTS: ReadonlySet<number> = new Set([
|
|
16
|
+
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
|
|
17
|
+
6679, 6697, 10_080,
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** Listen on a port of the OS's choosing. */
|
|
21
|
+
const listenOnAnyPort = (): PortProbe => {
|
|
22
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
23
|
+
return { port: probe.port ?? 0, stop: () => void probe.stop(true) };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A port nothing holds right now, asked of the OS and handed to the child — and one a browser will
|
|
28
|
+
* open. A refused port is KEPT listening while the next is asked for, so the OS cannot answer with
|
|
29
|
+
* it again: the walk ends after at most one ask per refused port, with no counter and no retry.
|
|
30
|
+
*/
|
|
31
|
+
export const freePort = (listen: () => PortProbe = listenOnAnyPort): number => {
|
|
32
|
+
const refused: PortProbe[] = [];
|
|
33
|
+
try {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const probe = listen();
|
|
36
|
+
if (BROWSER_REFUSED_PORTS.has(probe.port)) {
|
|
37
|
+
refused.push(probe);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
probe.stop();
|
|
41
|
+
return probe.port;
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
for (const probe of refused) probe.stop();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// An interval scheduler on the FROZEN clock: a tick fires when a test moves time past it, never on
|
|
2
|
+
// the wall clock. What the `runJobs` fixture renews its leases and slots on, so a running body
|
|
3
|
+
// hears its cancel at the next `clock.advance()` and no job test pays for a real-time loop.
|
|
4
|
+
|
|
5
|
+
import type { IntervalScheduler } from '@ultimat3/jobs';
|
|
6
|
+
import { frozenNow, onClockMoved } from './determinism';
|
|
7
|
+
|
|
8
|
+
interface Armed {
|
|
9
|
+
readonly tick: () => void;
|
|
10
|
+
readonly everyMs: number;
|
|
11
|
+
dueAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FrozenScheduler extends Disposable {
|
|
15
|
+
readonly schedule: IntervalScheduler;
|
|
16
|
+
/** Ticks armed right now. A number that does not fall back to `0` is a renewal nobody stopped. */
|
|
17
|
+
armed(): number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One tick per armed renewal per move, however far the clock jumped: a renewal is idempotent, and
|
|
22
|
+
* firing a three-day advance as 259,200 one-second ticks would be the wall-clock loop again.
|
|
23
|
+
*/
|
|
24
|
+
export function frozenScheduler(): FrozenScheduler {
|
|
25
|
+
const timers = new Set<Armed>();
|
|
26
|
+
const stopListening = onClockMoved(() => {
|
|
27
|
+
const now = frozenNow().getTime();
|
|
28
|
+
for (const timer of [...timers]) {
|
|
29
|
+
if (!timers.has(timer) || timer.dueAt > now) continue;
|
|
30
|
+
timer.dueAt = now + timer.everyMs;
|
|
31
|
+
timer.tick();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const schedule: IntervalScheduler = (tick, everyMs) => {
|
|
35
|
+
const timer: Armed = { tick, everyMs, dueAt: frozenNow().getTime() + everyMs };
|
|
36
|
+
timers.add(timer);
|
|
37
|
+
return () => {
|
|
38
|
+
timers.delete(timer);
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
schedule,
|
|
43
|
+
armed: () => timers.size,
|
|
44
|
+
[Symbol.dispose]: () => {
|
|
45
|
+
stopListening();
|
|
46
|
+
timers.clear();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/harness.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, test } from 'bun:test';
|
|
6
6
|
import { captureDeterminism, installDeterminism, restoreCapturedDeterminism } from './determinism';
|
|
7
|
+
import { AppNotBootedError } from './errors';
|
|
7
8
|
import {
|
|
8
9
|
allowHost,
|
|
9
10
|
captureNetwork,
|
|
@@ -171,7 +172,7 @@ export function describeApp(
|
|
|
171
172
|
booted = undefined;
|
|
172
173
|
});
|
|
173
174
|
body(() => {
|
|
174
|
-
if (booted === undefined) throw new
|
|
175
|
+
if (booted === undefined) throw new AppNotBootedError({ suite: name });
|
|
175
176
|
return booted.handle;
|
|
176
177
|
});
|
|
177
178
|
});
|