@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 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.1.0",
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.1.0",
41
- "@ultimat3/admin": "11.1.0",
42
- "@ultimat3/ai": "11.1.0",
43
- "@ultimat3/auth": "11.1.0",
44
- "@ultimat3/cache": "11.1.0",
45
- "@ultimat3/core": "11.1.0",
46
- "@ultimat3/db": "11.1.0",
47
- "@ultimat3/entity": "11.1.0",
48
- "@ultimat3/flags": "11.1.0",
49
- "@ultimat3/http": "11.1.0",
50
- "@ultimat3/i18n": "11.1.0",
51
- "@ultimat3/jobs": "11.1.0",
52
- "@ultimat3/mail": "11.1.0",
53
- "@ultimat3/manifest": "11.1.0",
54
- "@ultimat3/mcp": "11.1.0",
55
- "@ultimat3/money": "11.1.0",
56
- "@ultimat3/policy": "11.1.0",
57
- "@ultimat3/pwa": "11.1.0",
58
- "@ultimat3/query": "11.1.0",
59
- "@ultimat3/realtime": "11.1.0",
60
- "@ultimat3/render": "11.1.0",
61
- "@ultimat3/schema": "11.1.0",
62
- "@ultimat3/scraping": "11.1.0",
63
- "@ultimat3/seo": "11.1.0",
64
- "@ultimat3/storage": "11.1.0",
65
- "@ultimat3/testing": "11.1.0",
66
- "@ultimat3/time": "11.1.0",
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)) continue;
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>;
@@ -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 there a `launch` to call?
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 { launch?: unknown; default?: unknown };
48
- if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
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
- if (candidate.default === undefined || candidate.default === candidate) return undefined;
53
- return launcherIn(candidate.default);
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 launch()`,
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
+ });