@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.
- package/CLAUDE.md +104 -10
- package/package.json +29 -29
- package/src/app-root.ts +10 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +13 -1
- package/src/compile-externals.ts +11 -4
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +11 -0
- package/src/index.ts +27 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +4 -0
- package/src/prerender.ts +26 -2
- package/src/pwa-artifacts.ts +44 -2
- package/src/serve.ts +12 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +4 -1
|
@@ -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
|
|
package/src/compile-externals.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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`.
|
|
14
|
-
* structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
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 {
|
package/src/error-codes.ts
CHANGED
|
@@ -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',
|