@ultimat3/testing 23.0.0 → 25.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 +20 -20
- package/README.md +95 -13
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +47 -11
- package/src/cdp-connection.ts +41 -10
- package/src/cdp-e2e-session.ts +130 -13
- package/src/cdp-errors.ts +129 -8
- package/src/cdp-launch-attempt.ts +224 -0
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +92 -140
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +35 -6
- package/src/e2e-app.ts +17 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +22 -12
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +58 -17
- package/src/errors.ts +34 -66
- package/src/factories.ts +4 -6
- package/src/file-boundary.ts +12 -7
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +50 -98
- package/src/fixture-jobs.ts +16 -12
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/free-port.ts +46 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +29 -10
- package/src/island-dom.ts +57 -13
- package/src/island-globals.ts +73 -0
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +105 -0
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +6 -4
- package/src/preload.ts +2 -8
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +7 -4
- package/src/registry-snapshot.ts +41 -2
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +50 -5
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +8 -5
- package/src/test-types.ts +6 -2
package/src/cdp-launch.ts
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
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 { LAUNCH_REAP_MS, launchAttempt } from './cdp-launch-attempt';
|
|
10
|
+
import { chromeCandidates } from './cdp-launch-candidates';
|
|
11
|
+
import type { CdpWire } from './cdp-launch-wire';
|
|
12
|
+
import { defaultWire, wireFlag } from './cdp-launch-wire';
|
|
13
|
+
|
|
14
|
+
export type { LaunchedBrowser } from './cdp-launch-attempt';
|
|
15
|
+
export { CLOSE_GRACE_MS, LAUNCH_REAP_MS } from './cdp-launch-attempt';
|
|
16
|
+
export { chromeCandidates } from './cdp-launch-candidates';
|
|
17
|
+
export type { CdpWire } from './cdp-launch-wire';
|
|
18
|
+
export { defaultWire } from './cdp-launch-wire';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
|
|
18
|
-
* answer and the only one that can be right on a machine none of the rest describes; the
|
|
19
|
-
*
|
|
20
|
-
* browser-backed suite run in CI with
|
|
22
|
+
* answer and the only one that can be right on a machine none of the rest describes; after it, the
|
|
23
|
+
* install locations of THIS platform (`cdp-launch-candidates.ts`) — on Linux the `/usr/bin` names
|
|
24
|
+
* GitHub-hosted `ubuntu-latest` ships, which is what lets the browser-backed suite run in CI with
|
|
25
|
+
* **no download step and no new dependency**; on Windows Chrome and Edge; on macOS the app bundles.
|
|
21
26
|
*/
|
|
22
27
|
export const CHROME_PATH_ENV = 'CHROME_PATH';
|
|
23
|
-
export const CHROME_CANDIDATES: readonly string[] =
|
|
24
|
-
'/usr/bin/google-chrome',
|
|
25
|
-
'/usr/bin/google-chrome-stable',
|
|
26
|
-
'/usr/bin/chromium',
|
|
27
|
-
'/usr/bin/chromium-browser',
|
|
28
|
-
];
|
|
28
|
+
export const CHROME_CANDIDATES: readonly string[] = chromeCandidates(process.platform, process.env);
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The first candidate that exists, or `undefined`. An absent browser is a SKIP, never a failure.
|
|
32
|
+
* `platform` is a parameter so the Windows and macOS lists are asserted on a Linux runner too.
|
|
33
|
+
*/
|
|
31
34
|
export async function findChrome(
|
|
32
35
|
env: Readonly<Record<string, string | undefined>>,
|
|
36
|
+
platform: string = process.platform,
|
|
33
37
|
): Promise<string | undefined> {
|
|
34
38
|
const declared = env[CHROME_PATH_ENV];
|
|
35
|
-
const candidates =
|
|
39
|
+
const candidates =
|
|
40
|
+
declared === undefined || declared === '' ? chromeCandidates(platform, env) : [declared];
|
|
36
41
|
for (const candidate of candidates) {
|
|
37
42
|
if (await Bun.file(candidate).exists()) return candidate;
|
|
38
43
|
}
|
|
@@ -54,15 +59,19 @@ export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disa
|
|
|
54
59
|
/**
|
|
55
60
|
* The flags, and every one of them earns its line.
|
|
56
61
|
*
|
|
57
|
-
* `--headless=new` is Chrome's own headless rather than the retired shim.
|
|
58
|
-
*
|
|
59
|
-
* machine can never collide, and nothing but this process
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
+
* `--headless=new` is Chrome's own headless rather than the retired shim. The wire flag is
|
|
63
|
+
* `--remote-debugging-pipe` wherever the pipe can be held (`cdp-pipe.ts` says why it is not the
|
|
64
|
+
* WebSocket): no port, so two suites on one machine can never collide, and nothing but this process
|
|
65
|
+
* can drive the browser — and `--remote-debugging-port=0` on Windows (`cdp-launch-wire.ts`). A
|
|
66
|
+
* throwaway `--user-data-dir` because a run sharing a profile with a real browser inherits its
|
|
67
|
+
* cookies and locks its files.
|
|
62
68
|
*/
|
|
63
|
-
export const chromeLaunchFlags = (
|
|
69
|
+
export const chromeLaunchFlags = (
|
|
70
|
+
profileDir: string,
|
|
71
|
+
wire: CdpWire = defaultWire(process.platform),
|
|
72
|
+
): readonly string[] => [
|
|
64
73
|
'--headless=new',
|
|
65
|
-
|
|
74
|
+
wireFlag(wire),
|
|
66
75
|
`--user-data-dir=${profileDir}`,
|
|
67
76
|
...CONTAINER_CHROME_ARGS,
|
|
68
77
|
'--disable-gpu',
|
|
@@ -81,130 +90,71 @@ export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
|
|
|
81
90
|
'about:blank',
|
|
82
91
|
];
|
|
83
92
|
|
|
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
93
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
94
|
+
* How long a COLD start may take before the browser is given up on. Its own number, never the
|
|
95
|
+
* per-call deadline: measured 2026-10-02 over `scaffold-smoke` on free `ubuntu-latest` runners, the
|
|
96
|
+
* first launch of a job cost 5-19 s more than a warm one in 11 green jobs, and the one red job was
|
|
97
|
+
* still printing start-up lines when a 30 s deadline killed it. Twice that, and finite: a browser
|
|
98
|
+
* that exits says so at once (its pipe closes), so only one alive and silent waits this long.
|
|
112
99
|
*/
|
|
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
|
-
}
|
|
100
|
+
export const LAUNCH_TIMEOUT_MS = 60_000;
|
|
127
101
|
|
|
128
102
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
103
|
+
* Starts per launch: the first, and ONE more on a fresh profile after the first was reaped. A
|
|
104
|
+
* process start is the one step here whose failure can belong to the machine rather than the
|
|
105
|
+
* binary — and the second start of a cold binary is a warm one. Never a loop: two starts that both
|
|
106
|
+
* went unanswered are `X_CDP_LAUNCH_FAILED` carrying both.
|
|
133
107
|
*/
|
|
134
|
-
const
|
|
108
|
+
export const LAUNCH_ATTEMPTS = 2;
|
|
135
109
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The longest `launchChrome` is DESIGNED to take, for any `timeoutMs` up to `LAUNCH_TIMEOUT_MS`:
|
|
112
|
+
* every start given its whole deadline, each failed one its bounded reap. A hook that wraps a launch
|
|
113
|
+
* and gives it less is killed by Bun before the relaunch, and the `X_CDP_LAUNCH_FAILED` that would
|
|
114
|
+
* have named the slow step never surfaces — the shape of four CI flakes (plan 101, F1).
|
|
115
|
+
*/
|
|
116
|
+
export const LAUNCH_BUDGET_MS = LAUNCH_ATTEMPTS * (LAUNCH_TIMEOUT_MS + LAUNCH_REAP_MS);
|
|
138
117
|
|
|
139
|
-
|
|
140
|
-
|
|
118
|
+
export interface LaunchOptions {
|
|
119
|
+
readonly executable: string;
|
|
120
|
+
/** Every CDP call's deadline once the browser is up. */
|
|
121
|
+
readonly timeoutMs: number;
|
|
122
|
+
/** The first answer's deadline, per start. Defaults to the larger of `timeoutMs` and `LAUNCH_TIMEOUT_MS`. */
|
|
123
|
+
readonly launchTimeoutMs?: number | undefined;
|
|
124
|
+
/** The wire. Defaults to this platform's (`defaultWire`): the pipe, and on Windows the port. */
|
|
125
|
+
readonly wire?: CdpWire | undefined;
|
|
126
|
+
}
|
|
141
127
|
|
|
142
128
|
/**
|
|
143
|
-
* Start Chrome on a throwaway profile and answer once it has answered one CDP call.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
129
|
+
* Start Chrome on a throwaway profile and answer once it has answered one CDP call. A browser that
|
|
130
|
+
* dies or stays silent is started once more; twice is `X_CDP_LAUNCH_FAILED` with each start's own
|
|
131
|
+
* stderr and whether it exited or was killed at the deadline.
|
|
146
132
|
*/
|
|
147
133
|
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,
|
|
134
|
+
// Screened: `setTimeout(fn, NaN)` fires in 1 ms, which reports a healthy browser as silent.
|
|
135
|
+
const launchTimeoutMs = finiteCount(
|
|
136
|
+
'launchChrome',
|
|
137
|
+
'launchTimeoutMs',
|
|
138
|
+
options.launchTimeoutMs ?? Math.max(options.timeoutMs, LAUNCH_TIMEOUT_MS),
|
|
139
|
+
1,
|
|
168
140
|
);
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
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({
|
|
141
|
+
const wire = options.wire ?? defaultWire(process.platform);
|
|
142
|
+
const failures: CdpLaunchAttempt[] = [];
|
|
143
|
+
while (failures.length < LAUNCH_ATTEMPTS) {
|
|
144
|
+
const started = await launchAttempt({
|
|
201
145
|
executable: options.executable,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
146
|
+
flags: (profileDir) => chromeLaunchFlags(profileDir, wire),
|
|
147
|
+
wire,
|
|
148
|
+
timeoutMs: options.timeoutMs,
|
|
149
|
+
launchTimeoutMs,
|
|
206
150
|
});
|
|
151
|
+
if (started.ok) return started.browser;
|
|
152
|
+
failures.push(started.failure);
|
|
153
|
+
// A process that outlived SIGKILL's grace still holds the machine; a second beside it is the
|
|
154
|
+
// load that made the first one late.
|
|
155
|
+
if (!started.reaped) break;
|
|
207
156
|
}
|
|
157
|
+
throw new CdpLaunchFailedError({ executable: options.executable, attempts: failures });
|
|
208
158
|
}
|
|
209
159
|
|
|
210
160
|
/** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
|
|
@@ -213,6 +163,8 @@ export async function launchFoundChrome(
|
|
|
213
163
|
timeoutMs: number,
|
|
214
164
|
): Promise<LaunchedBrowser> {
|
|
215
165
|
const executable = await findChrome(env);
|
|
216
|
-
if (executable === undefined)
|
|
166
|
+
if (executable === undefined) {
|
|
167
|
+
throw new CdpBrowserMissingError({ tried: chromeCandidates(process.platform, env) });
|
|
168
|
+
}
|
|
217
169
|
return launchChrome({ executable, timeoutMs });
|
|
218
170
|
}
|
|
@@ -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
|
+
}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// applies are `island-states-check.ts`. Every default a declaration leaves out is resolved HERE, so
|
|
4
4
|
// nothing downstream has to decide what an absent viewport or an absent theme list meant.
|
|
5
5
|
|
|
6
|
+
import { decodeRecordEnvelope, encodeRecordEnvelope, isUltimateError } from '@ultimat3/core';
|
|
6
7
|
import { DEFAULT_NOW } from './determinism';
|
|
7
8
|
import {
|
|
8
9
|
IslandStateDuplicateError,
|
|
9
10
|
IslandStateIdInvalidError,
|
|
10
11
|
IslandStateInstantInvalidError,
|
|
11
12
|
IslandStateJsonInvalidError,
|
|
13
|
+
IslandStateRecordsInvalidError,
|
|
12
14
|
IslandStateStubInvalidError,
|
|
13
15
|
IslandStatesEmptyError,
|
|
14
16
|
IslandStateZoneInvalidError,
|
|
@@ -18,6 +20,7 @@ import type {
|
|
|
18
20
|
IslandStateDecl,
|
|
19
21
|
IslandStatesDecl,
|
|
20
22
|
IslandStatesManifest,
|
|
23
|
+
IslandStubResponse,
|
|
21
24
|
IslandTheme,
|
|
22
25
|
IslandViewport,
|
|
23
26
|
} from './island-states';
|
|
@@ -101,14 +104,7 @@ function normalizeState(
|
|
|
101
104
|
if (!isStubMatch(stub.match)) {
|
|
102
105
|
throw new IslandStateStubInvalidError({ island: decl.island, match: stub.match });
|
|
103
106
|
}
|
|
104
|
-
|
|
105
|
-
const bodyFault = jsonFault(
|
|
106
|
-
stub.respond.body,
|
|
107
|
-
`state "${state.id}" routes[${index}].respond.body`,
|
|
108
|
-
);
|
|
109
|
-
if (bodyFault !== undefined) {
|
|
110
|
-
throw new IslandStateJsonInvalidError({ island: decl.island, ...bodyFault });
|
|
111
|
-
}
|
|
107
|
+
checkStubBody(decl.island, stub.respond, `state "${state.id}" routes[${index}].respond`);
|
|
112
108
|
}
|
|
113
109
|
|
|
114
110
|
return {
|
|
@@ -122,6 +118,49 @@ function normalizeState(
|
|
|
122
118
|
};
|
|
123
119
|
}
|
|
124
120
|
|
|
121
|
+
type RecordsStub = Extract<IslandStubResponse, { kind: 'records' }>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The envelope the harness will send, through core's own pair: `encodeRecordEnvelope` builds it
|
|
125
|
+
* (as `island-harness-script.ts` does) and `decodeRecordEnvelope` is the client's own reader, so a
|
|
126
|
+
* stub is held to exactly what the page store will accept — never to a copy of that rule.
|
|
127
|
+
*/
|
|
128
|
+
function envelopeFault(respond: RecordsStub): string | undefined {
|
|
129
|
+
try {
|
|
130
|
+
decodeRecordEnvelope(encodeRecordEnvelope(respond.data, respond.records, respond.removed));
|
|
131
|
+
return undefined;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!isUltimateError(error)) throw error;
|
|
134
|
+
return error.cause;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A stub's answer survives the JSON trip, and a `records` one is an envelope the store adopts. */
|
|
139
|
+
function checkStubBody(island: string, respond: IslandStubResponse, path: string): void {
|
|
140
|
+
if (respond.kind !== 'json' && respond.kind !== 'records') return;
|
|
141
|
+
const parts: readonly (readonly [string, unknown])[] =
|
|
142
|
+
respond.kind === 'json'
|
|
143
|
+
? [['body', respond.body]]
|
|
144
|
+
: [
|
|
145
|
+
['data', respond.data],
|
|
146
|
+
['records', respond.records],
|
|
147
|
+
['removed', respond.removed ?? null],
|
|
148
|
+
];
|
|
149
|
+
for (const [member, value] of parts) {
|
|
150
|
+
const fault = jsonFault(value, `${path}.${member}`);
|
|
151
|
+
if (fault !== undefined) throw new IslandStateJsonInvalidError({ island, ...fault });
|
|
152
|
+
}
|
|
153
|
+
if (respond.kind !== 'records') return;
|
|
154
|
+
const fault = envelopeFault(respond);
|
|
155
|
+
if (fault !== undefined) {
|
|
156
|
+
throw new IslandStateRecordsInvalidError({
|
|
157
|
+
island,
|
|
158
|
+
path,
|
|
159
|
+
reason: `is not a records envelope (${fault})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
125
164
|
/**
|
|
126
165
|
* Declare the states one island can be photographed in. Validated here rather than by the command
|
|
127
166
|
* that takes the pictures, because a manifest that is only checked at capture time is a manifest
|
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;
|
|
@@ -191,16 +191,38 @@ export function setFrozenClock(now: string | number): void {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/** Run `body` with the clock frozen at `now`, then restore whatever was there before. */
|
|
194
|
-
export async function
|
|
194
|
+
export async function withFrozenClock<T>(now: string, body: () => T | Promise<T>): Promise<T> {
|
|
195
195
|
// Resolved before `previous` is spent: a refusal here must not run the body and must not leave
|
|
196
196
|
// the outer instant behind a `finally` that restores something already overwritten.
|
|
197
|
-
const at = instantMs('
|
|
197
|
+
const at = instantMs('withFrozenClock', now);
|
|
198
198
|
const previous = frozenAt;
|
|
199
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.
|
|
200
202
|
try {
|
|
203
|
+
// Inside the `try`: a listener that throws on entry must still get the outer instant back.
|
|
204
|
+
announceMove();
|
|
201
205
|
return await body();
|
|
202
206
|
} finally {
|
|
203
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;
|
|
204
226
|
}
|
|
205
227
|
}
|
|
206
228
|
|
|
@@ -211,8 +233,15 @@ export async function frozenClock<T>(now: string, body: () => T | Promise<T>): P
|
|
|
211
233
|
export async function assertDeterministic<T>(what: string, body: () => T | Promise<T>): Promise<T> {
|
|
212
234
|
const first = await body();
|
|
213
235
|
const second = await body();
|
|
214
|
-
const a =
|
|
215
|
-
const b =
|
|
216
|
-
|
|
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
|
+
}
|
|
217
246
|
return first;
|
|
218
247
|
}
|
package/src/e2e-app.ts
CHANGED
|
@@ -11,10 +11,11 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { finiteCount } from '@ultimat3/core';
|
|
13
13
|
import type { E2eAppMode } from './e2e-spawn';
|
|
14
|
-
import { inherited, refuse, spawnE2eApp, xBin } from './e2e-spawn';
|
|
14
|
+
import { E2E_APP_STOP_MS, inherited, READY_PROBE_MS, refuse, spawnE2eApp, xBin } from './e2e-spawn';
|
|
15
15
|
import { testSealKeyEnv } from './test-seal-key';
|
|
16
16
|
|
|
17
17
|
export type { E2eAppMode } from './e2e-spawn';
|
|
18
|
+
export { E2E_APP_STOP_MS } from './e2e-spawn';
|
|
18
19
|
|
|
19
20
|
export interface StartE2eAppOptions {
|
|
20
21
|
/** The app root — the directory holding `app.config.ts`. */
|
|
@@ -52,12 +53,27 @@ export interface E2eApp {
|
|
|
52
53
|
|
|
53
54
|
const DEFAULT_READY_TIMEOUT_MS = 90_000;
|
|
54
55
|
|
|
56
|
+
/** Sequential steps `startE2eApp` bounds at `DEFAULT_READY_TIMEOUT_MS`: `x db reset`, `x db seed`, `/readyz`. */
|
|
57
|
+
const START_PHASES = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The longest `startE2eApp()` is DESIGNED to take, at its default deadline: the reset, the seed and
|
|
61
|
+
* the readiness wait each given the whole of it, the one probe that may straddle the end, and the
|
|
62
|
+
* bounded stop of an app that never got ready. THE deadline a hook that boots an e2e app derives
|
|
63
|
+
* from — with `E2E_BROWSER_OPEN_MS` beside it when the same hook opens a browser.
|
|
64
|
+
*/
|
|
65
|
+
export const E2E_APP_START_MS =
|
|
66
|
+
START_PHASES * DEFAULT_READY_TIMEOUT_MS + READY_PROBE_MS + E2E_APP_STOP_MS;
|
|
67
|
+
|
|
55
68
|
function x(bin: string, args: readonly string[], root: string, env: Record<string, string>): void {
|
|
56
69
|
const run = Bun.spawnSync(['bun', bin, ...args], {
|
|
57
70
|
cwd: root,
|
|
58
71
|
env: { ...inherited(), ...env },
|
|
59
72
|
stdout: 'pipe',
|
|
60
73
|
stderr: 'pipe',
|
|
74
|
+
// Synchronous inside a test, where the test's own timeout cannot interrupt it: a hung reset is
|
|
75
|
+
// killed here and refused below with what it printed, never a suite that waits forever.
|
|
76
|
+
timeout: DEFAULT_READY_TIMEOUT_MS,
|
|
61
77
|
});
|
|
62
78
|
if (run.exitCode !== 0) {
|
|
63
79
|
throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
|