@ultimat3/cli 19.4.0 → 20.1.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.
@@ -1,270 +1,60 @@
1
- // `x shot --island <name>` — one component, in the states it declares, photographed one address at
2
- // a time. The order matters and is the whole design: the complete expected picture list is
3
- // computed from the states file BEFORE a browser exists, and what landed on disk is diffed against
4
- // that list afterwards, so "produced nothing and exited 0" is a state this command can refuse.
1
+ // `x shot --island` — the RUN: which islands, in which order, and which artifacts land. One
2
+ // picture at a time is `island-capture.ts`; the order here is the whole design, because the
3
+ // complete expected picture list is computed from the states files BEFORE a browser exists and
4
+ // what landed on disk is diffed against that list afterwards, so "produced nothing and exited 0"
5
+ // is a state this command can refuse.
6
+ //
7
+ // A sweep never aborts on a failure: every state the app CAN photograph is captured, every verdict
8
+ // and the index are written, and only then does the missing-picture gate turn the reasons into a
9
+ // non-zero exit. One island that will not mount must not cost a reader the other nineteen.
5
10
 
6
11
  // why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
7
12
  import { join } from 'node:path';
8
13
  import { finiteCount } from '@ultimat3/core';
9
- import type { CaptureClip, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
10
- import { systemScrapeClock } from '@ultimat3/scraping';
11
- import type { IslandShotTarget, IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
12
- import { islandShotTargets, islandStatesFile } from '@ultimat3/testing';
13
- import { ISLAND_HARNESS_PATH } from './island-harness';
14
- import { readinessProbe } from './island-harness-script';
15
- import {
16
- IslandRequestUnstubbedError,
17
- IslandShotsMissingError,
18
- IslandUnphotographableError,
19
- } from './island-shot-errors';
20
- import type { IslandArtifacts, IslandReadiness, IslandStateShot } from './island-verdict';
21
- import { buildIslandVerdict, islandVerdictJson, parseReadiness } from './island-verdict';
14
+ import type { IslandShotTarget, IslandStatesManifest } from '@ultimat3/testing';
15
+ import { islandShotPlan, islandShotTargets } from '@ultimat3/testing';
16
+ import type { IslandCaptureRun } from './island-capture';
17
+ import { captureIslandState, MIN_SHOT_BYTES } from './island-capture';
18
+ import { IslandShotsMissingError } from './island-shot-errors';
19
+ import { ISLAND_INDEX, renderIslandIndex } from './island-shot-index';
20
+ import type { IslandArtifacts, IslandStateShot, IslandSweepArtifacts } from './island-verdict';
21
+ import { buildIslandVerdict, islandVerdictJson } from './island-verdict';
22
22
  import type { ShotServer } from './shot-server';
23
- import { allowHostsFrom } from './shot-server';
24
- import { SETTLE_POLL_MS, settleReadiness } from './shot-settle';
25
23
 
26
24
  /** Where a component's pictures land, under the same `.x/shot` tree a route's picture does. */
27
25
  export const ISLAND_SHOT_DIR = 'island';
28
26
  export const ISLAND_VERDICT = 'verdict.json';
29
27
 
30
- /**
31
- * A backstop and not a quality bar: it catches the answers that are not an image at all — a driver
32
- * that hands back a handshake, an empty buffer, a PNG signature with nothing behind it. A real
33
- * capture of any viewport clears it by an order of magnitude.
34
- */
35
- export const MIN_SHOT_BYTES = 512;
28
+ // Re-exported, not re-declared: the capture's own vocabulary is what a caller and a test both
29
+ // name, and a second spelling of a browser or a floor would be a second answer.
30
+ export type { IslandBrowser } from './island-capture';
31
+ export { ISLAND_CROP_MARGIN_PX, MIN_SHOT_BYTES, photographFault } from './island-capture';
32
+ export { ISLAND_INDEX } from './island-shot-index';
36
33
 
37
- /**
38
- * A browser sized to one viewport. A FUNCTION and not a driver, because the shipped browser port
39
- * takes the viewport as a LAUNCH option (`LocalBrowserOptions.options`) and a state declares its
40
- * own — so "photograph this state at 480x320" is a different browser, not a different call.
41
- */
42
- export type IslandBrowser = (viewport: IslandViewport) => Promise<ScrapeDriver>;
43
-
44
- export interface IslandShotRun {
45
- readonly manifest: IslandStatesManifest;
34
+ export interface IslandSweepRun extends IslandCaptureRun {
35
+ /** Every island this run photographs. One entry is the `--island <name>` form. */
36
+ readonly manifests: readonly IslandStatesManifest[];
46
37
  /**
47
- * `--state`, or every declared state when absent. The caller validates it: `defineIslandStates`
48
- * refuses a manifest with no states, so the only way this expansion comes back empty is a filter
49
- * naming a state that does not exist — which is a typo, and belongs to the flag that made it.
38
+ * `--state`, or every declared state when absent. The caller validates it against ONE manifest:
39
+ * `defineIslandStates` refuses a manifest with no states, so the only way this expansion comes
40
+ * back empty is a filter naming a state that does not exist — a typo, and it belongs to the flag
41
+ * that made it.
50
42
  */
51
43
  readonly state?: string | undefined;
52
- readonly outDir: string;
53
- readonly driver: IslandBrowser;
54
44
  readonly boot: () => Promise<ShotServer>;
55
- readonly settleMs: number;
56
- readonly timeoutMs: number;
57
- readonly extraHosts?: string | undefined;
58
45
  readonly minBytes?: number | undefined;
59
46
  readonly now?: (() => Date) | undefined;
60
47
  }
61
48
 
49
+ /** The one-island form, kept as its own shape because its artifacts name one directory. */
50
+ export interface IslandShotRun extends Omit<IslandSweepRun, 'manifests'> {
51
+ readonly manifest: IslandStatesManifest;
52
+ }
53
+
62
54
  const quietly = async (stop: () => Promise<void>): Promise<void> => {
63
55
  await stop().catch(() => undefined);
64
56
  };
65
57
 
66
- /**
67
- * Every assertion that has to hold before a shutter opens, in the order a failure is most useful
68
- * in. Each one names a fact the picture would have hidden rather than shown: an absent harness is
69
- * a dev server that does not know this island, an unattached host photographs the frame's
70
- * background, a zero box photographs whatever is behind it, and an empty box is a component that
71
- * mounted and rendered nothing — every one of which comes out as a plausible image of the wrong
72
- * thing.
73
- */
74
- interface Refusal {
75
- readonly reason: string;
76
- readonly fix: string;
77
- }
78
-
79
- /**
80
- * The first assertion that does not hold, in the order a failure is most useful in — or
81
- * `undefined`, which is the only way a shutter opens. Each clause names a fact the picture would
82
- * have hidden rather than shown: an absent harness is a dev server that does not know this island,
83
- * an unattached host photographs the frame's background, a zero box photographs whatever is behind
84
- * it, and an empty box is a component that mounted and rendered nothing. Every one of them comes
85
- * out as a plausible image of the wrong thing.
86
- *
87
- * A value and not a throw, so the whole ladder is one pure function a test can walk.
88
- */
89
- export function photographFault(
90
- target: IslandShotTarget,
91
- seen: IslandReadiness | null,
92
- ): Refusal | undefined {
93
- const settle = `x shot --island ${target.name} --settle 8000 --json`;
94
- if (seen === null) {
95
- return {
96
- reason: 'answered no readiness probe at all, so nothing about the page can be asserted',
97
- fix: `x shot --island ${target.name} --state ${target.state} --timeout 60000 --json`,
98
- };
99
- }
100
- if (!seen.harness) {
101
- return {
102
- reason:
103
- 'was served a document that is not the shot harness — the dev server this run reused was booted against a different set of states files',
104
- fix: 'restart x dev, then run this command again',
105
- };
106
- }
107
- if (!seen.attached) {
108
- return { reason: 'rendered no [data-x-island] host element', fix: hostFix(target) };
109
- }
110
- if (seen.failed !== null) {
111
- return {
112
- reason: `mounted and its mount() REJECTED: ${seen.failed}`,
113
- fix: `x shot --island ${target.name} --state ${target.state} --json # the verdict carries the throw and its frame`,
114
- };
115
- }
116
- if (!seen.mounted) {
117
- return { reason: 'did not finish mounting inside the settle window', fix: settle };
118
- }
119
- if (!seen.ready) {
120
- return {
121
- reason:
122
- 'never went quiet: something kept starting or settling requests for the whole settle window',
123
- fix: settle,
124
- };
125
- }
126
- if (seen.box.width === 0 || seen.box.height === 0) {
127
- return {
128
- reason: `has a ${seen.box.width}x${seen.box.height} bounding box, so the picture would be of whatever is behind it`,
129
- fix: cropFix(target),
130
- };
131
- }
132
- if (!seen.filled) {
133
- return {
134
- reason:
135
- 'has a box with no child elements and no text in it — it mounted and rendered nothing',
136
- fix: cropFix(target),
137
- };
138
- }
139
- return undefined;
140
- }
141
-
142
- /**
143
- * The capture rectangle for a readiness answer, in PAGE coordinates.
144
- *
145
- * `seen` is non-null and its box has area by the time this is reached — `photographFault` refuses
146
- * both above, and it refuses them BEFORE the shutter for exactly this reason: a zero-area clip is
147
- * `X_SCRAPE_CAPTURE_CLIP_EMPTY` from the port, which is a worse report of the same fault than
148
- * "rendered nothing". The `?? 0` pair is the parser's floor and not a second opinion.
149
- */
150
- const clipFor = (seen: IslandReadiness | null): CaptureClip => ({
151
- x: (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0),
152
- y: (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0),
153
- width: seen?.box.width ?? 0,
154
- height: seen?.box.height ?? 0,
155
- });
156
-
157
- const hostFix = (target: IslandShotTarget): string =>
158
- `in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
159
-
160
- const cropFix = (target: IslandShotTarget): string =>
161
- `in ${islandStatesFile(target.island)} set target to a selector the component really renders, or widen the state's props`;
162
-
163
- /**
164
- * One address, one full page load, one picture. Never a client-side switch between states: the
165
- * previous state's fixtures, its resolved resources and its mounted DOM would ride into the next
166
- * picture, which is the one way a screenshot tool can lie about its own subject.
167
- *
168
- * A session PER TARGET, and it costs a browser launch each: `page.console()` and `page.pageErrors()`
169
- * are bounded rings over the whole SESSION, so a shared one would file state A's console errors
170
- * under state B — and per-state attribution is the half of this artifact that gates.
171
- */
172
- async function captureOne(
173
- options: IslandShotRun,
174
- server: ShotServer,
175
- target: IslandShotTarget,
176
- floor: number,
177
- ): Promise<IslandStateShot> {
178
- const url = new URL(`${ISLAND_HARNESS_PATH}${target.query}`, server.url).toString();
179
- let session: ScrapeSession | undefined;
180
- try {
181
- const driver = await options.driver(target.viewport);
182
- session = await driver.open({
183
- name: 'x shot --island',
184
- rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
185
- clock: systemScrapeClock,
186
- timeoutMs: options.timeoutMs,
187
- });
188
- const page = session.page;
189
- // BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
190
- // media query, and the theme a component resolves on mount is the one it will keep.
191
- //
192
- // This is the INPUT and the harness's `data-theme` attribute is the OUTCOME, and both are set
193
- // deliberately. The attribute is right for a component that READS a theme it does not own; the
194
- // preference is the only thing that reaches one that RESOLVES its own. `examples/dummy`'s
195
- // settings island is the second kind — its state's `theme` prop is `'system'`, so on mount it
196
- // DELETES the attribute the harness set, both pictures fall through to `:root`, and the two
197
- // came back byte-identical with the same md5 (issue #338). Re-setting the attribute after
198
- // readiness is not the repair: it photographs a state the component would never reach.
199
- await page.colorScheme(target.theme);
200
- await page.goto(url, { timeout: options.timeoutMs });
201
- const expression = readinessProbe(target.target ?? '[data-x-island]');
202
- const probe = (): Promise<IslandReadiness | null> =>
203
- page
204
- .evaluate(expression)
205
- .then(parseReadiness)
206
- .catch(() => null);
207
- const seen = await settleReadiness(probe, {
208
- windowMs: options.settleMs,
209
- pollMs: SETTLE_POLL_MS,
210
- });
211
- // Ahead of every other assertion about the picture: a component whose fetch went unanswered
212
- // paints its own loading branch, and the picture then shows a fixture gap dressed up as a
213
- // real component state. The list is the page's own, published by the seal.
214
- if (seen !== null && seen.unstubbed.length > 0) {
215
- throw new IslandRequestUnstubbedError({
216
- island: target.island,
217
- state: target.state,
218
- requests: seen.unstubbed,
219
- statesFile: islandStatesFile(target.island),
220
- });
221
- }
222
- const fault = photographFault(target, seen);
223
- if (fault !== undefined) {
224
- throw new IslandUnphotographableError({
225
- island: target.island,
226
- state: target.state,
227
- theme: target.theme,
228
- ...fault,
229
- });
230
- }
231
- // The COMPONENT, not the viewport it happens to sit in — the crop this feature was designed
232
- // around, and which nothing passed until 2026-08-26 (issue #338). The rectangle is the
233
- // readiness probe's own box, which is the crop target the manifest declared, translated from
234
- // the DOM's viewport coordinates into the page coordinates a capture clip is in.
235
- //
236
- // The clip ALONE. `fullPage: false` beside it is accepted — `assertCaptureFraming` refuses only
237
- // `=== true`, and `cdp-target.ts` sends `{ clip }` and nothing else either way, so all four
238
- // pictures really were written with the pair — but it is a field that says nothing: the two
239
- // are exclusive, and spelling out the default of the one you did not ask for reads as a choice.
240
- const bytes = await page.screenshot({ clip: clipFor(seen) });
241
- if (bytes.byteLength < floor) {
242
- throw new IslandUnphotographableError({
243
- island: target.island,
244
- state: target.state,
245
- theme: target.theme,
246
- reason: `produced ${bytes.byteLength} bytes, under the ${floor}-byte floor — that is not an image`,
247
- fix: `x shot --island ${target.name} --browser /usr/bin/chromium --json`,
248
- });
249
- }
250
- await Bun.write(join(options.outDir, target.file), bytes);
251
- return {
252
- state: target.state,
253
- theme: target.theme,
254
- file: target.file,
255
- bytes: bytes.byteLength,
256
- box: seen?.box ?? { x: 0, y: 0, width: 0, height: 0 },
257
- mounted: seen?.mounted === true,
258
- unstubbed: seen?.unstubbed ?? [],
259
- console: page.console(),
260
- pageErrors: page.pageErrors(),
261
- };
262
- } finally {
263
- const open = session;
264
- if (open !== undefined) await quietly(() => open.close());
265
- }
266
- }
267
-
268
58
  /** Declared pictures that are not on disk. Read from the EXPANSION, never from the loop's beliefs. */
269
59
  export async function missingShots(
270
60
  outDir: string,
@@ -278,34 +68,34 @@ export async function missingShots(
278
68
  }
279
69
 
280
70
  /**
281
- * Boot (or find) the server, photograph every declared state, write the pictures and the verdict,
282
- * then refuse if any declared picture is absent. The driver and the boot are ARGUMENTS for the
283
- * reason `runShot`'s are: `bun test` drives this with a fake browser and a stub server, so the
284
- * whole command is proved on a machine with no Chrome.
71
+ * Boot (or find) the server, photograph every declared state of every island named, write the
72
+ * pictures, the per-island verdicts and the index, then refuse if any declared picture is absent.
73
+ * The driver and the boot are ARGUMENTS for `runShot`'s reason: `bun test` drives this with a fake
74
+ * browser and a stub server, so the whole command is proved on a machine with no Chrome.
285
75
  */
286
- export async function runIslandShot(options: IslandShotRun): Promise<IslandArtifacts> {
76
+ export async function runIslandSweep(options: IslandSweepRun): Promise<IslandSweepArtifacts> {
287
77
  // A Set and not an `===`: `bun run secret-compare` reads the NAME of a comparison's operands and
288
78
  // `state` is on its list, because an OAuth handshake state is compared under exactly that name.
289
79
  // This one is a screenshot filename stem, and the membership test says so.
290
80
  const chosen = options.state === undefined ? null : new Set([options.state]);
291
- const targets = islandShotTargets(options.manifest).filter(
292
- (target) => chosen === null || chosen.has(target.state),
293
- );
81
+ const wanted = (target: IslandShotTarget): boolean => chosen === null || chosen.has(target.state);
82
+ const targets = islandShotPlan(options.manifests).filter(wanted);
294
83
  // Before the boot, and before a browser: `bytes.byteLength < NaN` is false for every picture, so
295
84
  // an unchecked floor does not lower the backstop — it removes it, and "produced nothing and
296
85
  // exited 0" is the one outcome a reader cannot tell from success. 0 stays legal and is what
297
86
  // `island-shot.test.ts` passes: the fake driver answers an 8-byte PNG signature.
298
- const floor = finiteCount('runIslandShot', 'minBytes', options.minBytes ?? MIN_SHOT_BYTES);
87
+ const floor = finiteCount('runIslandSweep', 'minBytes', options.minBytes ?? MIN_SHOT_BYTES);
299
88
  const server = await options.boot();
300
89
  const shots: IslandStateShot[] = [];
301
90
  const failures: unknown[] = [];
302
91
  try {
303
92
  for (const target of targets) {
304
- // A state that cannot be photographed does not stop the run: the reader wants every picture
305
- // the app CAN produce plus a named reason for each one it cannot, and the missing-shot gate
306
- // below is what turns those reasons into a non-zero exit.
93
+ // A state that cannot be photographed does not stop the run — not the next state, and not
94
+ // the next ISLAND. The reader wants every picture the app CAN produce plus a named reason
95
+ // for each one it cannot, and the missing-shot gate below is what turns those reasons into
96
+ // a non-zero exit.
307
97
  try {
308
- shots.push(await captureOne(options, server, target, floor));
98
+ shots.push(await captureIslandState(options, server, target, floor));
309
99
  } catch (error) {
310
100
  failures.push(error);
311
101
  }
@@ -313,30 +103,70 @@ export async function runIslandShot(options: IslandShotRun): Promise<IslandArtif
313
103
  } finally {
314
104
  await quietly(() => server.stop());
315
105
  }
316
- const missing = await missingShots(options.outDir, targets);
317
- const verdict = buildIslandVerdict({
318
- island: options.manifest.island,
319
- name: options.manifest.name,
320
- server: server.origin,
321
- capturedAt: (options.now ?? (() => new Date()))().toISOString(),
322
- expected: targets,
323
- shots,
324
- missing,
325
- });
326
- const dir = join(options.outDir, options.manifest.name);
327
- const verdictFile = join(dir, ISLAND_VERDICT);
328
- await Bun.write(verdictFile, `${JSON.stringify(islandVerdictJson(verdict), null, 2)}\n`);
106
+ const capturedAt = (options.now ?? (() => new Date()))().toISOString();
107
+ const pairs = [];
108
+ for (const manifest of options.manifests) {
109
+ const expected = islandShotTargets(manifest).filter(wanted);
110
+ const verdict = buildIslandVerdict({
111
+ island: manifest.island,
112
+ name: manifest.name,
113
+ server: server.origin,
114
+ capturedAt,
115
+ expected,
116
+ shots: shots.filter((shot) => expected.some((target) => target.file === shot.file)),
117
+ missing: await missingShots(options.outDir, expected),
118
+ });
119
+ await Bun.write(
120
+ join(options.outDir, manifest.name, ISLAND_VERDICT),
121
+ `${JSON.stringify(islandVerdictJson(verdict), null, 2)}\n`,
122
+ );
123
+ pairs.push({ manifest, verdict });
124
+ }
125
+ // The index is written for a single-island run too: the file that says what a picture IS cannot
126
+ // be a property of how many islands the reader asked for.
127
+ const indexFile = join(options.outDir, ISLAND_INDEX);
128
+ await Bun.write(
129
+ indexFile,
130
+ renderIslandIndex({ pairs, capturedAt, blind: pairs[0]?.verdict.blind ?? [] }),
131
+ );
132
+ const artifacts: IslandSweepArtifacts = {
133
+ verdicts: pairs.map((pair) => pair.verdict),
134
+ dir: options.outDir,
135
+ indexFile,
136
+ ok: pairs.every((pair) => pair.verdict.ok),
137
+ };
329
138
  // The first failure is re-thrown ONLY when it explains a missing picture. A run that took every
330
139
  // declared picture and also logged a failure is a contradiction; the artifact is what decides.
331
- if (missing.length > 0) {
140
+ const short = artifacts.verdicts.find((verdict) => verdict.missing.length > 0);
141
+ if (short !== undefined) {
332
142
  const first = failures[0];
333
143
  if (first !== undefined) throw first;
334
144
  throw new IslandShotsMissingError({
335
- island: options.manifest.name,
336
- missing,
337
- expected: targets.length,
338
- dir,
145
+ island: short.name,
146
+ missing: short.missing,
147
+ expected: short.expected.length,
148
+ dir: join(options.outDir, short.name),
149
+ });
150
+ }
151
+ return artifacts;
152
+ }
153
+
154
+ /**
155
+ * One island. The sweep with a single manifest, so there is one capture loop and one artifact
156
+ * writer in this package — two would be two answers to "what did this run produce".
157
+ */
158
+ export async function runIslandShot(options: IslandShotRun): Promise<IslandArtifacts> {
159
+ const { manifest, ...rest } = options;
160
+ const artifacts = await runIslandSweep({ ...rest, manifests: [manifest] });
161
+ const verdict = artifacts.verdicts[0];
162
+ if (verdict === undefined) {
163
+ throw new IslandShotsMissingError({
164
+ island: manifest.name,
165
+ missing: islandShotTargets(manifest).map((target) => target.file),
166
+ expected: manifest.states.length,
167
+ dir: join(options.outDir, manifest.name),
339
168
  });
340
169
  }
341
- return { verdict, dir, verdictFile };
170
+ const dir = join(options.outDir, manifest.name);
171
+ return { verdict, dir, verdictFile: join(dir, ISLAND_VERDICT), indexFile: artifacts.indexFile };
342
172
  }
@@ -18,6 +18,7 @@ export const ISLAND_SHOT_MESSAGE_KEYS = [
18
18
  'cli.shot.island.picture',
19
19
  'cli.shot.island.verdict',
20
20
  'cli.shot.island.state',
21
+ 'cli.shot.island.index',
21
22
  'cli.shot.island.blind.crop',
22
23
  'cli.shot.island.blind.locale',
23
24
  ] as const;
@@ -66,6 +67,19 @@ export interface IslandReadiness {
66
67
  * answer there.
67
68
  */
68
69
  readonly scroll: { readonly x: number; readonly y: number };
70
+ /**
71
+ * Whether the crop target's own content is wider or taller than its box — `scrollWidth >
72
+ * clientWidth` on the element the picture is of. RECORDED, never gating: content spilling out of
73
+ * its box is the single most common ugly-UI symptom and a pixel-tight PNG frequently cannot show
74
+ * it, so a reader needs the fact stated. Gating on it would be gating on a layout opinion.
75
+ */
76
+ readonly overflow: { readonly x: boolean; readonly y: boolean };
77
+ /**
78
+ * The scrollable extent of the document, in PAGE coordinates. The one bound a crop margin can be
79
+ * clamped against: a margin that runs off the page asks CDP to photograph coordinates no content
80
+ * is at, and a rectangle a driver silently resolves is the picture lying about its own subject.
81
+ */
82
+ readonly page: { readonly width: number; readonly height: number };
69
83
  }
70
84
 
71
85
  const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
@@ -78,6 +92,8 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
78
92
  filled: t.boolean,
79
93
  box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
80
94
  scroll: t.object({ x: t.number, y: t.number }),
95
+ overflow: t.object({ x: t.boolean, y: t.boolean }),
96
+ page: t.object({ width: t.number, height: t.number }),
81
97
  }) as unknown as StandardSchemaV1<unknown, IslandReadiness>;
82
98
 
83
99
  /**
@@ -101,9 +117,27 @@ export interface IslandStateShot {
101
117
  readonly unstubbed: readonly string[];
102
118
  readonly console: readonly ConsoleLine[];
103
119
  readonly pageErrors: readonly PageError[];
120
+ /**
121
+ * The crop target's own overflow at the moment the shutter opened. On `IslandStateShot` and in
122
+ * `--json` because a PNG cannot carry it — and deliberately absent from `stateShotOk` below.
123
+ */
124
+ readonly overflow: { readonly x: boolean; readonly y: boolean };
104
125
  }
105
126
 
106
- /** One state's picture is clean when nothing on the page logged, threw, or went unanswered. */
127
+ /**
128
+ * Console lines at `warn`. Its own reader rather than a second field: `page.console()` already
129
+ * records every level and `shotJson` already ships the whole array, so a `warnings` array beside
130
+ * `console` would be one fact in two places — what was missing is that nobody COUNTED them.
131
+ */
132
+ export const stateShotWarnings = (shot: IslandStateShot): readonly ConsoleLine[] =>
133
+ shot.console.filter((line) => line.level === 'warn');
134
+
135
+ /**
136
+ * One state's picture is clean when nothing on the page logged an ERROR, threw, or went
137
+ * unanswered. A console WARNING and an overflowing box are recorded beside it and read by neither
138
+ * clause on purpose: a signal that fails a run is a signal an author switches off, and both of
139
+ * these are facts a reviewer judges rather than verdicts a machine can reach.
140
+ */
107
141
  export const stateShotOk = (shot: IslandStateShot): boolean =>
108
142
  shot.mounted &&
109
143
  shot.unstubbed.length === 0 &&
@@ -146,6 +180,9 @@ const shotJson = (shot: IslandStateShot): JsonValue => ({
146
180
  box: { x: shot.box.x, y: shot.box.y, width: shot.box.width, height: shot.box.height },
147
181
  mounted: shot.mounted,
148
182
  ok: stateShotOk(shot),
183
+ // Counted, never gating — `ok` above is computed without either of them.
184
+ warnings: stateShotWarnings(shot).length,
185
+ overflow: { x: shot.overflow.x, y: shot.overflow.y },
149
186
  unstubbed: [...shot.unstubbed],
150
187
  console: shot.console.map((line) => ({ level: line.level, text: line.text, at: line.at })),
151
188
  pageErrors: shot.pageErrors.map((error) => ({
@@ -175,6 +212,8 @@ export interface IslandArtifacts {
175
212
  /** Absolute path of the directory the pictures and the verdict were written to. */
176
213
  readonly dir: string;
177
214
  readonly verdictFile: string;
215
+ /** The gallery index, written for a single-island run as well as for a sweep. */
216
+ readonly indexFile: string;
178
217
  }
179
218
 
180
219
  export function islandShotLines(artifacts: IslandArtifacts): readonly string[] {
@@ -192,9 +231,59 @@ export function islandShotLines(artifacts: IslandArtifacts): readonly string[] {
192
231
  ...verdict.missing.map((file) => msg('cli.shot.island.missing', { file })),
193
232
  msg('cli.shot.island.picture', { path: artifacts.dir }),
194
233
  msg('cli.shot.island.verdict', { path: artifacts.verdictFile }),
234
+ msg('cli.shot.island.index', { path: artifacts.indexFile }),
235
+ ];
236
+ }
237
+
238
+ export interface IslandSweepArtifacts {
239
+ readonly verdicts: readonly IslandVerdict[];
240
+ /** The island root — every island's own directory sits under it, and so does the index. */
241
+ readonly dir: string;
242
+ readonly indexFile: string;
243
+ readonly ok: boolean;
244
+ }
245
+
246
+ /**
247
+ * The sweep's own lines: one per state per island, every missing picture, then the index. The
248
+ * per-island verdict files are named by the index rather than repeated here — a sweep over twenty
249
+ * islands would otherwise print twenty paths nobody reads before the one that matters.
250
+ */
251
+ export function islandSweepLines(artifacts: IslandSweepArtifacts): readonly string[] {
252
+ return [
253
+ ...artifacts.verdicts.flatMap((verdict) => [
254
+ ...verdict.shots.map((shot) =>
255
+ msg('cli.shot.island.state', {
256
+ state: `${verdict.name}/${shot.state}`,
257
+ theme: shot.theme,
258
+ width: shot.box.width,
259
+ height: shot.box.height,
260
+ file: shot.file,
261
+ }),
262
+ ),
263
+ ...verdict.missing.map((file) => msg('cli.shot.island.missing', { file })),
264
+ ]),
265
+ msg('cli.shot.island.picture', { path: artifacts.dir }),
266
+ msg('cli.shot.island.index', { path: artifacts.indexFile }),
195
267
  ];
196
268
  }
197
269
 
270
+ /**
271
+ * The same two keys the one-island summary uses, with the island NAMES joined — never a count with
272
+ * a hand-written plural in it, which would be a user-facing string built in code rather than in
273
+ * the catalog. A reader who asked for every island is told which ones there were.
274
+ */
275
+ export function islandSweepSummary(artifacts: IslandSweepArtifacts): string {
276
+ const island = artifacts.verdicts.map((verdict) => verdict.name).join(', ');
277
+ const taken = artifacts.verdicts.reduce((total, verdict) => total + verdict.shots.length, 0);
278
+ const expected = artifacts.verdicts.reduce(
279
+ (total, verdict) => total + verdict.expected.length,
280
+ 0,
281
+ );
282
+ return artifacts.ok
283
+ ? msg('cli.shot.island.ok', { island, pictures: taken })
284
+ : msg('cli.shot.island.failed', { island, taken, expected });
285
+ }
286
+
198
287
  /** The one line a reader sees first, and it names the gating fact rather than the file count. */
199
288
  export const islandShotSummary = (verdict: IslandVerdict): string =>
200
289
  verdict.ok
package/src/messages.ts CHANGED
@@ -216,8 +216,9 @@ const CATALOG = {
216
216
  'cli.shot.island.missing': ' missing {file} — no picture was taken for this declared state',
217
217
  'cli.shot.island.picture': ' pictures {path}',
218
218
  'cli.shot.island.verdict': ' verdict {path}',
219
+ 'cli.shot.island.index': ' index {path}',
219
220
  'cli.shot.island.blind.crop':
220
- 'the picture is the crop target and nothing around it — a component that overflows its own box, or whose fault is the space beside it, is outside the frame',
221
+ "the picture is the crop target and a thin margin around it — anything further out, including the space a component's own fault sits in, is outside the frame; content that overflows the box is recorded per state as overflow, never shown",
221
222
  'cli.shot.island.blind.locale':
222
223
  'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
223
224
  'cli.ci.failed':
package/src/prerender.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  SW_REGISTER_PATH,
29
29
  serviceWorkerArtifacts,
30
30
  serviceWorkerHead,
31
+ serviceWorkerRegistration,
31
32
  } from './sw-artifacts';
32
33
 
33
34
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
@@ -194,6 +195,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
194
195
  // header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
195
196
  // script the export does not carry.
196
197
  const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
198
+ // The registration's BYTES now too, and not beside `sw.js` at the end. Every document below
199
+ // names this file and `measureDocumentJs` weighs it off disk, so writing it last made the
200
+ // measurement read whatever happened to be there: nothing on a clean `out` (recorded as 0) and
201
+ // the PREVIOUS build's copy on a reused one. That is the run-order dependence the `jsBytes`
202
+ // split removed, and it came straight back in `frameworkJsBytes` because the field changed and
203
+ // the ORDER did not. `serviceWorkerRegistration()` depends on two constants and no document, so
204
+ // it has nothing to wait for; `sw.js` still comes last, because its precache manifest really is
205
+ // built from the hashes of pages that do not exist yet.
206
+ if (swHead !== undefined) {
207
+ await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorkerRegistration());
208
+ }
197
209
  if (pwa !== undefined) {
198
210
  await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
199
211
  // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
@@ -252,6 +264,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
252
264
  routes.push({
253
265
  path: entry.path,
254
266
  jsBytes: measured.jsBytes,
267
+ frameworkJsBytes: measured.frameworkBytes,
255
268
  ...(chain === undefined ? {} : { heaviestChain: chain }),
256
269
  });
257
270
  } catch (error) {
@@ -314,6 +327,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
314
327
  heaviest = {
315
328
  path: entry.path,
316
329
  jsBytes: measured.jsBytes,
330
+ frameworkJsBytes: measured.frameworkBytes,
317
331
  ...(chain === undefined ? {} : { heaviestChain: chain }),
318
332
  };
319
333
  }
@@ -334,9 +348,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
334
348
  styles,
335
349
  documents,
336
350
  });
351
+ // `sw.js` only: `serviceWorker.register` IS `serviceWorkerRegistration()`, already on disk above
352
+ // and identical by construction. A second writer of one path is how the two could ever disagree.
337
353
  if (serviceWorker !== undefined) {
338
354
  await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
339
- await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
340
355
  }
341
356
  const stats = await writeBuildStats(options.root, { routes });
342
357
  // Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does