@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/cdp-launch.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// One responsibility:
|
|
2
|
-
//
|
|
1
|
+
// One responsibility: WHICH Chrome, with which flags, and how many starts it gets — the candidate
|
|
2
|
+
// list, the launch deadline and the one relaunch. One start of the process is
|
|
3
|
+
// `cdp-launch-attempt.ts`; the connection is `cdp-connection.ts`, the page `cdp-e2e-page.ts`.
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
-
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
8
|
-
import { tmpdir } from 'node:os';
|
|
9
|
-
// why: Bun exposes no path-join primitive.
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import type { CdpConnection } from './cdp-connection';
|
|
12
|
-
import { cdpConnectOver } from './cdp-connection';
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { CdpLaunchAttempt } from './cdp-errors';
|
|
13
7
|
import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
|
|
14
|
-
import {
|
|
8
|
+
import type { LaunchedBrowser } from './cdp-launch-attempt';
|
|
9
|
+
import { launchAttempt } from './cdp-launch-attempt';
|
|
10
|
+
|
|
11
|
+
export type { LaunchedBrowser } from './cdp-launch-attempt';
|
|
12
|
+
export { CLOSE_GRACE_MS } from './cdp-launch-attempt';
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
|
|
@@ -81,130 +79,59 @@ export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
|
|
|
81
79
|
'about:blank',
|
|
82
80
|
];
|
|
83
81
|
|
|
84
|
-
export interface LaunchedBrowser {
|
|
85
|
-
/** The browser's own CDP connection, over its debugging pipe. Already answering. */
|
|
86
|
-
readonly connection: CdpConnection;
|
|
87
|
-
/** Idempotent: closes the connection, kills the process, deletes the profile. */
|
|
88
|
-
close(): void;
|
|
89
|
-
/**
|
|
90
|
-
* `close()`, then resolves once the process has EXITED — SIGTERM, then SIGKILL after
|
|
91
|
-
* `CLOSE_GRACE_MS`, never waiting longer than twice that. What a suite's `afterAll` awaits: a
|
|
92
|
-
* Chrome told to die but still shutting down competes with the next suite's launch on a 4-CPU
|
|
93
|
-
* runner, and one still running keeps the test process from exiting at all. Optional so a test
|
|
94
|
-
* double of a launch need not model a process; `launchChrome` always provides it.
|
|
95
|
-
*/
|
|
96
|
-
closed?(): Promise<void>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface LaunchOptions {
|
|
100
|
-
readonly executable: string;
|
|
101
|
-
/** How long Chrome has to answer its first call, and every call's deadline after that. */
|
|
102
|
-
readonly timeoutMs: number;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const STDERR_TAIL_CHARS = 4_000;
|
|
106
|
-
|
|
107
82
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
83
|
+
* How long a COLD start may take before the browser is given up on. Its own number, never the
|
|
84
|
+
* per-call deadline: measured 2026-10-02 over `scaffold-smoke` on free `ubuntu-latest` runners, the
|
|
85
|
+
* first launch of a job cost 5-19 s more than a warm one in 11 green jobs, and the one red job was
|
|
86
|
+
* still printing start-up lines when a 30 s deadline killed it. Twice that, and finite: a browser
|
|
87
|
+
* that exits says so at once (its pipe closes), so only one alive and silent waits this long.
|
|
112
88
|
*/
|
|
113
|
-
|
|
114
|
-
readonly text: () => string;
|
|
115
|
-
/** Settles once the stream has ended — every byte the process wrote has been read. */
|
|
116
|
-
readonly drained: Promise<void>;
|
|
117
|
-
} {
|
|
118
|
-
let text = '';
|
|
119
|
-
const drained = (async () => {
|
|
120
|
-
const decoder = new TextDecoder();
|
|
121
|
-
for await (const chunk of stream) {
|
|
122
|
-
text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
|
|
123
|
-
}
|
|
124
|
-
})().catch(() => undefined);
|
|
125
|
-
return { text: () => text, drained };
|
|
126
|
-
}
|
|
89
|
+
export const LAUNCH_TIMEOUT_MS = 60_000;
|
|
127
90
|
|
|
128
91
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
92
|
+
* Starts per launch: the first, and ONE more on a fresh profile after the first was reaped. A
|
|
93
|
+
* process start is the one step here whose failure can belong to the machine rather than the
|
|
94
|
+
* binary — and the second start of a cold binary is a warm one. Never a loop: two starts that both
|
|
95
|
+
* went unanswered are `X_CDP_LAUNCH_FAILED` carrying both.
|
|
133
96
|
*/
|
|
134
|
-
const
|
|
97
|
+
export const LAUNCH_ATTEMPTS = 2;
|
|
135
98
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
99
|
+
export interface LaunchOptions {
|
|
100
|
+
readonly executable: string;
|
|
101
|
+
/** Every CDP call's deadline once the browser is up. */
|
|
102
|
+
readonly timeoutMs: number;
|
|
103
|
+
/** The first answer's deadline, per start. Defaults to the larger of `timeoutMs` and `LAUNCH_TIMEOUT_MS`. */
|
|
104
|
+
readonly launchTimeoutMs?: number | undefined;
|
|
105
|
+
}
|
|
141
106
|
|
|
142
107
|
/**
|
|
143
|
-
* Start Chrome on a throwaway profile and answer once it has answered one CDP call.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
108
|
+
* Start Chrome on a throwaway profile and answer once it has answered one CDP call. A browser that
|
|
109
|
+
* dies or stays silent is started once more; twice is `X_CDP_LAUNCH_FAILED` with each start's own
|
|
110
|
+
* stderr and whether it exited or was killed at the deadline.
|
|
146
111
|
*/
|
|
147
112
|
export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
|
|
155
|
-
const sink = Bun.file(toBrowser ?? -1).writer();
|
|
156
|
-
const connection = cdpConnectOver(
|
|
157
|
-
pipeTransport({
|
|
158
|
-
write: (bytes) => {
|
|
159
|
-
sink.write(bytes);
|
|
160
|
-
void sink.flush();
|
|
161
|
-
},
|
|
162
|
-
read: Bun.file(fromBrowser ?? -1).stream(),
|
|
163
|
-
end: () => {
|
|
164
|
-
void Promise.resolve(sink.end()).catch(() => undefined);
|
|
165
|
-
},
|
|
166
|
-
}),
|
|
167
|
-
options.timeoutMs,
|
|
113
|
+
// Screened: `setTimeout(fn, NaN)` fires in 1 ms, which reports a healthy browser as silent.
|
|
114
|
+
const launchTimeoutMs = finiteCount(
|
|
115
|
+
'launchChrome',
|
|
116
|
+
'launchTimeoutMs',
|
|
117
|
+
options.launchTimeoutMs ?? Math.max(options.timeoutMs, LAUNCH_TIMEOUT_MS),
|
|
118
|
+
1,
|
|
168
119
|
);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
closed = true;
|
|
173
|
-
connection.close();
|
|
174
|
-
child.kill();
|
|
175
|
-
rmSync(profileDir, { recursive: true, force: true });
|
|
176
|
-
};
|
|
177
|
-
let exiting: Promise<void> | undefined;
|
|
178
|
-
const closeAndWait = (): Promise<void> => {
|
|
179
|
-
close();
|
|
180
|
-
exiting ??= (async () => {
|
|
181
|
-
const exited =
|
|
182
|
-
(await within(
|
|
183
|
-
CLOSE_GRACE_MS,
|
|
184
|
-
child.exited.then(() => true),
|
|
185
|
-
)) === true;
|
|
186
|
-
if (exited) return;
|
|
187
|
-
child.kill('SIGKILL');
|
|
188
|
-
await within(CLOSE_GRACE_MS, child.exited);
|
|
189
|
-
})();
|
|
190
|
-
return exiting;
|
|
191
|
-
};
|
|
192
|
-
try {
|
|
193
|
-
await connection.send('Browser.getVersion');
|
|
194
|
-
return { connection, close, closed: closeAndWait };
|
|
195
|
-
} catch {
|
|
196
|
-
await within(FAILURE_DRAIN_MS, child.exited);
|
|
197
|
-
close();
|
|
198
|
-
await within(FAILURE_DRAIN_MS, tail.drained);
|
|
199
|
-
const seen = tail.text().trim();
|
|
200
|
-
throw new CdpLaunchFailedError({
|
|
120
|
+
const failures: CdpLaunchAttempt[] = [];
|
|
121
|
+
while (failures.length < LAUNCH_ATTEMPTS) {
|
|
122
|
+
const started = await launchAttempt({
|
|
201
123
|
executable: options.executable,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: seen.split('\n').slice(-3).join(' | '),
|
|
124
|
+
flags: chromeLaunchFlags,
|
|
125
|
+
timeoutMs: options.timeoutMs,
|
|
126
|
+
launchTimeoutMs,
|
|
206
127
|
});
|
|
128
|
+
if (started.ok) return started.browser;
|
|
129
|
+
failures.push(started.failure);
|
|
130
|
+
// A process that outlived SIGKILL's grace still holds the machine; a second beside it is the
|
|
131
|
+
// load that made the first one late.
|
|
132
|
+
if (!started.reaped) break;
|
|
207
133
|
}
|
|
134
|
+
throw new CdpLaunchFailedError({ executable: options.executable, attempts: failures });
|
|
208
135
|
}
|
|
209
136
|
|
|
210
137
|
/** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
|
|
12
12
|
// restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
|
|
13
13
|
|
|
14
|
+
import { stringField } from '@ultimat3/core';
|
|
14
15
|
import type { CdpResult } from './cdp-connection';
|
|
16
|
+
import { CdpCallFailedError } from './cdp-errors';
|
|
15
17
|
|
|
16
18
|
type Send = (
|
|
17
19
|
method: string,
|
|
@@ -68,9 +70,37 @@ export function offlineScripts(send: Send): OfflineScripts {
|
|
|
68
70
|
async remove(session) {
|
|
69
71
|
const identifier = held.get(session);
|
|
70
72
|
if (identifier === undefined) return undefined;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// Both halves are ATTEMPTED whatever the first answers, and a refusal of either surfaces.
|
|
74
|
+
// Traced 2026-10-02: a navigation in flight made the removal answer `Script not found`, the
|
|
75
|
+
// throw skipped the restore, and the open document kept reading `navigator.onLine === false`.
|
|
76
|
+
const refused: string[] = [];
|
|
77
|
+
const attempt = async (step: string, call: () => Promise<CdpResult>): Promise<boolean> => {
|
|
78
|
+
try {
|
|
79
|
+
await call();
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
refused.push(
|
|
83
|
+
`${step}: ${stringField(error, 'cause') ?? stringField(error, 'message') ?? 'refused'}`,
|
|
84
|
+
);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const removed = await attempt('removing the first-script override', () =>
|
|
89
|
+
send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session),
|
|
90
|
+
);
|
|
91
|
+
// Kept while the browser may still hold it: the next `remove` asks again, and `add` cannot
|
|
92
|
+
// register a second copy beside one that is still there.
|
|
93
|
+
if (removed) held.delete(session);
|
|
94
|
+
await attempt('restoring the open document', () =>
|
|
95
|
+
send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session),
|
|
96
|
+
);
|
|
97
|
+
if (refused.length > 0) {
|
|
98
|
+
throw new CdpCallFailedError({
|
|
99
|
+
method: `putting navigator.onLine back on session ${session}`,
|
|
100
|
+
detail: refused.join(' · '),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
74
104
|
},
|
|
75
105
|
};
|
|
76
106
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// One responsibility: remember what the wire did, so a call that is never answered can say WHY —
|
|
2
|
+
// its target went away, frames arrived that nothing could parse, or the target simply stayed
|
|
3
|
+
// silent. The connection (`cdp-connection.ts`) feeds every frame in; `CdpTimeoutError` reads it out.
|
|
4
|
+
|
|
5
|
+
import type { CdpTargetGone, CdpTimeoutObservation } from './cdp-errors';
|
|
6
|
+
|
|
7
|
+
/** How many of the latest frames a timeout quotes. Bounded: a busy page sends thousands. */
|
|
8
|
+
export const LAST_FRAMES = 8;
|
|
9
|
+
|
|
10
|
+
/** Where the wire stood when a call was sent — what "since the call" is measured from. */
|
|
11
|
+
export interface WireMark {
|
|
12
|
+
readonly arrived: number;
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
readonly navigations: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WireWatch {
|
|
18
|
+
/** One frame that parsed: a reply or an event. */
|
|
19
|
+
frame(frame: Record<string, unknown>): void;
|
|
20
|
+
/** One frame nothing could parse. It has no `id`, so no call can be failed with it — only counted. */
|
|
21
|
+
drop(): void;
|
|
22
|
+
mark(sessionId: string | undefined): WireMark;
|
|
23
|
+
/** What happened between `mark` and now, for the call sent on `sessionId`. */
|
|
24
|
+
observe(mark: WireMark, sessionId: string | undefined, open: boolean): CdpTimeoutObservation;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = (from: unknown, key: string): string | undefined => {
|
|
28
|
+
const value =
|
|
29
|
+
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
30
|
+
return typeof value === 'string' ? value : undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function wireWatch(): WireWatch {
|
|
34
|
+
let arrived = 0;
|
|
35
|
+
let dropped = 0;
|
|
36
|
+
const last: string[] = [];
|
|
37
|
+
const targetOf = new Map<string, string>(); // sessionId → targetId
|
|
38
|
+
const gone = new Map<string, CdpTargetGone>(); // sessionId → how it went
|
|
39
|
+
const navigations = new Map<string, number>(); // sessionId → main-frame navigations
|
|
40
|
+
|
|
41
|
+
const goneTarget = (targetId: string | undefined, how: CdpTargetGone): void => {
|
|
42
|
+
for (const [session, target] of targetOf) if (target === targetId) gone.set(session, how);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The few events that say a session can no longer answer, or that its page was replaced. */
|
|
46
|
+
const track = (method: string, session: string | undefined, params: unknown): void => {
|
|
47
|
+
if (method === 'Target.attachedToTarget') {
|
|
48
|
+
const attached = text(params, 'sessionId');
|
|
49
|
+
const target = text(
|
|
50
|
+
(params as Record<string, unknown> | undefined)?.['targetInfo'],
|
|
51
|
+
'targetId',
|
|
52
|
+
);
|
|
53
|
+
if (attached !== undefined && target !== undefined) targetOf.set(attached, target);
|
|
54
|
+
} else if (method === 'Target.detachedFromTarget') {
|
|
55
|
+
const detached = text(params, 'sessionId');
|
|
56
|
+
if (detached !== undefined) gone.set(detached, 'detached');
|
|
57
|
+
} else if (method === 'Target.targetDestroyed') {
|
|
58
|
+
goneTarget(text(params, 'targetId'), 'destroyed');
|
|
59
|
+
} else if (method === 'Target.targetCrashed') {
|
|
60
|
+
goneTarget(text(params, 'targetId'), 'crashed');
|
|
61
|
+
} else if (method === 'Inspector.targetCrashed' && session !== undefined) {
|
|
62
|
+
gone.set(session, 'crashed');
|
|
63
|
+
} else if (method === 'Page.frameNavigated' && session !== undefined) {
|
|
64
|
+
const frame = (params as Record<string, unknown> | undefined)?.['frame'];
|
|
65
|
+
// A frame with a parent is an iframe: the document the call was sent to is still there.
|
|
66
|
+
if (text(frame, 'parentId') === undefined) {
|
|
67
|
+
navigations.set(session, (navigations.get(session) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const note = (label: string): void => {
|
|
73
|
+
last.push(label);
|
|
74
|
+
if (last.length > LAST_FRAMES) last.shift();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
frame(frame): void {
|
|
79
|
+
arrived += 1;
|
|
80
|
+
const session = text(frame, 'sessionId');
|
|
81
|
+
const id = frame['id'];
|
|
82
|
+
const method = text(frame, 'method');
|
|
83
|
+
if (typeof id === 'number') note(`reply ${String(id)}`);
|
|
84
|
+
else if (method === undefined) note('a frame with neither an id nor a method');
|
|
85
|
+
else {
|
|
86
|
+
note(session === undefined ? method : `${method} @${session}`);
|
|
87
|
+
track(method, session, frame['params']);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
drop(): void {
|
|
91
|
+
dropped += 1;
|
|
92
|
+
},
|
|
93
|
+
mark: (sessionId) => ({
|
|
94
|
+
arrived,
|
|
95
|
+
dropped,
|
|
96
|
+
navigations: sessionId === undefined ? 0 : (navigations.get(sessionId) ?? 0),
|
|
97
|
+
}),
|
|
98
|
+
observe(mark, sessionId, open): CdpTimeoutObservation {
|
|
99
|
+
const since = arrived - mark.arrived;
|
|
100
|
+
return {
|
|
101
|
+
sessionId: sessionId ?? null,
|
|
102
|
+
framesArrived: since,
|
|
103
|
+
// Only frames that arrived AFTER the call: an older one says nothing about its reply.
|
|
104
|
+
lastFrames: since === 0 ? [] : last.slice(-Math.min(since, LAST_FRAMES)),
|
|
105
|
+
framesDropped: dropped - mark.dropped,
|
|
106
|
+
transportOpen: open,
|
|
107
|
+
targetGone: sessionId === undefined ? null : (gone.get(sessionId) ?? null),
|
|
108
|
+
navigations:
|
|
109
|
+
sessionId === undefined ? 0 : (navigations.get(sessionId) ?? 0) - mark.navigations,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/determinism.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// bug: a frozen clock means "advance it", a seeded RNG means "same values every run", and both are
|
|
3
3
|
// restorable so a test that genuinely needs real time can opt out explicitly.
|
|
4
4
|
|
|
5
|
-
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
5
|
+
import { canonicalJson, finiteCount, finiteOption, renderCauseValue } from '@ultimat3/core';
|
|
6
6
|
import { NondeterministicError } from './errors';
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_SEED = 20260101;
|
|
@@ -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. */
|
|
@@ -177,10 +197,32 @@ export async function frozenClock<T>(now: string, body: () => T | Promise<T>): P
|
|
|
177
197
|
const at = instantMs('frozenClock', now);
|
|
178
198
|
const previous = frozenAt;
|
|
179
199
|
frozenAt = at;
|
|
200
|
+
// Both moves announced, as `setFrozenClock` announces one: the frozen scheduler renews leases on
|
|
201
|
+
// the announcement alone, so an unannounced jump is time its timers never see.
|
|
180
202
|
try {
|
|
203
|
+
// Inside the `try`: a listener that throws on entry must still get the outer instant back.
|
|
204
|
+
announceMove();
|
|
181
205
|
return await body();
|
|
182
206
|
} finally {
|
|
183
207
|
frozenAt = previous;
|
|
208
|
+
announceMove();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Core's canonical form — a BigInt, a Date, a Map have tokens of their own where `JSON.stringify`
|
|
214
|
+
* threw a bare `TypeError` or folded them to `{}` — or `undefined` for the one shape it cannot
|
|
215
|
+
* walk, a cycle, which `Bun.deepEquals` then compares instead. A top-level `undefined`, function
|
|
216
|
+
* or symbol is a token of its type.
|
|
217
|
+
*/
|
|
218
|
+
function comparable(value: unknown): string | undefined {
|
|
219
|
+
// Canonical JSON spells all three `null`, so `undefined` and `null` compared equal: a token each.
|
|
220
|
+
const kind = typeof value;
|
|
221
|
+
if (kind === 'undefined' || kind === 'function' || kind === 'symbol') return `<${kind}>`;
|
|
222
|
+
try {
|
|
223
|
+
return canonicalJson(value);
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
184
226
|
}
|
|
185
227
|
}
|
|
186
228
|
|
|
@@ -191,8 +233,15 @@ export async function frozenClock<T>(now: string, body: () => T | Promise<T>): P
|
|
|
191
233
|
export async function assertDeterministic<T>(what: string, body: () => T | Promise<T>): Promise<T> {
|
|
192
234
|
const first = await body();
|
|
193
235
|
const second = await body();
|
|
194
|
-
const a =
|
|
195
|
-
const b =
|
|
196
|
-
|
|
236
|
+
const a = comparable(first);
|
|
237
|
+
const b = comparable(second);
|
|
238
|
+
const same = a === undefined || b === undefined ? Bun.deepEquals(first, second) : a === b;
|
|
239
|
+
if (!same) {
|
|
240
|
+
throw new NondeterministicError({
|
|
241
|
+
what,
|
|
242
|
+
first: a ?? renderCauseValue(first),
|
|
243
|
+
second: b ?? renderCauseValue(second),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
197
246
|
return first;
|
|
198
247
|
}
|
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
|
/**
|
|
@@ -52,6 +58,9 @@ function x(bin: string, args: readonly string[], root: string, env: Record<strin
|
|
|
52
58
|
env: { ...inherited(), ...env },
|
|
53
59
|
stdout: 'pipe',
|
|
54
60
|
stderr: 'pipe',
|
|
61
|
+
// Synchronous inside a test, where the test's own timeout cannot interrupt it: a hung reset is
|
|
62
|
+
// killed here and refused below with what it printed, never a suite that waits forever.
|
|
63
|
+
timeout: DEFAULT_READY_TIMEOUT_MS,
|
|
55
64
|
});
|
|
56
65
|
if (run.exitCode !== 0) {
|
|
57
66
|
throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
|
|
@@ -72,7 +81,13 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
|
|
|
72
81
|
// Before the state directory too: an app that cannot name its `x` leaves nothing behind.
|
|
73
82
|
const bin = await xBin(options.root);
|
|
74
83
|
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
|
|
75
|
-
|
|
84
|
+
// The app is a server, not a test process: it installs no seal key for itself. The harness hands
|
|
85
|
+
// down the throwaway one when the app's root has none, so a `.sealed()` column works under e2e.
|
|
86
|
+
const env: Record<string, string> = {
|
|
87
|
+
...testSealKeyEnv({ root: options.root }),
|
|
88
|
+
...options.env,
|
|
89
|
+
ULTIMATE_STATE_DIR: stateDir,
|
|
90
|
+
};
|
|
76
91
|
const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
|
|
77
92
|
try {
|
|
78
93
|
x(bin, ['db', 'reset'], options.root, env);
|
|
@@ -93,6 +108,7 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
|
|
|
93
108
|
return {
|
|
94
109
|
base: spawned.base,
|
|
95
110
|
stateDir,
|
|
111
|
+
log: () => spawned.log(),
|
|
96
112
|
restart: (next) => spawned.restart(next),
|
|
97
113
|
async stop(): Promise<void> {
|
|
98
114
|
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.
|
|
@@ -74,14 +78,15 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
|
|
|
74
78
|
const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
|
|
75
79
|
if (alive) return;
|
|
76
80
|
deployed = false;
|
|
77
|
-
browser.close();
|
|
81
|
+
await browser.close();
|
|
78
82
|
browser = await deps.openBrowser();
|
|
79
83
|
republishE2eBrowser(browser);
|
|
80
84
|
});
|
|
81
85
|
|
|
82
86
|
deps.afterAll(async () => {
|
|
87
|
+
setFailureContext(undefined);
|
|
83
88
|
uninstall();
|
|
84
|
-
browser.close();
|
|
89
|
+
await browser.close();
|
|
85
90
|
await app.stop();
|
|
86
91
|
});
|
|
87
92
|
}
|
package/src/e2e-spawn.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { get } from 'node:http';
|
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { assert } from '@ultimat3/core';
|
|
12
12
|
import { E2eAppFailedError } from './e2e-errors';
|
|
13
|
+
import { freePort } from './free-port';
|
|
13
14
|
|
|
14
15
|
/** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
|
|
15
16
|
export type E2eAppMode = 'dev' | 'serve';
|
|
@@ -45,6 +46,8 @@ const POLL_MS = 250;
|
|
|
45
46
|
export interface SpawnedE2eApp {
|
|
46
47
|
/** `http://localhost:<port>`, no trailing slash. */
|
|
47
48
|
readonly base: string;
|
|
49
|
+
/** The last of what the CURRENT process printed, stdout and stderr, bounded. */
|
|
50
|
+
log(): string;
|
|
48
51
|
/** Kill the process. Idempotent. */
|
|
49
52
|
stop(): Promise<void>;
|
|
50
53
|
/** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
|
|
@@ -62,14 +65,6 @@ export interface SpawnE2eAppOptions {
|
|
|
62
65
|
readonly bin?: string | undefined;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
/** A port nothing holds right now, asked of the OS and handed to the child. */
|
|
66
|
-
const freePort = (): number => {
|
|
67
|
-
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
68
|
-
const port = probe.port ?? 0;
|
|
69
|
-
probe.stop(true);
|
|
70
|
-
return port;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
68
|
/**
|
|
74
69
|
* This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
|
|
75
70
|
* `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
|
|
@@ -142,6 +137,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
142
137
|
}
|
|
143
138
|
return {
|
|
144
139
|
base,
|
|
140
|
+
log: () => tail(),
|
|
145
141
|
stop,
|
|
146
142
|
async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
|
|
147
143
|
// `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
|
package/src/errors.ts
CHANGED
|
@@ -47,6 +47,8 @@ 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',
|
|
51
|
+
'X_TEST_APP_NOT_BOOTED',
|
|
50
52
|
] as const;
|
|
51
53
|
|
|
52
54
|
export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
|
|
@@ -85,6 +87,8 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
|
|
|
85
87
|
X_TEST_POLICY_EXPECTED: 'a matcher expected a policy and got something else',
|
|
86
88
|
X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
|
|
87
89
|
X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
|
|
90
|
+
X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
|
|
91
|
+
X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
|
|
88
92
|
};
|
|
89
93
|
|
|
90
94
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -133,7 +137,18 @@ export class NondeterministicError extends UltimateError {
|
|
|
133
137
|
super({
|
|
134
138
|
code: 'X_TEST_NONDETERMINISTIC',
|
|
135
139
|
cause: `${input.what} produced "${input.first}" then "${input.second}"`,
|
|
136
|
-
fix: '
|
|
140
|
+
fix: "assertDeterministic('…', () => { installDeterminism(); return body(); })",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `describeApp`'s `app()` read while the block was still being registered, before `beforeAll`. */
|
|
146
|
+
export class AppNotBootedError extends UltimateError {
|
|
147
|
+
constructor(input: { suite: string }) {
|
|
148
|
+
super({
|
|
149
|
+
code: 'X_TEST_APP_NOT_BOOTED',
|
|
150
|
+
cause: `describeApp('${input.suite}') — app() was called while the describe block was being registered, before its beforeAll booted the app`,
|
|
151
|
+
fix: "test('…', () => { const handle = app(); })",
|
|
137
152
|
});
|
|
138
153
|
}
|
|
139
154
|
}
|