@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.
Files changed (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. package/src/test-types.ts +6 -2
@@ -13,11 +13,52 @@ import type { E2eSession } from './cdp-e2e-session';
13
13
  import { cdpE2eSession } from './cdp-e2e-session';
14
14
  import { CdpBrowserMissingError } from './cdp-errors';
15
15
  import type { LaunchedBrowser } from './cdp-launch';
16
- import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
16
+ import { CHROME_CANDIDATES, findChrome, LAUNCH_BUDGET_MS, launchChrome } from './cdp-launch';
17
+ import { LAUNCHED_CLOSE_MS } from './cdp-launch-attempt';
17
18
 
18
19
  /** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
19
20
  export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
20
21
 
22
+ /**
23
+ * Sequential deadlines `cdpE2eSession` spends before it answers — `Target.setDiscoverTargets`,
24
+ * then `Target.setAutoAttach` — and `newTab()` spends per tab: `Target.createTarget`, then the
25
+ * attach that publishes it. Counts of calls in `cdp-e2e-session.ts`, held to it by
26
+ * `cdp-browser.test.ts` over a fake connection.
27
+ */
28
+ export const SESSION_SETUP_DEADLINES = 2;
29
+ export const TAB_OPEN_DEADLINES = 2;
30
+
31
+ /** The longest one more `session.newTab()` is designed to take, at the default per-call deadline. */
32
+ export const E2E_TAB_OPEN_MS = TAB_OPEN_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
33
+
34
+ /**
35
+ * The longest `openE2eBrowser()` is DESIGNED to take, at the default deadline: the whole launch
36
+ * budget — both starts and their reaps — then the session's setup and the first tab. THE deadline
37
+ * for a `beforeAll` or `afterAll` that opens or closes a browser: one below it is killed by Bun
38
+ * before the relaunch, and the failure that names the slow step never surfaces. Derived, never
39
+ * restated: `e2e-browser-hooks.test.ts` refuses a browser hook whose deadline does not start here.
40
+ */
41
+ export const E2E_BROWSER_OPEN_MS =
42
+ LAUNCH_BUDGET_MS + SESSION_SETUP_DEADLINES * DEFAULT_CDP_TIMEOUT_MS + E2E_TAB_OPEN_MS;
43
+
44
+ /**
45
+ * The longest `E2eBrowser.close()` is DESIGNED to take — the launched browser's close, every step
46
+ * bounded (`LAUNCHED_CLOSE_MS`). THE deadline for a hook that closes a browser it already holds: a
47
+ * hook left at Bun's 5 s default is killed mid-close, and when it is the run's last the process
48
+ * exits with the profile half-removed — the `x-e2e-chrome-*` directories found leaked in `/tmp`.
49
+ */
50
+ export const E2E_BROWSER_CLOSE_MS = LAUNCHED_CLOSE_MS;
51
+
52
+ /**
53
+ * Sequential deadlines one `tab.goto()` spends: the load event raced against `Page.navigate`'s
54
+ * reply (one deadline, two waiters), then the two reads of the document (`cdp-e2e-page.ts`). Held
55
+ * to the method by `cdp-browser.test.ts` over a fake connection, as the tab's count is.
56
+ */
57
+ export const GOTO_DEADLINES = 3;
58
+
59
+ /** The longest one `tab.goto()` is designed to take, at the default per-call deadline. */
60
+ export const E2E_GOTO_MS = GOTO_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
61
+
21
62
  export interface E2eBrowser {
22
63
  /** The first tab — what `installE2eDriver({ page })` drives. */
23
64
  readonly page: E2eTab;
@@ -27,13 +68,12 @@ export interface E2eBrowser {
27
68
  * drives, on the same launch as `page` — one harness, never a second one beside the driver.
28
69
  */
29
70
  readonly session: E2eSession;
30
- /** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
31
- close(): void;
32
71
  /**
33
- * `close()`, resolving once the process has exited (bounded — `CLOSE_GRACE_MS`, then SIGKILL).
34
- * Optional so a test double need not model a process; the launched browser always has it.
72
+ * THE close, idempotent, awaited: the CDP connection, then the process, its process group and its
73
+ * profile (bounded — `CLOSE_GRACE_MS` per step). Unawaited, a process that exits next leaves a
74
+ * Chrome child and a profile directory behind.
35
75
  */
36
- closed?(): Promise<void>;
76
+ close(): Promise<void>;
37
77
  }
38
78
 
39
79
  export interface OpenE2eBrowserOptions {
@@ -60,10 +100,6 @@ const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab):
60
100
  // the process out from under an open connection makes every in-flight call report "the browser
61
101
  // closed the CDP connection", which is true and useless.
62
102
  close: () => launched.close(),
63
- closed: async () => {
64
- if (launched.closed === undefined) launched.close();
65
- else await launched.closed();
66
- },
67
103
  });
68
104
 
69
105
  /**
@@ -107,7 +143,7 @@ async function openLaunched(
107
143
  });
108
144
  return compose(launched, session, await session.newTab());
109
145
  } catch (error) {
110
- launched.close();
146
+ await launched.close();
111
147
  throw error;
112
148
  }
113
149
  }
@@ -11,8 +11,9 @@
11
11
  // small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
12
12
  // non-negotiable is that Bun's natives replace most of them.
13
13
 
14
- import { assert } from '@ultimat3/core';
14
+ import { assert, stringField } from '@ultimat3/core';
15
15
  import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
16
+ import { wireWatch } from './cdp-wire-watch';
16
17
 
17
18
  /** One CDP result. `unknown` because every payload here is somebody else's JSON. */
18
19
  export interface CdpResult {
@@ -20,8 +21,17 @@ export interface CdpResult {
20
21
  }
21
22
 
22
23
  export interface CdpConnection {
23
- /** Send one command. `sessionId` targets an attached page rather than the browser itself. */
24
- send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<CdpResult>;
24
+ /**
25
+ * Send one command. `sessionId` targets an attached page rather than the browser itself.
26
+ * `deadlineMs` replaces the connection's per-call deadline for THIS call — the launcher's first
27
+ * call is a cold start, not a call, and is the only caller that passes one.
28
+ */
29
+ send(
30
+ method: string,
31
+ params?: Record<string, unknown>,
32
+ sessionId?: string,
33
+ deadlineMs?: number,
34
+ ): Promise<CdpResult>;
25
35
  /**
26
36
  * Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
27
37
  * event arrived and `false` when it did not — it never throws, because every caller has a better
@@ -89,6 +99,16 @@ export interface CdpConnectionOptions {
89
99
  readonly timeoutMs: number;
90
100
  }
91
101
 
102
+ /**
103
+ * What went wrong, in the socket's own words when it has any. Bun hands `onerror` an `ErrorEvent`
104
+ * whose `message` is the only place the reason lives — "Failed to connect", "Expected 101 status
105
+ * code" — and a refusal that drops it names the step and not the cause.
106
+ */
107
+ const failure = (event: unknown, what: string): string => {
108
+ const message = stringField(event, 'message');
109
+ return message === undefined || message === '' ? what : `${what}: ${message}`;
110
+ };
111
+
92
112
  /**
93
113
  * A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
94
114
  * `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
@@ -115,11 +135,14 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
115
135
  };
116
136
  // `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
117
137
  // has no pending call to abandon, and rejecting is the only way the caller hears about it.
118
- socket.onerror = (): void => {
138
+ socket.onerror = (event: Event): void => {
119
139
  clearTimeout(timer);
120
140
  socket.close();
121
141
  reject(
122
- new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
142
+ new CdpCallFailedError({
143
+ method: 'connect',
144
+ detail: failure(event, 'the browser refused the connection'),
145
+ }),
123
146
  );
124
147
  };
125
148
  });
@@ -132,7 +155,8 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
132
155
  handlers.message(typeof event.data === 'string' ? event.data : '');
133
156
  };
134
157
  socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
135
- socket.onerror = (): void => handlers.closed('the CDP connection failed');
158
+ socket.onerror = (event: Event): void =>
159
+ handlers.closed(failure(event, 'the CDP connection failed'));
136
160
  },
137
161
  };
138
162
  return cdpConnectOver(transport, options.timeoutMs);
@@ -145,6 +169,8 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
145
169
  const listeners = new Map<string, Set<CdpEventListener>>();
146
170
  let nextId = 0;
147
171
  let closed = false;
172
+ // What a call that is never answered reports instead of a bare "did not answer".
173
+ const watch = wireWatch();
148
174
 
149
175
  // Every in-flight call is settled on close. Without this a suite whose browser died waits out
150
176
  // one full deadline per call and reports a timeout, where the true fault is a dead browser.
@@ -167,9 +193,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
167
193
  frame = JSON.parse(raw) as Record<string, unknown>;
168
194
  } catch {
169
195
  // An unparseable frame is the browser's, not ours, and there is no call to fail with it:
170
- // correlation is by `id`, and a frame we cannot read has none. Events land here too.
196
+ // correlation is by `id`, and a frame we cannot read has none. COUNTED, though: the call
197
+ // whose reply it was waits out its deadline, and its timeout says frames were dropped.
198
+ watch.drop();
171
199
  return;
172
200
  }
201
+ watch.frame(frame);
173
202
  const id = frame['id'];
174
203
  if (typeof id !== 'number') {
175
204
  const method = frame['method'];
@@ -206,7 +235,7 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
206
235
  transport.listen({ message, closed: abandon });
207
236
 
208
237
  return {
209
- send(method, params = {}, sessionId): Promise<CdpResult> {
238
+ send(method, params = {}, sessionId, deadlineMs = timeoutMs): Promise<CdpResult> {
210
239
  if (closed) {
211
240
  return Promise.reject(
212
241
  new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
@@ -215,10 +244,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
215
244
  nextId += 1;
216
245
  const id = nextId;
217
246
  return new Promise<CdpResult>((resolve, reject) => {
247
+ const sentAt = watch.mark(sessionId);
218
248
  const timer = setTimeout(() => {
219
249
  pending.delete(id);
220
- reject(new CdpTimeoutError({ method, timeoutMs: timeoutMs }));
221
- }, timeoutMs);
250
+ const observed = watch.observe(sentAt, sessionId, !closed);
251
+ reject(new CdpTimeoutError({ method, timeoutMs: deadlineMs, observed }));
252
+ }, deadlineMs);
222
253
  pending.set(id, { resolve, reject, timer });
223
254
  transport.send(
224
255
  JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
@@ -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.
@@ -155,21 +252,41 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
155
252
  }
156
253
  // And `navigator.onLine` from a new document's first script (`cdp-offline-script.ts`) — the
157
254
  // network condition alone reaches a reloaded page only after its scripts have run.
255
+ // A page that is still THERE and refuses is kept and reported once every page has been asked:
256
+ // dropping it meant every later `offline()` skipped that tab in silence. Only a session the
257
+ // loop above just found gone is dropped — a closed tab refuses every call.
258
+ const refused: unknown[] = [];
158
259
  for (const session of [...pageSessions]) {
260
+ if (!sessions.has(session)) {
261
+ pageSessions.delete(session);
262
+ continue;
263
+ }
159
264
  const toggled = enabled ? onLineScripts.add(session) : onLineScripts.remove(session);
160
- await toggled.catch(() => pageSessions.delete(session));
265
+ await toggled.catch((error: unknown) => refused.push(error));
161
266
  }
267
+ if (refused.length > 0) throw refused[0];
268
+ await confirmed(!enabled);
162
269
  };
163
270
 
164
- const attached = async (targetId: string): Promise<string> => {
165
- for (let waited = 0; waited < options.loadTimeoutMs; waited += POLL_MS) {
166
- const session = pages.get(targetId);
167
- if (session !== undefined) return session;
168
- await Bun.sleep(POLL_MS);
169
- }
170
- throw new CdpTimeoutError({
171
- method: `Target.attachedToTarget for tab ${targetId}`,
172
- 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
+ });
173
290
  });
174
291
  };
175
292
 
package/src/cdp-errors.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
3
3
  // interpolated — the rule `e2e-errors.ts` already states.
4
4
 
5
- import { renderCauseValue, UltimateError } from '@ultimat3/core';
5
+ import { isFixShellSafe, renderCauseValue, UltimateError } from '@ultimat3/core';
6
6
  // Bare: the titles these constructors' codes render with are registered there.
7
7
  import './e2e-error-codes';
8
8
 
@@ -20,13 +20,66 @@ export class CdpBrowserMissingError extends UltimateError {
20
20
  }
21
21
  }
22
22
 
23
- /** The binary ran and never announced an endpoint — a crash, a bad flag, or a sandbox refusal. */
23
+ /** One start of the browser that ended without an answer — what `cdp-launch-attempt.ts` reports. */
24
+ export interface CdpLaunchAttempt {
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';
30
+ readonly waitedMs: number;
31
+ /** The code it exited with on its own, or `null` when the launcher had to kill it. */
32
+ readonly exitCode: number | null;
33
+ /** The last lines it wrote to stderr; empty when it wrote none. */
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;
40
+ }
41
+
42
+ const HAND_FLAGS =
43
+ '--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=0 about:blank';
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
+
55
+ const attemptText = (attempt: CdpLaunchAttempt, index: number): string => {
56
+ const saw =
57
+ attempt.detail === undefined || attempt.detail === ''
58
+ ? ''
59
+ : `: ${renderCauseValue(attempt.detail)}`;
60
+ const said = attempt.stderr === '' ? 'it printed nothing' : renderCauseValue(attempt.stderr);
61
+ return `launch ${String(index + 1)}: ${endedText(attempt)}${saw}; stderr: ${said}`;
62
+ };
63
+
64
+ /**
65
+ * The binary ran and never answered a DevTools call — a crash, a bad flag, a sandbox refusal, or a
66
+ * machine too loaded to start it inside the launch deadline. Every attempt is in the cause with its
67
+ * own stderr, because Chrome's start-up noise reads the same in a healthy browser and a dead one:
68
+ * what tells them apart is whether it EXITED or was still starting when the deadline passed.
69
+ */
24
70
  export class CdpLaunchFailedError extends UltimateError {
25
- constructor(input: { readonly executable: string; readonly detail: string }) {
71
+ constructor(input: {
72
+ readonly executable: string;
73
+ readonly attempts: readonly CdpLaunchAttempt[];
74
+ }) {
75
+ // Run by hand, a healthy Chrome prints `DevTools listening on ws://…` and a broken one prints
76
+ // why. A path that is not shell-inert is the operator's own CHROME_PATH — every candidate is.
77
+ const binary = isFixShellSafe(input.executable) ? input.executable : '"$CHROME_PATH"';
26
78
  super({
27
79
  code: 'X_CDP_LAUNCH_FAILED',
28
- cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${renderCauseValue(input.detail)}`,
29
- fix: 'run the same binary by hand with --headless=new --remote-debugging-port=0 and read its stderr; inside a container add --no-sandbox --disable-dev-shm-usage, which this launcher already passes',
80
+ cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: it answered no DevTools call in ${String(input.attempts.length)} ${input.attempts.length === 1 ? 'launch' : 'launches, each on its own fresh profile'} — ${input.attempts.map(attemptText).join(' · ')}`,
81
+ fix: `${binary} ${HAND_FLAGS}`,
82
+ meta: { executable: input.executable, attempts: input.attempts },
30
83
  });
31
84
  }
32
85
  }
@@ -42,17 +95,85 @@ export class CdpCallFailedError extends UltimateError {
42
95
  }
43
96
  }
44
97
 
98
+ /** How a call's target went away: its session detached, its target was destroyed, or it crashed. */
99
+ export type CdpTargetGone = 'detached' | 'destroyed' | 'crashed';
100
+
101
+ /** What the wire did between a call being sent and its deadline (`cdp-wire-watch.ts`). */
102
+ export interface CdpTimeoutObservation {
103
+ /** The session the call was sent on; `null` for a browser-level call. */
104
+ readonly sessionId: string | null;
105
+ /** Frames that arrived and parsed after the call was sent — replies to other calls, and events. */
106
+ readonly framesArrived: number;
107
+ /** The last of those, oldest first, bounded: `reply <id>`, or `<method> @<session>`. */
108
+ readonly lastFrames: readonly string[];
109
+ /** Frames that arrived after the call and could not be parsed. One of them may be its reply. */
110
+ readonly framesDropped: number;
111
+ readonly transportOpen: boolean;
112
+ /** `detached`, `destroyed` or `crashed` when the call's target went away; `null` while it is there. */
113
+ readonly targetGone: CdpTargetGone | null;
114
+ /** Main-frame navigations on the call's session since it was sent. */
115
+ readonly navigations: number;
116
+ }
117
+
118
+ /** The one-word verdict a reader — or a script over `--json` — branches on. */
119
+ export type CdpTimeoutReading = 'target-gone' | 'lost-in-transport' | 'no-answer';
120
+
121
+ export const readTimeout = (seen: CdpTimeoutObservation): CdpTimeoutReading =>
122
+ seen.targetGone !== null
123
+ ? 'target-gone'
124
+ : seen.framesDropped > 0
125
+ ? 'lost-in-transport'
126
+ : 'no-answer';
127
+
128
+ const READING: Record<CdpTimeoutReading, (seen: CdpTimeoutObservation) => string> = {
129
+ 'target-gone': (seen) =>
130
+ `its target is gone (${String(seen.targetGone)}), so nothing was left to answer`,
131
+ 'lost-in-transport': (seen) =>
132
+ `${String(seen.framesDropped)} frame(s) arrived unparseable after it was sent and were dropped, so its reply may have been lost in transport (${String(seen.framesArrived)} other frame(s) arrived intact)`,
133
+ 'no-answer': (seen) => {
134
+ if (seen.framesArrived === 0) {
135
+ return `nothing at all arrived after the call was sent, on a transport that is ${seen.transportOpen ? 'still open' : 'closed'} — the whole browser went silent`;
136
+ }
137
+ const moved =
138
+ seen.navigations === 0
139
+ ? ''
140
+ : `; the page navigated ${String(seen.navigations)} time(s) since the call`;
141
+ return `the target did not answer: ${String(seen.framesArrived)} other frame(s) arrived intact after the call, the last ${renderCauseValue(seen.lastFrames)}${moved}`;
142
+ },
143
+ };
144
+
45
145
  /**
46
146
  * A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
47
147
  * WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
48
148
  * `Runtime.evaluate` is an expression that never settles.
149
+ *
150
+ * `observed` is what the connection saw meanwhile, and it is what tells three failures apart that
151
+ * all read "did not answer": the target went away, the reply was dropped on the wire, or the target
152
+ * was there and silent. A deadline the connection did not keep (`waitFor`, the handshake) has none.
49
153
  */
50
154
  export class CdpTimeoutError extends UltimateError {
51
- constructor(input: { readonly method: string; readonly timeoutMs: number }) {
155
+ constructor(input: {
156
+ readonly method: string;
157
+ readonly timeoutMs: number;
158
+ readonly observed?: CdpTimeoutObservation | undefined;
159
+ }) {
160
+ const { observed } = input;
161
+ const base = `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`;
162
+ const reading = observed === undefined ? undefined : readTimeout(observed);
52
163
  super({
53
164
  code: 'X_CDP_TIMEOUT',
54
- cause: `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`,
55
- fix: 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response',
165
+ cause:
166
+ observed === undefined || reading === undefined
167
+ ? base
168
+ : `${base} — ${READING[reading](observed)}`,
169
+ fix:
170
+ reading === undefined || reading === 'no-answer'
171
+ ? 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response'
172
+ : // `--json`: the rerun's `meta.reading` and `meta.framesDropped` are only printed there.
173
+ 'x test e2e --json',
174
+ ...(observed === undefined
175
+ ? {}
176
+ : { meta: { method: input.method, timeoutMs: input.timeoutMs, reading, ...observed } }),
56
177
  });
57
178
  }
58
179
  }