@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/src/cmd-shot.ts CHANGED
@@ -11,17 +11,17 @@ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
11
  import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
12
12
  import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
13
  import { requireAppRoot } from './app-root';
14
- import { appBrowser, browserBinaryExists, executablePathFrom } from './browser-launcher';
15
- import { startDev } from './cmd-dev';
14
+ import { appBrowser } from './browser-launcher';
15
+ import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
16
16
  import type { CliCommand, CommandContext } from './command';
17
- import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
18
- import { DEV_BINDING } from './dev-roles';
19
- import { resolveServices } from './dev-services';
20
17
  import { BadFlagError, MissingPositionalError } from './errors';
21
18
  import { intFlagOr, PORT_RANGE } from './flag-number';
22
19
  import type { CommandResult } from './output';
23
20
  import type { ParsedArgs } from './parse';
24
21
  import { flagBool, flagString } from './parse';
22
+ import { shotBrowserChoice } from './shot-browser';
23
+ import type { BootDevServer, ShotServer } from './shot-server';
24
+ import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
25
25
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
26
26
  import type { IslandCount, ShotArtifacts } from './shot-verdict';
27
27
  import {
@@ -45,7 +45,11 @@ const DEFAULT_PORT = 0;
45
45
  */
46
46
  export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
47
47
 
48
- export const SHOT_DIR = join('.x', 'shot');
48
+ // Re-exported, not re-declared: `cmd-shot.test.ts` and the island path both name them, and a
49
+ // second declaration of a path or an allow-list rule is a second answer.
50
+ export type { BootDevServer, ShotServer };
51
+ export { allowHostsFrom, devServerFor, SHOT_DIR };
52
+
49
53
  export const SHOT_IMAGE = 'shot.png';
50
54
  export const SHOT_VERDICT = 'verdict.json';
51
55
 
@@ -164,24 +168,6 @@ const intFlag = (
164
168
  fallback,
165
169
  );
166
170
 
167
- /**
168
- * How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
169
- * call, for the reason every `Runner` in this package is one: the failure path below — a boot that
170
- * throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
171
- */
172
- export type BootDevServer = (input: {
173
- readonly root: string;
174
- readonly port: number;
175
- readonly env: Readonly<Record<string, string | undefined>>;
176
- }) => Promise<{ readonly url: string; stop(): Promise<void> }>;
177
-
178
- export interface ShotServer {
179
- readonly url: string;
180
- /** Which server the picture is of. Reported, because the two have different failure modes. */
181
- readonly origin: 'booted' | 'reused';
182
- stop(): Promise<void>;
183
- }
184
-
185
171
  export interface ShotRun {
186
172
  readonly route: string;
187
173
  readonly outDir: string;
@@ -274,65 +260,6 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
274
260
  }
275
261
  }
276
262
 
277
- /**
278
- * One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
279
- * Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
280
- * on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
281
- */
282
- export async function devServerFor(
283
- root: string,
284
- env: Readonly<Record<string, string | undefined>>,
285
- port: number,
286
- boot: BootDevServer = (input) => startDev(input),
287
- ): Promise<ShotServer> {
288
- const services = resolveServices(root, env);
289
- const file = Bun.file(lockPath(services.stateDir));
290
- if (await file.exists()) {
291
- const lock = parseLock(await file.text());
292
- if (lock !== null && isProcessAlive(lock.pid)) {
293
- return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
294
- }
295
- }
296
- const { release } = await preflight({
297
- stateDir: services.stateDir,
298
- port,
299
- hostname: DEV_BINDING.hostname,
300
- embeddedDb: services.db.mode === 'embedded',
301
- });
302
- // The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
303
- // looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
304
- // same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
305
- // checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
306
- // teardown must never replace the failure it is cleaning up after.
307
- const dev = await boot({ root, port, env }).catch((error: unknown) => {
308
- release();
309
- throw error;
310
- });
311
- await writeLock(services.stateDir, {
312
- pid: process.pid,
313
- port,
314
- url: dev.url,
315
- startedAt: new Date().toISOString(),
316
- });
317
- return {
318
- url: dev.url,
319
- origin: 'booted',
320
- async stop() {
321
- clearLock(services.stateDir);
322
- await dev.stop();
323
- },
324
- };
325
- }
326
-
327
- /** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
328
- export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
329
- const named = (extra ?? '')
330
- .split(',')
331
- .map((host) => host.trim())
332
- .filter((host) => host.length > 0);
333
- return [new URL(url).hostname, ...named];
334
- };
335
-
336
263
  export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
337
264
  ok: artifacts.verdict.ok,
338
265
  command: 'shot',
@@ -348,8 +275,9 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
348
275
  export const shotCommand: CliCommand = {
349
276
  spec: {
350
277
  name: 'shot',
351
- summary: 'photograph one route from a real browser, with a verdict a picture cannot carry',
352
- usage: 'x shot <route> [--port 0] [--out <dir>] [--no-full] [--settle 2000] [--json]',
278
+ summary: 'photograph one route, or one island in a state it declares, from a real browser',
279
+ usage:
280
+ 'x shot <route> | --island <name> [--state <id>] [--port 0] [--out <dir>] [--settle 2000] [--json]',
353
281
  requiresApp: true,
354
282
  flags: [
355
283
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -358,33 +286,76 @@ export const shotCommand: CliCommand = {
358
286
  { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
359
287
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
360
288
  { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
289
+ {
290
+ name: 'cdp-url',
291
+ type: 'string',
292
+ summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
293
+ },
361
294
  { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
295
+ // A FLAG on `x shot` and never a second command: photographing a route and photographing a
296
+ // component are one job with two subjects, and a parallel command would be the second path
297
+ // axiom 1 refuses.
298
+ {
299
+ name: 'island',
300
+ type: 'string',
301
+ summary: 'photograph one island in every state it declares',
302
+ },
303
+ {
304
+ name: 'state',
305
+ type: 'string',
306
+ summary: 'one declared state of that island, not all of them',
307
+ },
362
308
  ],
363
309
  },
364
310
  async run(ctx: CommandContext): Promise<CommandResult> {
365
311
  const root = requireAppRoot('shot', ctx.cwd).dir;
366
312
  // Every value read before anything boots: a typo must not cost a browser and a dev server to
367
313
  // report, which is the rule `x routes` and `x mcp` already follow.
368
- const route = readRoute(ctx.args.positionals[0]);
314
+ const island = flagString(ctx.args, 'island');
315
+ const positional = ctx.args.positionals[0];
316
+ if (island !== undefined && island !== '' && positional !== undefined) {
317
+ refuseRouteWithIsland(positional, island);
318
+ }
319
+ const route = island === undefined || island === '' ? readRoute(positional) : '';
369
320
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
370
321
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
371
322
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
372
- const executablePath = executablePathFrom(flagString(ctx.args, 'browser'), ctx.env);
373
- if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
374
- throw new BadFlagError({
375
- flag: 'browser',
376
- command: 'shot',
377
- reason: `no executable at "${executablePath}"`,
378
- fix: `x shot ${route} --browser /usr/bin/chromium`,
379
- });
380
- }
323
+ // Which browser this run gets — start one here, or attach to one somebody else is running.
324
+ // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
325
+ // provider session exists to pay for a typo.
326
+ const { cdpUrl, executablePath } = shotBrowserChoice({
327
+ cdpFlag: flagString(ctx.args, 'cdp-url'),
328
+ browserFlag: flagString(ctx.args, 'browser'),
329
+ env: ctx.env,
330
+ });
381
331
  const out = flagString(ctx.args, 'out');
382
332
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
333
+ if (island !== undefined && island !== '') {
334
+ return islandShotResult(
335
+ await islandShot({
336
+ root,
337
+ island,
338
+ ...(flagString(ctx.args, 'state') === undefined
339
+ ? {}
340
+ : { state: flagString(ctx.args, 'state') }),
341
+ ...(out === undefined ? {} : { out }),
342
+ settleMs,
343
+ timeoutMs,
344
+ ...(executablePath === undefined ? {} : { executablePath }),
345
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
346
+ ...(flagString(ctx.args, 'allow-hosts') === undefined
347
+ ? {}
348
+ : { extraHosts: flagString(ctx.args, 'allow-hosts') }),
349
+ boot,
350
+ }),
351
+ );
352
+ }
383
353
  // Resolved before the boot for the same reason: an app with no browser installed must not pay
384
354
  // an embedded Postgres to be told to run `bun add -d puppeteer-core`.
385
355
  const driver = await appBrowser({
386
356
  root,
387
357
  ...(executablePath === undefined ? {} : { executablePath }),
358
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
388
359
  });
389
360
  return shotResult(
390
361
  await runShot({
@@ -8,10 +8,13 @@
8
8
  * `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
9
9
  * loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
10
10
  * config file is ever loaded and neither line is reachable at run time. The bundler walks them
11
- * anyway, and that is the whole failure: Bun 1.3 (what CI pins and what `docker/Dockerfile` builds
12
- * on) refuses the build with `Could not resolve: "@babel/preset-typescript/package.json"`, while
13
- * Bun 1.4 bundles the unresolvable `require` as a runtime throw — so one tree compiled on a laptop
14
- * and did not in CI.
11
+ * anyway, and that is the whole failure: Bun 1.3 refuses the build with
12
+ * `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
13
+ * unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
14
+ * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
+ * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
+ * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
+ * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
15
18
  *
16
19
  * Marking the dead specifier external rather than the two live ones: `serve.ts` calls
17
20
  * `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
@@ -114,6 +114,13 @@ export const CLI_OWNED_ERROR_CODES = [
114
114
  'X_SECRETS_EDIT_FAILED',
115
115
  'X_WORKSPACE_DEP_UNDECLARED',
116
116
  'X_SHOT_BROWSER_MISSING',
117
+ // `x shot --island` — one code per way a component's named state fails to become a picture.
118
+ // The last of the four is the one that gates: it is checked against the expansion computed
119
+ // before a browser existed, so a capture loop that swallowed a failure cannot exit 0.
120
+ 'X_SHOT_ISLAND_STATES_EMPTY',
121
+ 'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
122
+ 'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
123
+ 'X_SHOT_ISLAND_MISSING',
117
124
  'X_GH_UNAVAILABLE',
118
125
  'X_GH_NOT_AUTHENTICATED',
119
126
  'X_GH_COMMAND_FAILED',
@@ -218,6 +225,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
218
225
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
219
226
  X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
220
227
  X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
228
+ X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
229
+ X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
230
+ X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
231
+ X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
221
232
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
222
233
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
223
234
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
@@ -10,7 +10,6 @@ import { renderThrowable } from '@ultimat3/core';
10
10
  import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
11
11
  import { contentHash } from '@ultimat3/render/server';
12
12
  import { IslandBuildFailedError } from './errors';
13
- import { solidProductionPlugin } from './island-solid-production';
14
13
  import { islandStylesPlugin } from './island-styles';
15
14
  import { solidJsxPlugin } from './solid-loader';
16
15
 
@@ -84,12 +83,24 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
84
83
  // classic `React.createElement` — emitted into a browser chunk that imports no React, with
85
84
  // `success: true` and no log. Every island shipped that way through five majors.
86
85
  //
87
- // The other two close the same shape of failure — a wrong answer `Bun.build` reports as
88
- // `success: true`: without the second, `target: 'browser'` resolves the `development`
89
- // export condition and the chunk carries Solid's dev build; without the third, Bun's file
90
- // loader resolves a `.module.scss` to its asset PATH, so `styles['x']` is `undefined` and
91
- // every element renders unclassed.
92
- plugins: [solidJsxPlugin, solidProductionPlugin, islandStylesPlugin],
86
+ // The second closes the same shape of failure — a wrong answer `Bun.build` reports as
87
+ // `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
88
+ // PATH, so `styles['x']` is `undefined` and every element renders unclassed.
89
+ plugins: [solidJsxPlugin, islandStylesPlugin],
90
+ // The third one, and it is a `define` rather than the plugin this used to be: Bun selects
91
+ // the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
92
+ // and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
93
+ // `solid-js/web` plus `solid-js/store`: unset → dev build, `test` → dev build, `production`
94
+ // → production build, this line → production build in all three, byte for byte.
95
+ //
96
+ // So without it a chunk built anywhere a container did not run — `x dev`, `x build` on a
97
+ // laptop, `bun test` — ships Solid's development build, and the island's own
98
+ // `process.env.NODE_ENV` reads `"development"` in the file a browser downloads.
99
+ //
100
+ // Pinned rather than inherited, because an island chunk is only ever built to be shipped:
101
+ // `x dev` serves the same chunk the container does, and bytes that depend on the ambient
102
+ // NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
103
+ define: { 'process.env.NODE_ENV': '"production"' },
93
104
  });
94
105
  } catch (error) {
95
106
  throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
@@ -0,0 +1,91 @@
1
+ // Serving the harness. It is a `x dev` route and not a second server, for the reason `x shot`
2
+ // reuses a running dev server at all: the chunks, the app's stylesheet registry and the island
3
+ // bundle all live in the process that built them, and embedded Postgres is single-writer so there
4
+ // can only be one of those per checkout.
5
+
6
+ import type { Route, UltimateRequest } from '@ultimat3/http';
7
+ import { html, json } from '@ultimat3/http';
8
+ import type { IslandStatesManifest } from '@ultimat3/testing';
9
+ import {
10
+ islandShotFile,
11
+ islandShotTargets,
12
+ islandStatesMatching,
13
+ islandStatesNames,
14
+ parseIslandAddress,
15
+ } from '@ultimat3/testing';
16
+ import { harnessPage, ISLAND_HARNESS_PATH } from './island-harness';
17
+ import type { IslandSource } from './island-routes';
18
+
19
+ /**
20
+ * A getter, never the manifests: `x dev` rebuilds on the watcher tick, and a set captured when the
21
+ * route was mounted would serve a state the author has since edited for the rest of the session.
22
+ * It is async because reading a states file is an `import()`, and one that throws is answered as a
23
+ * refusal rather than taking the dev server down.
24
+ */
25
+ export type IslandStatesSource = () => Promise<readonly IslandStatesManifest[]>;
26
+
27
+ const refused = (cause: string, fix: string): Response =>
28
+ json(
29
+ { ok: false, error: { code: 'X_SHOT_ISLAND_UNPHOTOGRAPHABLE', cause, fix } },
30
+ { status: 404 },
31
+ );
32
+
33
+ export interface HarnessRouteInput {
34
+ readonly islands: IslandSource;
35
+ readonly states: IslandStatesSource;
36
+ }
37
+
38
+ /**
39
+ * `GET /_x/island?island=…&state=…&theme=…`. The address is parsed by the vocabulary's own
40
+ * `parseIslandAddress`, which is `islandAddress`'s inverse and is TOTAL — a mistyped theme falls
41
+ * back rather than throwing, because a page that renders an error over a typo turns a typo into a
42
+ * screenshot of the framework.
43
+ *
44
+ * An island or a state this process does not know is the one case that IS refused, and it can only
45
+ * mean the two processes disagree: `x shot` computed its picture list from the states files on disk
46
+ * and this server was booted against an older set.
47
+ */
48
+ export function islandHarnessRoutes(input: HarnessRouteInput): readonly Route[] {
49
+ return [
50
+ {
51
+ method: 'GET',
52
+ path: ISLAND_HARNESS_PATH,
53
+ meta: { name: 'dev._x.island', auth: 'public', tags: ['dev'] },
54
+ handler: async (request: UltimateRequest): Promise<Response> => {
55
+ const address = parseIslandAddress(request.url.search);
56
+ const all = await input.states();
57
+ const manifest = islandStatesMatching(all, address.island)[0];
58
+ if (manifest === undefined) {
59
+ return refused(
60
+ `this dev server declares no island states for ${address.island === '' ? '(no island named)' : address.island} — it knows ${islandStatesNames(all).join(', ') || 'none'}`,
61
+ 'restart x dev, then: x shot --island <name> --json',
62
+ );
63
+ }
64
+ // `wanted`, never a local called `state`: `bun run secret-compare` reads the NAME of a
65
+ // comparison's operands and `state` is on its list — an OAuth handshake state is compared
66
+ // under exactly that name, and this one is a screenshot filename stem.
67
+ const wanted = address.state;
68
+ const declared = manifest.states.find((one) => one.id === wanted);
69
+ const file = islandShotFile(manifest.name, wanted, address.theme);
70
+ const target = islandShotTargets(manifest).find((one) => one.file === file);
71
+ if (declared === undefined || target === undefined) {
72
+ return refused(
73
+ `${manifest.island} declares no state ${wanted === '' ? '(none named)' : wanted} in theme ${address.theme}`,
74
+ `x shot --island ${manifest.name} --state ${manifest.states[0]?.id ?? '<id>'} --json`,
75
+ );
76
+ }
77
+ // The chunk is looked up rather than built here: `x dev` already built every island at
78
+ // boot and rebuilds on the watcher tick, so a second build would be a second answer to
79
+ // "what code does this island run".
80
+ const chunk = input.islands().chunks.find((one) => one.file === manifest.island);
81
+ if (chunk === undefined) {
82
+ return refused(
83
+ `${manifest.island} is declared as an island's states file but this build has no chunk for it`,
84
+ `x g island ${manifest.name} --at ${manifest.island.split('/').slice(0, -1).join('/')}`,
85
+ );
86
+ }
87
+ return html(harnessPage({ target, state: declared, entry: chunk.url }));
88
+ },
89
+ },
90
+ ];
91
+ }
@@ -0,0 +1,147 @@
1
+ // The inline script the harness page runs BEFORE the island's chunk: the sealed network, the
2
+ // pinned clock and the readiness signal. A classic `<script>` in `<head>` evaluates before any
3
+ // module script, which is the only ordering in which a component's first `fetch` can be caught.
4
+ //
5
+ // A string and not a module, deliberately: this runs in the browser and the CLI has no bundler in
6
+ // the path that serves a page. `@ultimat3/testing`'s `sealNetwork()` patches THIS process's
7
+ // `globalThis.fetch` and cannot reach the page's realm.
8
+
9
+ import type { IslandRouteStub } from '@ultimat3/testing';
10
+
11
+ /**
12
+ * Consecutive animation frames with an unchanged activity counter before a page is called ready.
13
+ * QUIET, never zero in flight: a state whose fixture is deliberately `pending` has a request that
14
+ * never settles, so waiting for zero hangs forever — and a fixed sleep photographs whatever a slow
15
+ * machine had painted by then.
16
+ */
17
+ export const QUIET_FRAMES = 3;
18
+
19
+ /** The one global the CLI reads back. Namespaced so an app's own page state can never collide. */
20
+ export const HARNESS_GLOBAL = '__xShot';
21
+
22
+ /**
23
+ * Embedded as a JS string literal, so `<` is escaped: a `</script` anywhere inside a stub body
24
+ * would otherwise end the tag and the rest of the page would be parsed as markup. Escaping the
25
+ * character rather than the sequence is the total form — there is no second spelling of it.
26
+ */
27
+ const embed = (value: unknown): string => JSON.stringify(value ?? null).replaceAll('<', '\\u003c');
28
+
29
+ export interface HarnessScriptOptions {
30
+ readonly stubs: readonly IslandRouteStub[];
31
+ /** The frozen instant, with an explicit offset — the manifest's `now`. */
32
+ readonly now: string;
33
+ /** The IANA zone every unzoned `Intl.DateTimeFormat` in the page is given. */
34
+ readonly timeZone: string;
35
+ }
36
+
37
+ /**
38
+ * The seal. Four egress surfaces, and an unmatched request on any of them REJECTS while recording
39
+ * itself: a component whose fetch quietly hangs paints its own loading branch, and the picture then
40
+ * shows a fixture gap dressed up as a real component state.
41
+ *
42
+ * `activity` counts a request STARTING and a request SETTLING, which is what lets readiness be
43
+ * "nothing changed for N frames" rather than "nothing is in flight" — the second is unreachable for
44
+ * a `pending` fixture, which is a state an author declares on purpose.
45
+ */
46
+ const sealScript = (stubs: readonly IslandRouteStub[]): string => `
47
+ var W=window.${HARNESS_GLOBAL};var STUBS=${embed(stubs)};
48
+ function bump(){W.activity+=1}
49
+ function stubFor(method,path){var k=method.toUpperCase()+' '+path;
50
+ for(var i=0;i<STUBS.length;i+=1){if(k.indexOf(STUBS[i].match)===0)return STUBS[i].respond}
51
+ W.unstubbed.push(k);return null}
52
+ // Pathname AND query: the vocabulary says a stub's \`match\` is a PREFIX, so \`'GET /api/quota'\`
53
+ // catching \`/api/quota?window=day\` is a property of the key carrying the query, not of the match.
54
+ function pathOf(url){try{var u=new URL(url,location.href);return u.pathname+u.search}catch(e){return String(url)}}
55
+ function refuse(k){return new Error('x shot: no stub answers '+k+' — declare it in the state\\'s routes')}
56
+ function answer(respond,k){
57
+ if(respond===null)return Promise.reject(refuse(k));
58
+ if(respond.kind==='pending')return new Promise(function(){});
59
+ if(respond.kind==='offline')return Promise.reject(new TypeError('x shot: offline fixture for '+k));
60
+ return Promise.resolve(new Response(JSON.stringify(respond.body===undefined?null:respond.body),
61
+ {status:respond.status||200,headers:{'content-type':'application/json'}}))}
62
+ window.fetch=function(input,init){
63
+ var url=typeof input==='string'?input:(input&&input.url)||String(input);
64
+ var method=(init&&init.method)||(typeof input==='object'&&input&&input.method)||'GET';
65
+ var path=pathOf(url);var k=method.toUpperCase()+' '+path;
66
+ var respond=stubFor(method,path);bump();
67
+ return answer(respond,k).then(function(r){bump();return r},function(e){bump();throw e})};
68
+ // A socket and an event stream have no stub vocabulary at all, so both are refused outright and
69
+ // recorded: a live component that opened one would otherwise sit in its loading branch forever.
70
+ window.WebSocket=function(url){W.unstubbed.push('WS '+url);throw refuse('WS '+url)};
71
+ window.EventSource=function(url){W.unstubbed.push('SSE '+url);throw refuse('SSE '+url)};
72
+ var RealXHR=window.XMLHttpRequest;
73
+ window.XMLHttpRequest=function(){var xhr=new RealXHR();var open=xhr.open;
74
+ xhr.open=function(method,url){W.unstubbed.push(String(method).toUpperCase()+' '+pathOf(url));
75
+ return open.apply(xhr,arguments)};return xhr};
76
+ // Nothing here serves a service worker, and one an earlier page registered would answer requests
77
+ // this seal never sees. A no-op registration keeps a component that asks from throwing.
78
+ if(navigator.serviceWorker)navigator.serviceWorker.register=function(){return Promise.resolve(undefined)};
79
+ `;
80
+
81
+ /**
82
+ * The clock, pinned in both halves. A harness that freezes the INSTANT and leaves the zone ambient
83
+ * renders `12:00` on one machine and `14:00` on the next, and the review diff then reports a
84
+ * component change that never happened — so the zone is filled in for every `Intl.DateTimeFormat`
85
+ * built without one, and the instant replaces the argumentless `new Date()`.
86
+ *
87
+ * `toLocaleString` on a Date is NOT covered: it reaches the engine's own Intl and not this global.
88
+ * The framework's rule is that no date is formatted without an explicit `timeZone`, so a component
89
+ * obeying it is pinned; one that does not is a blind spot the verdict names rather than hides.
90
+ */
91
+ const clockScript = (now: string, timeZone: string): string => `
92
+ var FIXED=${embed(Date.parse(now))};var ZONE=${embed(timeZone)};
93
+ class ShotDate extends Date{constructor(){if(arguments.length===0)super(FIXED);else super(...arguments)}
94
+ static now(){return FIXED}}
95
+ window.Date=ShotDate;
96
+ var RealDTF=Intl.DateTimeFormat;
97
+ function zoned(options){return options&&options.timeZone?options:Object.assign({},options,{timeZone:ZONE})}
98
+ function ShotDTF(locales,options){return new RealDTF(locales,zoned(options))}
99
+ ShotDTF.prototype=RealDTF.prototype;
100
+ ShotDTF.supportedLocalesOf=RealDTF.supportedLocalesOf.bind(RealDTF);
101
+ Intl.DateTimeFormat=ShotDTF;
102
+ `;
103
+
104
+ /**
105
+ * Ready is quiet, not idle. Fonts first — a picture taken mid-swap photographs the fallback face —
106
+ * then `QUIET_FRAMES` consecutive frames in which nothing started and nothing settled.
107
+ */
108
+ const readyScript = (): string => `
109
+ var last=-1;var still=0;
110
+ function tick(){var seen=W.activity;
111
+ if(seen===last)still+=1;else{still=0;last=seen}
112
+ if(still>=${QUIET_FRAMES}){W.ready=true;return}
113
+ requestAnimationFrame(tick)}
114
+ (document.fonts?document.fonts.ready:Promise.resolve()).then(function(){requestAnimationFrame(tick)});
115
+ `;
116
+
117
+ /** The whole prelude, in the one order that works: state, seal, clock, then the readiness watch. */
118
+ export function harnessScript(options: HarnessScriptOptions): string {
119
+ return [
120
+ `window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[]};`,
121
+ sealScript(options.stubs),
122
+ clockScript(options.now, options.timeZone),
123
+ readyScript(),
124
+ ].join('\n');
125
+ }
126
+
127
+ /**
128
+ * What the CLI evaluates before every capture, as one expression — `CdpPageLike.evaluate` takes
129
+ * the string form only. Every clause is a fact a picture cannot carry: a host that never attached,
130
+ * a mount that rejected, a box of zero pixels, a box holding nothing, a request nobody stubbed.
131
+ *
132
+ * `selector` is the crop target the manifest declared; the island's own host element when absent.
133
+ */
134
+ export const readinessProbe = (selector: string): string =>
135
+ `(function(){var W=window.${HARNESS_GLOBAL}||{};` +
136
+ 'var host=document.querySelector("[data-x-island]");' +
137
+ `var box=document.querySelector(${JSON.stringify(selector)})||host;` +
138
+ 'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
139
+ 'return{harness:W.harness===true,ready:W.ready===true,' +
140
+ 'unstubbed:(W.unstubbed||[]).slice(),' +
141
+ 'attached:host!==null&&document.body.contains(host),' +
142
+ 'mounted:host!==null&&host.hasAttribute("data-x-mounted"),' +
143
+ 'failed:host&&host.hasAttribute("data-x-failed")?host.getAttribute("data-x-failed"):null,' +
144
+ // Children OR text: a component that renders one text node has painted, and one that mounted
145
+ // and rendered nothing is the silence a non-zero box would otherwise read as success.
146
+ 'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
147
+ 'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}};})()';
@@ -0,0 +1,98 @@
1
+ // The harness document: one island, one state, one theme, mounted over the SEAM the framework
2
+ // already has — `data-x-entry` for the chunk, `data-x-props` for the props, and
3
+ // `@ultimat3/render`'s own hydration runtime to boot it. A second mounting mechanism here would be
4
+ // a picture of something no page ever renders.
5
+
6
+ // why: no Bun native takes a path apart; the surface is a segment of an app-root-relative path.
7
+ import { basename } from 'node:path';
8
+ import type { IslandDirective, Surface } from '@ultimat3/render';
9
+ import {
10
+ emitIslandAttributes,
11
+ emitIslandProps,
12
+ hydrateRuntime,
13
+ islandModuleId,
14
+ SURFACES,
15
+ } from '@ultimat3/render';
16
+ import { stylesFor } from '@ultimat3/render/server';
17
+ import type { IslandShotTarget, IslandState } from '@ultimat3/testing';
18
+ import { harnessScript } from './island-harness-script';
19
+
20
+ /** Where the harness lives in `x dev`'s own namespace, so no app route can shadow it. */
21
+ export const ISLAND_HARNESS_PATH = '/_x/island';
22
+
23
+ /**
24
+ * `idle`, and not because the picture should wait: it is the only strategy whose runtime boots an
25
+ * island nothing has scrolled to or clicked, and reusing a shipped strategy is what keeps the
26
+ * `data-x-mounted` / `data-x-failed` markers — the two facts a picture cannot carry — landing here
27
+ * exactly as they land on a real page.
28
+ */
29
+ const HARNESS_STRATEGY = 'idle';
30
+
31
+ /**
32
+ * `apps/web/app/settings/settings.island.tsx` → `app`. The CSS a document carries is per surface
33
+ * (axiom 6 applied to bytes the browser parses), so a `site/` island photographed against `app/`'s
34
+ * stylesheet would be a picture of styling that page never receives.
35
+ */
36
+ export function surfaceOf(island: string): Surface | null {
37
+ const segment = island.split('/')[2];
38
+ return SURFACES.find((surface) => surface === segment) ?? null;
39
+ }
40
+
41
+ /**
42
+ * The frame, and every rule in it is about what a REVIEWER sees. Animations and transitions are
43
+ * off because a picture taken mid-transition is a picture of a moment no user experiences; the
44
+ * caret is invisible because a focused input blinks and two otherwise identical runs then differ.
45
+ * Colours are semantic tokens, never literals — the app's own global layer defines them.
46
+ */
47
+ const FRAME_STYLE = `
48
+ *,*::before,*::after{animation:none !important;transition:none !important;
49
+ scroll-behavior:auto !important;caret-color:transparent !important}
50
+ html{background:rgb(var(--color-bg) / 1)}
51
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
52
+ background:rgb(var(--color-bg) / 1)}
53
+ #x-shot-frame{padding:var(--space-4, 16px);max-width:100%;box-sizing:border-box}
54
+ `.trim();
55
+
56
+ export interface HarnessPageInput {
57
+ readonly target: IslandShotTarget;
58
+ readonly state: IslandState;
59
+ /** The built chunk's immutable URL — what `data-x-entry` carries and what the runtime imports. */
60
+ readonly entry: string;
61
+ }
62
+
63
+ /**
64
+ * One address, one document, and every address is a FULL PAGE LOAD. Switching islands or states
65
+ * client-side would carry the previous state's fixtures, its resolved resources and its mounted
66
+ * DOM into the next picture, which is the one way a screenshot tool can lie about its own subject.
67
+ */
68
+ export function harnessPage(input: HarnessPageInput): string {
69
+ const moduleId = islandModuleId(basename(input.target.island));
70
+ const directive: IslandDirective = {
71
+ islandId: moduleId,
72
+ moduleId,
73
+ strategy: HARNESS_STRATEGY,
74
+ entry: input.entry,
75
+ props: input.state.props,
76
+ };
77
+ const css = stylesFor(surfaceOf(input.target.island));
78
+ return [
79
+ '<!doctype html>',
80
+ `<html lang="en" data-theme="${input.target.theme}">`,
81
+ '<head><meta charset="utf-8">',
82
+ `<title>${input.target.name} · ${input.target.state} · ${input.target.theme}</title>`,
83
+ css.length === 0 ? '' : `<style>${css}</style>`,
84
+ `<style>${FRAME_STYLE}</style>`,
85
+ // Before the body and before every module script, which is the only ordering in which the
86
+ // seal can catch a component's first request.
87
+ `<script>${harnessScript({
88
+ stubs: input.state.routes,
89
+ now: input.target.now,
90
+ timeZone: input.target.timeZone,
91
+ })}</script>`,
92
+ '</head><body>',
93
+ `<div id="x-shot-frame"><div ${emitIslandAttributes(directive)}></div></div>`,
94
+ emitIslandProps(directive),
95
+ hydrateRuntime([directive]),
96
+ '</body></html>',
97
+ ].join('');
98
+ }