@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.
Files changed (49) hide show
  1. package/CLAUDE.md +12 -12
  2. package/README.md +55 -18
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/cdp-browser-lease.ts +112 -0
  6. package/src/cdp-browser.ts +42 -1
  7. package/src/cdp-connection.ts +18 -4
  8. package/src/cdp-e2e-session.ts +120 -12
  9. package/src/cdp-errors.ts +25 -9
  10. package/src/cdp-launch-attempt.ts +112 -80
  11. package/src/cdp-launch-candidates.ts +62 -0
  12. package/src/cdp-launch-profile.ts +106 -0
  13. package/src/cdp-launch-reap.ts +68 -0
  14. package/src/cdp-launch-wire.ts +171 -0
  15. package/src/cdp-launch.ts +47 -22
  16. package/src/define-island-states.ts +47 -8
  17. package/src/determinism.ts +2 -2
  18. package/src/e2e-app.ts +14 -1
  19. package/src/e2e-errors.ts +14 -3
  20. package/src/e2e-locale.ts +12 -21
  21. package/src/e2e-page.ts +12 -26
  22. package/src/e2e-run.ts +21 -11
  23. package/src/e2e-service-worker.ts +92 -0
  24. package/src/e2e-spawn.ts +57 -9
  25. package/src/errors.ts +20 -65
  26. package/src/fixture-clock.ts +1 -1
  27. package/src/fixture-island.ts +39 -56
  28. package/src/fixture-jobs.ts +7 -7
  29. package/src/fixture-mail.ts +3 -3
  30. package/src/fixture-network.ts +1 -1
  31. package/src/fixture-statements.ts +1 -1
  32. package/src/fixture-subscribe.ts +3 -3
  33. package/src/framework-fixtures.ts +13 -13
  34. package/src/index.ts +17 -10
  35. package/src/island-dom.ts +49 -9
  36. package/src/island-globals.ts +73 -0
  37. package/src/island-scratch.ts +67 -3
  38. package/src/island-state-errors.ts +16 -0
  39. package/src/island-states.ts +23 -0
  40. package/src/live-node.ts +4 -4
  41. package/src/quiet-logs.ts +1 -1
  42. package/src/registry-leak-error.ts +81 -0
  43. package/src/registry-leak-guard.ts +4 -3
  44. package/src/registry-snapshot.ts +19 -1
  45. package/src/render-view.ts +3 -3
  46. package/src/sealed-network.ts +45 -4
  47. package/src/sealed-sockets.ts +59 -0
  48. package/src/template-db.ts +2 -2
  49. package/src/test-types.ts +6 -2
@@ -35,11 +35,25 @@ export interface CdpE2eSessionOptions {
35
35
  readonly acceptLanguage?: string | undefined;
36
36
  }
37
37
 
38
- const POLL_MS = 50;
38
+ /** What a page reports to its own code — the state a test's next `fetch` runs under. */
39
+ const ON_LINE = 'navigator.onLine';
40
+
41
+ /**
42
+ * Answers `true` at once when the page already reads `online`, else on the `online`/`offline`
43
+ * event that flips it. The read and the `addEventListener` run in ONE task, so the event cannot
44
+ * land between them. `false` only when the event came and the state still disagrees: ask again.
45
+ */
46
+ const readsOnLine = (online: boolean): string => {
47
+ const reads = `${ON_LINE} === ${String(online)}`;
48
+ const flip = online ? 'online' : 'offline';
49
+ return `${reads} || new Promise((resolve) => addEventListener('${flip}', () => resolve(${reads}), { once: true }))`;
50
+ };
51
+
52
+ const member = (from: unknown, key: string): unknown =>
53
+ typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
39
54
 
40
55
  const field = (from: unknown, key: string): string | undefined => {
41
- const value =
42
- typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
56
+ const value = member(from, key);
43
57
  return typeof value === 'string' ? value : undefined;
44
58
  };
45
59
 
@@ -55,6 +69,8 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
55
69
  const send = connection.send.bind(connection);
56
70
  const sessions = new Set<string>();
57
71
  const pages = new Map<string, string>(); // targetId → sessionId
72
+ // targetId → the `newTab()` waiting for that tab to be published.
73
+ const published = new Map<string, (session: string) => void>();
58
74
  const scripts: string[] = [];
59
75
  const sockets: string[] = [];
60
76
  const requests: string[] = [];
@@ -137,6 +153,7 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
137
153
  const targetId = field(info, 'targetId');
138
154
  if (type === 'page' && targetId !== undefined && sessions.has(session)) {
139
155
  pages.set(targetId, session);
156
+ published.get(targetId)?.(session);
140
157
  }
141
158
  })();
142
159
  });
@@ -147,6 +164,86 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
147
164
  flatten: true,
148
165
  });
149
166
 
167
+ /** Settles `true` on `event` from `session` and `false` at `deadline`, whichever is first. */
168
+ const nextEvent = (event: string, session: string, deadline: number) => {
169
+ let heard: () => void = () => undefined;
170
+ const arrived = new Promise<void>((resolve) => {
171
+ heard = resolve;
172
+ });
173
+ const off = connection.on(event, (_params, on) => {
174
+ if (on === session) heard();
175
+ });
176
+ let timer: ReturnType<typeof setTimeout> | undefined;
177
+ return {
178
+ /** Subscribed when created, so an event that lands before this is awaited still counts. */
179
+ wait: (): Promise<boolean> =>
180
+ Promise.race([
181
+ arrived.then(() => true),
182
+ new Promise<boolean>((resolve) => {
183
+ timer = setTimeout(() => resolve(false), Math.max(0, deadline - performance.now()));
184
+ }),
185
+ ]),
186
+ close: (): void => {
187
+ off();
188
+ clearTimeout(timer);
189
+ },
190
+ };
191
+ };
192
+
193
+ /**
194
+ * Whether one page came to read `navigator.onLine === online` before `deadline`. The page itself
195
+ * says when — the evaluated promise resolves on the event that flips it, `awaitPromise` holds the
196
+ * reply until then — so nothing is polled and nothing sleeps. A navigation destroys the context
197
+ * the promise lived in and refuses the call; the page is asked again in the document that
198
+ * replaces it, once Chrome announces that document's context.
199
+ */
200
+ const pageReads = async (session: string, online: boolean, deadline: number) => {
201
+ for (;;) {
202
+ const remaining = deadline - performance.now();
203
+ if (remaining <= 0) return false;
204
+ const context = nextEvent('Runtime.executionContextCreated', session, deadline);
205
+ try {
206
+ const answer = await send(
207
+ 'Runtime.evaluate',
208
+ { expression: readsOnLine(online), returnByValue: true, awaitPromise: true },
209
+ session,
210
+ remaining,
211
+ );
212
+ const value = member(member(answer.result, 'result'), 'value');
213
+ if (value === true) return true;
214
+ // `false`: the event fired and the state disagrees, so the next ask awaits the next one.
215
+ // Anything else is a page that could not run the read — the same as a refusal, below.
216
+ if (value !== false && !(await context.wait())) return false;
217
+ } catch (error) {
218
+ if (error instanceof CdpTimeoutError) return false;
219
+ if (!(await context.wait())) return false;
220
+ } finally {
221
+ context.close();
222
+ }
223
+ }
224
+ };
225
+
226
+ // #572. `offline()` SENDS the condition, and the renderer applies it when it gets to it: a `fetch`
227
+ // on the caller's next line raced that on a slow runner and went through under a switch the test
228
+ // had already thrown. So the switch is thrown when every page READS the state, and not before —
229
+ // never a fixed sleep, which is either too long everywhere or too short on the runner that
230
+ // matters. Bounded by the load budget, because a page mid-navigation answers only once it lands;
231
+ // a page that never reads it is a timeout naming the page, never a resolve the test trusts.
232
+ const confirmed = async (online: boolean): Promise<void> => {
233
+ const deadline = performance.now() + options.loadTimeoutMs;
234
+ const open = [...pageSessions].filter((session) => sessions.has(session));
235
+ const answers = await Promise.all(
236
+ open.map(async (session) => [session, await pageReads(session, online, deadline)] as const),
237
+ );
238
+ const waiting = answers.filter(([, reads]) => !reads).map(([session]) => session);
239
+ if (waiting.length > 0) {
240
+ throw new CdpTimeoutError({
241
+ method: `${ON_LINE} === ${String(online)} in page session ${waiting.join(', ')}`,
242
+ timeoutMs: options.loadTimeoutMs,
243
+ });
244
+ }
245
+ };
246
+
150
247
  const offline = async (enabled: boolean): Promise<void> => {
151
248
  cut = enabled;
152
249
  // Sequential, and a session that has gone away is dropped rather than taking the rest down.
@@ -168,17 +265,28 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
168
265
  await toggled.catch((error: unknown) => refused.push(error));
169
266
  }
170
267
  if (refused.length > 0) throw refused[0];
268
+ await confirmed(!enabled);
171
269
  };
172
270
 
173
- const attached = async (targetId: string): Promise<string> => {
174
- for (let waited = 0; waited < options.loadTimeoutMs; waited += POLL_MS) {
175
- const session = pages.get(targetId);
176
- if (session !== undefined) return session;
177
- await Bun.sleep(POLL_MS);
178
- }
179
- throw new CdpTimeoutError({
180
- method: `Target.attachedToTarget for tab ${targetId}`,
181
- timeoutMs: options.loadTimeoutMs,
271
+ /** The tab's session once it is published — told by the attach itself, never polled for. */
272
+ const attached = (targetId: string): Promise<string> => {
273
+ const session = pages.get(targetId);
274
+ if (session !== undefined) return Promise.resolve(session);
275
+ return new Promise<string>((resolve, reject) => {
276
+ const timer = setTimeout(() => {
277
+ published.delete(targetId);
278
+ reject(
279
+ new CdpTimeoutError({
280
+ method: `Target.attachedToTarget for tab ${targetId}`,
281
+ timeoutMs: options.loadTimeoutMs,
282
+ }),
283
+ );
284
+ }, options.loadTimeoutMs);
285
+ published.set(targetId, (ready) => {
286
+ clearTimeout(timer);
287
+ published.delete(targetId);
288
+ resolve(ready);
289
+ });
182
290
  });
183
291
  };
184
292
 
package/src/cdp-errors.ts CHANGED
@@ -22,27 +22,43 @@ export class CdpBrowserMissingError extends UltimateError {
22
22
 
23
23
  /** One start of the browser that ended without an answer — what `cdp-launch-attempt.ts` reports. */
24
24
  export interface CdpLaunchAttempt {
25
- /** `deadline`: alive and silent for `waitedMs`. `closed`: its pipe ended before any answer. */
26
- readonly why: 'deadline' | 'closed';
25
+ /**
26
+ * `deadline`: alive and silent for `waitedMs`. `closed`: its wire ended, or its dial was
27
+ * refused, before any answer. `spawn`: the binary could not be started at all — it never ran.
28
+ */
29
+ readonly why: 'deadline' | 'closed' | 'spawn';
27
30
  readonly waitedMs: number;
28
31
  /** The code it exited with on its own, or `null` when the launcher had to kill it. */
29
32
  readonly exitCode: number | null;
30
33
  /** The last lines it wrote to stderr; empty when it wrote none. */
31
34
  readonly stderr: string;
35
+ /**
36
+ * What the LAUNCHER saw end the attempt, rendered: the refused dial and its endpoint, the call
37
+ * that timed out, the spawn's own error. Absent on a record that predates it.
38
+ */
39
+ readonly detail?: string | undefined;
32
40
  }
33
41
 
34
42
  const HAND_FLAGS =
35
43
  '--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=0 about:blank';
36
44
 
45
+ const endedText = (attempt: CdpLaunchAttempt): string => {
46
+ const after = `after ${String(attempt.waitedMs)}ms`;
47
+ if (attempt.why === 'spawn') return 'could not be started';
48
+ if (attempt.why === 'deadline')
49
+ return `no answer inside ${String(attempt.waitedMs)}ms, so it was killed`;
50
+ return attempt.exitCode === null
51
+ ? `its DevTools connection ended ${after} without an answer, and it was killed`
52
+ : `exited with code ${String(attempt.exitCode)} ${after} without answering`;
53
+ };
54
+
37
55
  const attemptText = (attempt: CdpLaunchAttempt, index: number): string => {
38
- const ended =
39
- attempt.why === 'deadline'
40
- ? `no answer inside ${String(attempt.waitedMs)}ms, so it was killed`
41
- : attempt.exitCode === null
42
- ? `closed its DevTools pipe after ${String(attempt.waitedMs)}ms without answering, and was killed`
43
- : `exited with code ${String(attempt.exitCode)} after ${String(attempt.waitedMs)}ms without answering`;
56
+ const saw =
57
+ attempt.detail === undefined || attempt.detail === ''
58
+ ? ''
59
+ : `: ${renderCauseValue(attempt.detail)}`;
44
60
  const said = attempt.stderr === '' ? 'it printed nothing' : renderCauseValue(attempt.stderr);
45
- return `launch ${String(index + 1)}: ${ended}; stderr: ${said}`;
61
+ return `launch ${String(index + 1)}: ${endedText(attempt)}${saw}; stderr: ${said}`;
46
62
  };
47
63
 
48
64
  /**
@@ -2,26 +2,34 @@
2
2
  // first DevTools answer, and on no answer reap it (killed, awaited, profile removed) and say why.
3
3
  // Which binary, which flags and how many starts is `cdp-launch.ts`.
4
4
 
5
- // why: Bun exposes no recursive-remove and no temp-root primitive, so the throwaway profile
6
- // directory this launcher must create and delete needs both.
7
- import { mkdtempSync, rmSync } from 'node:fs';
8
- // why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
9
- import { tmpdir } from 'node:os';
10
- // why: Bun exposes no path-join primitive.
11
- import { join } from 'node:path';
5
+ import { renderThrowable } from '@ultimat3/core';
12
6
  import type { CdpConnection } from './cdp-connection';
13
- import { cdpConnectOver } from './cdp-connection';
14
7
  import type { CdpLaunchAttempt } from './cdp-errors';
15
8
  import { CdpTimeoutError } from './cdp-errors';
16
- import { pipeTransport } from './cdp-pipe';
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;
17
24
 
18
25
  export interface LaunchedBrowser {
19
- /** The browser's own CDP connection, over its debugging pipe. Already answering. */
26
+ /** The browser's own CDP connection, over its debugging pipe or port. Already answering. */
20
27
  readonly connection: CdpConnection;
21
28
  /**
22
29
  * THE close, idempotent, and awaited by every caller: the connection closed, SIGTERM, SIGKILL
23
- * after `CLOSE_GRACE_MS`, then the browser's whole process group killed and the profile removed —
24
- * bounded at three graces. A promise because a Chrome still shutting down competes with the next
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
25
33
  * launch on a 4-CPU runner, and its children re-create a profile removed before they are gone.
26
34
  */
27
35
  close(): Promise<void>;
@@ -31,6 +39,10 @@ export interface LaunchAttemptOptions {
31
39
  readonly executable: string;
32
40
  /** What the process is started with, given the profile directory this attempt made for it. */
33
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;
34
46
  /** Every call's deadline AFTER the first. */
35
47
  readonly timeoutMs: number;
36
48
  /** The first call's deadline: how long a cold start may take. */
@@ -83,93 +95,91 @@ function stderrTail(stream: ReadableStream<Uint8Array>): {
83
95
  */
84
96
  const FAILURE_DRAIN_MS = 1_000;
85
97
 
86
- /** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
87
- export const CLOSE_GRACE_MS = 5_000;
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;
88
104
 
89
105
  const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
90
106
  Promise.race([work, Bun.sleep(ms)]);
91
107
 
92
- const GROUP_POLL_MS = 10;
93
-
94
- /**
95
- * Kill whatever is left of the browser's process group and wait until the group is EMPTY — the
96
- * condition, asked of the kernel, not a pause: signal 0 to a group with no member throws. Bounded
97
- * by `CLOSE_GRACE_MS` for a container whose init never reaps a zombie.
98
- */
99
- async function reapGroup(pgid: number): Promise<void> {
100
- try {
101
- process.kill(-pgid, 'SIGKILL');
102
- for (let looks = CLOSE_GRACE_MS / GROUP_POLL_MS; looks > 0; looks -= 1) {
103
- await Bun.sleep(GROUP_POLL_MS);
104
- process.kill(-pgid, 0);
105
- }
106
- } catch {
107
- // ESRCH: nothing is left in the group, which is the answer being waited for.
108
- }
109
- }
110
-
111
108
  /**
112
109
  * Start the browser once and answer when it has answered one CDP call. With a pipe there is no
113
110
  * "DevTools listening" line to wait for — the first reply IS the readiness signal.
114
111
  */
115
112
  export async function launchAttempt(options: LaunchAttemptOptions): Promise<LaunchAttemptResult> {
116
- const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
117
- const removeProfile = (): void => {
118
- try {
119
- rmSync(profileDir, { recursive: true, force: true });
120
- } catch {
121
- // A throwaway directory that will not go is litter, never the launch's or the close's verdict.
122
- }
123
- };
113
+ const platform = options.platform ?? process.platform;
114
+ const profile = throwawayProfile();
115
+ const profileDir = profile.path;
124
116
  const began = performance.now();
125
- const child = Bun.spawn([options.executable, ...options.flags(profileDir)], {
126
- // Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
127
- stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
128
- // Its own process GROUP, so the zygote, GPU and network processes can be reaped with it
129
- // (`reapGroup`). It still ends with this process: Chrome exits when its fd 3 closes.
130
- detached: true,
131
- });
132
- const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
133
- const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
134
- const sink = Bun.file(toBrowser ?? -1).writer();
135
- const connection = cdpConnectOver(
136
- pipeTransport({
137
- write: (bytes) => {
138
- sink.write(bytes);
139
- void sink.flush();
140
- },
141
- read: Bun.file(fromBrowser ?? -1).stream(),
142
- end: () => {
143
- void Promise.resolve(sink.end()).catch(() => undefined);
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),
144
133
  },
145
- }),
146
- options.timeoutMs,
147
- );
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;
148
140
  let exiting: Promise<void> | undefined;
149
141
  const close = (): Promise<void> => {
150
142
  exiting ??= (async () => {
151
- connection.close();
152
- child.kill();
153
- removeProfile();
154
- const exited =
155
- (await within(
156
- CLOSE_GRACE_MS,
157
- child.exited.then(() => true),
158
- )) === true;
159
- if (!exited) {
160
- child.kill('SIGKILL');
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);
161
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);
162
163
  }
163
- await reapGroup(child.pid);
164
- // Again, now that nothing is writing: Chrome's network process outlives the browser process
165
- // and flushes into the profile, re-creating a directory removed a moment before. Measured on
166
- // Chrome 150, 30 launches each: left behind 6 and 12 times without the group reap, 0 with.
167
- removeProfile();
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);
168
169
  })();
169
170
  return exiting;
170
171
  };
171
172
  try {
172
- await connection.send('Browser.getVersion', {}, undefined, options.launchTimeoutMs);
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);
173
183
  return { ok: true, browser: { connection, close } };
174
184
  } catch (error) {
175
185
  const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
@@ -185,8 +195,30 @@ export async function launchAttempt(options: LaunchAttemptOptions): Promise<Laun
185
195
  await within(FAILURE_DRAIN_MS, tail.drained);
186
196
  return {
187
197
  ok: false,
188
- failure: { why, waitedMs, exitCode, stderr: tail.lastLines() },
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
+ },
189
207
  reaped: child.exitCode !== null || child.signalCode !== null,
190
208
  };
191
209
  }
192
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
+ }