@ultimat3/cli 18.0.0 → 19.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,209 @@
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
3
+ // wire is `cdp-connection.ts`.
4
+ //
5
+ // FIVE methods, which is the whole reason this exists next to `@ultimat3/scraping` rather than
6
+ // through it: `ScrapePage` is a full scraping surface whose intended implementation is
7
+ // `puppeteer-core`, and an e2e driver needs none of it.
8
+
9
+ import { assert } from '@ultimat3/core';
10
+ import type { CdpConnection } from './cdp-connection';
11
+ import { CdpCallFailedError } from './cdp-errors';
12
+ import type { E2eBrowserPage } from './e2e-page';
13
+
14
+ /** `Runtime.evaluate`'s answer, unwrapped. Every field here is somebody else's JSON. */
15
+ 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
+ });
23
+ }
24
+ const remote = result?.['result'];
25
+ if (typeof remote !== 'object' || remote === null) return undefined;
26
+ return (remote as Record<string, unknown>)['value'];
27
+ };
28
+
29
+ export interface CdpE2ePageOptions {
30
+ readonly connection: CdpConnection;
31
+ /**
32
+ * How long a navigation's load event may take. Distinct from the connection's per-call deadline:
33
+ * `Page.navigate` ANSWERS as soon as the navigation is committed, so the wait for the load event
34
+ * is a second budget and is the one an app makes long.
35
+ */
36
+ readonly loadTimeoutMs: number;
37
+ }
38
+
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);
69
+
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
+ );
118
+
119
+ // `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed
120
+ // url is tracked here. Seeded with the tab's own starting url rather than '' so a `reload()`
121
+ // before any `goto()` navigates somewhere real.
122
+ let current = 'about:blank';
123
+
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);
131
+ };
132
+
133
+ return {
134
+ url: () => current,
135
+ async goto(url: string): Promise<unknown> {
136
+ // **The load EVENT is the signal, not the reply.** Chrome drops `Page.navigate`'s own reply
137
+ // whenever the navigation swaps the render process — measured on Chrome 150 against a local
138
+ // server: the page loads, the server is hit, a later `Runtime.evaluate` answers `document
139
+ // .title` from the new document, and the navigate frame never comes back at all. Awaiting
140
+ // the reply therefore waited out the full deadline on the most ordinary navigation there is,
141
+ // `about:blank` → `http://localhost:<port>/`. So the waiter is registered BEFORE the send,
142
+ // and the reply is raced against it rather than depended on.
143
+ const loaded = options.connection.once(
144
+ 'Page.loadEventFired',
145
+ sessionId,
146
+ options.loadTimeoutMs,
147
+ );
148
+ // A dropped reply is expected, so its rejection is answered rather than thrown: what the
149
+ // reply is still worth reading for is `errorText`, which is the ONLY place a refused
150
+ // navigation is named — an unreachable host loads no page and fires no load event.
151
+ const answered = send('Page.navigate', { url }, sessionId).then(
152
+ (answer) => {
153
+ const failed = answer.result?.['errorText'];
154
+ return typeof failed === 'string' && failed !== '' ? failed : undefined;
155
+ },
156
+ () => undefined,
157
+ );
158
+ const failed = await Promise.race([loaded.then(() => undefined), answered]);
159
+ if (failed !== undefined) {
160
+ throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed });
161
+ }
162
+ current = url;
163
+ // The load event may already have fired before the waiter was registered on a same-process
164
+ // navigation, and `answered` can win the race on one too. So the document is asked directly:
165
+ // a `readyState` that is already `complete` resolves at once, and the deadline resolves
166
+ // rather than throwing — a slow page is the app's business, and the assertion after this is
167
+ // what should fail.
168
+ await evaluate(`(() => new Promise((resolve) => {
169
+ if (document.readyState === 'complete') { resolve(true); return; }
170
+ const done = () => resolve(true);
171
+ addEventListener('load', done, { once: true });
172
+ setTimeout(done, ${String(options.loadTimeoutMs)});
173
+ }))()`);
174
+ // The app may have redirected, so the committed url is re-read rather than assumed.
175
+ const settled = await evaluate('location.href');
176
+ if (typeof settled === 'string' && settled !== '') current = settled;
177
+ return undefined;
178
+ },
179
+ evaluate,
180
+ async click(selector: string): Promise<void> {
181
+ // In-page rather than a synthesised `Input.dispatchMouseEvent`: the port takes a SELECTOR,
182
+ // and turning one into coordinates means a box model read, a scroll and a hit test — three
183
+ // more CDP calls, each with its own way to be wrong about an element the page can click.
184
+ const clicked = await evaluate(
185
+ `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`,
186
+ );
187
+ if (clicked !== true) {
188
+ throw new CdpCallFailedError({
189
+ method: `click(${selector})`,
190
+ detail: 'no element in the page matches that selector',
191
+ });
192
+ }
193
+ },
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
+ }
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,56 @@
1
+ // One constructor per way the raw-CDP e2e browser refuses. Every cause quotes a value that came
2
+ // out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
3
+ // interpolated — the rule `e2e-errors.ts` already states.
4
+
5
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * No browser to drive. This is the one an author meets first, so its fix names the two ways out:
9
+ * point the driver at a binary, or accept that this machine cannot run the check.
10
+ */
11
+ export class CdpBrowserMissingError extends UltimateError {
12
+ constructor(input: { readonly tried: readonly string[] }) {
13
+ super({
14
+ code: 'X_CDP_BROWSER_MISSING',
15
+ cause: `no Chrome or Chromium executable was found — tried ${renderCauseValue(input.tried)}`,
16
+ fix: 'set CHROME_PATH to a Chrome or Chromium binary (GitHub-hosted ubuntu runners ship one at /usr/bin/google-chrome), or skip the browser-backed e2e suite by leaving it unset',
17
+ });
18
+ }
19
+ }
20
+
21
+ /** The binary ran and never announced an endpoint — a crash, a bad flag, or a sandbox refusal. */
22
+ export class CdpLaunchFailedError extends UltimateError {
23
+ constructor(input: { readonly executable: string; readonly detail: string }) {
24
+ super({
25
+ code: 'X_CDP_LAUNCH_FAILED',
26
+ cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${renderCauseValue(input.detail)}`,
27
+ 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',
28
+ });
29
+ }
30
+ }
31
+
32
+ /** A CDP call answered with an error frame, or the connection died under it. */
33
+ export class CdpCallFailedError extends UltimateError {
34
+ constructor(input: { readonly method: string; readonly detail: string }) {
35
+ super({
36
+ code: 'X_CDP_CALL_FAILED',
37
+ cause: `the browser refused ${renderCauseValue(input.method)}: ${renderCauseValue(input.detail)}`,
38
+ fix: 'print what the page had: await page.evaluate(() => document.body.innerHTML) — a call that refuses the same way means the browser itself is gone, so read the launch above it',
39
+ });
40
+ }
41
+ }
42
+
43
+ /**
44
+ * A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
45
+ * WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
46
+ * `Runtime.evaluate` is an expression that never settles.
47
+ */
48
+ export class CdpTimeoutError extends UltimateError {
49
+ constructor(input: { readonly method: string; readonly timeoutMs: number }) {
50
+ super({
51
+ code: 'X_CDP_TIMEOUT',
52
+ cause: `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`,
53
+ 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',
54
+ });
55
+ }
56
+ }
@@ -0,0 +1,130 @@
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`.
3
+
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 { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
12
+
13
+ /**
14
+ * Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
15
+ * answer and the only one that can be right on a machine none of the rest describes; the two
16
+ * `/usr/bin` names after it are what GitHub-hosted `ubuntu-latest` ships, which is what lets the
17
+ * browser-backed suite run in CI with **no download step and no new dependency**.
18
+ */
19
+ export const CHROME_PATH_ENV = 'CHROME_PATH';
20
+ export const CHROME_CANDIDATES: readonly string[] = [
21
+ '/usr/bin/google-chrome',
22
+ '/usr/bin/google-chrome-stable',
23
+ '/usr/bin/chromium',
24
+ '/usr/bin/chromium-browser',
25
+ ];
26
+
27
+ /** The first candidate that exists, or `undefined`. An absent browser is a SKIP, never a failure. */
28
+ export async function findChrome(
29
+ env: Readonly<Record<string, string | undefined>>,
30
+ ): Promise<string | undefined> {
31
+ const declared = env[CHROME_PATH_ENV];
32
+ const candidates = declared === undefined || declared === '' ? CHROME_CANDIDATES : [declared];
33
+ for (const candidate of candidates) {
34
+ if (await Bun.file(candidate).exists()) return candidate;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * The flags, and every one of them earns its line.
41
+ *
42
+ * `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-port=0`
43
+ * asks the OS for a free port, so two suites on one machine never collide — the port is read back
44
+ * off stderr, which is the only place Chrome states the one it took. A throwaway `--user-data-dir`
45
+ * because a run sharing a profile with a real browser inherits its cookies and locks its files.
46
+ * `--no-sandbox` and `--disable-dev-shm-usage` are the two a container needs: the sandbox needs
47
+ * privileges CI does not grant, and `/dev/shm` is 64 MB in a default container, which crashes the
48
+ * renderer on any real page.
49
+ */
50
+ const flags = (profileDir: string): readonly string[] => [
51
+ '--headless=new',
52
+ '--remote-debugging-port=0',
53
+ `--user-data-dir=${profileDir}`,
54
+ '--no-sandbox',
55
+ '--disable-dev-shm-usage',
56
+ '--disable-gpu',
57
+ // Nothing here should reach the network on its own account, and a first-run bubble or an update
58
+ // check is a page load the test did not ask for.
59
+ '--no-first-run',
60
+ '--no-default-browser-check',
61
+ '--disable-extensions',
62
+ 'about:blank',
63
+ ];
64
+
65
+ const ENDPOINT = /DevTools listening on (ws:\/\/\S+)/;
66
+
67
+ export interface LaunchedBrowser {
68
+ readonly endpoint: string;
69
+ /** Idempotent: killing a dead process and deleting a gone directory are both no-ops. */
70
+ close(): void;
71
+ }
72
+
73
+ export interface LaunchOptions {
74
+ readonly executable: string;
75
+ /** How long Chrome has to announce its endpoint before this gives up and kills it. */
76
+ readonly timeoutMs: number;
77
+ }
78
+
79
+ /**
80
+ * Chrome announces `DevTools listening on ws://…` on **stderr**, once, before it is usable. Reading
81
+ * it there rather than polling `/json/version` is what makes `--remote-debugging-port=0` safe: with
82
+ * a random port there is no URL to poll until Chrome has said which one it took.
83
+ */
84
+ export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
85
+ const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
86
+ const child = Bun.spawn([options.executable, ...flags(profileDir)], {
87
+ stderr: 'pipe',
88
+ stdout: 'ignore',
89
+ });
90
+ const close = (): void => {
91
+ child.kill();
92
+ rmSync(profileDir, { recursive: true, force: true });
93
+ };
94
+
95
+ const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
96
+ const decoder = new TextDecoder();
97
+ let seen = '';
98
+ const deadline = Bun.nanoseconds() + options.timeoutMs * 1_000_000;
99
+ try {
100
+ while (Bun.nanoseconds() < deadline) {
101
+ const { value, done } = await reader.read();
102
+ if (done) break;
103
+ seen += decoder.decode(value, { stream: true });
104
+ const found = ENDPOINT.exec(seen);
105
+ if (found?.[1] !== undefined) return { endpoint: found[1], close };
106
+ }
107
+ } finally {
108
+ reader.releaseLock();
109
+ }
110
+ close();
111
+ // Chrome's own stderr is the actionable half — a missing library, a sandbox refusal, a bad flag
112
+ // are all named there — so it is reported rather than "the launch failed".
113
+ throw new CdpLaunchFailedError({
114
+ executable: options.executable,
115
+ detail:
116
+ seen.trim() === ''
117
+ ? 'it printed nothing before the deadline'
118
+ : seen.trim().split('\n').slice(-3).join(' | '),
119
+ });
120
+ }
121
+
122
+ /** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
123
+ export async function launchFoundChrome(
124
+ env: Readonly<Record<string, string | undefined>>,
125
+ timeoutMs: number,
126
+ ): Promise<LaunchedBrowser> {
127
+ const executable = await findChrome(env);
128
+ if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
129
+ return launchChrome({ executable, timeoutMs });
130
+ }
package/src/cmd-dev.ts CHANGED
@@ -14,6 +14,7 @@ import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
14
14
  import { asCtx } from '@ultimat3/http';
15
15
  import type { Manifest } from '@ultimat3/manifest';
16
16
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
17
+ import { describeRoutes } from '@ultimat3/render';
17
18
  import { apiRoutes } from './api-routes';
18
19
  import { loadSignInPath } from './app-auth';
19
20
  import { loadApp } from './app-load';
@@ -49,6 +50,8 @@ import { flagString } from './parse';
49
50
  import { loadPwaArtifacts } from './pwa-artifacts';
50
51
  import { metricsPortFor } from './serve';
51
52
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
53
+ import { serviceWorkerArtifacts } from './sw-artifacts';
54
+ import { serviceWorkerRoutes } from './sw-routes';
52
55
 
53
56
  const DEFAULT_PORT = 3000;
54
57
 
@@ -176,6 +179,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
176
179
  // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
177
180
  // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
178
181
  const pwa = await loadPwaArtifacts(options.root);
182
+ // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
183
+ // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
184
+ // worker that changes under a page it already controls is the update path, and re-emitting one
185
+ // per keystroke would exercise it on every save.
186
+ const serviceWorker =
187
+ pwa === undefined
188
+ ? undefined
189
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands: state.islands });
179
190
 
180
191
  const routes: readonly Route[] = [
181
192
  ...devDashboardRoutes(dashboard),
@@ -203,10 +214,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
203
214
  islands: () => state.islands,
204
215
  states: () => loadIslandStates(options.root),
205
216
  }),
217
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
206
218
  ...appRoutes({
207
219
  buildId,
208
220
  resolveIsland: (file) => state.islands.resolverFor(file),
209
- ...(pwa === undefined ? {} : { pwaHead: pwa.head }),
221
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
210
222
  }),
211
223
  ];
212
224
 
@@ -11,10 +11,17 @@
11
11
  * anyway, and that is the whole failure: Bun 1.3 refuses the build with
12
12
  * `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
13
13
  * unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
14
- * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
- * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
- * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
- * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
14
+ *
15
+ * **This list is what closed that, and the version pin never was.** The paragraph here said the
16
+ * skew was closed `As of 2026-08-20` by CI moving to `1.4.x`, "so every builder now takes the
17
+ * second branch", and added that the external stays regardless. Read together those are two fixes
18
+ * for one bug, and only the second is a fix: pinning the whole repository to the Bun that TOLERATES
19
+ * an unresolvable `require` leaves the `--compile` graph still reaching one, so the next Bun that
20
+ * tightens resolution breaks the build again. Marking the specifier external means the graph never
21
+ * reaches it, on either Bun — measured on 2026-08-27, when the 1.4 pin was trialled in reverse:
22
+ * `docker build -f docker/Dockerfile` is green on `oven/bun:1.3-slim` and the image answers
23
+ * `--version`. So this file does not depend on the series above it, and a future move of that pin
24
+ * costs it nothing.
18
25
  *
19
26
  * Marking the dead specifier external rather than the two live ones: `serve.ts` calls
20
27
  * `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
package/src/e2e-driver.ts CHANGED
@@ -11,34 +11,52 @@ import {
11
11
  unavailableFixture,
12
12
  useE2eDriver,
13
13
  } from '@ultimat3/testing';
14
- import type { E2ePageOptions } from './e2e-page';
14
+ import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
15
15
  import { e2ePage } from './e2e-page';
16
16
 
17
17
  export type E2eDriverOptions = E2ePageOptions;
18
18
 
19
19
  /**
20
- * The three `E2eFixtures` members this driver cannot build, and why each is a REFUSAL rather than
21
- * a no-op. A fixture that silently did nothing would make the assertion after it read as proof:
22
- * `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline test.
23
- *
24
- * All three are genuinely out of reach of the shipped port, not merely unimplemented:
25
- * `CdpPageLike` (`packages/scraping/src/cdp-port.ts`) declares twelve methods and none of them is
26
- * `setOfflineMode`, and a new build id is a fact about the SERVER, which no page port has ever
27
- * been able to speak for.
20
+ * A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
21
+ * nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
22
+ * rendered" is the app's ONLINE page passing an offline test.
28
23
  */
29
24
  const refuse =
30
25
  (name: string, needs: string): (() => Promise<void>) =>
31
26
  () =>
32
27
  Promise.reject(new FixtureUnavailableError({ name, needs }));
33
28
 
34
- /** What `e2eTest` hands its body: a real page, and three members that say what they are missing. */
35
- export const e2eFixtures = (page: PageLike): E2eFixtures => ({
29
+ /**
30
+ * `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
31
+ * contradicted on the day it was written: this file said `CdpPageLike`
32
+ * (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
33
+ * `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
34
+ * `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
35
+ * exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
36
+ * (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
37
+ * check as out of reach.
38
+ *
39
+ * Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
40
+ * same method: this port is the shape of somebody ELSE's object, and a six-line test double must
41
+ * still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
42
+ * rather than a capability the framework does not have.
43
+ */
44
+ const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
45
+ const setOffline = browser.offline?.bind(browser);
46
+ if (setOffline === undefined) {
47
+ const needs =
48
+ "a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
49
+ return { offline: refuse('offline', needs), online: refuse('online', needs) };
50
+ }
51
+ return { offline: () => setOffline(true), online: () => setOffline(false) };
52
+ };
53
+
54
+ /** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
55
+ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixtures => ({
36
56
  page,
37
- offline: refuse(
38
- 'offline',
39
- "a CDP method for the browser's own network state — the shipped CdpPageLike has no setOfflineMode",
40
- ),
41
- online: refuse('online', 'the same CDP method offline() needs, in order to undo it'),
57
+ ...networkFixtures(browser),
58
+ // The one that is still genuinely out of reach, and it is not a port gap: a new build id is a
59
+ // fact about the SERVER, which no page port has ever been able to speak for.
42
60
  update: refuse(
43
61
  'update',
44
62
  'a second build served under a new immutable build id, which is a server fact',
@@ -65,7 +83,7 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
65
83
  const page = e2ePage(options);
66
84
  defineFixtures({ page: () => page });
67
85
  useE2eDriver((name, body: E2eBody) => {
68
- bunTest(name, () => body(e2eFixtures(page)));
86
+ bunTest(name, () => body(e2eFixtures(page, options.page)));
69
87
  });
70
88
  return () => {
71
89
  // Both halves, because both were installed. Putting the DECLARATION back — rather than
package/src/e2e-page.ts CHANGED
@@ -10,15 +10,27 @@ import { e2eLocator } from './e2e-locator';
10
10
  import type { E2eSelection } from './e2e-selection';
11
11
 
12
12
  /**
13
- * What this adapter needs of a browser: four members, every one of them on `ScrapePage`. Declared
14
- * structurally rather than as `ScrapePage` so a test can stand one up in six lines — the same
15
- * bargain `cdp-port.ts` makes about puppeteer, one layer up.
13
+ * What this adapter needs of a browser: four required members, every one of them on `ScrapePage`.
14
+ * Declared structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
15
+ * same bargain `cdp-port.ts` makes about puppeteer, one layer up.
16
16
  */
17
17
  export interface E2eBrowserPage {
18
18
  url(): string;
19
19
  goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<unknown>;
20
20
  evaluate(expression: string): Promise<unknown>;
21
21
  click(selector: string): Promise<void>;
22
+ /**
23
+ * The browser's own network condition, which `E2eFixtures.offline()`/`online()` forward to.
24
+ * `ScrapePage` has it (`page-over-target.ts`), reaching `CdpPageLike.setOfflineMode` through
25
+ * `cdp-target.ts`'s guard.
26
+ *
27
+ * OPTIONAL for the reason the four above are structural: this port is the shape of somebody
28
+ * ELSE's object, and requiring it would cost every six-line double a type error for a capability
29
+ * a test that never goes offline does not need. Absent, `e2e-driver.ts` keeps refusing by name —
30
+ * a coded refusal, never a silent no-op, because an `offline()` that did nothing would let the
31
+ * app's ONLINE page pass an offline test.
32
+ */
33
+ offline?(enabled: boolean): Promise<void>;
22
34
  }
23
35
 
24
36
  export interface E2ePageOptions {
@@ -166,6 +166,13 @@ export const CLI_OWNED_ERROR_CODES = [
166
166
  'X_E2E_LOCATOR_EMPTY',
167
167
  'X_E2E_LOCATOR_AMBIGUOUS',
168
168
  'X_E2E_SERVICE_WORKER_ABSENT',
169
+ // The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
170
+ // `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
171
+ // install a browser, read the browser's own stderr, look at the page, raise a deadline.
172
+ 'X_CDP_BROWSER_MISSING',
173
+ 'X_CDP_LAUNCH_FAILED',
174
+ 'X_CDP_CALL_FAILED',
175
+ 'X_CDP_TIMEOUT',
169
176
  'X_GH_UNAVAILABLE',
170
177
  'X_GH_NOT_AUTHENTICATED',
171
178
  'X_GH_COMMAND_FAILED',
@@ -294,6 +301,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
294
301
  X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
295
302
  X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
296
303
  X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
304
+ X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
305
+ X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
306
+ X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
307
+ X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
297
308
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
298
309
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
299
310
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
package/src/index.ts CHANGED
@@ -25,6 +25,33 @@ export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
25
25
  export { planBoundaryCuts } from './boundary-cuts';
26
26
  export type { BuildStats, RouteStats } from './budgets';
27
27
  export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
28
+ // The raw-CDP browser the driver above runs on. `openE2eBrowserIfAvailable()` is what an app's
29
+ // test preload calls: it answers `undefined` on a machine with no Chrome, so the browser-backed
30
+ // suite SKIPS rather than turning a gate red for a reason unrelated to the change.
31
+ export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
32
+ export {
33
+ DEFAULT_CDP_TIMEOUT_MS,
34
+ openE2eBrowser,
35
+ openE2eBrowserIfAvailable,
36
+ } from './cdp-browser';
37
+ export type { CdpConnection, CdpConnectionOptions, CdpResult } from './cdp-connection';
38
+ export { cdpConnect } from './cdp-connection';
39
+ export type { CdpE2ePageOptions } from './cdp-e2e-page';
40
+ export { cdpE2ePage } from './cdp-e2e-page';
41
+ export {
42
+ CdpBrowserMissingError,
43
+ CdpCallFailedError,
44
+ CdpLaunchFailedError,
45
+ CdpTimeoutError,
46
+ } from './cdp-errors';
47
+ export type { LaunchedBrowser, LaunchOptions } from './cdp-launch';
48
+ export {
49
+ CHROME_CANDIDATES,
50
+ CHROME_PATH_ENV,
51
+ findChrome,
52
+ launchChrome,
53
+ launchFoundChrome,
54
+ } from './cdp-launch';
28
55
  export type { BuildTarget } from './cmd-build';
29
56
  export {
30
57
  argsFor,
package/src/mcp-errors.ts CHANGED
@@ -78,6 +78,15 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
78
78
  X_E2E_LOCATOR_AMBIGUOUS:
79
79
  'x test e2e --json # the fix line carries the same call with .first() on it',
80
80
  X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
81
+ // The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
82
+ // whole job is reporting what this machine does not have; the other three are raised inside a
83
+ // running suite, so the runnable half is the command that re-runs it.
84
+ X_CDP_BROWSER_MISSING:
85
+ 'x doctor --json # or set CHROME_PATH to a Chrome binary; unset, the browser-backed suite skips',
86
+ X_CDP_LAUNCH_FAILED:
87
+ 'x test e2e --json # the cause carries the last lines of the browser\u2019s own stderr',
88
+ X_CDP_CALL_FAILED: 'x test e2e --json # the cause names the DevTools call the browser refused',
89
+ X_CDP_TIMEOUT: 'x test e2e --json # the cause names the call that never answered',
81
90
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
82
91
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
83
92
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',