@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// One responsibility: ONE start of a Chrome process — spawn it on a throwaway profile, wait for its
|
|
2
|
+
// first DevTools answer, and on no answer reap it (killed, awaited, profile removed) and say why.
|
|
3
|
+
// Which binary, which flags and how many starts is `cdp-launch.ts`.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import type { CdpConnection } from './cdp-connection';
|
|
7
|
+
import type { CdpLaunchAttempt } from './cdp-errors';
|
|
8
|
+
import { CdpTimeoutError } from './cdp-errors';
|
|
9
|
+
import { throwawayProfile } from './cdp-launch-profile';
|
|
10
|
+
import { CLOSE_GRACE_MS, killTree } from './cdp-launch-reap';
|
|
11
|
+
import type { CdpWire } from './cdp-launch-wire';
|
|
12
|
+
import { pipeConnection, portConnection, wireStdio } from './cdp-launch-wire';
|
|
13
|
+
|
|
14
|
+
export { CLOSE_GRACE_MS } from './cdp-launch-reap';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The longest `LaunchedBrowser.close()` is DESIGNED to take: SIGTERM's grace, SIGKILL's, the group
|
|
18
|
+
* reap, then the profile's removal — four `CLOSE_GRACE_MS`, each step bounded by its own. A hook
|
|
19
|
+
* that closes a browser and gives it less is killed by Bun mid-close, and when that hook is the
|
|
20
|
+
* run's last the process exits with the profile half-removed: the `Default/Cache` tree the leaked
|
|
21
|
+
* `x-e2e-chrome-*` directories held.
|
|
22
|
+
*/
|
|
23
|
+
export const LAUNCHED_CLOSE_MS = 4 * CLOSE_GRACE_MS;
|
|
24
|
+
|
|
25
|
+
export interface LaunchedBrowser {
|
|
26
|
+
/** The browser's own CDP connection, over its debugging pipe or port. Already answering. */
|
|
27
|
+
readonly connection: CdpConnection;
|
|
28
|
+
/**
|
|
29
|
+
* THE close, idempotent, and awaited by every caller: the connection closed, SIGTERM, SIGKILL
|
|
30
|
+
* after `CLOSE_GRACE_MS`, then the browser's whole process group killed and, only then, the
|
|
31
|
+
* profile removed — bounded at `LAUNCHED_CLOSE_MS`. On Windows, which has no SIGTERM to grant grace to, the process TREE
|
|
32
|
+
* is killed outright (`killTree`) and awaited. A promise because a Chrome still shutting down competes with the next
|
|
33
|
+
* launch on a 4-CPU runner, and its children re-create a profile removed before they are gone.
|
|
34
|
+
*/
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LaunchAttemptOptions {
|
|
39
|
+
readonly executable: string;
|
|
40
|
+
/** What the process is started with, given the profile directory this attempt made for it. */
|
|
41
|
+
readonly flags: (profileDir: string) => readonly string[];
|
|
42
|
+
/** Which wire the flags opened — `cdp-launch-wire.ts`. */
|
|
43
|
+
readonly wire: CdpWire;
|
|
44
|
+
/** Whose process rules the reap follows. Defaults to this process's platform. */
|
|
45
|
+
readonly platform?: string | undefined;
|
|
46
|
+
/** Every call's deadline AFTER the first. */
|
|
47
|
+
readonly timeoutMs: number;
|
|
48
|
+
/** The first call's deadline: how long a cold start may take. */
|
|
49
|
+
readonly launchTimeoutMs: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LaunchAttemptResult =
|
|
53
|
+
| { readonly ok: true; readonly browser: LaunchedBrowser }
|
|
54
|
+
/** `reaped: false` is a process that outlived SIGKILL's grace — never start another beside it. */
|
|
55
|
+
| { readonly ok: false; readonly failure: CdpLaunchAttempt; readonly reaped: boolean };
|
|
56
|
+
|
|
57
|
+
const STDERR_TAIL_CHARS = 4_000;
|
|
58
|
+
/** How much of the tail one attempt hands a cause: the last lines, each cut to a readable width. */
|
|
59
|
+
const TAIL_LINES = 3;
|
|
60
|
+
const TAIL_LINE_CHARS = 160;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
|
|
64
|
+
* reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
|
|
65
|
+
* stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
|
|
66
|
+
* a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
|
|
67
|
+
*/
|
|
68
|
+
function stderrTail(stream: ReadableStream<Uint8Array>): {
|
|
69
|
+
readonly lastLines: () => string;
|
|
70
|
+
/** Settles once the stream has ended — every byte the process wrote has been read. */
|
|
71
|
+
readonly drained: Promise<void>;
|
|
72
|
+
} {
|
|
73
|
+
let text = '';
|
|
74
|
+
const drained = (async () => {
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
for await (const chunk of stream) {
|
|
77
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
|
|
78
|
+
}
|
|
79
|
+
})().catch(() => undefined);
|
|
80
|
+
const lastLines = (): string =>
|
|
81
|
+
text
|
|
82
|
+
.trim()
|
|
83
|
+
.split('\n')
|
|
84
|
+
.slice(-TAIL_LINES)
|
|
85
|
+
.map((line) => line.slice(0, TAIL_LINE_CHARS))
|
|
86
|
+
.join(' | ');
|
|
87
|
+
return { lastLines, drained };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How long a browser that CLOSED ITS PIPE gets to finish dying, and any browser's stderr to finish
|
|
92
|
+
* draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
|
|
93
|
+
* are two unordered events; read at the first, the reason a browser died was reported as "printed
|
|
94
|
+
* nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
|
|
95
|
+
*/
|
|
96
|
+
const FAILURE_DRAIN_MS = 1_000;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The longest a FAILED attempt spends after its deadline before the next can start: the drain a
|
|
100
|
+
* browser that hung up gets, the bounded close (`LAUNCHED_CLOSE_MS`), then the stderr drain. A
|
|
101
|
+
* caller's hook deadline adds this per attempt.
|
|
102
|
+
*/
|
|
103
|
+
export const LAUNCH_REAP_MS = FAILURE_DRAIN_MS + LAUNCHED_CLOSE_MS + FAILURE_DRAIN_MS;
|
|
104
|
+
|
|
105
|
+
const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
|
|
106
|
+
Promise.race([work, Bun.sleep(ms)]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Start the browser once and answer when it has answered one CDP call. With a pipe there is no
|
|
110
|
+
* "DevTools listening" line to wait for — the first reply IS the readiness signal.
|
|
111
|
+
*/
|
|
112
|
+
export async function launchAttempt(options: LaunchAttemptOptions): Promise<LaunchAttemptResult> {
|
|
113
|
+
const platform = options.platform ?? process.platform;
|
|
114
|
+
const profile = throwawayProfile();
|
|
115
|
+
const profileDir = profile.path;
|
|
116
|
+
const began = performance.now();
|
|
117
|
+
let child: ReturnType<typeof spawnBrowser>;
|
|
118
|
+
try {
|
|
119
|
+
child = spawnBrowser(options, profileDir, platform);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// A binary that cannot be started at all — not there, not executable, not an executable FORMAT
|
|
122
|
+
// this OS runs (a `#!` script on Windows). Bun throws from `spawn`, synchronously, and that
|
|
123
|
+
// throw escaped as a bare ENOENT where every other way a launch fails is X_CDP_LAUNCH_FAILED.
|
|
124
|
+
await profile.remove(undefined, CLOSE_GRACE_MS);
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
failure: {
|
|
128
|
+
why: 'spawn',
|
|
129
|
+
waitedMs: 0,
|
|
130
|
+
exitCode: null,
|
|
131
|
+
stderr: '',
|
|
132
|
+
detail: renderThrowable(error),
|
|
133
|
+
},
|
|
134
|
+
reaped: true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
|
|
138
|
+
let connection: CdpConnection | undefined =
|
|
139
|
+
options.wire === 'pipe' ? pipeConnection(child.stdio, options.timeoutMs) : undefined;
|
|
140
|
+
let exiting: Promise<void> | undefined;
|
|
141
|
+
const close = (): Promise<void> => {
|
|
142
|
+
exiting ??= (async () => {
|
|
143
|
+
// Read while the browser still runs: a clean exit unlinks it, a killed one never does.
|
|
144
|
+
const singleton = profile.singletonDir();
|
|
145
|
+
connection?.close();
|
|
146
|
+
if (platform === 'win32') {
|
|
147
|
+
// No SIGTERM to grant grace to: a kill on Windows is TerminateProcess, and taking the
|
|
148
|
+
// browser alone would orphan the children that hold the profile's files open.
|
|
149
|
+
await killTree(child.pid, platform);
|
|
150
|
+
await within(CLOSE_GRACE_MS, child.exited);
|
|
151
|
+
} else {
|
|
152
|
+
child.kill();
|
|
153
|
+
const exited =
|
|
154
|
+
(await within(
|
|
155
|
+
CLOSE_GRACE_MS,
|
|
156
|
+
child.exited.then(() => true),
|
|
157
|
+
)) === true;
|
|
158
|
+
if (!exited) {
|
|
159
|
+
child.kill('SIGKILL');
|
|
160
|
+
await within(CLOSE_GRACE_MS, child.exited);
|
|
161
|
+
}
|
|
162
|
+
await killTree(child.pid, platform);
|
|
163
|
+
}
|
|
164
|
+
// Only now, with the tree gone: removed under a live browser, the profile is re-created by
|
|
165
|
+
// its shutdown — the network process flushes its cache, the browser writes its prefs — and
|
|
166
|
+
// a close cut short there leaves exactly that tree. Measured on Chrome 150, 30 launches
|
|
167
|
+
// each: left behind 6 and 12 times without the group reap, 0 with.
|
|
168
|
+
await profile.remove(singleton, CLOSE_GRACE_MS);
|
|
169
|
+
})();
|
|
170
|
+
return exiting;
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
// The pipe needs no wait to exist; the port is not known until the browser writes it down, and
|
|
174
|
+
// finding it spends the same launch deadline the first answer does.
|
|
175
|
+
connection ??= await portConnection({
|
|
176
|
+
profileDir,
|
|
177
|
+
exited: () => child.exitCode !== null || child.signalCode !== null,
|
|
178
|
+
deadlineMs: options.launchTimeoutMs,
|
|
179
|
+
timeoutMs: options.timeoutMs,
|
|
180
|
+
});
|
|
181
|
+
const left = Math.max(1, options.launchTimeoutMs - (performance.now() - began));
|
|
182
|
+
await connection.send('Browser.getVersion', {}, undefined, left);
|
|
183
|
+
return { ok: true, browser: { connection, close } };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
|
|
186
|
+
// The deadline itself when that is what ended it: the number the reader can raise.
|
|
187
|
+
const waitedMs =
|
|
188
|
+
why === 'deadline' ? options.launchTimeoutMs : Math.round(performance.now() - began);
|
|
189
|
+
// Only a browser that hung up may be on its way out with the reason still unwritten. One that
|
|
190
|
+
// was silent for the whole deadline has had its time.
|
|
191
|
+
if (why === 'closed') await within(FAILURE_DRAIN_MS, child.exited);
|
|
192
|
+
// Read BEFORE the kill: after it, an exit code is ours and says nothing about the browser.
|
|
193
|
+
const exitCode = child.exitCode;
|
|
194
|
+
await close();
|
|
195
|
+
await within(FAILURE_DRAIN_MS, tail.drained);
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
// `detail`: the error itself. Without it a refused dial after "DevTools listening" read as a
|
|
199
|
+
// browser that hung up (seen on windows), and nothing said which step had failed.
|
|
200
|
+
failure: {
|
|
201
|
+
why,
|
|
202
|
+
waitedMs,
|
|
203
|
+
exitCode,
|
|
204
|
+
stderr: tail.lastLines(),
|
|
205
|
+
detail: renderThrowable(error),
|
|
206
|
+
},
|
|
207
|
+
reaped: child.exitCode !== null || child.signalCode !== null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The process, on its own process GROUP on POSIX so the zygote, GPU and network processes are
|
|
214
|
+
* reaped with it (`killTree`). It still ends with this process on the pipe wire: Chrome exits when
|
|
215
|
+
* its fd 3 closes. Windows has no group to make — `detached` there is a console flag — so not.
|
|
216
|
+
*/
|
|
217
|
+
function spawnBrowser(options: LaunchAttemptOptions, profileDir: string, platform: string) {
|
|
218
|
+
// Spread into a fresh tuple: Bun's `stdio` type takes a mutable tuple, never a readonly one.
|
|
219
|
+
const [stdin, stdout, stderr, ...extra] = wireStdio(options.wire);
|
|
220
|
+
return Bun.spawn([options.executable, ...options.flags(profileDir)], {
|
|
221
|
+
stdio: [stdin, stdout, stderr, ...extra],
|
|
222
|
+
detached: platform !== 'win32',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// One responsibility: WHERE a Chrome is installed, per platform — the ordered list `findChrome`
|
|
2
|
+
// probes when `CHROME_PATH` names none. Which binary wins and how it starts is `cdp-launch.ts`.
|
|
3
|
+
|
|
4
|
+
// why: a Windows candidate is spelled with `\` whatever OS computes the list (a test, or the
|
|
5
|
+
// Linux runner asserting it), and only node:path's win32 half joins that way. Bun has no path API.
|
|
6
|
+
import { win32 } from 'node:path';
|
|
7
|
+
|
|
8
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
9
|
+
|
|
10
|
+
/** What GitHub-hosted `ubuntu-latest` ships, which lets CI run the browser suite with no download. */
|
|
11
|
+
const LINUX: readonly string[] = [
|
|
12
|
+
'/usr/bin/google-chrome',
|
|
13
|
+
'/usr/bin/google-chrome-stable',
|
|
14
|
+
'/usr/bin/chromium',
|
|
15
|
+
'/usr/bin/chromium-browser',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const set = (value: string | undefined): string | undefined =>
|
|
19
|
+
value === undefined || value === '' ? undefined : value;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Chrome under each root an installer uses — machine-wide 64- and 32-bit, then per-user — then
|
|
23
|
+
* Edge, which is Chromium, speaks the same DevTools protocol and is on every Windows 10 and 11. A
|
|
24
|
+
* root the environment does not name falls back to its fixed default; a per-user root has none, so
|
|
25
|
+
* it is not guessed at.
|
|
26
|
+
*/
|
|
27
|
+
const windows = (env: Env): readonly string[] => {
|
|
28
|
+
const programFiles = set(env['ProgramFiles']) ?? 'C:\\Program Files';
|
|
29
|
+
const programFilesX86 = set(env['ProgramFiles(x86)']) ?? 'C:\\Program Files (x86)';
|
|
30
|
+
const localAppData = set(env['LOCALAPPDATA']);
|
|
31
|
+
const chromeRoots = [programFiles, programFilesX86, ...(localAppData ? [localAppData] : [])];
|
|
32
|
+
return [
|
|
33
|
+
...chromeRoots.map((root) => win32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe')),
|
|
34
|
+
// x86 first for Edge: it is where the Windows image installs it, 64-bit or not.
|
|
35
|
+
...[programFilesX86, programFiles].map((root) =>
|
|
36
|
+
win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
37
|
+
),
|
|
38
|
+
];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const MAC_CHROME = 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
42
|
+
|
|
43
|
+
/** The app bundles' executables: system-wide, then the per-user `~/Applications` an admin-less install uses. */
|
|
44
|
+
const mac = (env: Env): readonly string[] => {
|
|
45
|
+
const home = set(env['HOME']);
|
|
46
|
+
return [
|
|
47
|
+
`/${MAC_CHROME}`,
|
|
48
|
+
...(home ? [`${home}/${MAC_CHROME}`] : []),
|
|
49
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
50
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
51
|
+
];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where a Chrome is on `platform`, in the order worth trying. Any platform that is neither Windows
|
|
56
|
+
* nor macOS gets the Linux paths — the BSDs install to the same names when they install at all.
|
|
57
|
+
*/
|
|
58
|
+
export function chromeCandidates(platform: string, env: Env): readonly string[] {
|
|
59
|
+
if (platform === 'win32') return windows(env);
|
|
60
|
+
if (platform === 'darwin') return mac(env);
|
|
61
|
+
return LINUX;
|
|
62
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// One responsibility: the throwaway profile a launch hands Chrome, and its removal — after every
|
|
2
|
+
// process of the browser is gone, retried while something re-creates it, bounded — together with
|
|
3
|
+
// the singleton-socket directory Chrome makes BESIDE it in the temp root.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no temp-root, recursive-remove or readlink primitive; the profile needs all three.
|
|
6
|
+
import { existsSync, mkdtempSync, readdirSync, readlinkSync, rmdirSync, 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 { basename, dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long a removed profile is watched for coming back before it counts as gone. Chrome's late
|
|
14
|
+
* writers are the network service flushing its disk cache and the browser's atomic preference
|
|
15
|
+
* write (`Default/.com.google.Chrome.XXXXXX`) — the exact tree the leaked profiles held. Removal
|
|
16
|
+
* waits for the process tree first, so the look is for what the tree kill cannot see: a process
|
|
17
|
+
* outside the group (Chrome's crashpad handler calls `setsid`), and on Windows a child `taskkill`
|
|
18
|
+
* has not finished ending when it returns.
|
|
19
|
+
*/
|
|
20
|
+
export const PROFILE_SETTLE_MS = 50;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What Chrome's ProcessSingleton puts in its socket directory, and nothing else. The directory is
|
|
24
|
+
* `<temp>/com.google.Chrome.XXXXXX` (Chromium: `org.chromium.Chromium.…`), linked from the profile's
|
|
25
|
+
* `SingletonSocket`, and a browser that does not shut down cleanly never removes it: measured, one
|
|
26
|
+
* left in the temp root per launch — 444 of them beside 146 leaked profiles.
|
|
27
|
+
*/
|
|
28
|
+
const SINGLETON_FILES: ReadonlySet<string> = new Set([
|
|
29
|
+
'SingletonSocket',
|
|
30
|
+
'SingletonCookie',
|
|
31
|
+
'SingletonLock',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export interface ThrowawayProfile {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
/**
|
|
37
|
+
* Where the browser's singleton socket lives, read off the profile's own link. Asked BEFORE the
|
|
38
|
+
* browser is signalled: a browser that exits cleanly removes the link first, and one killed
|
|
39
|
+
* leaves the directory with nothing in the profile pointing at it.
|
|
40
|
+
*/
|
|
41
|
+
singletonDir(): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Remove the profile — and `singleton`, when it holds Chrome's singleton files and nothing else —
|
|
44
|
+
* and answer once neither has come back for `PROFILE_SETTLE_MS`, or at `deadlineMs`. Never throws:
|
|
45
|
+
* a directory that will not go is litter, never the close's verdict.
|
|
46
|
+
*/
|
|
47
|
+
remove(singleton: string | undefined, deadlineMs: number): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const quietly = (work: () => void): void => {
|
|
51
|
+
try {
|
|
52
|
+
work();
|
|
53
|
+
} catch {
|
|
54
|
+
// Already gone, or held open past the deadline: the loop's next look decides which.
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Only a directory whose every entry is a singleton file is Chrome's to have made: a link that
|
|
60
|
+
* points anywhere else is somebody's directory, and is left alone.
|
|
61
|
+
*/
|
|
62
|
+
function isSingletonDir(dir: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
return readdirSync(dir).every((entry) => SINGLETON_FILES.has(entry));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** File by file, then a plain `rmdir` — never a recursive remove of a path read off a link. */
|
|
71
|
+
function removeSingleton(dir: string): void {
|
|
72
|
+
for (const entry of SINGLETON_FILES) quietly(() => rmSync(join(dir, entry), { force: true }));
|
|
73
|
+
quietly(() => rmdirSync(dir));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fresh profile directory under the temp root, named so a leak is recognisable as ours. */
|
|
77
|
+
export function throwawayProfile(root: string = tmpdir()): ThrowawayProfile {
|
|
78
|
+
const path = mkdtempSync(join(root, 'x-e2e-chrome-'));
|
|
79
|
+
return {
|
|
80
|
+
path,
|
|
81
|
+
singletonDir() {
|
|
82
|
+
try {
|
|
83
|
+
const target = readlinkSync(join(path, 'SingletonSocket'));
|
|
84
|
+
return basename(target) === 'SingletonSocket' ? dirname(target) : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async remove(singleton, deadlineMs) {
|
|
90
|
+
const until = performance.now() + deadlineMs;
|
|
91
|
+
const owned = singleton !== undefined && isSingletonDir(singleton) ? singleton : undefined;
|
|
92
|
+
const present = (): boolean => existsSync(path) || (owned !== undefined && existsSync(owned));
|
|
93
|
+
do {
|
|
94
|
+
quietly(() => rmSync(path, { recursive: true, force: true }));
|
|
95
|
+
if (owned !== undefined) removeSingleton(owned);
|
|
96
|
+
if (present()) {
|
|
97
|
+
await Bun.sleep(PROFILE_SETTLE_MS / 5);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Gone NOW is not gone: the look after the settle is what a late writer fails.
|
|
101
|
+
await Bun.sleep(PROFILE_SETTLE_MS);
|
|
102
|
+
if (!present()) return;
|
|
103
|
+
} while (performance.now() < until);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|