@ultimat3/testing 22.14.0 → 23.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 +29 -36
- package/README.md +247 -8
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/determinism.ts +20 -0
- package/src/e2e-app.ts +14 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +5 -0
- package/src/e2e-spawn.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/failure-context.ts +42 -0
- package/src/fixture-jobs.ts +110 -25
- package/src/fixtures.ts +9 -4
- package/src/frozen-scheduler.ts +49 -0
- package/src/index.ts +29 -1
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -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 +13 -0
- package/src/quiet-logs.ts +56 -0
- package/src/render-view.ts +118 -0
- 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/determinism.ts
CHANGED
|
@@ -154,6 +154,24 @@ export function restoreCapturedDeterminism(snapshot: DeterminismSnapshot): void
|
|
|
154
154
|
|
|
155
155
|
export const isDeterminismInstalled = (): boolean => installed;
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Told each time a test MOVES the clock — `advanceClock` and `setFrozenClock`. A frozen clock that
|
|
159
|
+
* nothing can hear move is one a timer can only ignore: `frozen-scheduler.ts` is the listener, and
|
|
160
|
+
* it is how the `runJobs` fixture renews a lease when time passes instead of on the wall clock.
|
|
161
|
+
*/
|
|
162
|
+
const clockMoved = new Set<() => void>();
|
|
163
|
+
|
|
164
|
+
export function onClockMoved(listener: () => void): () => void {
|
|
165
|
+
clockMoved.add(listener);
|
|
166
|
+
return () => {
|
|
167
|
+
clockMoved.delete(listener);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const announceMove = (): void => {
|
|
172
|
+
for (const listener of [...clockMoved]) listener();
|
|
173
|
+
};
|
|
174
|
+
|
|
157
175
|
/** Move the frozen clock forward. The only legal way for time to pass inside a test. */
|
|
158
176
|
export function advanceClock(ms: number): Date {
|
|
159
177
|
// A required parameter with no default, so no `??` and no ratchet can see it — and it writes the
|
|
@@ -161,6 +179,7 @@ export function advanceClock(ms: number): Date {
|
|
|
161
179
|
// process, which no later `advance` and no later `set` undoes. Negative is legal: a test may
|
|
162
180
|
// move the clock backwards.
|
|
163
181
|
frozenAt += finiteOption('advanceClock', 'ms', ms);
|
|
182
|
+
announceMove();
|
|
164
183
|
return new RealDate(frozenAt);
|
|
165
184
|
}
|
|
166
185
|
|
|
@@ -168,6 +187,7 @@ export const frozenNow = (): Date => new RealDate(frozenAt);
|
|
|
168
187
|
|
|
169
188
|
export function setFrozenClock(now: string | number): void {
|
|
170
189
|
frozenAt = instantMs('setFrozenClock', now);
|
|
190
|
+
announceMove();
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
/** Run `body` with the clock frozen at `now`, then restore whatever was there before. */
|
package/src/e2e-app.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { finiteCount } from '@ultimat3/core';
|
|
13
13
|
import type { E2eAppMode } from './e2e-spawn';
|
|
14
14
|
import { inherited, refuse, spawnE2eApp, xBin } from './e2e-spawn';
|
|
15
|
+
import { testSealKeyEnv } from './test-seal-key';
|
|
15
16
|
|
|
16
17
|
export type { E2eAppMode } from './e2e-spawn';
|
|
17
18
|
|
|
@@ -35,6 +36,11 @@ export interface E2eApp {
|
|
|
35
36
|
readonly base: string;
|
|
36
37
|
/** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
|
|
37
38
|
readonly stateDir: string;
|
|
39
|
+
/**
|
|
40
|
+
* The last of what the app printed, bounded — what a failing e2e test carries in its message
|
|
41
|
+
* (`failure-context.ts`), so a live-path bug is readable without running `x dev` by hand.
|
|
42
|
+
*/
|
|
43
|
+
log(): string;
|
|
38
44
|
/** Kill the app and delete its state directory. Idempotent. */
|
|
39
45
|
stop(): Promise<void>;
|
|
40
46
|
/**
|
|
@@ -72,7 +78,13 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
|
|
|
72
78
|
// Before the state directory too: an app that cannot name its `x` leaves nothing behind.
|
|
73
79
|
const bin = await xBin(options.root);
|
|
74
80
|
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
|
|
75
|
-
|
|
81
|
+
// The app is a server, not a test process: it installs no seal key for itself. The harness hands
|
|
82
|
+
// down the throwaway one when the app's root has none, so a `.sealed()` column works under e2e.
|
|
83
|
+
const env: Record<string, string> = {
|
|
84
|
+
...testSealKeyEnv({ root: options.root }),
|
|
85
|
+
...options.env,
|
|
86
|
+
ULTIMATE_STATE_DIR: stateDir,
|
|
87
|
+
};
|
|
76
88
|
const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
|
|
77
89
|
try {
|
|
78
90
|
x(bin, ['db', 'reset'], options.root, env);
|
|
@@ -93,6 +105,7 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
|
|
|
93
105
|
return {
|
|
94
106
|
base: spawned.base,
|
|
95
107
|
stateDir,
|
|
108
|
+
log: () => spawned.log(),
|
|
96
109
|
restart: (next) => spawned.restart(next),
|
|
97
110
|
async stop(): Promise<void> {
|
|
98
111
|
await spawned.stop();
|
package/src/e2e-driver.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { test as bunTest } from 'bun:test';
|
|
|
6
6
|
import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
|
|
7
7
|
import { e2ePage } from './e2e-page';
|
|
8
8
|
import { FixtureUnavailableError } from './errors';
|
|
9
|
+
import { withFailureContext } from './failure-context';
|
|
9
10
|
import { unavailableFixture } from './fixture-drivers';
|
|
10
11
|
import { defineFixtures } from './fixtures';
|
|
11
12
|
import type { E2eBody, E2eFixtures, PageLike } from './test-types';
|
|
@@ -97,8 +98,16 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
|
97
98
|
page: () => page,
|
|
98
99
|
...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
|
|
99
100
|
});
|
|
100
|
-
useE2eDriver((name, body: E2eBody) => {
|
|
101
|
-
|
|
101
|
+
useE2eDriver((name, body: E2eBody, test) => {
|
|
102
|
+
// Through `withFailureContext`, as `fixtureTest` is: the failing test carries the app's log.
|
|
103
|
+
const run = async (): Promise<void> => {
|
|
104
|
+
try {
|
|
105
|
+
await body(e2eFixtures(page, options.page, newBuild));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw withFailureContext(error);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
bunTest(name, run, test?.timeoutMs);
|
|
102
111
|
});
|
|
103
112
|
return () => {
|
|
104
113
|
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
package/src/e2e-run.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-ha
|
|
|
10
10
|
import type { E2eDriverOptions } from './e2e-driver';
|
|
11
11
|
import type { E2eBrowserPage } from './e2e-page';
|
|
12
12
|
import { answersWithin } from './e2e-probe';
|
|
13
|
+
import { setFailureContext } from './failure-context';
|
|
13
14
|
|
|
14
15
|
/** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
|
|
15
16
|
export const PROBE_MS = 5_000;
|
|
@@ -54,6 +55,9 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
|
|
|
54
55
|
};
|
|
55
56
|
let browser = await openOrStop(app, deps.openBrowser);
|
|
56
57
|
publishE2eRun({ browser, app });
|
|
58
|
+
// Every test that fails from here on carries the app's log tail: the server's half of a failure
|
|
59
|
+
// the browser can only report as "the row never arrived".
|
|
60
|
+
setFailureContext(() => app.log());
|
|
57
61
|
// Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
|
|
58
62
|
// is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
|
|
59
63
|
// every test defined before it driving a closed browser.
|
|
@@ -80,6 +84,7 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
|
|
|
80
84
|
});
|
|
81
85
|
|
|
82
86
|
deps.afterAll(async () => {
|
|
87
|
+
setFailureContext(undefined);
|
|
83
88
|
uninstall();
|
|
84
89
|
browser.close();
|
|
85
90
|
await app.stop();
|
package/src/e2e-spawn.ts
CHANGED
|
@@ -45,6 +45,8 @@ const POLL_MS = 250;
|
|
|
45
45
|
export interface SpawnedE2eApp {
|
|
46
46
|
/** `http://localhost:<port>`, no trailing slash. */
|
|
47
47
|
readonly base: string;
|
|
48
|
+
/** The last of what the CURRENT process printed, stdout and stderr, bounded. */
|
|
49
|
+
log(): string;
|
|
48
50
|
/** Kill the process. Idempotent. */
|
|
49
51
|
stop(): Promise<void>;
|
|
50
52
|
/** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
|
|
@@ -142,6 +144,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
142
144
|
}
|
|
143
145
|
return {
|
|
144
146
|
base,
|
|
147
|
+
log: () => tail(),
|
|
145
148
|
stop,
|
|
146
149
|
async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
|
|
147
150
|
// `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
|
package/src/errors.ts
CHANGED
|
@@ -47,6 +47,7 @@ export const TESTING_ERROR_CODES = [
|
|
|
47
47
|
'X_TEST_POLICY_EXPECTED',
|
|
48
48
|
'X_TEST_OPENAPI_EXPECTED',
|
|
49
49
|
'X_TEST_NUMBER_EXPECTED',
|
|
50
|
+
'X_TEST_ISLAND_STATE_UNKNOWN',
|
|
50
51
|
] as const;
|
|
51
52
|
|
|
52
53
|
export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
|
|
@@ -85,6 +86,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
|
|
|
85
86
|
X_TEST_POLICY_EXPECTED: 'a matcher expected a policy and got something else',
|
|
86
87
|
X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
|
|
87
88
|
X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
|
|
89
|
+
X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -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/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, {
|
|
@@ -162,6 +245,8 @@ export async function createRunJobs(): Promise<RunJobs> {
|
|
|
162
245
|
inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
|
|
163
246
|
[Symbol.asyncDispose]: async (): Promise<void> => {
|
|
164
247
|
await driver.close?.();
|
|
248
|
+
renewals[Symbol.dispose]();
|
|
249
|
+
jobs.setEventBus(previousBus);
|
|
165
250
|
if (previous === undefined) jobs.resetJobDriver();
|
|
166
251
|
else jobs.setJobDriver(previous);
|
|
167
252
|
},
|
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
|
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ export {
|
|
|
23
23
|
// `test` is OURS (fixture-injecting); everything else passes through. Re-exported so an app
|
|
24
24
|
// test has one import line, and so `expect` carries this package's matchers already installed.
|
|
25
25
|
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test';
|
|
26
|
+
// The two arguments `hooks.authenticate` is handed, for a test of an app's own authenticator.
|
|
27
|
+
// `@ultimat3/http` is imported inside the function.
|
|
28
|
+
export type { AuthRequestInit } from './auth-request';
|
|
29
|
+
export { authRequest } from './auth-request';
|
|
26
30
|
// The browser-backed e2e driver and the raw-CDP browser under it — moved here from
|
|
27
31
|
// `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
|
|
28
32
|
// `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
|
|
@@ -167,7 +171,14 @@ export type {
|
|
|
167
171
|
SharedChunkLike,
|
|
168
172
|
} from './fixture-island';
|
|
169
173
|
export { mountIsland } from './fixture-island';
|
|
170
|
-
export type {
|
|
174
|
+
export type {
|
|
175
|
+
JobDrainAs,
|
|
176
|
+
JobEnqueueAs,
|
|
177
|
+
JobRunTrace,
|
|
178
|
+
RunJobs,
|
|
179
|
+
RunJobsOptions,
|
|
180
|
+
StepTally,
|
|
181
|
+
} from './fixture-jobs';
|
|
171
182
|
export { createRunJobs } from './fixture-jobs';
|
|
172
183
|
export type { MailRef, TestMail } from './fixture-mail';
|
|
173
184
|
export { createTestMail } from './fixture-mail';
|
|
@@ -201,6 +212,14 @@ export {
|
|
|
201
212
|
// The file an error tells the reader to edit — the island's own name with `.states.ts` where
|
|
202
213
|
// `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does.
|
|
203
214
|
export { islandStatesFile } from './island-state-errors';
|
|
215
|
+
// One declared state, mounted: `describeIslandState` owns a block's mount and teardown,
|
|
216
|
+
// `mountIslandState` is the one mount a single test asks for with `using`.
|
|
217
|
+
export type { IslandStateMountOptions, IslandStateSuiteOptions } from './island-state-mount';
|
|
218
|
+
export {
|
|
219
|
+
describeIslandState,
|
|
220
|
+
ISLAND_MOUNT_TIMEOUT_MS,
|
|
221
|
+
mountIslandState,
|
|
222
|
+
} from './island-state-mount';
|
|
204
223
|
export type {
|
|
205
224
|
IslandRouteStub,
|
|
206
225
|
IslandState,
|
|
@@ -254,6 +273,9 @@ export {
|
|
|
254
273
|
export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
255
274
|
export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
|
|
256
275
|
export { createLiveNode } from './live-node';
|
|
276
|
+
// The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
|
|
277
|
+
export type { LocalRows, MemoryLocalTx } from './local-tx';
|
|
278
|
+
export { memoryLocalTx } from './local-tx';
|
|
257
279
|
/**
|
|
258
280
|
* The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
|
|
259
281
|
* `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
|
|
@@ -272,6 +294,10 @@ export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
|
|
|
272
294
|
export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
|
|
273
295
|
export type { ProcessRegistrySnapshot } from './registry-snapshot';
|
|
274
296
|
export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
|
|
297
|
+
// What the server renders, for a unit test. `@ultimat3/render` is imported inside the two
|
|
298
|
+
// functions, so a tier-0 test importing this barrel for `expect` still loads no renderer.
|
|
299
|
+
export type { RenderedRoute, RenderedView, RouteModule, RouteRequest } from './render-view';
|
|
300
|
+
export { renderRoute, renderView } from './render-view';
|
|
275
301
|
export type { DatabaseSnapshot } from './reusable-database';
|
|
276
302
|
export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
|
|
277
303
|
export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
|
|
@@ -308,12 +334,14 @@ export {
|
|
|
308
334
|
} from './template-db';
|
|
309
335
|
export type {
|
|
310
336
|
E2eBody,
|
|
337
|
+
E2eDriver,
|
|
311
338
|
E2eFixtures,
|
|
312
339
|
EvalCase,
|
|
313
340
|
EvalOptions,
|
|
314
341
|
LocatorLike,
|
|
315
342
|
OpenApiLike,
|
|
316
343
|
PageLike,
|
|
344
|
+
TestOptions,
|
|
317
345
|
TestType,
|
|
318
346
|
} from './test-types';
|
|
319
347
|
export {
|
|
@@ -234,3 +234,22 @@ export class IslandStatesAmbiguousError extends UltimateError {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A state id the manifest does not declare — a test naming `idel`, or a state renamed in the
|
|
240
|
+
* manifest and not in the test beside it. Listing the declared ids is the whole value, for the
|
|
241
|
+
* reason `IslandStatesUnknownError` lists names: a typo and a rename are one symptom.
|
|
242
|
+
*/
|
|
243
|
+
export class IslandStateUnknownError extends UltimateError {
|
|
244
|
+
constructor(input: {
|
|
245
|
+
readonly island: string;
|
|
246
|
+
readonly id: string;
|
|
247
|
+
readonly known: readonly string[];
|
|
248
|
+
}) {
|
|
249
|
+
super({
|
|
250
|
+
code: 'X_TEST_ISLAND_STATE_UNKNOWN',
|
|
251
|
+
cause: `${renderCauseValue(input.island)} declares no state ${renderCauseValue(input.id)}; declared: ${input.known.join(', ')}`,
|
|
252
|
+
fix: `name one of them instead: ${input.known[0] ?? ''} — or declare the state in ${at(input.island)}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|