@ultimat3/testing 24.0.0 → 25.1.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 +12 -12
- package/README.md +55 -18
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +42 -1
- package/src/cdp-connection.ts +18 -4
- package/src/cdp-e2e-session.ts +120 -12
- package/src/cdp-errors.ts +25 -9
- package/src/cdp-launch-attempt.ts +112 -80
- 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 +47 -22
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +2 -2
- package/src/e2e-app.ts +14 -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 +21 -11
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +57 -9
- package/src/errors.ts +20 -65
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +39 -56
- package/src/fixture-jobs.ts +7 -7
- 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/index.ts +17 -10
- package/src/island-dom.ts +49 -9
- package/src/island-globals.ts +73 -0
- package/src/island-scratch.ts +67 -3
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/live-node.ts +4 -4
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +4 -3
- package/src/registry-snapshot.ts +19 -1
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +45 -4
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +2 -2
- package/src/test-types.ts +6 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// One responsibility: nothing of a launched browser outlives its close — on POSIX its process GROUP,
|
|
2
|
+
// on Windows its process TREE — and the close waits until that is so, bounded.
|
|
3
|
+
|
|
4
|
+
/** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
|
|
5
|
+
export const CLOSE_GRACE_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
const GROUP_POLL_MS = 10;
|
|
8
|
+
|
|
9
|
+
/** Runs a command to its exit; the seam the Windows branch is asserted through on any runner. */
|
|
10
|
+
export type RunCommand = (argv: readonly string[]) => Promise<number>;
|
|
11
|
+
|
|
12
|
+
const runQuiet: RunCommand = (argv) =>
|
|
13
|
+
// Bounded: taskkill ends a tree in well under a second; a stuck one must not stall `close()`.
|
|
14
|
+
Bun.spawn([...argv], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 10_000 }).exited;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `/T` takes every process the browser started — renderer, GPU, network, crashpad — and `/F` does
|
|
18
|
+
* not ask: a tree kill is the last step of a close, after the graceful one had its chance.
|
|
19
|
+
*/
|
|
20
|
+
export const taskkillArgv = (pid: number): readonly string[] => [
|
|
21
|
+
'taskkill',
|
|
22
|
+
'/T',
|
|
23
|
+
'/F',
|
|
24
|
+
'/PID',
|
|
25
|
+
String(pid),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
|
|
29
|
+
Promise.race([work, Bun.sleep(ms)]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Kill whatever is left of the browser's process group and wait until the group is EMPTY — the
|
|
33
|
+
* condition, asked of the kernel, not a pause: signal 0 to a group with no member throws. Bounded
|
|
34
|
+
* by `CLOSE_GRACE_MS` for a container whose init never reaps a zombie — on the monotonic clock,
|
|
35
|
+
* not a count of sleeps: a loaded runner oversleeps every 10 ms, and the close's designed bound
|
|
36
|
+
* (`LAUNCHED_CLOSE_MS`) is what a hook deadline is derived from.
|
|
37
|
+
*/
|
|
38
|
+
async function reapGroup(pgid: number): Promise<void> {
|
|
39
|
+
const until = performance.now() + CLOSE_GRACE_MS;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(-pgid, 'SIGKILL');
|
|
42
|
+
while (performance.now() < until) {
|
|
43
|
+
await Bun.sleep(GROUP_POLL_MS);
|
|
44
|
+
process.kill(-pgid, 0);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// ESRCH: nothing is left in the group, which is the answer being waited for.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Everything the browser `pid` started, gone. On Windows `process.kill(-pgid)` is no call at all —
|
|
53
|
+
* there is no process group, and the browser's children hold the profile's files open — so the
|
|
54
|
+
* tree is walked by `taskkill`, which must run while the browser is still ALIVE: its children are
|
|
55
|
+
* found by parent pid, and a dead parent's children are nobody's.
|
|
56
|
+
*/
|
|
57
|
+
export async function killTree(
|
|
58
|
+
pid: number,
|
|
59
|
+
platform: string = process.platform,
|
|
60
|
+
run: RunCommand = runQuiet,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
if (platform !== 'win32') return reapGroup(pid);
|
|
63
|
+
try {
|
|
64
|
+
await within(CLOSE_GRACE_MS, run(taskkillArgv(pid)));
|
|
65
|
+
} catch {
|
|
66
|
+
// No taskkill, or a tree already gone (exit 128): litter at worst, never the close's verdict.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// One responsibility: the WIRE a launched Chrome is driven over, per platform — its debugging pipe
|
|
2
|
+
// (fds 3 and 4) where the parent can hold those fds, else a loopback port the browser picks and
|
|
3
|
+
// announces in its profile's `DevToolsActivePort`. Spawning and reaping are `cdp-launch-attempt.ts`.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no path-join primitive; the announcement file lives in the profile directory.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
8
|
+
import type { CdpConnection } from './cdp-connection';
|
|
9
|
+
import { cdpConnect, cdpConnectOver } from './cdp-connection';
|
|
10
|
+
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
11
|
+
import { pipeTransport } from './cdp-pipe';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `pipe` everywhere Bun hands the parent the child's extra fds as descriptors, which is POSIX: no
|
|
15
|
+
* port, so two suites on one machine never collide and nothing else can drive the browser.
|
|
16
|
+
* `port` on Windows, where those fds are not descriptors `Bun.file` can open: Chrome binds a
|
|
17
|
+
* loopback port of its own choosing (`=0`, so never a collision either) and writes it down.
|
|
18
|
+
*/
|
|
19
|
+
export type CdpWire = 'pipe' | 'port';
|
|
20
|
+
|
|
21
|
+
export const defaultWire = (platform: string): CdpWire => (platform === 'win32' ? 'port' : 'pipe');
|
|
22
|
+
|
|
23
|
+
/** The one flag that differs between the two wires. */
|
|
24
|
+
export const wireFlag = (wire: CdpWire): string =>
|
|
25
|
+
wire === 'pipe' ? '--remote-debugging-pipe' : '--remote-debugging-port=0';
|
|
26
|
+
|
|
27
|
+
/** Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies — pipe wire only. */
|
|
28
|
+
const PIPE_STDIO = ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] as const;
|
|
29
|
+
const PORT_STDIO = ['ignore', 'ignore', 'pipe'] as const;
|
|
30
|
+
export const wireStdio = (wire: CdpWire): typeof PIPE_STDIO | typeof PORT_STDIO =>
|
|
31
|
+
wire === 'pipe' ? PIPE_STDIO : PORT_STDIO;
|
|
32
|
+
|
|
33
|
+
/** The pipe wire over a spawned child's fds 3 and 4. Answers nothing until the first `send`. */
|
|
34
|
+
export function pipeConnection(stdio: unknown, timeoutMs: number): CdpConnection {
|
|
35
|
+
const [, , , toBrowser, fromBrowser] = stdio as readonly number[];
|
|
36
|
+
const sink = Bun.file(toBrowser ?? -1).writer();
|
|
37
|
+
return cdpConnectOver(
|
|
38
|
+
pipeTransport({
|
|
39
|
+
write: (bytes) => {
|
|
40
|
+
sink.write(bytes);
|
|
41
|
+
void sink.flush();
|
|
42
|
+
},
|
|
43
|
+
read: Bun.file(fromBrowser ?? -1).stream(),
|
|
44
|
+
end: () => {
|
|
45
|
+
void Promise.resolve(sink.end()).catch(() => undefined);
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
timeoutMs,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEVTOOLS_ACTIVE_PORT = 'DevToolsActivePort';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `<port>\n/devtools/browser/<id>\n` → the browser's WebSocket url, or `undefined` while the file is
|
|
56
|
+
* still half-written. Loopback by address, never `localhost`, which may resolve to `::1` first
|
|
57
|
+
* while Chrome listens on IPv4.
|
|
58
|
+
*/
|
|
59
|
+
export function devToolsEndpoint(text: string): string | undefined {
|
|
60
|
+
const [port, path] = text.split(/\r?\n/);
|
|
61
|
+
if (port === undefined || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65_535) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (path === undefined || !/^\/devtools\/browser\/[\w-]+$/.test(path)) return undefined;
|
|
65
|
+
return `ws://127.0.0.1:${port}${path}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ANNOUNCE_POLL_MS = 25;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Dial, inside what is left of the LAUNCH deadline — `cdpConnect`'s own deadline is the per-call one
|
|
72
|
+
* the connection keeps afterwards, so it cannot also be the launch's. A dial that loses the race
|
|
73
|
+
* and connects later is closed, never left holding a socket nobody reads.
|
|
74
|
+
*/
|
|
75
|
+
async function dialWithin(
|
|
76
|
+
endpoint: string,
|
|
77
|
+
leftMs: number,
|
|
78
|
+
timeoutMs: number,
|
|
79
|
+
): Promise<CdpConnection> {
|
|
80
|
+
const dialing = cdpConnect({ endpoint, timeoutMs });
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const late = new Promise<never>((_, reject) => {
|
|
83
|
+
timer = setTimeout(
|
|
84
|
+
() => reject(new CdpTimeoutError({ method: 'connect', timeoutMs: leftMs })),
|
|
85
|
+
leftMs,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([dialing, late]);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
void dialing.then((connection) => connection.close()).catch(() => undefined);
|
|
92
|
+
// A timeout stays one — the launcher reads it as the deadline. Any other refusal names the
|
|
93
|
+
// endpoint it was for: the browser announced it, so the dial is the step that failed.
|
|
94
|
+
if (error instanceof CdpTimeoutError) throw error;
|
|
95
|
+
throw new CdpCallFailedError({
|
|
96
|
+
method: `connect ${endpoint}`,
|
|
97
|
+
detail: renderThrowable(error),
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PortConnectionInput {
|
|
105
|
+
readonly profileDir: string;
|
|
106
|
+
/** True once the browser process has exited — it will never write the file then. */
|
|
107
|
+
readonly exited: () => boolean;
|
|
108
|
+
/** How long the browser has to announce itself AND accept the connection, from now. */
|
|
109
|
+
readonly deadlineMs: number;
|
|
110
|
+
/** Every call's deadline once connected. */
|
|
111
|
+
readonly timeoutMs: number;
|
|
112
|
+
/**
|
|
113
|
+
* How the announcement is read — `Bun.file(path).text()` unless a test hands a reader that
|
|
114
|
+
* refuses the way Windows does, which no Linux file system can be made to.
|
|
115
|
+
*/
|
|
116
|
+
readonly readText?: ((path: string) => Promise<string>) | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The read errors that mean "not announced YET", never "the launch failed": `ENOENT`, the file
|
|
121
|
+
* gone between the existence check and the read, and `EBUSY`, which is how Bun reports a Windows
|
|
122
|
+
* sharing violation — Chrome writes `DevToolsActivePort` in place and holds it open without read
|
|
123
|
+
* sharing while it does. Seen on windows-latest (PR #668): "DevTools listening" on stderr, the file
|
|
124
|
+
* present, the read refused, and the launch reported `closed` with the browser alive.
|
|
125
|
+
*/
|
|
126
|
+
const NOT_YET = new Set(['ENOENT', 'EBUSY']);
|
|
127
|
+
|
|
128
|
+
const readFile = (path: string): Promise<string> => Bun.file(path).text();
|
|
129
|
+
|
|
130
|
+
/** The announcement's text, or `undefined` while its writer still holds it. */
|
|
131
|
+
async function readAnnouncement(
|
|
132
|
+
path: string,
|
|
133
|
+
read: (path: string) => Promise<string>,
|
|
134
|
+
): Promise<string | undefined> {
|
|
135
|
+
try {
|
|
136
|
+
return await read(path);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (NOT_YET.has(stringField(error, 'code') ?? '')) return undefined;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The port wire: wait for the profile's `DevToolsActivePort`, then dial it. A browser that exits
|
|
145
|
+
* first is a CLOSED launch and one still silent at the deadline a TIMED-OUT one — the same two
|
|
146
|
+
* answers the pipe gives, so the launcher's failure report reads alike on either wire.
|
|
147
|
+
*/
|
|
148
|
+
export async function portConnection(input: PortConnectionInput): Promise<CdpConnection> {
|
|
149
|
+
const path = join(input.profileDir, DEVTOOLS_ACTIVE_PORT);
|
|
150
|
+
const began = performance.now();
|
|
151
|
+
for (;;) {
|
|
152
|
+
// A FRESH handle per look: a `BunFile` that once answered "absent" keeps answering it (measured
|
|
153
|
+
// on Bun 1.4.2 — `exists()` false and `text()` empty after the file was written).
|
|
154
|
+
const text = (await Bun.file(path).exists())
|
|
155
|
+
? await readAnnouncement(path, input.readText ?? readFile)
|
|
156
|
+
: undefined;
|
|
157
|
+
const endpoint = text === undefined ? undefined : devToolsEndpoint(text);
|
|
158
|
+
const left = input.deadlineMs - (performance.now() - began);
|
|
159
|
+
if (endpoint !== undefined) return dialWithin(endpoint, Math.max(1, left), input.timeoutMs);
|
|
160
|
+
if (input.exited()) {
|
|
161
|
+
throw new CdpCallFailedError({
|
|
162
|
+
method: 'the launch',
|
|
163
|
+
detail: `the browser exited before it wrote ${DEVTOOLS_ACTIVE_PORT}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (left <= 0) {
|
|
167
|
+
throw new CdpTimeoutError({ method: DEVTOOLS_ACTIVE_PORT, timeoutMs: input.deadlineMs });
|
|
168
|
+
}
|
|
169
|
+
await Bun.sleep(Math.min(ANNOUNCE_POLL_MS, left));
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/cdp-launch.ts
CHANGED
|
@@ -6,31 +6,38 @@ import { finiteCount } from '@ultimat3/core';
|
|
|
6
6
|
import type { CdpLaunchAttempt } from './cdp-errors';
|
|
7
7
|
import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
|
|
8
8
|
import type { LaunchedBrowser } from './cdp-launch-attempt';
|
|
9
|
-
import { launchAttempt } 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';
|
|
10
13
|
|
|
11
14
|
export type { LaunchedBrowser } from './cdp-launch-attempt';
|
|
12
|
-
export { CLOSE_GRACE_MS } 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';
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
|
|
16
|
-
* answer and the only one that can be right on a machine none of the rest describes; the
|
|
17
|
-
*
|
|
18
|
-
* 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.
|
|
19
26
|
*/
|
|
20
27
|
export const CHROME_PATH_ENV = 'CHROME_PATH';
|
|
21
|
-
export const CHROME_CANDIDATES: readonly string[] =
|
|
22
|
-
'/usr/bin/google-chrome',
|
|
23
|
-
'/usr/bin/google-chrome-stable',
|
|
24
|
-
'/usr/bin/chromium',
|
|
25
|
-
'/usr/bin/chromium-browser',
|
|
26
|
-
];
|
|
28
|
+
export const CHROME_CANDIDATES: readonly string[] = chromeCandidates(process.platform, process.env);
|
|
27
29
|
|
|
28
|
-
/**
|
|
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
|
+
*/
|
|
29
34
|
export async function findChrome(
|
|
30
35
|
env: Readonly<Record<string, string | undefined>>,
|
|
36
|
+
platform: string = process.platform,
|
|
31
37
|
): Promise<string | undefined> {
|
|
32
38
|
const declared = env[CHROME_PATH_ENV];
|
|
33
|
-
const candidates =
|
|
39
|
+
const candidates =
|
|
40
|
+
declared === undefined || declared === '' ? chromeCandidates(platform, env) : [declared];
|
|
34
41
|
for (const candidate of candidates) {
|
|
35
42
|
if (await Bun.file(candidate).exists()) return candidate;
|
|
36
43
|
}
|
|
@@ -52,15 +59,19 @@ export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disa
|
|
|
52
59
|
/**
|
|
53
60
|
* The flags, and every one of them earns its line.
|
|
54
61
|
*
|
|
55
|
-
* `--headless=new` is Chrome's own headless rather than the retired shim.
|
|
56
|
-
*
|
|
57
|
-
* machine can never collide, and nothing but this process
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
60
68
|
*/
|
|
61
|
-
export const chromeLaunchFlags = (
|
|
69
|
+
export const chromeLaunchFlags = (
|
|
70
|
+
profileDir: string,
|
|
71
|
+
wire: CdpWire = defaultWire(process.platform),
|
|
72
|
+
): readonly string[] => [
|
|
62
73
|
'--headless=new',
|
|
63
|
-
|
|
74
|
+
wireFlag(wire),
|
|
64
75
|
`--user-data-dir=${profileDir}`,
|
|
65
76
|
...CONTAINER_CHROME_ARGS,
|
|
66
77
|
'--disable-gpu',
|
|
@@ -96,12 +107,22 @@ export const LAUNCH_TIMEOUT_MS = 60_000;
|
|
|
96
107
|
*/
|
|
97
108
|
export const LAUNCH_ATTEMPTS = 2;
|
|
98
109
|
|
|
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);
|
|
117
|
+
|
|
99
118
|
export interface LaunchOptions {
|
|
100
119
|
readonly executable: string;
|
|
101
120
|
/** Every CDP call's deadline once the browser is up. */
|
|
102
121
|
readonly timeoutMs: number;
|
|
103
122
|
/** The first answer's deadline, per start. Defaults to the larger of `timeoutMs` and `LAUNCH_TIMEOUT_MS`. */
|
|
104
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;
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
/**
|
|
@@ -117,11 +138,13 @@ export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrow
|
|
|
117
138
|
options.launchTimeoutMs ?? Math.max(options.timeoutMs, LAUNCH_TIMEOUT_MS),
|
|
118
139
|
1,
|
|
119
140
|
);
|
|
141
|
+
const wire = options.wire ?? defaultWire(process.platform);
|
|
120
142
|
const failures: CdpLaunchAttempt[] = [];
|
|
121
143
|
while (failures.length < LAUNCH_ATTEMPTS) {
|
|
122
144
|
const started = await launchAttempt({
|
|
123
145
|
executable: options.executable,
|
|
124
|
-
flags: chromeLaunchFlags,
|
|
146
|
+
flags: (profileDir) => chromeLaunchFlags(profileDir, wire),
|
|
147
|
+
wire,
|
|
125
148
|
timeoutMs: options.timeoutMs,
|
|
126
149
|
launchTimeoutMs,
|
|
127
150
|
});
|
|
@@ -140,6 +163,8 @@ export async function launchFoundChrome(
|
|
|
140
163
|
timeoutMs: number,
|
|
141
164
|
): Promise<LaunchedBrowser> {
|
|
142
165
|
const executable = await findChrome(env);
|
|
143
|
-
if (executable === undefined)
|
|
166
|
+
if (executable === undefined) {
|
|
167
|
+
throw new CdpBrowserMissingError({ tried: chromeCandidates(process.platform, env) });
|
|
168
|
+
}
|
|
144
169
|
return launchChrome({ executable, timeoutMs });
|
|
145
170
|
}
|
|
@@ -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
|
@@ -191,10 +191,10 @@ 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
200
|
// Both moves announced, as `setFrozenClock` announces one: the frozen scheduler renews leases on
|
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,6 +53,18 @@ 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,
|
package/src/e2e-errors.ts
CHANGED
|
@@ -95,11 +95,22 @@ export class E2eLocatorAmbiguousError extends UltimateError {
|
|
|
95
95
|
* test that hangs, and CI then reports a runner timeout with no assertion anywhere in it.
|
|
96
96
|
*/
|
|
97
97
|
export class E2eServiceWorkerAbsentError extends UltimateError {
|
|
98
|
-
constructor(input: {
|
|
98
|
+
constructor(input: {
|
|
99
|
+
readonly url: string;
|
|
100
|
+
readonly timeoutMs: number;
|
|
101
|
+
/**
|
|
102
|
+
* True when a worker was ACTIVE and the page was reloaded once for it to answer — a first
|
|
103
|
+
* page `clients.claim()` missed (#678) — and the reload was not controlled either.
|
|
104
|
+
*/
|
|
105
|
+
readonly reloaded?: boolean | undefined;
|
|
106
|
+
}) {
|
|
99
107
|
super({
|
|
100
108
|
code: 'X_E2E_SERVICE_WORKER_ABSENT',
|
|
101
|
-
cause:
|
|
102
|
-
|
|
109
|
+
cause:
|
|
110
|
+
input.reloaded === true
|
|
111
|
+
? `a service worker is active for ${renderCauseValue(input.url)} but did not control it, even reloaded once, within ${String(input.timeoutMs)}ms`
|
|
112
|
+
: `no service worker took control of ${renderCauseValue(input.url)} within ${String(input.timeoutMs)}ms`,
|
|
113
|
+
fix: `x build --target static — the worker is generated by the pwa build; then confirm the route at ${renderFixLiteral(input.url, URL_PLACEHOLDER)} registers it, or raise this call's budget: await page.waitForServiceWorker({ timeoutMs: 30_000 })`,
|
|
103
114
|
});
|
|
104
115
|
}
|
|
105
116
|
}
|
package/src/e2e-locale.ts
CHANGED
|
@@ -1,30 +1,21 @@
|
|
|
1
|
-
// The locale an e2e run's browser asks for: the app's
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import { join } from 'node:path'; // why: Bun ships no path join.
|
|
6
|
-
|
|
7
|
-
/** The app root's config module and the export holding the config — `x new` writes both. */
|
|
8
|
-
const APP_CONFIG_FILE = 'app.config.ts';
|
|
9
|
-
const APP_CONFIG_EXPORT = 'config';
|
|
1
|
+
// The locale an e2e run's browser asks for: the default the app's `defineCatalogs()` declared —
|
|
2
|
+
// the one place an app names its locales since 25.0.0 (`app.config.ts`'s `defaultLocale` and
|
|
3
|
+
// `locales` keys were deleted). Read through `@ultimat3/i18n/app-catalogs`, the one reader the CLI
|
|
4
|
+
// shares; what is this file's own is the answer to a module that will not load.
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
|
-
* The app's
|
|
13
|
-
* Never a guessed `en`:
|
|
14
|
-
*
|
|
15
|
-
* The import runs the
|
|
7
|
+
* The app's default locale, or `undefined` when it cannot be read — and then nothing is pinned.
|
|
8
|
+
* Never a guessed `en`: `loadAppCatalogs` answers only when a `defineCatalogs()` was declared.
|
|
9
|
+
* Pinning the wrong language would make every run photograph and assert on a page no visitor at
|
|
10
|
+
* the unprefixed URL sees. The import runs the app's module, which may validate an environment
|
|
11
|
+
* this process lacks — so a module that throws is "nothing pinned", never a failed run.
|
|
16
12
|
*/
|
|
17
13
|
export async function e2eDefaultLocale(root: string): Promise<string | undefined> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let module: Record<string, unknown>;
|
|
14
|
+
// Dynamic, like every non-core dependency of this package: only the e2e preload pays for it.
|
|
15
|
+
const { loadAppCatalogs } = await import('@ultimat3/i18n/app-catalogs');
|
|
21
16
|
try {
|
|
22
|
-
|
|
17
|
+
return (await loadAppCatalogs(root))?.defaultLocale;
|
|
23
18
|
} catch {
|
|
24
19
|
return undefined;
|
|
25
20
|
}
|
|
26
|
-
const config = Object.hasOwn(module, APP_CONFIG_EXPORT) ? module[APP_CONFIG_EXPORT] : undefined;
|
|
27
|
-
if (typeof config !== 'object' || config === null) return undefined;
|
|
28
|
-
const locale = (config as Record<string, unknown>)['defaultLocale'];
|
|
29
|
-
return typeof locale === 'string' && locale !== '' ? locale : undefined;
|
|
30
21
|
}
|
package/src/e2e-page.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// may import the other — so the join is here, in the one package allowed to know about both.
|
|
4
4
|
|
|
5
5
|
import { finiteCount } from '@ultimat3/core';
|
|
6
|
-
import { E2eServiceWorkerAbsentError } from './e2e-errors';
|
|
7
6
|
import { evaluateClosure } from './e2e-evaluate';
|
|
8
7
|
import { e2eLocator } from './e2e-locator';
|
|
9
8
|
import type { E2eSelection } from './e2e-selection';
|
|
9
|
+
import { callBudget, waitForServiceWorker } from './e2e-service-worker';
|
|
10
10
|
import type { LocatorLike, PageLike } from './test-types';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -39,7 +39,10 @@ export interface E2ePageOptions {
|
|
|
39
39
|
readonly baseUrl: string;
|
|
40
40
|
/** Per-navigation deadline, handed to the driver rather than enforced here. */
|
|
41
41
|
readonly timeoutMs?: number | undefined;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* How long `waitForServiceWorker()` waits before refusing, the handover reload included. Bounded
|
|
44
|
+
* IN THE PAGE. One test raises its own with `waitForServiceWorker({ timeoutMs })`.
|
|
45
|
+
*/
|
|
43
46
|
readonly serviceWorkerTimeoutMs?: number | undefined;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -77,24 +80,6 @@ const firstFlushExpression = (url: string): string =>
|
|
|
77
80
|
.then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
|
|
78
81
|
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
79
82
|
|
|
80
|
-
/**
|
|
81
|
-
* `ready` alone is not control: a first load activates a worker that is not yet the page's
|
|
82
|
-
* controller, and an offline assertion made in that window tests nothing. So this waits for
|
|
83
|
-
* `controllerchange` too — an EVENT, not a poll, so the harness still has exactly one retry loop.
|
|
84
|
-
*/
|
|
85
|
-
const serviceWorkerExpression = (timeoutMs: number): string =>
|
|
86
|
-
`(() => {
|
|
87
|
-
if (!navigator.serviceWorker) return JSON.stringify({ controlled: false });
|
|
88
|
-
const controlled = new Promise((resolve) => {
|
|
89
|
-
if (navigator.serviceWorker.controller) { resolve(true); return; }
|
|
90
|
-
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(true), { once: true });
|
|
91
|
-
});
|
|
92
|
-
const deadline = new Promise((resolve) => setTimeout(() => resolve(false), ${String(timeoutMs)}));
|
|
93
|
-
return Promise.race([navigator.serviceWorker.ready.then(() => controlled), deadline])
|
|
94
|
-
.catch(() => false)
|
|
95
|
-
.then((ok) => JSON.stringify({ controlled: ok === true }));
|
|
96
|
-
})()`;
|
|
97
|
-
|
|
98
83
|
/**
|
|
99
84
|
* The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
|
|
100
85
|
* navigation the test makes, so nothing here may capture a URL, a document or an element.
|
|
@@ -132,12 +117,13 @@ export function e2ePage(options: E2ePageOptions): PageLike {
|
|
|
132
117
|
const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
|
|
133
118
|
return { html: typeof html === 'string' ? html : '' };
|
|
134
119
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
120
|
+
// The handover is `e2e-service-worker.ts`'s: an unclaimed first page is reloaded once (#678).
|
|
121
|
+
waitForServiceWorker: async (waitOptions) =>
|
|
122
|
+
waitForServiceWorker({
|
|
123
|
+
page,
|
|
124
|
+
timeoutMs: callBudget(swTimeout, waitOptions?.timeoutMs),
|
|
125
|
+
navigationTimeoutMs: timeout,
|
|
126
|
+
}),
|
|
141
127
|
evaluate: <T>(fn: () => T): Promise<T> => evaluateClosure(page, fn) as unknown as Promise<T>,
|
|
142
128
|
locator: (selector) => locate({ kind: 'css', selector, first: false }),
|
|
143
129
|
getByRole: (role, roleOptions) =>
|