@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.
- package/CLAUDE.md +56 -3
- package/package.json +29 -29
- package/src/budgets.ts +47 -2
- package/src/cmd-doctor.ts +97 -3
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/index.ts +8 -2
- package/src/island-capture.ts +278 -0
- package/src/island-harness-script.ts +10 -1
- package/src/island-shot-index.ts +155 -0
- package/src/island-shot.ts +106 -276
- package/src/island-verdict.ts +90 -1
- package/src/messages.ts +2 -1
- package/src/prerender.ts +16 -1
- package/src/sw-artifacts.ts +9 -2
- package/src/templates/github/ci.yml.ts +74 -0
- package/src/templates/guard-animated-layout-property.ts +269 -0
- package/src/templates/guard-focus-visible.ts +240 -0
- package/src/templates/guard-image-dimensions.ts +225 -0
- package/src/templates/guard-island-without-states.ts +128 -0
- package/src/templates/guard-raw-colour.ts +112 -8
- package/src/templates/guard-semantic-interactive.ts +244 -0
- package/src/templates/guard-untranslated-string.ts +54 -6
- package/src/templates/island.ts +44 -1
- package/src/templates/resource-form-island.ts +67 -0
- package/src/templates/scaffold-claude-agents.ts +10 -1
- package/src/templates/scaffold-claude-commands.ts +20 -9
- package/src/templates/scaffold-docs.ts +40 -5
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +22 -2
package/src/island-shot.ts
CHANGED
|
@@ -1,270 +1,60 @@
|
|
|
1
|
-
// `x shot --island
|
|
2
|
-
// a time.
|
|
3
|
-
// computed from the states
|
|
4
|
-
// that list afterwards, so "produced nothing and exited 0"
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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:
|
|
48
|
-
* refuses a manifest with no states, so the only way this expansion comes
|
|
49
|
-
* naming a state that does not exist —
|
|
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
|
|
282
|
-
* then refuse if any declared picture is absent.
|
|
283
|
-
*
|
|
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
|
|
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
|
|
292
|
-
|
|
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('
|
|
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
|
|
305
|
-
// the
|
|
306
|
-
// below is what turns those reasons into
|
|
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
|
|
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
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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:
|
|
336
|
-
missing,
|
|
337
|
-
expected:
|
|
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
|
-
|
|
170
|
+
const dir = join(options.outDir, manifest.name);
|
|
171
|
+
return { verdict, dir, verdictFile: join(dir, ISLAND_VERDICT), indexFile: artifacts.indexFile };
|
|
342
172
|
}
|
package/src/island-verdict.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|