@ultimat3/testing 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
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
+ // 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';
12
+ import type { CdpConnection } from './cdp-connection';
13
+ import { cdpConnectOver } from './cdp-connection';
14
+ import type { CdpLaunchAttempt } from './cdp-errors';
15
+ import { CdpTimeoutError } from './cdp-errors';
16
+ import { pipeTransport } from './cdp-pipe';
17
+
18
+ export interface LaunchedBrowser {
19
+ /** The browser's own CDP connection, over its debugging pipe. Already answering. */
20
+ readonly connection: CdpConnection;
21
+ /**
22
+ * 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
25
+ * launch on a 4-CPU runner, and its children re-create a profile removed before they are gone.
26
+ */
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ export interface LaunchAttemptOptions {
31
+ readonly executable: string;
32
+ /** What the process is started with, given the profile directory this attempt made for it. */
33
+ readonly flags: (profileDir: string) => readonly string[];
34
+ /** Every call's deadline AFTER the first. */
35
+ readonly timeoutMs: number;
36
+ /** The first call's deadline: how long a cold start may take. */
37
+ readonly launchTimeoutMs: number;
38
+ }
39
+
40
+ export type LaunchAttemptResult =
41
+ | { readonly ok: true; readonly browser: LaunchedBrowser }
42
+ /** `reaped: false` is a process that outlived SIGKILL's grace — never start another beside it. */
43
+ | { readonly ok: false; readonly failure: CdpLaunchAttempt; readonly reaped: boolean };
44
+
45
+ const STDERR_TAIL_CHARS = 4_000;
46
+ /** How much of the tail one attempt hands a cause: the last lines, each cut to a readable width. */
47
+ const TAIL_LINES = 3;
48
+ const TAIL_LINE_CHARS = 160;
49
+
50
+ /**
51
+ * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
52
+ * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
53
+ * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
54
+ * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
55
+ */
56
+ function stderrTail(stream: ReadableStream<Uint8Array>): {
57
+ readonly lastLines: () => string;
58
+ /** Settles once the stream has ended — every byte the process wrote has been read. */
59
+ readonly drained: Promise<void>;
60
+ } {
61
+ let text = '';
62
+ const drained = (async () => {
63
+ const decoder = new TextDecoder();
64
+ for await (const chunk of stream) {
65
+ text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
66
+ }
67
+ })().catch(() => undefined);
68
+ const lastLines = (): string =>
69
+ text
70
+ .trim()
71
+ .split('\n')
72
+ .slice(-TAIL_LINES)
73
+ .map((line) => line.slice(0, TAIL_LINE_CHARS))
74
+ .join(' | ');
75
+ return { lastLines, drained };
76
+ }
77
+
78
+ /**
79
+ * How long a browser that CLOSED ITS PIPE gets to finish dying, and any browser's stderr to finish
80
+ * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
81
+ * are two unordered events; read at the first, the reason a browser died was reported as "printed
82
+ * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
83
+ */
84
+ const FAILURE_DRAIN_MS = 1_000;
85
+
86
+ /** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
87
+ export const CLOSE_GRACE_MS = 5_000;
88
+
89
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
90
+ Promise.race([work, Bun.sleep(ms)]);
91
+
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
+ /**
112
+ * Start the browser once and answer when it has answered one CDP call. With a pipe there is no
113
+ * "DevTools listening" line to wait for — the first reply IS the readiness signal.
114
+ */
115
+ 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
+ };
124
+ 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);
144
+ },
145
+ }),
146
+ options.timeoutMs,
147
+ );
148
+ let exiting: Promise<void> | undefined;
149
+ const close = (): Promise<void> => {
150
+ 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');
161
+ await within(CLOSE_GRACE_MS, child.exited);
162
+ }
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();
168
+ })();
169
+ return exiting;
170
+ };
171
+ try {
172
+ await connection.send('Browser.getVersion', {}, undefined, options.launchTimeoutMs);
173
+ return { ok: true, browser: { connection, close } };
174
+ } catch (error) {
175
+ const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
176
+ // The deadline itself when that is what ended it: the number the reader can raise.
177
+ const waitedMs =
178
+ why === 'deadline' ? options.launchTimeoutMs : Math.round(performance.now() - began);
179
+ // Only a browser that hung up may be on its way out with the reason still unwritten. One that
180
+ // was silent for the whole deadline has had its time.
181
+ if (why === 'closed') await within(FAILURE_DRAIN_MS, child.exited);
182
+ // Read BEFORE the kill: after it, an exit code is ours and says nothing about the browser.
183
+ const exitCode = child.exitCode;
184
+ await close();
185
+ await within(FAILURE_DRAIN_MS, tail.drained);
186
+ return {
187
+ ok: false,
188
+ failure: { why, waitedMs, exitCode, stderr: tail.lastLines() },
189
+ reaped: child.exitCode !== null || child.signalCode !== null,
190
+ };
191
+ }
192
+ }
package/src/cdp-launch.ts CHANGED
@@ -1,17 +1,15 @@
1
- // One responsibility: start a Chrome in this container and hand back its DevTools endpoint and the
2
- // way to stop it. The connection is `cdp-connection.ts` and the page surface `cdp-e2e-page.ts`.
1
+ // One responsibility: WHICH Chrome, with which flags, and how many starts it gets — the candidate
2
+ // list, the launch deadline and the one relaunch. One start of the process is
3
+ // `cdp-launch-attempt.ts`; the connection is `cdp-connection.ts`, the page `cdp-e2e-page.ts`.
3
4
 
4
- // why: Bun exposes no recursive-remove and no temp-root primitive, so the throwaway profile
5
- // directory this launcher must create and delete needs both.
6
- import { mkdtempSync, rmSync } from 'node:fs';
7
- // why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
8
- import { tmpdir } from 'node:os';
9
- // why: Bun exposes no path-join primitive.
10
- import { join } from 'node:path';
11
- import type { CdpConnection } from './cdp-connection';
12
- import { cdpConnectOver } from './cdp-connection';
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import type { CdpLaunchAttempt } from './cdp-errors';
13
7
  import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
14
- import { pipeTransport } from './cdp-pipe';
8
+ import type { LaunchedBrowser } from './cdp-launch-attempt';
9
+ import { launchAttempt } from './cdp-launch-attempt';
10
+
11
+ export type { LaunchedBrowser } from './cdp-launch-attempt';
12
+ export { CLOSE_GRACE_MS } from './cdp-launch-attempt';
15
13
 
16
14
  /**
17
15
  * Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
@@ -81,130 +79,59 @@ export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
81
79
  'about:blank',
82
80
  ];
83
81
 
84
- export interface LaunchedBrowser {
85
- /** The browser's own CDP connection, over its debugging pipe. Already answering. */
86
- readonly connection: CdpConnection;
87
- /** Idempotent: closes the connection, kills the process, deletes the profile. */
88
- close(): void;
89
- /**
90
- * `close()`, then resolves once the process has EXITED — SIGTERM, then SIGKILL after
91
- * `CLOSE_GRACE_MS`, never waiting longer than twice that. What a suite's `afterAll` awaits: a
92
- * Chrome told to die but still shutting down competes with the next suite's launch on a 4-CPU
93
- * runner, and one still running keeps the test process from exiting at all. Optional so a test
94
- * double of a launch need not model a process; `launchChrome` always provides it.
95
- */
96
- closed?(): Promise<void>;
97
- }
98
-
99
- export interface LaunchOptions {
100
- readonly executable: string;
101
- /** How long Chrome has to answer its first call, and every call's deadline after that. */
102
- readonly timeoutMs: number;
103
- }
104
-
105
- const STDERR_TAIL_CHARS = 4_000;
106
-
107
82
  /**
108
- * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
109
- * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
110
- * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
111
- * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
83
+ * How long a COLD start may take before the browser is given up on. Its own number, never the
84
+ * per-call deadline: measured 2026-10-02 over `scaffold-smoke` on free `ubuntu-latest` runners, the
85
+ * first launch of a job cost 5-19 s more than a warm one in 11 green jobs, and the one red job was
86
+ * still printing start-up lines when a 30 s deadline killed it. Twice that, and finite: a browser
87
+ * that exits says so at once (its pipe closes), so only one alive and silent waits this long.
112
88
  */
113
- function stderrTail(stream: ReadableStream<Uint8Array>): {
114
- readonly text: () => string;
115
- /** Settles once the stream has ended — every byte the process wrote has been read. */
116
- readonly drained: Promise<void>;
117
- } {
118
- let text = '';
119
- const drained = (async () => {
120
- const decoder = new TextDecoder();
121
- for await (const chunk of stream) {
122
- text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
123
- }
124
- })().catch(() => undefined);
125
- return { text: () => text, drained };
126
- }
89
+ export const LAUNCH_TIMEOUT_MS = 60_000;
127
90
 
128
91
  /**
129
- * How long a browser that failed its first call gets to finish dying, and its stderr to finish
130
- * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
131
- * are two unordered events; read at the first, the reason a browser died was reported as "printed
132
- * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
92
+ * Starts per launch: the first, and ONE more on a fresh profile after the first was reaped. A
93
+ * process start is the one step here whose failure can belong to the machine rather than the
94
+ * binary — and the second start of a cold binary is a warm one. Never a loop: two starts that both
95
+ * went unanswered are `X_CDP_LAUNCH_FAILED` carrying both.
133
96
  */
134
- const FAILURE_DRAIN_MS = 1_000;
97
+ export const LAUNCH_ATTEMPTS = 2;
135
98
 
136
- /** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
137
- export const CLOSE_GRACE_MS = 5_000;
138
-
139
- const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
140
- Promise.race([work, Bun.sleep(ms)]);
99
+ export interface LaunchOptions {
100
+ readonly executable: string;
101
+ /** Every CDP call's deadline once the browser is up. */
102
+ readonly timeoutMs: number;
103
+ /** The first answer's deadline, per start. Defaults to the larger of `timeoutMs` and `LAUNCH_TIMEOUT_MS`. */
104
+ readonly launchTimeoutMs?: number | undefined;
105
+ }
141
106
 
142
107
  /**
143
- * Start Chrome on a throwaway profile and answer once it has answered one CDP call. With a pipe
144
- * there is no "DevTools listening" line to wait for — the first reply IS the readiness signal, and
145
- * a browser that dies or stays silent before it is `X_CDP_LAUNCH_FAILED` carrying its own stderr.
108
+ * Start Chrome on a throwaway profile and answer once it has answered one CDP call. A browser that
109
+ * dies or stays silent is started once more; twice is `X_CDP_LAUNCH_FAILED` with each start's own
110
+ * stderr and whether it exited or was killed at the deadline.
146
111
  */
147
112
  export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
148
- const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
149
- const child = Bun.spawn([options.executable, ...chromeLaunchFlags(profileDir)], {
150
- // Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
151
- stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
152
- });
153
- const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
154
- const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
155
- const sink = Bun.file(toBrowser ?? -1).writer();
156
- const connection = cdpConnectOver(
157
- pipeTransport({
158
- write: (bytes) => {
159
- sink.write(bytes);
160
- void sink.flush();
161
- },
162
- read: Bun.file(fromBrowser ?? -1).stream(),
163
- end: () => {
164
- void Promise.resolve(sink.end()).catch(() => undefined);
165
- },
166
- }),
167
- options.timeoutMs,
113
+ // Screened: `setTimeout(fn, NaN)` fires in 1 ms, which reports a healthy browser as silent.
114
+ const launchTimeoutMs = finiteCount(
115
+ 'launchChrome',
116
+ 'launchTimeoutMs',
117
+ options.launchTimeoutMs ?? Math.max(options.timeoutMs, LAUNCH_TIMEOUT_MS),
118
+ 1,
168
119
  );
169
- let closed = false;
170
- const close = (): void => {
171
- if (closed) return;
172
- closed = true;
173
- connection.close();
174
- child.kill();
175
- rmSync(profileDir, { recursive: true, force: true });
176
- };
177
- let exiting: Promise<void> | undefined;
178
- const closeAndWait = (): Promise<void> => {
179
- close();
180
- exiting ??= (async () => {
181
- const exited =
182
- (await within(
183
- CLOSE_GRACE_MS,
184
- child.exited.then(() => true),
185
- )) === true;
186
- if (exited) return;
187
- child.kill('SIGKILL');
188
- await within(CLOSE_GRACE_MS, child.exited);
189
- })();
190
- return exiting;
191
- };
192
- try {
193
- await connection.send('Browser.getVersion');
194
- return { connection, close, closed: closeAndWait };
195
- } catch {
196
- await within(FAILURE_DRAIN_MS, child.exited);
197
- close();
198
- await within(FAILURE_DRAIN_MS, tail.drained);
199
- const seen = tail.text().trim();
200
- throw new CdpLaunchFailedError({
120
+ const failures: CdpLaunchAttempt[] = [];
121
+ while (failures.length < LAUNCH_ATTEMPTS) {
122
+ const started = await launchAttempt({
201
123
  executable: options.executable,
202
- detail:
203
- seen === ''
204
- ? 'it answered no DevTools call and printed nothing before the deadline'
205
- : seen.split('\n').slice(-3).join(' | '),
124
+ flags: chromeLaunchFlags,
125
+ timeoutMs: options.timeoutMs,
126
+ launchTimeoutMs,
206
127
  });
128
+ if (started.ok) return started.browser;
129
+ failures.push(started.failure);
130
+ // A process that outlived SIGKILL's grace still holds the machine; a second beside it is the
131
+ // load that made the first one late.
132
+ if (!started.reaped) break;
207
133
  }
134
+ throw new CdpLaunchFailedError({ executable: options.executable, attempts: failures });
208
135
  }
209
136
 
210
137
  /** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
@@ -11,7 +11,9 @@
11
11
  // `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
12
12
  // restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
13
13
 
14
+ import { stringField } from '@ultimat3/core';
14
15
  import type { CdpResult } from './cdp-connection';
16
+ import { CdpCallFailedError } from './cdp-errors';
15
17
 
16
18
  type Send = (
17
19
  method: string,
@@ -68,9 +70,37 @@ export function offlineScripts(send: Send): OfflineScripts {
68
70
  async remove(session) {
69
71
  const identifier = held.get(session);
70
72
  if (identifier === undefined) return undefined;
71
- held.delete(session);
72
- await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
73
- return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
73
+ // Both halves are ATTEMPTED whatever the first answers, and a refusal of either surfaces.
74
+ // Traced 2026-10-02: a navigation in flight made the removal answer `Script not found`, the
75
+ // throw skipped the restore, and the open document kept reading `navigator.onLine === false`.
76
+ const refused: string[] = [];
77
+ const attempt = async (step: string, call: () => Promise<CdpResult>): Promise<boolean> => {
78
+ try {
79
+ await call();
80
+ return true;
81
+ } catch (error) {
82
+ refused.push(
83
+ `${step}: ${stringField(error, 'cause') ?? stringField(error, 'message') ?? 'refused'}`,
84
+ );
85
+ return false;
86
+ }
87
+ };
88
+ const removed = await attempt('removing the first-script override', () =>
89
+ send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session),
90
+ );
91
+ // Kept while the browser may still hold it: the next `remove` asks again, and `add` cannot
92
+ // register a second copy beside one that is still there.
93
+ if (removed) held.delete(session);
94
+ await attempt('restoring the open document', () =>
95
+ send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session),
96
+ );
97
+ if (refused.length > 0) {
98
+ throw new CdpCallFailedError({
99
+ method: `putting navigator.onLine back on session ${session}`,
100
+ detail: refused.join(' · '),
101
+ });
102
+ }
103
+ return undefined;
74
104
  },
75
105
  };
76
106
  }
@@ -0,0 +1,113 @@
1
+ // One responsibility: remember what the wire did, so a call that is never answered can say WHY —
2
+ // its target went away, frames arrived that nothing could parse, or the target simply stayed
3
+ // silent. The connection (`cdp-connection.ts`) feeds every frame in; `CdpTimeoutError` reads it out.
4
+
5
+ import type { CdpTargetGone, CdpTimeoutObservation } from './cdp-errors';
6
+
7
+ /** How many of the latest frames a timeout quotes. Bounded: a busy page sends thousands. */
8
+ export const LAST_FRAMES = 8;
9
+
10
+ /** Where the wire stood when a call was sent — what "since the call" is measured from. */
11
+ export interface WireMark {
12
+ readonly arrived: number;
13
+ readonly dropped: number;
14
+ readonly navigations: number;
15
+ }
16
+
17
+ export interface WireWatch {
18
+ /** One frame that parsed: a reply or an event. */
19
+ frame(frame: Record<string, unknown>): void;
20
+ /** One frame nothing could parse. It has no `id`, so no call can be failed with it — only counted. */
21
+ drop(): void;
22
+ mark(sessionId: string | undefined): WireMark;
23
+ /** What happened between `mark` and now, for the call sent on `sessionId`. */
24
+ observe(mark: WireMark, sessionId: string | undefined, open: boolean): CdpTimeoutObservation;
25
+ }
26
+
27
+ const text = (from: unknown, key: string): string | undefined => {
28
+ const value =
29
+ typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
30
+ return typeof value === 'string' ? value : undefined;
31
+ };
32
+
33
+ export function wireWatch(): WireWatch {
34
+ let arrived = 0;
35
+ let dropped = 0;
36
+ const last: string[] = [];
37
+ const targetOf = new Map<string, string>(); // sessionId → targetId
38
+ const gone = new Map<string, CdpTargetGone>(); // sessionId → how it went
39
+ const navigations = new Map<string, number>(); // sessionId → main-frame navigations
40
+
41
+ const goneTarget = (targetId: string | undefined, how: CdpTargetGone): void => {
42
+ for (const [session, target] of targetOf) if (target === targetId) gone.set(session, how);
43
+ };
44
+
45
+ /** The few events that say a session can no longer answer, or that its page was replaced. */
46
+ const track = (method: string, session: string | undefined, params: unknown): void => {
47
+ if (method === 'Target.attachedToTarget') {
48
+ const attached = text(params, 'sessionId');
49
+ const target = text(
50
+ (params as Record<string, unknown> | undefined)?.['targetInfo'],
51
+ 'targetId',
52
+ );
53
+ if (attached !== undefined && target !== undefined) targetOf.set(attached, target);
54
+ } else if (method === 'Target.detachedFromTarget') {
55
+ const detached = text(params, 'sessionId');
56
+ if (detached !== undefined) gone.set(detached, 'detached');
57
+ } else if (method === 'Target.targetDestroyed') {
58
+ goneTarget(text(params, 'targetId'), 'destroyed');
59
+ } else if (method === 'Target.targetCrashed') {
60
+ goneTarget(text(params, 'targetId'), 'crashed');
61
+ } else if (method === 'Inspector.targetCrashed' && session !== undefined) {
62
+ gone.set(session, 'crashed');
63
+ } else if (method === 'Page.frameNavigated' && session !== undefined) {
64
+ const frame = (params as Record<string, unknown> | undefined)?.['frame'];
65
+ // A frame with a parent is an iframe: the document the call was sent to is still there.
66
+ if (text(frame, 'parentId') === undefined) {
67
+ navigations.set(session, (navigations.get(session) ?? 0) + 1);
68
+ }
69
+ }
70
+ };
71
+
72
+ const note = (label: string): void => {
73
+ last.push(label);
74
+ if (last.length > LAST_FRAMES) last.shift();
75
+ };
76
+
77
+ return {
78
+ frame(frame): void {
79
+ arrived += 1;
80
+ const session = text(frame, 'sessionId');
81
+ const id = frame['id'];
82
+ const method = text(frame, 'method');
83
+ if (typeof id === 'number') note(`reply ${String(id)}`);
84
+ else if (method === undefined) note('a frame with neither an id nor a method');
85
+ else {
86
+ note(session === undefined ? method : `${method} @${session}`);
87
+ track(method, session, frame['params']);
88
+ }
89
+ },
90
+ drop(): void {
91
+ dropped += 1;
92
+ },
93
+ mark: (sessionId) => ({
94
+ arrived,
95
+ dropped,
96
+ navigations: sessionId === undefined ? 0 : (navigations.get(sessionId) ?? 0),
97
+ }),
98
+ observe(mark, sessionId, open): CdpTimeoutObservation {
99
+ const since = arrived - mark.arrived;
100
+ return {
101
+ sessionId: sessionId ?? null,
102
+ framesArrived: since,
103
+ // Only frames that arrived AFTER the call: an older one says nothing about its reply.
104
+ lastFrames: since === 0 ? [] : last.slice(-Math.min(since, LAST_FRAMES)),
105
+ framesDropped: dropped - mark.dropped,
106
+ transportOpen: open,
107
+ targetGone: sessionId === undefined ? null : (gone.get(sessionId) ?? null),
108
+ navigations:
109
+ sessionId === undefined ? 0 : (navigations.get(sessionId) ?? 0) - mark.navigations,
110
+ };
111
+ },
112
+ };
113
+ }
@@ -2,7 +2,7 @@
2
2
  // bug: a frozen clock means "advance it", a seeded RNG means "same values every run", and both are
3
3
  // restorable so a test that genuinely needs real time can opt out explicitly.
4
4
 
5
- import { finiteCount, finiteOption } from '@ultimat3/core';
5
+ import { canonicalJson, finiteCount, finiteOption, renderCauseValue } from '@ultimat3/core';
6
6
  import { NondeterministicError } from './errors';
7
7
 
8
8
  export const DEFAULT_SEED = 20260101;
@@ -197,10 +197,32 @@ export async function frozenClock<T>(now: string, body: () => T | Promise<T>): P
197
197
  const at = instantMs('frozenClock', now);
198
198
  const previous = frozenAt;
199
199
  frozenAt = at;
200
+ // Both moves announced, as `setFrozenClock` announces one: the frozen scheduler renews leases on
201
+ // the announcement alone, so an unannounced jump is time its timers never see.
200
202
  try {
203
+ // Inside the `try`: a listener that throws on entry must still get the outer instant back.
204
+ announceMove();
201
205
  return await body();
202
206
  } finally {
203
207
  frozenAt = previous;
208
+ announceMove();
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Core's canonical form — a BigInt, a Date, a Map have tokens of their own where `JSON.stringify`
214
+ * threw a bare `TypeError` or folded them to `{}` — or `undefined` for the one shape it cannot
215
+ * walk, a cycle, which `Bun.deepEquals` then compares instead. A top-level `undefined`, function
216
+ * or symbol is a token of its type.
217
+ */
218
+ function comparable(value: unknown): string | undefined {
219
+ // Canonical JSON spells all three `null`, so `undefined` and `null` compared equal: a token each.
220
+ const kind = typeof value;
221
+ if (kind === 'undefined' || kind === 'function' || kind === 'symbol') return `<${kind}>`;
222
+ try {
223
+ return canonicalJson(value);
224
+ } catch {
225
+ return undefined;
204
226
  }
205
227
  }
206
228
 
@@ -211,8 +233,15 @@ export async function frozenClock<T>(now: string, body: () => T | Promise<T>): P
211
233
  export async function assertDeterministic<T>(what: string, body: () => T | Promise<T>): Promise<T> {
212
234
  const first = await body();
213
235
  const second = await body();
214
- const a = JSON.stringify(first);
215
- const b = JSON.stringify(second);
216
- if (a !== b) throw new NondeterministicError({ what, first: a, second: b });
236
+ const a = comparable(first);
237
+ const b = comparable(second);
238
+ const same = a === undefined || b === undefined ? Bun.deepEquals(first, second) : a === b;
239
+ if (!same) {
240
+ throw new NondeterministicError({
241
+ what,
242
+ first: a ?? renderCauseValue(first),
243
+ second: b ?? renderCauseValue(second),
244
+ });
245
+ }
217
246
  return first;
218
247
  }
package/src/e2e-app.ts CHANGED
@@ -58,6 +58,9 @@ function x(bin: string, args: readonly string[], root: string, env: Record<strin
58
58
  env: { ...inherited(), ...env },
59
59
  stdout: 'pipe',
60
60
  stderr: 'pipe',
61
+ // Synchronous inside a test, where the test's own timeout cannot interrupt it: a hung reset is
62
+ // killed here and refused below with what it printed, never a suite that waits forever.
63
+ timeout: DEFAULT_READY_TIMEOUT_MS,
61
64
  });
62
65
  if (run.exitCode !== 0) {
63
66
  throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
package/src/e2e-run.ts CHANGED
@@ -78,7 +78,7 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
78
78
  const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
79
79
  if (alive) return;
80
80
  deployed = false;
81
- browser.close();
81
+ await browser.close();
82
82
  browser = await deps.openBrowser();
83
83
  republishE2eBrowser(browser);
84
84
  });
@@ -86,7 +86,7 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
86
86
  deps.afterAll(async () => {
87
87
  setFailureContext(undefined);
88
88
  uninstall();
89
- browser.close();
89
+ await browser.close();
90
90
  await app.stop();
91
91
  });
92
92
  }