@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
package/CLAUDE.md
CHANGED
|
@@ -329,26 +329,120 @@ to know about everything — so the join is here, and it is the same rule
|
|
|
329
329
|
| `e2e-evaluate.ts` | the closure→string crossing, which is the only lossy edge in the adapter |
|
|
330
330
|
| `e2e-errors.ts` | one constructor per refusal |
|
|
331
331
|
| `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
`e2e`
|
|
332
|
+
| `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
|
|
333
|
+
| `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
|
|
334
|
+
| `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
|
|
335
|
+
| `cdp-e2e-page.ts` | `E2eBrowserPage`'s five methods over an attached, flattened session |
|
|
336
|
+
| `cdp-errors.ts` | one constructor per way the browser half refuses |
|
|
337
|
+
|
|
338
|
+
**Absent by default, and that is a requirement rather than a state.** Nothing here runs until
|
|
339
|
+
`installE2eDriver` is called, so `hasE2eDriver()` still answers `false` and the gate's `e2e` step
|
|
340
|
+
still refuses instead of passing over a browser it does not have. This paragraph also said "CI has
|
|
341
|
+
no Chrome" until 2026-08-27, and that is false and was the reason issue #390's fourth requirement
|
|
342
|
+
— a real browser check — was recorded as out of reach: GitHub-hosted `ubuntu-latest` ships one at
|
|
343
|
+
`/usr/bin/google-chrome`, preinstalled, with no download step and no new dependency.
|
|
344
|
+
|
|
345
|
+
**The browser is RAW CDP over Bun's own `WebSocket`, and carries no dependency.**
|
|
346
|
+
`packages/scraping/src/cdp-port.ts` declares a ~25-method port because `ScrapePage` is a full
|
|
347
|
+
scraping surface and its intended implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE
|
|
348
|
+
methods, and CDP's wire format is one JSON object with an `id` — so the whole thing an e2e driver
|
|
349
|
+
needs is four small modules, which is why `x test e2e` needs nothing installed that `bun install`
|
|
350
|
+
did not already put there. `e2e/cdp-browser.e2e.test.ts` drives a real Chrome against a real
|
|
351
|
+
`Bun.serve` and asserts all five methods; `openE2eBrowserIfAvailable()` answering `undefined` is
|
|
352
|
+
what makes it a SKIP on a laptop without one rather than a red step.
|
|
353
|
+
|
|
354
|
+
**The load EVENT is the completion signal, never `Page.navigate`'s reply.** Measured on Chrome 150:
|
|
355
|
+
a navigation that swaps the render process — `about:blank` → `http://localhost:<port>/`, the most
|
|
356
|
+
ordinary one there is — loads the page, hits the server and answers a later `Runtime.evaluate` from
|
|
357
|
+
the new document, and the navigate frame **never comes back at all**. A driver that awaited the
|
|
358
|
+
reply waited out its full deadline on every first navigation. So `cdpConnect().once()` registers a
|
|
359
|
+
`Page.loadEventFired` waiter BEFORE the send, and the reply is raced against it — still read, but
|
|
360
|
+
only for `errorText`, which is the one place a refused navigation is named.
|
|
361
|
+
|
|
362
|
+
**A CDP call is deadlined and a close settles every call in flight.** Without that, a suite whose
|
|
363
|
+
browser died waits out one full deadline per call and reports a timeout, where the true fault is a
|
|
364
|
+
dead browser. The four codes are four repairs, which is why they are not one:
|
|
365
|
+
`X_CDP_BROWSER_MISSING` (install one), `X_CDP_LAUNCH_FAILED` (read the browser's own stderr, which
|
|
366
|
+
the cause carries), `X_CDP_CALL_FAILED` (look at the page), `X_CDP_TIMEOUT` (raise the deadline).
|
|
336
367
|
|
|
337
368
|
**`evaluate` is the edge that cannot be lossless.** `PageLike.evaluate` takes a closure and every
|
|
338
369
|
browser port in this framework takes a string, so what crosses is `Function.prototype.toString()`
|
|
339
370
|
and nothing else. A zero-parameter closure naming only page globals is supported; a native or bound
|
|
340
371
|
function, a declared parameter and a method shorthand are refused STATICALLY, before a byte leaves;
|
|
341
372
|
a binding the page does not have comes back named, from the page's own `ReferenceError`. Measured on
|
|
342
|
-
Bun 1.4.0 and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
|
|
373
|
+
Bun 1.3.14 and 1.4.0 alike — re-measured on both when the repo moved back to the 1.3 series, because a version-stamped claim that names one runtime is unread evidence on the other — and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
|
|
343
374
|
runs**, so a captured PRIMITIVE can vanish from the source and never fail at all, while a captured
|
|
344
375
|
reference always survives as its name. No static rule in this process can see the difference — which
|
|
345
376
|
is why the refusal is raised from the page's answer rather than from a scan of the source.
|
|
346
377
|
|
|
347
|
-
**
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
378
|
+
**One of `E2eFixtures`' four members still refuses, and it is the one that is not a port gap.**
|
|
379
|
+
`update()` needs a second build served under a new immutable build id, which is a fact about the
|
|
380
|
+
SERVER, and no page port has ever been able to speak for one. `offline()`/`online()` FORWARD — to
|
|
381
|
+
`E2eBrowserPage.offline`, which `cdp-e2e-page.ts` implements as
|
|
382
|
+
`Network.emulateNetworkConditions` and `@ultimat3/scraping` implements through
|
|
383
|
+
`CdpPageLike.setOfflineMode`. They refused until 2026-08-27 on a reason the tree contradicted on
|
|
384
|
+
the day it was written. A fixture that silently no-opped would make the assertion after it read as
|
|
385
|
+
proof — `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline
|
|
386
|
+
test — so an `E2eBrowserPage` that declares no `offline` still gets the refusal, now naming the
|
|
387
|
+
method the double is missing rather than a capability the framework does not have.
|
|
388
|
+
|
|
389
|
+
## The service worker is emitted here, because the emitter needs facts only a build has
|
|
390
|
+
|
|
391
|
+
`@ultimat3/pwa` shipped `generateServiceWorker`, `buildPrecacheManifest`, `offlineFallbackSource`,
|
|
392
|
+
`backgroundSyncSource` and `pushSource` since it existed, and every one had **zero callers** outside
|
|
393
|
+
its own package. So `pwa.offline`, `pwa.backgroundSync`, `pwa.push` and every route's own `offline:`
|
|
394
|
+
were declarations with no build behind them, and no Ultimate app worked offline however its config
|
|
395
|
+
was written (#390). `sw-artifacts.ts` is the caller.
|
|
396
|
+
|
|
397
|
+
**Why here and not beside the manifest.** `loadPwaArtifacts(root)` needs a root and a config file;
|
|
398
|
+
the worker needs the ROUTE TABLE and the ISLAND BUNDLE as well — facts only a booted app and a
|
|
399
|
+
finished build have. Splitting them keeps `loadPwaArtifacts` callable before either exists, which
|
|
400
|
+
`x doctor` and the icon writer rely on. The route table is `describeRoutes()`, the one projection
|
|
401
|
+
`x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a route added to the app
|
|
402
|
+
cannot be missing from the precache manifest.
|
|
403
|
+
|
|
404
|
+
| Surface | What it does with the worker |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `cmd-dev.ts` | mounts `/sw.js` and `/x-sw-register.js`; built ONCE at boot and deliberately not rebuilt on the watcher tick — a worker that changes under a page it already controls is the update path, and re-emitting one per keystroke exercises it on every save |
|
|
407
|
+
| `serve.ts` | the same two routes in the container, from the same call |
|
|
408
|
+
| `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact |
|
|
409
|
+
|
|
410
|
+
**Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
|
|
411
|
+
preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
|
|
412
|
+
blocked in the container while passing report-only under `x dev` — which is how the hydration
|
|
413
|
+
runtime shipped broken once already.
|
|
414
|
+
|
|
415
|
+
**`sw.js` is served `no-store` with `Service-Worker-Allowed: /`.** A cached `sw.js` is a worker that
|
|
416
|
+
cannot be replaced: the browser re-fetches it to decide whether an update exists, and an
|
|
417
|
+
intermediary answering the old bytes pins every client to the deploy that shipped them. Without the
|
|
418
|
+
header the browser refuses to let a worker served from `/` control `/` — the failure `assertScope`
|
|
419
|
+
cannot see, because the scope a REGISTRATION asks for has to be allowed by the script's own response
|
|
420
|
+
and not only by its path.
|
|
421
|
+
|
|
422
|
+
**`api/` and `shared/` never cross.** An API response is a JSON document whose freshness is the
|
|
423
|
+
app's business, and precaching one serves a stale answer to a client that had a network; `shared/`
|
|
424
|
+
is not a URL at all. The filter is a `flatMap` rather than `filter().map()` because the predicate
|
|
425
|
+
does not narrow `surface` for the map that follows it, and a cast would hide the day a fifth surface
|
|
426
|
+
arrives.
|
|
427
|
+
|
|
428
|
+
**`pwa.push` is read and still wires nothing, and it says so.** `generateServiceWorker` emits a push
|
|
429
|
+
handler only when a VAPID key comes with the capability, there is no `pwa.vapid` config key, and it
|
|
430
|
+
drops the handler in SILENCE otherwise. `pushWarning` is this module's own finding, reported through
|
|
431
|
+
`x build --json`'s `serviceWorkerWarnings` — `jobs.driver`'s shape one package over, refused the same way.
|
|
432
|
+
|
|
433
|
+
**The browser check is what let any of this ship.** #390's fourth requirement was *"a real browser
|
|
434
|
+
check that the emitted worker installs, activates and serves the fallback offline. Until it exists,
|
|
435
|
+
do not ship the worker"* — a bad `sw.js` is sticky in a way a manifest is not.
|
|
436
|
+
`e2e/service-worker.e2e.test.ts` registers the emitted file in a real Chrome, waits for it to take
|
|
437
|
+
control, takes the network away, and asserts that a runtime route with nothing cached renders the
|
|
438
|
+
offline document.
|
|
439
|
+
|
|
440
|
+
**And it found the driver bug first.** `E2eFixtures.offline()` did not take the SERVICE WORKER
|
|
441
|
+
offline: a worker fetches on its own CDP target, the condition was only ever set on the page's, and
|
|
442
|
+
a `networkFirst` route the cache had never seen still answered from the network. So an offline
|
|
443
|
+
assertion made on a PWA tested nothing. `cdp-e2e-page.ts` now auto-attaches worker targets and
|
|
444
|
+
carries the condition onto each, including one that attaches AFTER `offline(true)` — the ordinary
|
|
445
|
+
case for a PWA.
|
|
352
446
|
|
|
353
447
|
## The `errors` step enforces the error contract
|
|
354
448
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/flags": "
|
|
49
|
-
"@ultimat3/http": "
|
|
50
|
-
"@ultimat3/i18n": "
|
|
51
|
-
"@ultimat3/jobs": "
|
|
52
|
-
"@ultimat3/mail": "
|
|
53
|
-
"@ultimat3/manifest": "
|
|
54
|
-
"@ultimat3/mcp": "
|
|
55
|
-
"@ultimat3/money": "
|
|
56
|
-
"@ultimat3/notify": "
|
|
57
|
-
"@ultimat3/policy": "
|
|
58
|
-
"@ultimat3/pwa": "
|
|
59
|
-
"@ultimat3/query": "
|
|
60
|
-
"@ultimat3/realtime": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/scraping": "
|
|
64
|
-
"@ultimat3/seo": "
|
|
65
|
-
"@ultimat3/storage": "
|
|
66
|
-
"@ultimat3/testing": "
|
|
67
|
-
"@ultimat3/time": "
|
|
40
|
+
"@ultimat3/action": "19.0.0",
|
|
41
|
+
"@ultimat3/admin": "19.0.0",
|
|
42
|
+
"@ultimat3/ai": "19.0.0",
|
|
43
|
+
"@ultimat3/auth": "19.0.0",
|
|
44
|
+
"@ultimat3/cache": "19.0.0",
|
|
45
|
+
"@ultimat3/core": "19.0.0",
|
|
46
|
+
"@ultimat3/db": "19.0.0",
|
|
47
|
+
"@ultimat3/entity": "19.0.0",
|
|
48
|
+
"@ultimat3/flags": "19.0.0",
|
|
49
|
+
"@ultimat3/http": "19.0.0",
|
|
50
|
+
"@ultimat3/i18n": "19.0.0",
|
|
51
|
+
"@ultimat3/jobs": "19.0.0",
|
|
52
|
+
"@ultimat3/mail": "19.0.0",
|
|
53
|
+
"@ultimat3/manifest": "19.0.0",
|
|
54
|
+
"@ultimat3/mcp": "19.0.0",
|
|
55
|
+
"@ultimat3/money": "19.0.0",
|
|
56
|
+
"@ultimat3/notify": "19.0.0",
|
|
57
|
+
"@ultimat3/policy": "19.0.0",
|
|
58
|
+
"@ultimat3/pwa": "19.0.0",
|
|
59
|
+
"@ultimat3/query": "19.0.0",
|
|
60
|
+
"@ultimat3/realtime": "19.0.0",
|
|
61
|
+
"@ultimat3/render": "19.0.0",
|
|
62
|
+
"@ultimat3/schema": "19.0.0",
|
|
63
|
+
"@ultimat3/scraping": "19.0.0",
|
|
64
|
+
"@ultimat3/seo": "19.0.0",
|
|
65
|
+
"@ultimat3/storage": "19.0.0",
|
|
66
|
+
"@ultimat3/testing": "19.0.0",
|
|
67
|
+
"@ultimat3/time": "19.0.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-root.ts
CHANGED
|
@@ -12,12 +12,21 @@ export const MANIFEST_FILE = 'x.manifest.json';
|
|
|
12
12
|
* through 2026-08-27 while `x test` spent `bun test --isolate` — a flag Bun introduced in
|
|
13
13
|
* **1.3.13** — so a user on a Bun this file declared supported got an unknown-flag failure out of
|
|
14
14
|
* the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
|
|
15
|
-
* the same release and is emitted now.
|
|
15
|
+
* the same release and is emitted now, so the floor may never fall below that patch.
|
|
16
16
|
*
|
|
17
17
|
* `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI pins
|
|
18
18
|
* `1.4.x`, both images build on `oven/bun:1.4-*`, and the per-worker database rests on
|
|
19
19
|
* `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4.0 and on nothing older. `scripts/bun-pin.test.ts`
|
|
20
20
|
* holds this to the same series as every other pin.
|
|
21
|
+
*
|
|
22
|
+
* **Lowering it to 1.3.14 was tried on 2026-08-27 and refused**, and the argument for trying was
|
|
23
|
+
* sound — `--isolate` and `--parallel` are 1.3.13 features, no package here calls a 1.4-only API
|
|
24
|
+
* (`bun run typecheck` is clean against `@types/bun@1.3.14`), and `>=1.4.0` therefore bars Bun 1.3
|
|
25
|
+
* users for a capability the framework does not use. What refused it is a Bun 1.3.14 defect, not
|
|
26
|
+
* the paperwork: a service shutdown against a destroyed database never resolves there
|
|
27
|
+
* (`queue.stop()`, reproduced by `dev-runtime.live.test.ts`), so an app on a runtime this line
|
|
28
|
+
* declared supported would hang on graceful shutdown the moment its database went away. The full
|
|
29
|
+
* measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
|
|
21
30
|
*/
|
|
22
31
|
export const REQUIRED_BUN = '1.4.0';
|
|
23
32
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// One responsibility: compose the three halves — find a browser, connect to it, attach a page —
|
|
2
|
+
// into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
|
|
3
|
+
//
|
|
4
|
+
// **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
|
|
5
|
+
// with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
|
|
6
|
+
// the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
|
|
7
|
+
// `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
|
|
8
|
+
// already decided a browser is required.
|
|
9
|
+
|
|
10
|
+
import { finiteCount } from '@ultimat3/core';
|
|
11
|
+
import type { CdpConnection } from './cdp-connection';
|
|
12
|
+
import { cdpConnect } from './cdp-connection';
|
|
13
|
+
import { cdpE2ePage } from './cdp-e2e-page';
|
|
14
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
15
|
+
import type { LaunchedBrowser } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
|
|
17
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
18
|
+
|
|
19
|
+
/** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
|
|
20
|
+
export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export interface E2eBrowser {
|
|
23
|
+
readonly page: E2eBrowserPage;
|
|
24
|
+
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OpenE2eBrowserOptions {
|
|
29
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
30
|
+
readonly timeoutMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
|
|
35
|
+
* launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
|
|
36
|
+
* for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
|
|
37
|
+
* 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
|
|
38
|
+
* answering. A misdiagnosis reported as a test failure is worse than the failure.
|
|
39
|
+
*/
|
|
40
|
+
const budget = (options: OpenE2eBrowserOptions): number =>
|
|
41
|
+
finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
|
|
42
|
+
|
|
43
|
+
const compose = (
|
|
44
|
+
launched: LaunchedBrowser,
|
|
45
|
+
connection: CdpConnection,
|
|
46
|
+
page: E2eBrowserPage,
|
|
47
|
+
): E2eBrowser => ({
|
|
48
|
+
page,
|
|
49
|
+
close(): void {
|
|
50
|
+
// The socket first: closing the process out from under an open connection makes every
|
|
51
|
+
// in-flight call report "the browser closed the CDP connection", which is true and useless.
|
|
52
|
+
connection.close();
|
|
53
|
+
launched.close();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
|
|
59
|
+
* nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
|
|
60
|
+
*/
|
|
61
|
+
export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
|
|
62
|
+
const timeoutMs = budget(options);
|
|
63
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
64
|
+
if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
65
|
+
return openLaunched(executable, timeoutMs);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `undefined` when this machine has no browser. Every other failure still throws. */
|
|
69
|
+
export async function openE2eBrowserIfAvailable(
|
|
70
|
+
options: OpenE2eBrowserOptions = {},
|
|
71
|
+
): Promise<E2eBrowser | undefined> {
|
|
72
|
+
const timeoutMs = budget(options);
|
|
73
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
74
|
+
if (executable === undefined) return undefined;
|
|
75
|
+
return openLaunched(executable, timeoutMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
|
|
80
|
+
* launched and then refused the CDP handshake would otherwise be left running, holding its profile
|
|
81
|
+
* directory, for the rest of the test process — one leaked browser per failing suite.
|
|
82
|
+
*/
|
|
83
|
+
async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
|
|
84
|
+
const launched = await launchChrome({ executable, timeoutMs });
|
|
85
|
+
let connection: CdpConnection;
|
|
86
|
+
try {
|
|
87
|
+
connection = await cdpConnect({ endpoint: launched.endpoint, timeoutMs });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
launched.close();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const page = await cdpE2ePage({ connection, loadTimeoutMs: timeoutMs });
|
|
94
|
+
return compose(launched, connection, page);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
connection.close();
|
|
97
|
+
launched.close();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// One responsibility: a Chrome DevTools Protocol connection over Bun's own WebSocket — request
|
|
2
|
+
// framing, response correlation, and the per-call deadline. Launching a browser is `cdp-launch.ts`
|
|
3
|
+
// and the page surface is `cdp-e2e-page.ts`; this file knows nothing about either.
|
|
4
|
+
//
|
|
5
|
+
// **No library, and that is the point rather than an economy.** `packages/scraping/src/cdp-port.ts`
|
|
6
|
+
// declares a ~25-method port because `ScrapePage` is a full scraping surface, and its intended
|
|
7
|
+
// implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE methods, and CDP's wire format is
|
|
8
|
+
// one JSON object with an `id` — so the whole thing an e2e driver needs is this file plus two
|
|
9
|
+
// small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
|
|
10
|
+
// non-negotiable is that Bun's natives replace most of them.
|
|
11
|
+
|
|
12
|
+
import { assert } from '@ultimat3/core';
|
|
13
|
+
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
14
|
+
|
|
15
|
+
/** One CDP result. `unknown` because every payload here is somebody else's JSON. */
|
|
16
|
+
export interface CdpResult {
|
|
17
|
+
readonly result?: Record<string, unknown> | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CdpConnection {
|
|
21
|
+
/** Send one command. `sessionId` targets an attached page rather than the browser itself. */
|
|
22
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<CdpResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
|
|
25
|
+
* event arrived and `false` when it did not — it never throws, because every caller has a better
|
|
26
|
+
* assertion to fail on than "the event was late".
|
|
27
|
+
*
|
|
28
|
+
* It exists because a command's reply is not always the signal. `Page.navigate`'s reply is
|
|
29
|
+
* DROPPED whenever the navigation swaps the render process — measured on Chrome 150: the page
|
|
30
|
+
* loads, the server is hit, a later `Runtime.evaluate` answers, and the navigate frame never
|
|
31
|
+
* comes back at all. A driver that treated the reply as the completion signal waits out its full
|
|
32
|
+
* deadline on the most ordinary navigation there is.
|
|
33
|
+
*/
|
|
34
|
+
once(method: string, sessionId: string | undefined, timeoutMs: number): Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to every occurrence of one CDP event, until the returned function is called.
|
|
37
|
+
*
|
|
38
|
+
* `once` cannot express what this is for: a target that attaches AFTER the driver stopped
|
|
39
|
+
* listening is a service worker whose network conditions nobody set, which is an `offline()`
|
|
40
|
+
* that silently does nothing to the one thing serving the page.
|
|
41
|
+
*/
|
|
42
|
+
on(method: string, listener: (params: Record<string, unknown>) => void): () => void;
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Pending {
|
|
47
|
+
readonly resolve: (value: CdpResult) => void;
|
|
48
|
+
readonly reject: (reason: Error) => void;
|
|
49
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A CDP error frame: `{ error: { code, message } }`, both fields somebody else's. */
|
|
53
|
+
const errorText = (frame: Record<string, unknown>): string | undefined => {
|
|
54
|
+
const error = frame['error'];
|
|
55
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
56
|
+
const message = (error as Record<string, unknown>)['message'];
|
|
57
|
+
return typeof message === 'string' ? message : 'the browser refused the call';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface CdpConnectionOptions {
|
|
61
|
+
readonly endpoint: string;
|
|
62
|
+
/** Per-call deadline. A CDP call that never answers is a suite that never finishes. */
|
|
63
|
+
readonly timeoutMs: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConnection> {
|
|
67
|
+
assert(
|
|
68
|
+
options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'),
|
|
69
|
+
`the CDP endpoint is ${options.endpoint === '' ? 'empty' : 'not a WebSocket url'}`,
|
|
70
|
+
'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers',
|
|
71
|
+
);
|
|
72
|
+
const socket = new WebSocket(options.endpoint);
|
|
73
|
+
const pending = new Map<number, Pending>();
|
|
74
|
+
const waiters = new Set<(method: string, sessionId: string | undefined) => void>();
|
|
75
|
+
const listeners = new Map<string, Set<(params: Record<string, unknown>) => void>>();
|
|
76
|
+
let nextId = 0;
|
|
77
|
+
let closed = false;
|
|
78
|
+
|
|
79
|
+
// Every in-flight call is settled on close. Without this a suite whose browser died waits out
|
|
80
|
+
// one full deadline per call and reports a timeout, where the true fault is a dead browser.
|
|
81
|
+
const abandon = (reason: string): void => {
|
|
82
|
+
closed = true;
|
|
83
|
+
for (const [, one] of pending) {
|
|
84
|
+
clearTimeout(one.timer);
|
|
85
|
+
one.reject(new CdpCallFailedError({ method: 'the connection', detail: reason }));
|
|
86
|
+
}
|
|
87
|
+
pending.clear();
|
|
88
|
+
// A waiter is a "did this happen" question, and on a dead connection the answer is no. Its
|
|
89
|
+
// own timer settles it, so nothing is left hanging; clearing the set only stops a late frame
|
|
90
|
+
// from resolving a waiter whose connection has gone.
|
|
91
|
+
waiters.clear();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
socket.onmessage = (event: MessageEvent): void => {
|
|
95
|
+
const raw = typeof event.data === 'string' ? event.data : '';
|
|
96
|
+
let frame: Record<string, unknown>;
|
|
97
|
+
try {
|
|
98
|
+
frame = JSON.parse(raw) as Record<string, unknown>;
|
|
99
|
+
} catch {
|
|
100
|
+
// An unparseable frame is the browser's, not ours, and there is no call to fail with it:
|
|
101
|
+
// correlation is by `id`, and a frame we cannot read has none. Events land here too.
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const id = frame['id'];
|
|
105
|
+
if (typeof id !== 'number') {
|
|
106
|
+
const method = frame['method'];
|
|
107
|
+
if (typeof method !== 'string') return;
|
|
108
|
+
const on = frame['sessionId'];
|
|
109
|
+
for (const waiter of [...waiters]) waiter(method, typeof on === 'string' ? on : undefined);
|
|
110
|
+
const subscribed = listeners.get(method);
|
|
111
|
+
if (subscribed !== undefined) {
|
|
112
|
+
const params = frame['params'];
|
|
113
|
+
const payload: Record<string, unknown> =
|
|
114
|
+
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {};
|
|
115
|
+
// A copy, because a listener may unsubscribe itself while this loop is running.
|
|
116
|
+
for (const listener of [...subscribed]) listener(payload);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const one = pending.get(id);
|
|
121
|
+
if (one === undefined) return;
|
|
122
|
+
pending.delete(id);
|
|
123
|
+
clearTimeout(one.timer);
|
|
124
|
+
const failed = errorText(frame);
|
|
125
|
+
if (failed !== undefined) {
|
|
126
|
+
one.reject(new CdpCallFailedError({ method: `call ${String(id)}`, detail: failed }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const result = frame['result'];
|
|
130
|
+
one.resolve({
|
|
131
|
+
result:
|
|
132
|
+
typeof result === 'object' && result !== null
|
|
133
|
+
? (result as Record<string, unknown>)
|
|
134
|
+
: undefined,
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
socket.onclose = (): void => abandon('the browser closed the CDP connection');
|
|
138
|
+
socket.onerror = (): void => abandon('the CDP connection failed');
|
|
139
|
+
|
|
140
|
+
await new Promise<void>((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
|
|
143
|
+
}, options.timeoutMs);
|
|
144
|
+
socket.onopen = (): void => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
// `onerror` is replaced for the handshake only, then restored above: a failure BEFORE open has
|
|
149
|
+
// no pending call to abandon, and rejecting is the only way the caller hears about it.
|
|
150
|
+
socket.onerror = (): void => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
reject(
|
|
153
|
+
new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
socket.onerror = (): void => abandon('the CDP connection failed');
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
send(method, params = {}, sessionId): Promise<CdpResult> {
|
|
161
|
+
if (closed) {
|
|
162
|
+
return Promise.reject(
|
|
163
|
+
new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
nextId += 1;
|
|
167
|
+
const id = nextId;
|
|
168
|
+
return new Promise<CdpResult>((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
pending.delete(id);
|
|
171
|
+
reject(new CdpTimeoutError({ method, timeoutMs: options.timeoutMs }));
|
|
172
|
+
}, options.timeoutMs);
|
|
173
|
+
pending.set(id, { resolve, reject, timer });
|
|
174
|
+
socket.send(
|
|
175
|
+
JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
once(method, sessionId, timeoutMs): Promise<boolean> {
|
|
180
|
+
if (closed) return Promise.resolve(false);
|
|
181
|
+
return new Promise<boolean>((resolve) => {
|
|
182
|
+
const waiter = (seen: string, on: string | undefined): void => {
|
|
183
|
+
if (seen !== method) return;
|
|
184
|
+
if (sessionId !== undefined && on !== sessionId) return;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
waiters.delete(waiter);
|
|
187
|
+
resolve(true);
|
|
188
|
+
};
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
waiters.delete(waiter);
|
|
191
|
+
resolve(false);
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
waiters.add(waiter);
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
on(method, listener): () => void {
|
|
197
|
+
const subscribed = listeners.get(method) ?? new Set();
|
|
198
|
+
subscribed.add(listener);
|
|
199
|
+
listeners.set(method, subscribed);
|
|
200
|
+
return () => {
|
|
201
|
+
subscribed.delete(listener);
|
|
202
|
+
if (subscribed.size === 0) listeners.delete(method);
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
close(): void {
|
|
206
|
+
listeners.clear();
|
|
207
|
+
abandon('the driver closed the CDP connection');
|
|
208
|
+
socket.close();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|