@ultimat3/cli 20.2.0 → 21.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 (53) hide show
  1. package/CLAUDE.md +70 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/cmd-shot.ts +30 -4
  16. package/src/dev-live-feed.ts +2 -0
  17. package/src/dev-render.ts +119 -20
  18. package/src/dev-route-table.ts +119 -0
  19. package/src/dev-services.ts +4 -1
  20. package/src/dev-sync.ts +5 -3
  21. package/src/e2e-app.ts +103 -0
  22. package/src/e2e-browser-handle.ts +55 -0
  23. package/src/e2e-driver.ts +32 -12
  24. package/src/e2e-errors.ts +14 -0
  25. package/src/e2e-page.ts +5 -2
  26. package/src/e2e-preload.ts +64 -0
  27. package/src/e2e-probe.ts +23 -0
  28. package/src/e2e-spawn.ts +169 -0
  29. package/src/error-codes.ts +7 -0
  30. package/src/error-unthrown.ts +130 -0
  31. package/src/errors.ts +8 -29
  32. package/src/index.ts +18 -4
  33. package/src/island-bundle.ts +38 -11
  34. package/src/island-realtime.ts +91 -0
  35. package/src/island-solid-dedupe.ts +108 -0
  36. package/src/island-verdict.ts +1 -1
  37. package/src/live-routes.ts +82 -42
  38. package/src/mcp-errors.ts +3 -0
  39. package/src/page-sync.ts +54 -0
  40. package/src/realtime-browser-probe-fixture.ts +2 -2
  41. package/src/serve.ts +9 -0
  42. package/src/shot-theme.ts +52 -0
  43. package/src/sw-artifacts.ts +13 -3
  44. package/src/sync-url.ts +31 -0
  45. package/src/templates/resource-form-island.ts +30 -21
  46. package/src/templates/route.ts +3 -0
  47. package/src/templates/scaffold-container.ts +18 -3
  48. package/src/templates/scaffold-dashboard-shared.ts +8 -5
  49. package/src/templates/scaffold-env.ts +6 -0
  50. package/src/verify-e2e.ts +38 -0
  51. package/src/verify-run.ts +105 -50
  52. package/src/verify-tests.ts +21 -4
  53. package/src/worker-bundle.ts +192 -0
@@ -1,136 +1,100 @@
1
- // One responsibility: `E2eBrowserPage` over a raw CDP connection — attach a tab, navigate,
2
- // evaluate, click, and set the browser's offline condition. Launching is `cdp-launch.ts` and the
1
+ // One responsibility: one TAB over a raw CDP session — navigate, reload, evaluate, click, wait. The
2
+ // session (`cdp-e2e-session.ts`) attaches it and owns everything browser-wide: the network
3
+ // condition, the init scripts, the socket and request log. Launching is `cdp-launch.ts` and the
3
4
  // wire is `cdp-connection.ts`.
4
5
  //
5
6
  // FIVE methods, which is the whole reason this exists next to `@ultimat3/scraping` rather than
6
7
  // through it: `ScrapePage` is a full scraping surface whose intended implementation is
7
8
  // `puppeteer-core`, and an e2e driver needs none of it.
8
9
 
9
- import { assert } from '@ultimat3/core';
10
10
  import type { CdpConnection } from './cdp-connection';
11
- import { CdpCallFailedError } from './cdp-errors';
11
+ import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
12
12
  import type { E2eBrowserPage } from './e2e-page';
13
13
 
14
+ /** What the page threw, when `Runtime.evaluate` answered with an exception rather than a value. */
15
+ const thrownIn = (result: Record<string, unknown> | undefined): string | undefined => {
16
+ const thrown = result?.['exceptionDetails'];
17
+ if (typeof thrown !== 'object' || thrown === null) return undefined;
18
+ const text = (thrown as Record<string, unknown>)['text'];
19
+ return typeof text === 'string' ? text : 'the expression threw in the page';
20
+ };
21
+
14
22
  /** `Runtime.evaluate`'s answer, unwrapped. Every field here is somebody else's JSON. */
15
23
  const evaluated = (result: Record<string, unknown> | undefined): unknown => {
16
- const thrown = result?.['exceptionDetails'];
17
- if (typeof thrown === 'object' && thrown !== null) {
18
- const text = (thrown as Record<string, unknown>)['text'];
19
- throw new CdpCallFailedError({
20
- method: 'Runtime.evaluate',
21
- detail: typeof text === 'string' ? text : 'the expression threw in the page',
22
- });
24
+ const threw = thrownIn(result);
25
+ if (threw !== undefined) {
26
+ throw new CdpCallFailedError({ method: 'Runtime.evaluate', detail: threw });
23
27
  }
24
28
  const remote = result?.['result'];
25
29
  if (typeof remote !== 'object' || remote === null) return undefined;
26
30
  return (remote as Record<string, unknown>)['value'];
27
31
  };
28
32
 
29
- export interface CdpE2ePageOptions {
33
+ /** One tab: the port the driver drives, plus what a multi-tab acceptance suite asks of one. */
34
+ export interface E2eTab extends E2eBrowserPage {
35
+ readonly targetId: string;
36
+ /** Browser-wide, like the switch it models: every tab AND every worker goes with it. */
37
+ offline(enabled: boolean): Promise<void>;
38
+ /** Reload and wait for the load, at the url the tab already had. */
39
+ reload(): Promise<void>;
40
+ /** Poll `expression` in the page until it is truthy, or refuse naming `what`. */
41
+ waitFor(expression: string, what: string, timeoutMs?: number): Promise<void>;
42
+ /** The IndexedDB databases this tab's origin holds, by name, sorted. */
43
+ indexedDbNames(): Promise<readonly string[]>;
44
+ close(): Promise<void>;
45
+ }
46
+
47
+ export interface CdpE2eTabOptions {
30
48
  readonly connection: CdpConnection;
49
+ /** The attached tab's flattened session — every page call carries it. */
50
+ readonly sessionId: string;
51
+ readonly targetId: string;
31
52
  /**
32
53
  * How long a navigation's load event may take. Distinct from the connection's per-call deadline:
33
54
  * `Page.navigate` ANSWERS as soon as the navigation is committed, so the wait for the load event
34
55
  * is a second budget and is the one an app makes long.
35
56
  */
36
57
  readonly loadTimeoutMs: number;
58
+ /** The session's browser-wide switch, which `offline()` forwards to. */
59
+ readonly offline: (enabled: boolean) => Promise<void>;
37
60
  }
38
61
 
39
- /**
40
- * Attach a fresh tab and give back the page.
41
- *
42
- * `flatten: true` is not optional: without it every page call has to be wrapped in
43
- * `Target.sendMessageToTarget` and the answers arrive as nested strings. Flattened, a `sessionId`
44
- * on the frame is the whole of it, which is what keeps `cdp-connection.ts` a single map.
45
- */
46
- export async function cdpE2ePage(options: CdpE2ePageOptions): Promise<E2eBrowserPage> {
47
- const send = options.connection.send.bind(options.connection);
48
- const created = await send('Target.createTarget', { url: 'about:blank' });
49
- const targetId = created.result?.['targetId'];
50
- assert(
51
- typeof targetId === 'string',
52
- 'the browser created a tab and answered no targetId',
53
- 'check the Chrome version supports Target.createTarget — every build since 60 does',
54
- );
55
- const attached = await send('Target.attachToTarget', { targetId, flatten: true });
56
- const sessionId = attached.result?.['sessionId'];
57
- assert(
58
- typeof sessionId === 'string',
59
- 'the browser attached to the tab and answered no sessionId',
60
- 'check the Chrome version supports Target.attachToTarget with flatten: true — every build since 79 does',
61
- );
62
-
63
- await send('Page.enable', {}, sessionId);
64
- await send('Runtime.enable', {}, sessionId);
65
- // Enabled at attach rather than inside `offline()`, because `Network.emulateNetworkConditions`
66
- // is silently ignored on a session whose Network domain was never enabled — the exact shape of
67
- // an `offline()` that does nothing while the assertion after it reads as proof.
68
- await send('Network.enable', {}, sessionId);
62
+ const POLL_MS = 100;
69
63
 
70
- // **A SERVICE WORKER FETCHES ON ITS OWN TARGET, and that is what `offline()` used to miss.**
71
- // Measured against the framework's own emitted `sw.js`: with the page session offline, a
72
- // `networkFirst` route the cache had never seen still answered from the network, because the
73
- // worker's fetches never crossed the session the condition was set on. The assertion after
74
- // `offline()` then read as proof of an offline fallback that had not run.
75
- //
76
- // So worker targets are AUTO-ATTACHED and carry the same condition. `waitForDebuggerOnStart:
77
- // false`, or every worker starts paused and the page that registered it never becomes
78
- // controlled.
79
- const workers = new Set<string>();
80
- let offline = false;
81
- const applyOffline = async (target: string | undefined): Promise<void> => {
82
- // `-1` is CDP's "no throttling" for both throughputs. Passing 0 would be a browser that can
83
- // never transfer a byte, which is a different failure wearing the same name.
84
- await send(
85
- 'Network.emulateNetworkConditions',
86
- { offline, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
87
- target,
88
- );
89
- };
90
- options.connection.on('Target.attachedToTarget', (params) => {
91
- const info = params['targetInfo'];
92
- const kind =
93
- typeof info === 'object' && info !== null
94
- ? (info as Record<string, unknown>)['type']
95
- : undefined;
96
- const attachedTo = params['sessionId'];
97
- if (typeof attachedTo !== 'string') return;
98
- if (kind !== 'service_worker' && kind !== 'worker' && kind !== 'shared_worker') return;
99
- workers.add(attachedTo);
100
- // A worker that attaches while the page is offline inherits the condition — it did not exist
101
- // when `offline(true)` ran, and a worker registered mid-test is the ordinary case for a PWA.
102
- void (async () => {
103
- try {
104
- await send('Network.enable', {}, attachedTo);
105
- if (offline) await applyOffline(attachedTo);
106
- } catch {
107
- // A worker that died between attaching and being configured is not this driver's problem;
108
- // its session is gone and every later call on it would report the same thing.
109
- workers.delete(attachedTo);
110
- }
111
- })();
112
- });
113
- await send(
114
- 'Target.setAutoAttach',
115
- { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
116
- sessionId,
117
- );
64
+ /** A tab over a session the caller has already attached and enabled (`cdp-e2e-session.ts`). */
65
+ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
66
+ const send = options.connection.send.bind(options.connection);
67
+ const { sessionId } = options;
118
68
 
119
69
  // `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed
120
70
  // url is tracked here. Seeded with the tab's own starting url rather than '' so a `reload()`
121
71
  // before any `goto()` navigates somewhere real.
122
72
  let current = 'about:blank';
123
73
 
124
- const evaluate = async (expression: string): Promise<unknown> => {
125
- const answer = await send(
126
- 'Runtime.evaluate',
127
- { expression, returnByValue: true, awaitPromise: true },
128
- sessionId,
129
- );
130
- return evaluated(answer.result);
74
+ const evaluateRaw = (expression: string) =>
75
+ send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId);
76
+ const evaluate = async (expression: string): Promise<unknown> =>
77
+ evaluated((await evaluateRaw(expression)).result);
78
+
79
+ // A poll that THROWS is a poll that does not hold yet, not a refusal: a click that navigates
80
+ // leaves the next poll reading a document whose `<body>` is not parsed, and
81
+ // `document.body.textContent` throws there once and holds a poll later. The last throw is kept
82
+ // and named at the deadline, so an expression that can never evaluate still says why.
83
+ const waitFor = async (expression: string, what: string, timeoutMs = options.loadTimeoutMs) => {
84
+ // Only the PAGE's throw is swallowed: a connection that died still refuses at once.
85
+ let threw: string | undefined;
86
+ for (let waited = 0; waited < timeoutMs; waited += POLL_MS) {
87
+ const answer = (await evaluateRaw(`Boolean(${expression})`)).result;
88
+ threw = thrownIn(answer);
89
+ if (threw === undefined && evaluated(answer) === true) return;
90
+ await Bun.sleep(POLL_MS);
91
+ }
92
+ const last = threw === undefined ? '' : `, and its last poll threw: ${threw}`;
93
+ throw new CdpTimeoutError({ method: `waitFor(${what})${last}`, timeoutMs });
131
94
  };
132
95
 
133
- return {
96
+ const tab: E2eTab = {
97
+ targetId: options.targetId,
134
98
  url: () => current,
135
99
  async goto(url: string): Promise<unknown> {
136
100
  // **The load EVENT is the signal, not the reply.** Chrome drops `Page.navigate`'s own reply
@@ -164,12 +128,14 @@ export async function cdpE2ePage(options: CdpE2ePageOptions): Promise<E2eBrowser
164
128
  // navigation, and `answered` can win the race on one too. So the document is asked directly:
165
129
  // a `readyState` that is already `complete` resolves at once, and the deadline resolves
166
130
  // rather than throwing — a slow page is the app's business, and the assertion after this is
167
- // what should fail.
131
+ // what should fail. HALF the budget, because the budget is also the connection's per-call
132
+ // deadline: a page timer of the full budget raced that deadline and lost, reporting a page
133
+ // that never fired `load` as "Runtime.evaluate did not answer" instead.
168
134
  await evaluate(`(() => new Promise((resolve) => {
169
135
  if (document.readyState === 'complete') { resolve(true); return; }
170
136
  const done = () => resolve(true);
171
137
  addEventListener('load', done, { once: true });
172
- setTimeout(done, ${String(options.loadTimeoutMs)});
138
+ setTimeout(done, ${String(Math.floor(options.loadTimeoutMs / 2))});
173
139
  }))()`);
174
140
  // The app may have redirected, so the committed url is re-read rather than assumed.
175
141
  const settled = await evaluate('location.href');
@@ -191,19 +157,24 @@ export async function cdpE2ePage(options: CdpE2ePageOptions): Promise<E2eBrowser
191
157
  });
192
158
  }
193
159
  },
194
- async offline(enabled: boolean): Promise<void> {
195
- offline = enabled;
196
- // The page first, then every worker attached to it. Sequential rather than `Promise.all`:
197
- // the set is small, and a worker session that has gone away must not take the page's own
198
- // condition down with it.
199
- await applyOffline(sessionId);
200
- for (const worker of [...workers]) {
201
- try {
202
- await applyOffline(worker);
203
- } catch {
204
- workers.delete(worker);
205
- }
206
- }
160
+ offline: (enabled: boolean) => options.offline(enabled),
161
+ async reload(): Promise<void> {
162
+ const at = current;
163
+ // Reload is a navigation to the url the tab already has — the one wait the port proves.
164
+ await tab.goto(at);
165
+ },
166
+ waitFor,
167
+ async indexedDbNames(): Promise<readonly string[]> {
168
+ const names = await evaluate(
169
+ '(async () => (await indexedDB.databases()).map((db) => db.name ?? "").sort())()',
170
+ );
171
+ return Array.isArray(names)
172
+ ? names.filter((name): name is string => typeof name === 'string')
173
+ : [];
174
+ },
175
+ async close(): Promise<void> {
176
+ await send('Target.closeTarget', { targetId: options.targetId });
207
177
  },
208
178
  };
179
+ return tab;
209
180
  }
@@ -0,0 +1,199 @@
1
+ // One responsibility: the BROWSER half of the e2e driver — every target auto-attached at browser
2
+ // level, so a second tab, a SharedWorker, a dedicated worker and a service worker are all watched
3
+ // from their first byte; the offline switch, the init scripts, and the log of every WebSocket and
4
+ // request the browser made. A tab is `cdp-e2e-page.ts`'s; this file decides what every tab shares.
5
+
6
+ import type { CdpConnection } from './cdp-connection';
7
+ import type { E2eTab } from './cdp-e2e-page';
8
+ import { cdpE2eTab } from './cdp-e2e-page';
9
+ import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
10
+ import { offlineScripts } from './cdp-offline-script';
11
+
12
+ export interface E2eSession {
13
+ /** A new tab in the same profile — same cookies, same origin storage, same SharedWorker. */
14
+ newTab(): Promise<E2eTab>;
15
+ /** Runs in every page before its own scripts, in tabs open now and tabs opened later. */
16
+ addInitScript(source: string): Promise<void>;
17
+ /** Cut or restore the network for every page AND every worker, including ones attached later. */
18
+ offline(enabled: boolean): Promise<void>;
19
+ setCookie(url: string, name: string, value: string): Promise<void>;
20
+ /** Every WebSocket url the browser opened, in any realm — page, dedicated, shared or service worker. */
21
+ sockets(): readonly string[];
22
+ /** Every request the browser sent, `METHOD url`, in order. */
23
+ requests(): readonly string[];
24
+ }
25
+
26
+ export interface CdpE2eSessionOptions {
27
+ readonly connection: CdpConnection;
28
+ /** The load budget every tab's `goto` waits on, and how long a new tab may take to attach. */
29
+ readonly loadTimeoutMs: number;
30
+ }
31
+
32
+ const POLL_MS = 50;
33
+
34
+ const field = (from: unknown, key: string): string | undefined => {
35
+ const value =
36
+ typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
37
+ return typeof value === 'string' ? value : undefined;
38
+ };
39
+
40
+ /**
41
+ * Start watching the browser. Targets are attached PAUSED (`waitForDebuggerOnStart: true`) and
42
+ * released only once their network domain is ENABLED, in dispatch order — a SharedWorker opens its
43
+ * socket at start-up, so a target configured any later than that is a socket nothing ever counted.
44
+ * Released with `Runtime.runIfWaitingForDebugger` whatever happens and without waiting for any
45
+ * answer, or a page that registered a worker never becomes controlled.
46
+ */
47
+ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eSession> {
48
+ const { connection } = options;
49
+ const send = connection.send.bind(connection);
50
+ const sessions = new Set<string>();
51
+ const pages = new Map<string, string>(); // targetId → sessionId
52
+ const scripts: string[] = [];
53
+ const sockets: string[] = [];
54
+ const requests: string[] = [];
55
+ let cut = false;
56
+
57
+ // `-1` is CDP's "no throttling" for both throughputs; 0 would be a browser that can never
58
+ // transfer a byte, which is a different failure wearing the same name.
59
+ const pageSessions = new Set<string>();
60
+ const onLineScripts = offlineScripts(send);
61
+ const condition = (session: string): Promise<unknown> =>
62
+ send(
63
+ 'Network.emulateNetworkConditions',
64
+ { offline: cut, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
65
+ session,
66
+ );
67
+
68
+ connection.on('Network.webSocketCreated', (params) => {
69
+ sockets.push(field(params, 'url') ?? '');
70
+ });
71
+ connection.on('Network.requestWillBeSent', (params) => {
72
+ const request = params['request'];
73
+ requests.push(`${field(request, 'method') ?? '?'} ${field(request, 'url') ?? ''}`);
74
+ });
75
+ connection.on('Target.attachedToTarget', (params) => {
76
+ const session = field(params, 'sessionId');
77
+ if (session === undefined) return;
78
+ const info = params['targetInfo'];
79
+ const type = field(info, 'type');
80
+ sessions.add(session);
81
+ void (async () => {
82
+ // Every configuration call is SENT before the release and none is AWAITED before it. A
83
+ // session dispatches in order, so the target is still configured before its first byte runs —
84
+ // but a paused target may answer nothing until it is released: measured in a full `x verify`,
85
+ // a SharedWorker's `Network.enable` went unanswered for the whole 30 s deadline, the worker
86
+ // stayed paused that long, and the shared gate page stalled behind it. Awaiting the answers
87
+ // before releasing was a deadlock with a timeout for an exit.
88
+ //
89
+ // `Network.enable` first: `emulateNetworkConditions` is silently ignored on a session whose
90
+ // Network domain is off — an `offline()` that does nothing while the assertion after it
91
+ // reads as proof.
92
+ const configured: Promise<unknown>[] = [send('Network.enable', {}, session)];
93
+ if (cut) configured.push(condition(session));
94
+ if (type === 'page') {
95
+ configured.push(send('Page.enable', {}, session), send('Runtime.enable', {}, session));
96
+ pageSessions.add(session);
97
+ for (const source of scripts) {
98
+ configured.push(send('Page.addScriptToEvaluateOnNewDocument', { source }, session));
99
+ }
100
+ if (cut) configured.push(onLineScripts.add(session));
101
+ // The page's own workers attach under it UNPAUSED. Measured: paused here, the emitted
102
+ // service worker never took control of its page (`e2e/service-worker.e2e.test.ts` went
103
+ // red), because it is also attached at browser level. A dedicated worker's first request
104
+ // can therefore precede its Network.enable — the SharedWorker, which is what owns the
105
+ // socket, is a browser-level target and IS paused.
106
+ configured.push(
107
+ send(
108
+ 'Target.setAutoAttach',
109
+ { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
110
+ session,
111
+ ),
112
+ );
113
+ }
114
+ const released = send('Runtime.runIfWaitingForDebugger', {}, session).catch(() => undefined);
115
+ // Settled, not `all`: a target that went away refuses EVERY call, and `all` would leave the
116
+ // refusals after the first as unhandled rejections. It has nothing left to watch.
117
+ const answers = await Promise.allSettled(configured);
118
+ if (answers.some((answer) => answer.status === 'rejected')) sessions.delete(session);
119
+ await released;
120
+ // Published only once RELEASED: a tab handed out while still paused would take its first
121
+ // `goto` into a page that is waiting for a debugger.
122
+ const targetId = field(info, 'targetId');
123
+ if (type === 'page' && targetId !== undefined && sessions.has(session)) {
124
+ pages.set(targetId, session);
125
+ }
126
+ })();
127
+ });
128
+ await send('Target.setDiscoverTargets', { discover: true });
129
+ await send('Target.setAutoAttach', {
130
+ autoAttach: true,
131
+ waitForDebuggerOnStart: true,
132
+ flatten: true,
133
+ });
134
+
135
+ const offline = async (enabled: boolean): Promise<void> => {
136
+ cut = enabled;
137
+ // Sequential, and a session that has gone away is dropped rather than taking the rest down.
138
+ for (const session of [...sessions]) {
139
+ await condition(session).catch(() => sessions.delete(session));
140
+ }
141
+ // And `navigator.onLine` from a new document's first script (`cdp-offline-script.ts`) — the
142
+ // network condition alone reaches a reloaded page only after its scripts have run.
143
+ for (const session of [...pageSessions]) {
144
+ const toggled = enabled ? onLineScripts.add(session) : onLineScripts.remove(session);
145
+ await toggled.catch(() => pageSessions.delete(session));
146
+ }
147
+ };
148
+
149
+ const attached = async (targetId: string): Promise<string> => {
150
+ for (let waited = 0; waited < options.loadTimeoutMs; waited += POLL_MS) {
151
+ const session = pages.get(targetId);
152
+ if (session !== undefined) return session;
153
+ await Bun.sleep(POLL_MS);
154
+ }
155
+ throw new CdpTimeoutError({
156
+ method: `Target.attachedToTarget for tab ${targetId}`,
157
+ timeoutMs: options.loadTimeoutMs,
158
+ });
159
+ };
160
+
161
+ return {
162
+ async newTab(): Promise<E2eTab> {
163
+ const created = await send('Target.createTarget', { url: 'about:blank' });
164
+ const targetId = field(created.result, 'targetId');
165
+ if (targetId === undefined) {
166
+ throw new CdpCallFailedError({
167
+ method: 'Target.createTarget',
168
+ detail: 'the browser created a tab and answered no targetId',
169
+ });
170
+ }
171
+ const sessionId = await attached(targetId);
172
+ return cdpE2eTab({
173
+ connection,
174
+ sessionId,
175
+ targetId,
176
+ loadTimeoutMs: options.loadTimeoutMs,
177
+ offline,
178
+ });
179
+ },
180
+ async addInitScript(source: string): Promise<void> {
181
+ scripts.push(source);
182
+ // A closed tab refuses every call; it is dropped rather than taking the open tabs down with
183
+ // it, exactly as `offline()` drops one.
184
+ for (const [targetId, session] of [...pages]) {
185
+ await send('Page.addScriptToEvaluateOnNewDocument', { source }, session).catch(() => {
186
+ pages.delete(targetId);
187
+ sessions.delete(session);
188
+ pageSessions.delete(session);
189
+ });
190
+ }
191
+ },
192
+ offline,
193
+ async setCookie(url: string, name: string, value: string): Promise<void> {
194
+ await send('Storage.setCookies', { cookies: [{ name, value, url, path: '/' }] });
195
+ },
196
+ sockets: () => [...sockets],
197
+ requests: () => [...requests],
198
+ };
199
+ }
package/src/cdp-launch.ts CHANGED
@@ -8,7 +8,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  // why: Bun exposes no path-join primitive.
10
10
  import { join } from 'node:path';
11
+ import type { CdpConnection } from './cdp-connection';
12
+ import { cdpConnectOver } from './cdp-connection';
11
13
  import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
14
+ import { pipeTransport } from './cdp-pipe';
12
15
 
13
16
  /**
14
17
  * Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
@@ -52,17 +55,25 @@ export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disa
52
55
  /**
53
56
  * The flags, and every one of them earns its line.
54
57
  *
55
- * `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-port=0`
56
- * asks the OS for a free port, so two suites on one machine never collide — the port is read back
57
- * off stderr, which is the only place Chrome states the one it took. A throwaway `--user-data-dir`
58
- * because a run sharing a profile with a real browser inherits its cookies and locks its files.
58
+ * `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-pipe`
59
+ * is the wire (`cdp-pipe.ts` says why it is not the WebSocket): no port, so two suites on one
60
+ * machine can never collide, and nothing but this process can drive the browser. A throwaway
61
+ * `--user-data-dir` because a run sharing a profile with a real browser inherits its cookies and
62
+ * locks its files.
59
63
  */
60
- const flags = (profileDir: string): readonly string[] => [
64
+ export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
61
65
  '--headless=new',
62
- '--remote-debugging-port=0',
66
+ '--remote-debugging-pipe',
63
67
  `--user-data-dir=${profileDir}`,
64
68
  ...CONTAINER_CHROME_ARGS,
65
69
  '--disable-gpu',
70
+ // The cookie store's encryption key comes from the OS keyring, asked over D-Bus on the first
71
+ // cookie access — which is the first navigation. With no keyring answering, Chrome waits out the
72
+ // D-Bus timeout: measured 7-25 s on the first `Page.navigate` of every launch, against a 30 s CDP
73
+ // deadline, which is the intermittent `X_CDP_TIMEOUT` of a full e2e run. A throwaway profile has
74
+ // no secret worth a keyring. `puppeteer-core` passes both by default, so `x shot` never had this.
75
+ '--password-store=basic',
76
+ '--use-mock-keychain',
66
77
  // Nothing here should reach the network on its own account, and a first-run bubble or an update
67
78
  // check is a page load the test did not ask for.
68
79
  '--no-first-run',
@@ -71,61 +82,104 @@ const flags = (profileDir: string): readonly string[] => [
71
82
  'about:blank',
72
83
  ];
73
84
 
74
- const ENDPOINT = /DevTools listening on (ws:\/\/\S+)/;
75
-
76
85
  export interface LaunchedBrowser {
77
- readonly endpoint: string;
78
- /** Idempotent: killing a dead process and deleting a gone directory are both no-ops. */
86
+ /** The browser's own CDP connection, over its debugging pipe. Already answering. */
87
+ readonly connection: CdpConnection;
88
+ /** Idempotent: closes the connection, kills the process, deletes the profile. */
79
89
  close(): void;
80
90
  }
81
91
 
82
92
  export interface LaunchOptions {
83
93
  readonly executable: string;
84
- /** How long Chrome has to announce its endpoint before this gives up and kills it. */
94
+ /** How long Chrome has to answer its first call, and every call's deadline after that. */
85
95
  readonly timeoutMs: number;
86
96
  }
87
97
 
98
+ const STDERR_TAIL_CHARS = 4_000;
99
+
100
+ /**
101
+ * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
102
+ * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
103
+ * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
104
+ * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
105
+ */
106
+ function stderrTail(stream: ReadableStream<Uint8Array>): {
107
+ readonly text: () => string;
108
+ /** Settles once the stream has ended — every byte the process wrote has been read. */
109
+ readonly drained: Promise<void>;
110
+ } {
111
+ let text = '';
112
+ const drained = (async () => {
113
+ const decoder = new TextDecoder();
114
+ for await (const chunk of stream) {
115
+ text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
116
+ }
117
+ })().catch(() => undefined);
118
+ return { text: () => text, drained };
119
+ }
120
+
88
121
  /**
89
- * Chrome announces `DevTools listening on ws://…` on **stderr**, once, before it is usable. Reading
90
- * it there rather than polling `/json/version` is what makes `--remote-debugging-port=0` safe: with
91
- * a random port there is no URL to poll until Chrome has said which one it took.
122
+ * How long a browser that failed its first call gets to finish dying, and its stderr to finish
123
+ * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
124
+ * are two unordered events; read at the first, the reason a browser died was reported as "printed
125
+ * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
126
+ */
127
+ const FAILURE_DRAIN_MS = 1_000;
128
+
129
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
130
+ Promise.race([work, Bun.sleep(ms)]);
131
+
132
+ /**
133
+ * Start Chrome on a throwaway profile and answer once it has answered one CDP call. With a pipe
134
+ * there is no "DevTools listening" line to wait for — the first reply IS the readiness signal, and
135
+ * a browser that dies or stays silent before it is `X_CDP_LAUNCH_FAILED` carrying its own stderr.
92
136
  */
93
137
  export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
94
138
  const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
95
- const child = Bun.spawn([options.executable, ...flags(profileDir)], {
96
- stderr: 'pipe',
97
- stdout: 'ignore',
139
+ const child = Bun.spawn([options.executable, ...chromeLaunchFlags(profileDir)], {
140
+ // Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
141
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
98
142
  });
143
+ const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
144
+ const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
145
+ const sink = Bun.file(toBrowser ?? -1).writer();
146
+ const connection = cdpConnectOver(
147
+ pipeTransport({
148
+ write: (bytes) => {
149
+ sink.write(bytes);
150
+ void sink.flush();
151
+ },
152
+ read: Bun.file(fromBrowser ?? -1).stream(),
153
+ end: () => {
154
+ void Promise.resolve(sink.end()).catch(() => undefined);
155
+ },
156
+ }),
157
+ options.timeoutMs,
158
+ );
159
+ let closed = false;
99
160
  const close = (): void => {
161
+ if (closed) return;
162
+ closed = true;
163
+ connection.close();
100
164
  child.kill();
101
165
  rmSync(profileDir, { recursive: true, force: true });
102
166
  };
103
-
104
- const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
105
- const decoder = new TextDecoder();
106
- let seen = '';
107
- const deadline = Bun.nanoseconds() + options.timeoutMs * 1_000_000;
108
167
  try {
109
- while (Bun.nanoseconds() < deadline) {
110
- const { value, done } = await reader.read();
111
- if (done) break;
112
- seen += decoder.decode(value, { stream: true });
113
- const found = ENDPOINT.exec(seen);
114
- if (found?.[1] !== undefined) return { endpoint: found[1], close };
115
- }
116
- } finally {
117
- reader.releaseLock();
168
+ await connection.send('Browser.getVersion');
169
+ return { connection, close };
170
+ } catch {
171
+ await within(FAILURE_DRAIN_MS, child.exited);
172
+ close();
173
+ await within(FAILURE_DRAIN_MS, tail.drained);
174
+ const seen = tail.text().trim();
175
+ throw new CdpLaunchFailedError({
176
+ executable: options.executable,
177
+ detail:
178
+ seen === ''
179
+ ? 'it answered no DevTools call and printed nothing before the deadline'
180
+ : seen.split('\n').slice(-3).join(' | '),
181
+ });
118
182
  }
119
- close();
120
- // Chrome's own stderr is the actionable half — a missing library, a sandbox refusal, a bad flag
121
- // are all named there — so it is reported rather than "the launch failed".
122
- throw new CdpLaunchFailedError({
123
- executable: options.executable,
124
- detail:
125
- seen.trim() === ''
126
- ? 'it printed nothing before the deadline'
127
- : seen.trim().split('\n').slice(-3).join(' | '),
128
- });
129
183
  }
130
184
 
131
185
  /** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */