@ultimat3/cli 11.1.0 → 11.3.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 +62 -1
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +91 -9
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +151 -0
- package/src/cmd-shot.ts +66 -95
- package/src/compile-externals.ts +7 -4
- package/src/error-codes.ts +11 -0
- package/src/island-bundle.ts +18 -7
- package/src/island-harness-route.ts +91 -0
- package/src/island-harness-script.ts +147 -0
- package/src/island-harness.ts +98 -0
- package/src/island-shot-errors.ts +94 -0
- package/src/island-shot.ts +303 -0
- package/src/island-states-load.ts +78 -0
- package/src/island-verdict.ts +194 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +14 -0
- package/src/reexport-manifest.ts +62 -0
- package/src/shot-browser.ts +84 -0
- package/src/shot-server.ts +90 -0
- package/src/shot-settle.ts +33 -3
- package/src/ts-scan.ts +34 -5
- package/src/workspace-checks.ts +23 -7
- package/src/island-solid-production.ts +0 -129
package/CLAUDE.md
CHANGED
|
@@ -166,7 +166,8 @@ change, and CI does not install one.
|
|
|
166
166
|
|
|
167
167
|
| | Reaches for | Never |
|
|
168
168
|
|---|---|---|
|
|
169
|
-
| `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
|
|
169
|
+
| `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` — launching Chrome here, or **attaching** to one over `--cdp-url` / `SCRAPE_CDP_URL`, which is what every stealth provider sells and what `remoteBrowser()` has called its primary path since it shipped | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
|
|
170
|
+
| `x shot --island <name>` | the same server and the same browser, plus the app's own `*.island.states.ts` | a second command — photographing a route and photographing a component are one job with two subjects, and `--island` with a route positional is refused by name |
|
|
170
171
|
| `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
|
|
171
172
|
| `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
|
|
172
173
|
|
|
@@ -174,6 +175,66 @@ change, and CI does not install one.
|
|
|
174
175
|
not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
|
|
175
176
|
than one that says so, because the omission reads as a clean result.
|
|
176
177
|
|
|
178
|
+
### `--island` photographs ONE component in a state nobody can click to
|
|
179
|
+
|
|
180
|
+
A failed read, an empty list, over-quota, read-only: the states a reviewer most needs to see are the
|
|
181
|
+
ones a running app will not produce on request. `--island` takes them, one address at a time.
|
|
182
|
+
|
|
183
|
+
| File | Job |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `island-states-load.ts` | discover `*.island.states.ts`, prove each pure, import it, check the set |
|
|
186
|
+
| `island-harness.ts` | the document that mounts ONE island over `data-x-entry` / `data-x-props` |
|
|
187
|
+
| `island-harness-script.ts` | what runs before the chunk does: the sealed network, the pinned clock, the readiness watch |
|
|
188
|
+
| `island-harness-route.ts` | `GET /_x/island`, mounted by `x dev` |
|
|
189
|
+
| `island-shot.ts` | the capture loop, the assertions before each shutter, the missing-shot gate |
|
|
190
|
+
| `shot-browser.ts` | which browser a run gets — launch one here, or attach over `--cdp-url` / `SCRAPE_CDP_URL` — as three rules over plain inputs |
|
|
191
|
+
| `island-verdict.ts` | the per-state verdict — a PNG cannot say the component threw or logged |
|
|
192
|
+
| `cmd-shot-island.ts` | the flags, and the one browser per declared viewport |
|
|
193
|
+
|
|
194
|
+
The vocabulary is **`@ultimat3/testing`'s**, not this package's: `defineIslandStates`,
|
|
195
|
+
`islandShotTargets`, `islandAddress` / `parseIslandAddress`, `findIslandStates`,
|
|
196
|
+
`assertIslandStatesPure`. `cli → testing` is a declared sideways edge and `cli → scraping` is
|
|
197
|
+
another, which is what makes `@ultimat3/cli` the only package that can hold both the mount half and
|
|
198
|
+
the screenshot half.
|
|
199
|
+
|
|
200
|
+
**The expected picture list exists before a browser does.** `loadIslandStates` → `findIslandStates`
|
|
201
|
+
→ `islandShotTargets` is a pure expansion off files on disk, and the run ends by diffing it against
|
|
202
|
+
what actually landed (`missingShots`, `X_SHOT_ISLAND_MISSING`). That diff is the point of the whole
|
|
203
|
+
design: a loop that swallowed every failure would otherwise report a clean run with no pictures in
|
|
204
|
+
it, and "produced nothing and exited 0" is the one outcome a reader cannot tell from success.
|
|
205
|
+
|
|
206
|
+
**An unstubbed request FAILS the run.** The page's own seal replaces `fetch`, `WebSocket`,
|
|
207
|
+
`EventSource` and `XMLHttpRequest` before the island's chunk is imported, answers the state's
|
|
208
|
+
`routes` and publishes everything else on `window.__xShot.unstubbed`; the capture refuses on a
|
|
209
|
+
non-empty list with `X_SHOT_ISLAND_UNSTUBBED_REQUEST`, naming each method and path. A component
|
|
210
|
+
whose fetch quietly hangs paints its own loading branch, and the picture then shows a fixture gap
|
|
211
|
+
dressed up as a real component state. `@ultimat3/testing`'s `sealNetwork()` is not reusable here:
|
|
212
|
+
it patches THIS process's `globalThis.fetch` and the component runs in the page's realm.
|
|
213
|
+
|
|
214
|
+
**Readiness is quiet, not idle.** Fonts ready, then N consecutive animation frames with an unchanged
|
|
215
|
+
network-ACTIVITY counter — never "nothing in flight", which never comes for a state whose fixture is
|
|
216
|
+
deliberately `pending`, and never a fixed sleep, which photographs whatever a slow machine painted.
|
|
217
|
+
|
|
218
|
+
**Eight assertions before a shutter opens** (`photographFault`), each naming a fact the picture would
|
|
219
|
+
have hidden rather than shown: no probe, not the harness, no host element, a mount that REJECTED, a
|
|
220
|
+
mount that never finished, a page that never went quiet, a zero-sized box, a box with no children
|
|
221
|
+
and no text. Then a byte floor as a backstop. Every one of them otherwise comes out as a plausible
|
|
222
|
+
image of the wrong thing.
|
|
223
|
+
|
|
224
|
+
**One session per picture, and that is not an optimisation to collapse.** `page.console()` and
|
|
225
|
+
`page.pageErrors()` are bounded rings over the whole SESSION, so a shared one files state A's
|
|
226
|
+
console errors under state B — and per-state attribution is the half of the artifact that gates.
|
|
227
|
+
|
|
228
|
+
**The picture is the VIEWPORT, not a crop.** `@ultimat3/scraping`'s `CaptureRequest` is `fullPage`
|
|
229
|
+
alone, so there is no clip rectangle to ask for; the framing knob is the state's own `viewport`,
|
|
230
|
+
passed to `launch()` as `defaultViewport` through `LocalBrowserOptions.options`. That is why there
|
|
231
|
+
is one browser per declared viewport, memoised. The verdict names it as a blind spot rather than
|
|
232
|
+
implying a crop it did not perform.
|
|
233
|
+
|
|
234
|
+
**`loadApp` does not import a states file**, for the reason it does not import an island: it
|
|
235
|
+
registers no primitive, and importing it would put `@ultimat3/testing` in the server module graph of
|
|
236
|
+
every `x dev`, every `x build` and every gate step that loads the app (axiom 6).
|
|
237
|
+
|
|
177
238
|
**`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
|
|
178
239
|
single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
|
|
179
240
|
server's `stop()` deliberately does not clear the other process's lock.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.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,33 +37,33 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "11.
|
|
41
|
-
"@ultimat3/admin": "11.
|
|
42
|
-
"@ultimat3/ai": "11.
|
|
43
|
-
"@ultimat3/auth": "11.
|
|
44
|
-
"@ultimat3/cache": "11.
|
|
45
|
-
"@ultimat3/core": "11.
|
|
46
|
-
"@ultimat3/db": "11.
|
|
47
|
-
"@ultimat3/entity": "11.
|
|
48
|
-
"@ultimat3/flags": "11.
|
|
49
|
-
"@ultimat3/http": "11.
|
|
50
|
-
"@ultimat3/i18n": "11.
|
|
51
|
-
"@ultimat3/jobs": "11.
|
|
52
|
-
"@ultimat3/mail": "11.
|
|
53
|
-
"@ultimat3/manifest": "11.
|
|
54
|
-
"@ultimat3/mcp": "11.
|
|
55
|
-
"@ultimat3/money": "11.
|
|
56
|
-
"@ultimat3/policy": "11.
|
|
57
|
-
"@ultimat3/pwa": "11.
|
|
58
|
-
"@ultimat3/query": "11.
|
|
59
|
-
"@ultimat3/realtime": "11.
|
|
60
|
-
"@ultimat3/render": "11.
|
|
61
|
-
"@ultimat3/schema": "11.
|
|
62
|
-
"@ultimat3/scraping": "11.
|
|
63
|
-
"@ultimat3/seo": "11.
|
|
64
|
-
"@ultimat3/storage": "11.
|
|
65
|
-
"@ultimat3/testing": "11.
|
|
66
|
-
"@ultimat3/time": "11.
|
|
40
|
+
"@ultimat3/action": "11.3.0",
|
|
41
|
+
"@ultimat3/admin": "11.3.0",
|
|
42
|
+
"@ultimat3/ai": "11.3.0",
|
|
43
|
+
"@ultimat3/auth": "11.3.0",
|
|
44
|
+
"@ultimat3/cache": "11.3.0",
|
|
45
|
+
"@ultimat3/core": "11.3.0",
|
|
46
|
+
"@ultimat3/db": "11.3.0",
|
|
47
|
+
"@ultimat3/entity": "11.3.0",
|
|
48
|
+
"@ultimat3/flags": "11.3.0",
|
|
49
|
+
"@ultimat3/http": "11.3.0",
|
|
50
|
+
"@ultimat3/i18n": "11.3.0",
|
|
51
|
+
"@ultimat3/jobs": "11.3.0",
|
|
52
|
+
"@ultimat3/mail": "11.3.0",
|
|
53
|
+
"@ultimat3/manifest": "11.3.0",
|
|
54
|
+
"@ultimat3/mcp": "11.3.0",
|
|
55
|
+
"@ultimat3/money": "11.3.0",
|
|
56
|
+
"@ultimat3/policy": "11.3.0",
|
|
57
|
+
"@ultimat3/pwa": "11.3.0",
|
|
58
|
+
"@ultimat3/query": "11.3.0",
|
|
59
|
+
"@ultimat3/realtime": "11.3.0",
|
|
60
|
+
"@ultimat3/render": "11.3.0",
|
|
61
|
+
"@ultimat3/schema": "11.3.0",
|
|
62
|
+
"@ultimat3/scraping": "11.3.0",
|
|
63
|
+
"@ultimat3/seo": "11.3.0",
|
|
64
|
+
"@ultimat3/storage": "11.3.0",
|
|
65
|
+
"@ultimat3/testing": "11.3.0",
|
|
66
|
+
"@ultimat3/time": "11.3.0",
|
|
67
67
|
"babel-preset-solid": "^1.9.15"
|
|
68
68
|
}
|
|
69
69
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -46,6 +46,15 @@ const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
|
|
|
46
46
|
*/
|
|
47
47
|
const CLIENT_ENTRY_POINT = /\.island\.tsx$/;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A `*.island.states.ts` is read by a TOOL, not by the server: `x shot --island` loads it, the
|
|
51
|
+
* harness route loads it, and a guard test loads it. It registers no primitive and it imports
|
|
52
|
+
* `@ultimat3/testing`, so importing it here would put the test-support package in the module graph
|
|
53
|
+
* of every `x dev`, every `x build` and every gate step that loads the app — the same rule the
|
|
54
|
+
* client entry point above follows, for the same reason (axiom 6).
|
|
55
|
+
*/
|
|
56
|
+
const STATES_FILE = /\.island\.states\.ts$/;
|
|
57
|
+
|
|
49
58
|
export interface LoadedApp {
|
|
50
59
|
readonly root: string;
|
|
51
60
|
/** App-root-relative POSIX paths of every module that imported, sorted. */
|
|
@@ -88,7 +97,9 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
88
97
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
89
98
|
if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
|
|
90
99
|
const file = relative(root, absolute).split(sep).join('/');
|
|
91
|
-
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file))
|
|
100
|
+
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
92
103
|
let module: Record<string, unknown>;
|
|
93
104
|
try {
|
|
94
105
|
module = (await import(absolute)) as Record<string, unknown>;
|
package/src/browser-launcher.ts
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
// of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
|
|
3
3
|
// precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
|
|
4
4
|
// command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
|
|
5
|
+
//
|
|
6
|
+
// Two ways to get a browser, and the second is the one production uses. `localBrowser()` starts
|
|
7
|
+
// Chrome in this container; `remoteBrowser({ cdpUrl })` ATTACHES to one somebody else is running,
|
|
8
|
+
// which is what every stealth provider sells — a session created over their API answers with a
|
|
9
|
+
// `wss://` CDP endpoint and a real, unfingerprintable Chromium behind it. `driver-cdp.ts` has
|
|
10
|
+
// called attach its primary path since it shipped, and until now no CLI command could reach it.
|
|
5
11
|
|
|
6
12
|
import { existsSync } from 'node:fs';
|
|
7
13
|
import { UltimateError } from '@ultimat3/core';
|
|
8
14
|
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
9
|
-
import { localBrowser } from '@ultimat3/scraping';
|
|
15
|
+
import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
@@ -18,6 +24,17 @@ export const BROWSER_PACKAGE = 'puppeteer-core';
|
|
|
18
24
|
/** Where a browser binary is named when the flag does not name one. Read in this order. */
|
|
19
25
|
export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
|
|
20
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Where a CDP endpoint is named when `--cdp-url` does not name one. `SCRAPE_CDP_URL` and not a
|
|
29
|
+
* name of this command's own: `@ultimat3/scraping`'s `remoteRequired` refusal already tells its
|
|
30
|
+
* reader `remoteBrowser({ cdpUrl: env.SCRAPE_CDP_URL })`, and a second spelling would make the
|
|
31
|
+
* package's own instruction wrong for the CLI that follows it.
|
|
32
|
+
*/
|
|
33
|
+
export const BROWSER_CDP_URL_VAR = 'SCRAPE_CDP_URL';
|
|
34
|
+
|
|
35
|
+
/** The schemes a CDP endpoint can arrive as: a provider's `wss://`, a sidecar's `http://`. */
|
|
36
|
+
const CDP_SCHEMES = ['ws:', 'wss:', 'http:', 'https:'] as const;
|
|
37
|
+
|
|
21
38
|
/**
|
|
22
39
|
* A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
|
|
23
40
|
* shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
|
|
@@ -27,6 +44,8 @@ export class ShotBrowserMissingError extends UltimateError {
|
|
|
27
44
|
constructor(input: { root: string; detail: string }) {
|
|
28
45
|
super({
|
|
29
46
|
code: 'X_SHOT_BROWSER_MISSING',
|
|
47
|
+
// The same install either way: attaching over CDP needs no Chrome on this box, but it still
|
|
48
|
+
// needs a client that speaks the protocol, and `puppeteer-core` is that client.
|
|
30
49
|
cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
|
|
31
50
|
// One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
|
|
32
51
|
// one literal, and a fix line the gate cannot read is a fix line nothing holds to the
|
|
@@ -40,23 +59,41 @@ export class ShotBrowserMissingError extends UltimateError {
|
|
|
40
59
|
/**
|
|
41
60
|
* Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
|
|
42
61
|
* transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
|
|
43
|
-
* question decides — is
|
|
62
|
+
* question decides — is the method this run needs there to call?
|
|
63
|
+
*
|
|
64
|
+
* WHICH method is the run's, not this function's. `cdp-port.ts` declares `launch` and `connect`
|
|
65
|
+
* both optional precisely so an attach-only provider SDK satisfies the port, and asking for
|
|
66
|
+
* `launch` when the run is going to `connect` would refuse exactly the library that works.
|
|
44
67
|
*/
|
|
45
|
-
const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
|
|
68
|
+
const launcherIn = (module: unknown, method: 'launch' | 'connect'): CdpLauncherLike | undefined => {
|
|
46
69
|
if (typeof module !== 'object' || module === null) return undefined;
|
|
47
|
-
const candidate = module as
|
|
48
|
-
if (typeof candidate
|
|
70
|
+
const candidate = module as Record<string, unknown>;
|
|
71
|
+
if (typeof candidate[method] === 'function') return candidate as CdpLauncherLike;
|
|
49
72
|
// `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
|
|
50
73
|
// refused rather than followed: one unbounded recursion here is a stack overflow instead of the
|
|
51
74
|
// instruction this whole function exists to produce.
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
const inner = candidate['default'];
|
|
76
|
+
if (inner === undefined || inner === candidate) return undefined;
|
|
77
|
+
return launcherIn(inner, method);
|
|
54
78
|
};
|
|
55
79
|
|
|
56
80
|
export interface AppBrowserOptions {
|
|
57
81
|
readonly root: string;
|
|
58
82
|
/** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
|
|
59
83
|
readonly executablePath?: string | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* A CDP endpoint to ATTACH to. When it is set nothing is launched here and `executablePath` is
|
|
86
|
+
* not read: the browser is somebody else's, and closing it ends their session too — which
|
|
87
|
+
* `remoteBrowser()` does deliberately, so a provider stops billing for a run that ended.
|
|
88
|
+
*/
|
|
89
|
+
readonly cdpUrl?: string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The page size this browser lays out at. A LAUNCH option and not a per-capture one, because
|
|
92
|
+
* that is the only place the shipped port has for it: `CaptureRequest` is `fullPage` alone
|
|
93
|
+
* (`packages/scraping/src/page.ts`), so the viewport IS the frame of every picture taken with
|
|
94
|
+
* this driver — which is why `x shot --island` builds one browser per declared viewport.
|
|
95
|
+
*/
|
|
96
|
+
readonly viewport?: { readonly width: number; readonly height: number } | undefined;
|
|
60
97
|
/** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
|
|
61
98
|
readonly resolve?: (specifier: string, from: string) => string;
|
|
62
99
|
readonly load?: (path: string) => Promise<unknown>;
|
|
@@ -78,6 +115,33 @@ export const executablePathFrom = (
|
|
|
78
115
|
/** True when a named executable is really there — a bad `--browser` is refused before a boot. */
|
|
79
116
|
export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
80
117
|
|
|
118
|
+
/** The endpoint a run will attach to, or `undefined` for "launch one here". */
|
|
119
|
+
export const cdpUrlFrom = (
|
|
120
|
+
flag: string | undefined,
|
|
121
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
122
|
+
): string | undefined => {
|
|
123
|
+
if (flag !== undefined && flag.length > 0) return flag;
|
|
124
|
+
const value = env[BROWSER_CDP_URL_VAR];
|
|
125
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The scheme, checked here rather than at `connect()`. A `--cdp-url` naming an https page or a
|
|
130
|
+
* bare host is a typo, and a typo must not cost an embedded Postgres and a provider session to
|
|
131
|
+
* report — the same rule `--browser` follows against the filesystem one line above.
|
|
132
|
+
*/
|
|
133
|
+
export const cdpUrlProblem = (url: string): string | undefined => {
|
|
134
|
+
let parsed: URL;
|
|
135
|
+
try {
|
|
136
|
+
parsed = new URL(url);
|
|
137
|
+
} catch {
|
|
138
|
+
return 'is not a URL';
|
|
139
|
+
}
|
|
140
|
+
return CDP_SCHEMES.includes(parsed.protocol as (typeof CDP_SCHEMES)[number])
|
|
141
|
+
? undefined
|
|
142
|
+
: `has scheme "${parsed.protocol}", and a CDP endpoint is ${CDP_SCHEMES.join(', ')}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
81
145
|
/**
|
|
82
146
|
* The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
|
|
83
147
|
* this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
|
|
@@ -86,22 +150,40 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
|
86
150
|
export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
|
|
87
151
|
const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
|
|
88
152
|
const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
|
|
153
|
+
const method = options.cdpUrl === undefined ? 'launch' : 'connect';
|
|
89
154
|
let entry: string;
|
|
90
155
|
try {
|
|
91
156
|
entry = resolve(BROWSER_PACKAGE, options.root);
|
|
92
157
|
} catch {
|
|
93
158
|
throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
|
|
94
159
|
}
|
|
95
|
-
const launcher = launcherIn(await load(entry));
|
|
160
|
+
const launcher = launcherIn(await load(entry), method);
|
|
96
161
|
if (launcher === undefined) {
|
|
97
162
|
throw new ShotBrowserMissingError({
|
|
98
163
|
root: options.root,
|
|
99
|
-
detail: `resolved to ${entry}, which exports no
|
|
164
|
+
detail: `resolved to ${entry}, which exports no ${method}()`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (options.cdpUrl !== undefined) {
|
|
168
|
+
return remoteBrowser({
|
|
169
|
+
launcher,
|
|
170
|
+
cdpUrl: options.cdpUrl,
|
|
171
|
+
// The viewport reaches `connect()` the same way it reaches `launch()` — through the
|
|
172
|
+
// pass-through slot — so `x shot --island`'s one-browser-per-viewport loop is unchanged by
|
|
173
|
+
// which half of the port a run took.
|
|
174
|
+
...(options.viewport === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { options: { defaultViewport: { ...options.viewport } } }),
|
|
100
177
|
});
|
|
101
178
|
}
|
|
102
179
|
return localBrowser({
|
|
103
180
|
launcher,
|
|
104
181
|
headless: true,
|
|
105
182
|
...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
|
|
183
|
+
// `LocalBrowserOptions.options` is passed through to `launch()` untouched, which is the seam
|
|
184
|
+
// that lets the CLI size a browser without `@ultimat3/scraping` naming a puppeteer type.
|
|
185
|
+
...(options.viewport === undefined
|
|
186
|
+
? {}
|
|
187
|
+
: { options: { defaultViewport: { ...options.viewport } } }),
|
|
106
188
|
});
|
|
107
189
|
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -38,7 +38,9 @@ import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
|
38
38
|
import { holdUntilShutdown } from './hold';
|
|
39
39
|
import type { IslandBundle } from './island-bundle';
|
|
40
40
|
import { buildIslands } from './island-bundle';
|
|
41
|
+
import { islandHarnessRoutes } from './island-harness-route';
|
|
41
42
|
import { islandRoutes } from './island-routes';
|
|
43
|
+
import { loadIslandStates } from './island-states-load';
|
|
42
44
|
import { msg } from './messages';
|
|
43
45
|
import type { CommandResult, Finding } from './output';
|
|
44
46
|
import { findingFrom } from './output';
|
|
@@ -181,6 +183,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
181
183
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
182
184
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
183
185
|
...islandRoutes(() => state.islands),
|
|
186
|
+
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
187
|
+
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
188
|
+
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
189
|
+
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
190
|
+
// command must not need a restart to see it.
|
|
191
|
+
...islandHarnessRoutes({
|
|
192
|
+
islands: () => state.islands,
|
|
193
|
+
states: () => loadIslandStates(options.root),
|
|
194
|
+
}),
|
|
184
195
|
...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
|
|
185
196
|
];
|
|
186
197
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `--island`'s half of `x shot`: read the flags, resolve the manifest, refuse the combinations
|
|
2
|
+
// that cannot mean anything, and hand `runIslandShot` values. Split from `cmd-shot.ts` so neither
|
|
3
|
+
// file has to hold both a route capture and a component capture — the two share a boot, a browser
|
|
4
|
+
// and an output tree, and nothing else.
|
|
5
|
+
|
|
6
|
+
// why: no Bun native joins or resolves a path; `--out` is resolved against the app root.
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import type { ScrapeDriver } from '@ultimat3/scraping';
|
|
9
|
+
import type { IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
|
|
10
|
+
import { findIslandStates } from '@ultimat3/testing';
|
|
11
|
+
import { appBrowser } from './browser-launcher';
|
|
12
|
+
import { BadFlagError } from './errors';
|
|
13
|
+
import type { IslandBrowser } from './island-shot';
|
|
14
|
+
import { ISLAND_SHOT_DIR, runIslandShot } from './island-shot';
|
|
15
|
+
import { loadIslandStates } from './island-states-load';
|
|
16
|
+
import type { IslandArtifacts } from './island-verdict';
|
|
17
|
+
import { islandShotLines, islandShotSummary, islandVerdictJson } from './island-verdict';
|
|
18
|
+
import type { CommandResult } from './output';
|
|
19
|
+
import type { ShotServer } from './shot-server';
|
|
20
|
+
import { SHOT_DIR } from './shot-server';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One browser per declared viewport, memoised. A state declares its own size and the viewport is a
|
|
24
|
+
* LAUNCH option, so two states at two sizes are two browsers — but two states at one size must not
|
|
25
|
+
* be, or a manifest of eight states pays eight launches for nothing.
|
|
26
|
+
*/
|
|
27
|
+
export function islandBrowser(input: {
|
|
28
|
+
readonly root: string;
|
|
29
|
+
readonly executablePath?: string | undefined;
|
|
30
|
+
readonly cdpUrl?: string | undefined;
|
|
31
|
+
}): IslandBrowser {
|
|
32
|
+
const byViewport = new Map<string, Promise<ScrapeDriver>>();
|
|
33
|
+
return (viewport: IslandViewport): Promise<ScrapeDriver> => {
|
|
34
|
+
const key = `${viewport.width}x${viewport.height}`;
|
|
35
|
+
const held = byViewport.get(key);
|
|
36
|
+
if (held !== undefined) return held;
|
|
37
|
+
const started = appBrowser({
|
|
38
|
+
root: input.root,
|
|
39
|
+
...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
|
|
40
|
+
...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
|
|
41
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
42
|
+
});
|
|
43
|
+
byViewport.set(key, started);
|
|
44
|
+
return started;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `--state <id>` against what the manifest really declares. Refused here rather than by an empty
|
|
50
|
+
* expansion downstream: a filter that matches nothing produces no picture, and a run that produces
|
|
51
|
+
* no picture and exits 0 is the outcome this whole command exists to make impossible.
|
|
52
|
+
*/
|
|
53
|
+
export function readStateFlag(
|
|
54
|
+
manifest: IslandStatesManifest,
|
|
55
|
+
wanted: string | undefined,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (wanted === undefined || wanted === '') return undefined;
|
|
58
|
+
if (manifest.states.some((one) => one.id === wanted)) return wanted;
|
|
59
|
+
const known = manifest.states.map((one) => one.id);
|
|
60
|
+
throw new BadFlagError({
|
|
61
|
+
flag: 'state',
|
|
62
|
+
command: 'shot',
|
|
63
|
+
reason: `${manifest.island} declares no state "${wanted}" — it declares ${known.join(', ')}`,
|
|
64
|
+
fix: `x shot --island ${manifest.name} --state ${known[0] ?? '<id>'} --json`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `--island` and a route positional are two different subjects and one command, so naming both is
|
|
70
|
+
* refused by name. Never silently preferring one: a reader who typed both has a belief about which
|
|
71
|
+
* one runs, and half of them would be wrong.
|
|
72
|
+
*/
|
|
73
|
+
export function refuseRouteWithIsland(route: string | undefined, island: string): never {
|
|
74
|
+
throw new BadFlagError({
|
|
75
|
+
flag: 'island',
|
|
76
|
+
command: 'shot',
|
|
77
|
+
reason: `--island ${island} photographs a component and "${route ?? ''}" is a route; x shot takes one subject`,
|
|
78
|
+
fix: `x shot --island ${island} --json`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IslandShotInput {
|
|
83
|
+
readonly root: string;
|
|
84
|
+
readonly island: string;
|
|
85
|
+
readonly state?: string | undefined;
|
|
86
|
+
readonly out?: string | undefined;
|
|
87
|
+
readonly settleMs: number;
|
|
88
|
+
readonly timeoutMs: number;
|
|
89
|
+
readonly extraHosts?: string | undefined;
|
|
90
|
+
readonly executablePath?: string | undefined;
|
|
91
|
+
/** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
|
|
92
|
+
readonly cdpUrl?: string | undefined;
|
|
93
|
+
readonly boot: () => Promise<ShotServer>;
|
|
94
|
+
/** Injected by a test, so the whole path is proved on a machine with no Chrome. */
|
|
95
|
+
readonly driver?: IslandBrowser | undefined;
|
|
96
|
+
readonly minBytes?: number | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The states are loaded, expanded and the name resolved BEFORE a browser or a dev server exists —
|
|
101
|
+
* a typo must not cost an embedded Postgres to report, and the expected picture list has to be
|
|
102
|
+
* knowable without either.
|
|
103
|
+
*/
|
|
104
|
+
export async function islandShot(input: IslandShotInput): Promise<IslandArtifacts> {
|
|
105
|
+
const all = await loadIslandStates(input.root);
|
|
106
|
+
if (all.length === 0) {
|
|
107
|
+
throw new BadFlagError({
|
|
108
|
+
flag: 'island',
|
|
109
|
+
command: 'shot',
|
|
110
|
+
reason: 'this app declares no island states at all, so there is nothing to photograph',
|
|
111
|
+
fix: "x g island settings --at apps/web/app/settings # then declare its states beside it with defineIslandStates({ island: '…', states: [...] })",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// `findIslandStates` is loose on the way in — `Settings`, `settings`, `settings.island.tsx` and
|
|
115
|
+
// the full path are one name — and refuses an unresolved one by listing every valid name, which
|
|
116
|
+
// is what tells a typo apart from an island whose states were never declared.
|
|
117
|
+
const manifest = findIslandStates(all, input.island);
|
|
118
|
+
const only = readStateFlag(manifest, input.state);
|
|
119
|
+
return runIslandShot({
|
|
120
|
+
manifest,
|
|
121
|
+
...(only === undefined ? {} : { state: only }),
|
|
122
|
+
outDir:
|
|
123
|
+
input.out === undefined
|
|
124
|
+
? join(input.root, SHOT_DIR, ISLAND_SHOT_DIR)
|
|
125
|
+
: resolve(input.root, input.out),
|
|
126
|
+
driver:
|
|
127
|
+
input.driver ??
|
|
128
|
+
islandBrowser({
|
|
129
|
+
root: input.root,
|
|
130
|
+
...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
|
|
131
|
+
...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
|
|
132
|
+
}),
|
|
133
|
+
boot: input.boot,
|
|
134
|
+
settleMs: input.settleMs,
|
|
135
|
+
timeoutMs: input.timeoutMs,
|
|
136
|
+
...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
|
|
137
|
+
...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const islandShotResult = (artifacts: IslandArtifacts): CommandResult => ({
|
|
142
|
+
ok: artifacts.verdict.ok,
|
|
143
|
+
command: 'shot',
|
|
144
|
+
summary: islandShotSummary(artifacts.verdict),
|
|
145
|
+
lines: islandShotLines(artifacts),
|
|
146
|
+
data: {
|
|
147
|
+
dir: artifacts.dir,
|
|
148
|
+
verdictFile: artifacts.verdictFile,
|
|
149
|
+
verdict: islandVerdictJson(artifacts.verdict),
|
|
150
|
+
},
|
|
151
|
+
});
|