@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/cmd-shot.ts
CHANGED
|
@@ -12,7 +12,16 @@ 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
14
|
import { appBrowser } from './browser-launcher';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
islandShot,
|
|
17
|
+
islandShotResult,
|
|
18
|
+
islandSweep,
|
|
19
|
+
islandSweepResult,
|
|
20
|
+
refuseRouteWithIsland,
|
|
21
|
+
refuseSweepWithIsland,
|
|
22
|
+
refuseSweepWithRoute,
|
|
23
|
+
refuseSweepWithState,
|
|
24
|
+
} from './cmd-shot-island';
|
|
16
25
|
import type { CliCommand, CommandContext } from './command';
|
|
17
26
|
import { BadFlagError, MissingPositionalError } from './errors';
|
|
18
27
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
@@ -275,9 +284,9 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
|
275
284
|
export const shotCommand: CliCommand = {
|
|
276
285
|
spec: {
|
|
277
286
|
name: 'shot',
|
|
278
|
-
summary: 'photograph one route,
|
|
287
|
+
summary: 'photograph one route, one island in a state it declares, or every island in the app',
|
|
279
288
|
usage:
|
|
280
|
-
'x shot <route> | --island <name> [--state <id>] [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
289
|
+
'x shot <route> | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
281
290
|
requiresApp: true,
|
|
282
291
|
flags: [
|
|
283
292
|
{ name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
|
|
@@ -305,6 +314,15 @@ export const shotCommand: CliCommand = {
|
|
|
305
314
|
type: 'string',
|
|
306
315
|
summary: 'one declared state of that island, not all of them',
|
|
307
316
|
},
|
|
317
|
+
// Its own SPELLING and never `--island` with no value: the parser refuses a bare `--island`
|
|
318
|
+
// ("expects a value") and `--island=` is an empty name, so "every island" had no form a
|
|
319
|
+
// reader could type that could not be read as a mistyped one. A boolean cannot be confused
|
|
320
|
+
// with a name, and `x shot --all-islands` says what it does beside `x shot --island <name>`.
|
|
321
|
+
{
|
|
322
|
+
name: 'all-islands',
|
|
323
|
+
type: 'boolean',
|
|
324
|
+
summary: 'every island in the app, in every state it declares, plus an index.md',
|
|
325
|
+
},
|
|
308
326
|
],
|
|
309
327
|
},
|
|
310
328
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -312,11 +330,20 @@ export const shotCommand: CliCommand = {
|
|
|
312
330
|
// Every value read before anything boots: a typo must not cost a browser and a dev server to
|
|
313
331
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
314
332
|
const island = flagString(ctx.args, 'island');
|
|
333
|
+
const state = flagString(ctx.args, 'state');
|
|
315
334
|
const positional = ctx.args.positionals[0];
|
|
335
|
+
const sweep = flagBool(ctx.args, 'all-islands');
|
|
336
|
+
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
337
|
+
// subjects has a belief about which one runs, and half of them would be wrong.
|
|
338
|
+
if (sweep && island !== undefined && island !== '') refuseSweepWithIsland(island);
|
|
339
|
+
if (sweep && positional !== undefined) refuseSweepWithRoute(positional);
|
|
340
|
+
// A state id is one manifest's vocabulary, so it cannot mean anything across every island.
|
|
341
|
+
if (sweep && state !== undefined && state !== '') refuseSweepWithState(state);
|
|
316
342
|
if (island !== undefined && island !== '' && positional !== undefined) {
|
|
317
343
|
refuseRouteWithIsland(positional, island);
|
|
318
344
|
}
|
|
319
|
-
const
|
|
345
|
+
const component = sweep || (island !== undefined && island !== '');
|
|
346
|
+
const route = component ? '' : readRoute(positional);
|
|
320
347
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
321
348
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
322
349
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
@@ -332,24 +359,22 @@ export const shotCommand: CliCommand = {
|
|
|
332
359
|
});
|
|
333
360
|
const out = flagString(ctx.args, 'out');
|
|
334
361
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
362
|
+
const shared = {
|
|
363
|
+
root,
|
|
364
|
+
...(out === undefined ? {} : { out }),
|
|
365
|
+
settleMs,
|
|
366
|
+
timeoutMs,
|
|
367
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
368
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
369
|
+
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
372
|
+
boot,
|
|
373
|
+
};
|
|
374
|
+
if (sweep) return islandSweepResult(await islandSweep(shared));
|
|
335
375
|
if (island !== undefined && island !== '') {
|
|
336
376
|
return islandShotResult(
|
|
337
|
-
await islandShot({
|
|
338
|
-
root,
|
|
339
|
-
island,
|
|
340
|
-
...(flagString(ctx.args, 'state') === undefined
|
|
341
|
-
? {}
|
|
342
|
-
: { state: flagString(ctx.args, 'state') }),
|
|
343
|
-
...(out === undefined ? {} : { out }),
|
|
344
|
-
settleMs,
|
|
345
|
-
timeoutMs,
|
|
346
|
-
...(executablePath === undefined ? {} : { executablePath }),
|
|
347
|
-
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
348
|
-
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
349
|
-
? {}
|
|
350
|
-
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
351
|
-
boot,
|
|
352
|
-
}),
|
|
377
|
+
await islandShot({ ...shared, island, ...(state === undefined ? {} : { state }) }),
|
|
353
378
|
);
|
|
354
379
|
}
|
|
355
380
|
// Resolved before the boot for the same reason: an app with no browser installed must not pay
|
package/src/index.ts
CHANGED
|
@@ -67,8 +67,14 @@ export type { DeployPlan } from './cmd-deploy';
|
|
|
67
67
|
export { deployCommand, planDeploy } from './cmd-deploy';
|
|
68
68
|
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
69
69
|
export { devCommand, startDev } from './cmd-dev';
|
|
70
|
-
export type { DoctorProbe } from './cmd-doctor';
|
|
71
|
-
export {
|
|
70
|
+
export type { DoctorProbe, EmbeddedDatabase } from './cmd-doctor';
|
|
71
|
+
export {
|
|
72
|
+
doctorCommand,
|
|
73
|
+
ENV_DEVELOPMENT,
|
|
74
|
+
embeddedDatabaseFinding,
|
|
75
|
+
probeFor,
|
|
76
|
+
runDoctor,
|
|
77
|
+
} from './cmd-doctor';
|
|
72
78
|
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
73
79
|
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
74
80
|
export type { GenerateOptions, Generator } from './cmd-generate';
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// One picture: the assertions that must hold before the shutter opens, the rectangle it opens on,
|
|
2
|
+
// and the one session it opens in. Split from `island-shot.ts` so that file holds the RUN — which
|
|
3
|
+
// islands, which order, which artifacts — and this one holds what happens at a single address.
|
|
4
|
+
|
|
5
|
+
// why: no Bun native joins a path; `Bun.write` takes one already joined.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { finiteCount } from '@ultimat3/core';
|
|
8
|
+
import type { CaptureClip, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
9
|
+
import { systemScrapeClock } from '@ultimat3/scraping';
|
|
10
|
+
import type { IslandShotTarget, IslandViewport } from '@ultimat3/testing';
|
|
11
|
+
import { islandStatesFile } from '@ultimat3/testing';
|
|
12
|
+
import { ISLAND_HARNESS_PATH } from './island-harness';
|
|
13
|
+
import { readinessProbe } from './island-harness-script';
|
|
14
|
+
import { IslandRequestUnstubbedError, IslandUnphotographableError } from './island-shot-errors';
|
|
15
|
+
import type { IslandReadiness, IslandStateShot } from './island-verdict';
|
|
16
|
+
import { parseReadiness } from './island-verdict';
|
|
17
|
+
import type { ShotServer } from './shot-server';
|
|
18
|
+
import { allowHostsFrom } from './shot-server';
|
|
19
|
+
import { SETTLE_POLL_MS, settleReadiness } from './shot-settle';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A backstop and not a quality bar: it catches the answers that are not an image at all — a driver
|
|
23
|
+
* that hands back a handshake, an empty buffer, a PNG signature with nothing behind it. A real
|
|
24
|
+
* capture of any viewport clears it by an order of magnitude.
|
|
25
|
+
*/
|
|
26
|
+
export const MIN_SHOT_BYTES = 512;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Page pixels kept on every side of the crop target.
|
|
30
|
+
*
|
|
31
|
+
* The clip was the readiness box EXACTLY, with no margin, and a pixel-tight rectangle shaves off
|
|
32
|
+
* the half of a component's appearance that lives outside its border box: a `box-shadow`, an
|
|
33
|
+
* `outline`, a focus ring, a hairline border that lands on a subpixel. The reviewer then reads a
|
|
34
|
+
* component with no elevation as flat, which is a change the component never made.
|
|
35
|
+
*
|
|
36
|
+
* Small deliberately, and clamped to the page: the frame is still the COMPONENT, not the viewport
|
|
37
|
+
* it happens to sit in, which is the crop this feature exists for.
|
|
38
|
+
*/
|
|
39
|
+
export const ISLAND_CROP_MARGIN_PX = 8;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A browser sized to one viewport. A FUNCTION and not a driver, because the shipped browser port
|
|
43
|
+
* takes the viewport as a LAUNCH option (`LocalBrowserOptions.options`) and a state declares its
|
|
44
|
+
* own — so "photograph this state at 480x320" is a different browser, not a different call.
|
|
45
|
+
*/
|
|
46
|
+
export type IslandBrowser = (viewport: IslandViewport) => Promise<ScrapeDriver>;
|
|
47
|
+
|
|
48
|
+
interface Refusal {
|
|
49
|
+
readonly reason: string;
|
|
50
|
+
readonly fix: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hostFix = (target: IslandShotTarget): string =>
|
|
54
|
+
`in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
|
|
55
|
+
|
|
56
|
+
const cropFix = (target: IslandShotTarget): string =>
|
|
57
|
+
`in ${islandStatesFile(target.island)} set target to a selector the component really renders, or widen the state's props`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The first assertion that does not hold, in the order a failure is most useful in — or
|
|
61
|
+
* `undefined`, which is the only way a shutter opens. Each clause names a fact the picture would
|
|
62
|
+
* have hidden rather than shown: an absent harness is a dev server that does not know this island,
|
|
63
|
+
* an unattached host photographs the frame's background, a zero box photographs whatever is behind
|
|
64
|
+
* it, and an empty box is a component that mounted and rendered nothing. Every one of them comes
|
|
65
|
+
* out as a plausible image of the wrong thing.
|
|
66
|
+
*
|
|
67
|
+
* A value and not a throw, so the whole ladder is one pure function a test can walk.
|
|
68
|
+
*/
|
|
69
|
+
export function photographFault(
|
|
70
|
+
target: IslandShotTarget,
|
|
71
|
+
seen: IslandReadiness | null,
|
|
72
|
+
): Refusal | undefined {
|
|
73
|
+
const settle = `x shot --island ${target.name} --settle 8000 --json`;
|
|
74
|
+
if (seen === null) {
|
|
75
|
+
return {
|
|
76
|
+
reason: 'answered no readiness probe at all, so nothing about the page can be asserted',
|
|
77
|
+
fix: `x shot --island ${target.name} --state ${target.state} --timeout 60000 --json`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!seen.harness) {
|
|
81
|
+
return {
|
|
82
|
+
reason:
|
|
83
|
+
'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',
|
|
84
|
+
fix: 'restart x dev, then run this command again',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!seen.attached) {
|
|
88
|
+
return { reason: 'rendered no [data-x-island] host element', fix: hostFix(target) };
|
|
89
|
+
}
|
|
90
|
+
if (seen.failed !== null) {
|
|
91
|
+
return {
|
|
92
|
+
reason: `mounted and its mount() REJECTED: ${seen.failed}`,
|
|
93
|
+
fix: `x shot --island ${target.name} --state ${target.state} --json # the verdict carries the throw and its frame`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!seen.mounted) {
|
|
97
|
+
return { reason: 'did not finish mounting inside the settle window', fix: settle };
|
|
98
|
+
}
|
|
99
|
+
if (!seen.ready) {
|
|
100
|
+
return {
|
|
101
|
+
reason:
|
|
102
|
+
'never went quiet: something kept starting or settling requests for the whole settle window',
|
|
103
|
+
fix: settle,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (seen.box.width === 0 || seen.box.height === 0) {
|
|
107
|
+
return {
|
|
108
|
+
reason: `has a ${seen.box.width}x${seen.box.height} bounding box, so the picture would be of whatever is behind it`,
|
|
109
|
+
fix: cropFix(target),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (!seen.filled) {
|
|
113
|
+
return {
|
|
114
|
+
reason:
|
|
115
|
+
'has a box with no child elements and no text in it — it mounted and rendered nothing',
|
|
116
|
+
fix: cropFix(target),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The capture rectangle for a readiness answer, in PAGE coordinates: the crop target's own box,
|
|
124
|
+
* translated out of the viewport coordinates `getBoundingClientRect()` answers in, then grown by
|
|
125
|
+
* `margin` on every side and CLAMPED to the document.
|
|
126
|
+
*
|
|
127
|
+
* Two invariants, and the clamp exists for the first: the rectangle never starts in negative space
|
|
128
|
+
* and never runs past the page, because coordinates no content is at are a picture with a blank
|
|
129
|
+
* band in it that looks like a component with whitespace. And the margin may only ever make the
|
|
130
|
+
* frame BIGGER — `Math.max` against the box's own size — so a document smaller than its own
|
|
131
|
+
* content can never crop the component this run is of.
|
|
132
|
+
*
|
|
133
|
+
* `seen` is non-null and its box has area by the time this is reached: `photographFault` refuses
|
|
134
|
+
* both above, and BEFORE the shutter, because a zero-area clip is `X_SCRAPE_CAPTURE_CLIP_EMPTY`
|
|
135
|
+
* from the port — a worse report of the same fault than "rendered nothing". The `?? 0` pairs are
|
|
136
|
+
* the parser's floor and not a second opinion.
|
|
137
|
+
*/
|
|
138
|
+
export function clipFor(seen: IslandReadiness | null, margin: number): CaptureClip {
|
|
139
|
+
const x = (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0);
|
|
140
|
+
const y = (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0);
|
|
141
|
+
const width = seen?.box.width ?? 0;
|
|
142
|
+
const height = seen?.box.height ?? 0;
|
|
143
|
+
const left = Math.max(0, x - margin);
|
|
144
|
+
const top = Math.max(0, y - margin);
|
|
145
|
+
const right = Math.min(seen?.page.width ?? x + width, x + width + margin);
|
|
146
|
+
const bottom = Math.min(seen?.page.height ?? y + height, y + height + margin);
|
|
147
|
+
return {
|
|
148
|
+
x: left,
|
|
149
|
+
y: top,
|
|
150
|
+
width: Math.max(width, right - left),
|
|
151
|
+
height: Math.max(height, bottom - top),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface IslandCaptureRun {
|
|
156
|
+
readonly outDir: string;
|
|
157
|
+
readonly driver: IslandBrowser;
|
|
158
|
+
readonly settleMs: number;
|
|
159
|
+
readonly timeoutMs: number;
|
|
160
|
+
readonly extraHosts?: string | undefined;
|
|
161
|
+
readonly cropMarginPx?: number | undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const quietly = async (stop: () => Promise<void>): Promise<void> => {
|
|
165
|
+
await stop().catch(() => undefined);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One address, one full page load, one picture. Never a client-side switch between states: the
|
|
170
|
+
* previous state's fixtures, its resolved resources and its mounted DOM would ride into the next
|
|
171
|
+
* picture, which is the one way a screenshot tool can lie about its own subject.
|
|
172
|
+
*
|
|
173
|
+
* A session PER TARGET, and it costs a browser launch each: `page.console()` and `page.pageErrors()`
|
|
174
|
+
* are bounded rings over the whole SESSION, so a shared one would file state A's console errors
|
|
175
|
+
* under state B — and per-state attribution is the half of this artifact that gates.
|
|
176
|
+
*/
|
|
177
|
+
export async function captureIslandState(
|
|
178
|
+
options: IslandCaptureRun,
|
|
179
|
+
server: ShotServer,
|
|
180
|
+
target: IslandShotTarget,
|
|
181
|
+
floor: number,
|
|
182
|
+
): Promise<IslandStateShot> {
|
|
183
|
+
const url = new URL(`${ISLAND_HARNESS_PATH}${target.query}`, server.url).toString();
|
|
184
|
+
let session: ScrapeSession | undefined;
|
|
185
|
+
try {
|
|
186
|
+
const driver = await options.driver(target.viewport);
|
|
187
|
+
session = await driver.open({
|
|
188
|
+
name: 'x shot --island',
|
|
189
|
+
rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
|
|
190
|
+
clock: systemScrapeClock,
|
|
191
|
+
timeoutMs: options.timeoutMs,
|
|
192
|
+
});
|
|
193
|
+
const page = session.page;
|
|
194
|
+
// BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
|
|
195
|
+
// media query, and the theme a component resolves on mount is the one it will keep.
|
|
196
|
+
//
|
|
197
|
+
// This is the INPUT and the harness's `data-theme` attribute is the OUTCOME, and both are set
|
|
198
|
+
// deliberately. The attribute is right for a component that READS a theme it does not own; the
|
|
199
|
+
// preference is the only thing that reaches one that RESOLVES its own. `examples/dummy`'s
|
|
200
|
+
// settings island is the second kind — its state's `theme` prop is `'system'`, so on mount it
|
|
201
|
+
// DELETES the attribute the harness set, both pictures fall through to `:root`, and the two
|
|
202
|
+
// came back byte-identical with the same md5 (issue #338). Re-setting the attribute after
|
|
203
|
+
// readiness is not the repair: it photographs a state the component would never reach.
|
|
204
|
+
await page.colorScheme(target.theme);
|
|
205
|
+
await page.goto(url, { timeout: options.timeoutMs });
|
|
206
|
+
const expression = readinessProbe(target.target ?? '[data-x-island]');
|
|
207
|
+
const probe = (): Promise<IslandReadiness | null> =>
|
|
208
|
+
page
|
|
209
|
+
.evaluate(expression)
|
|
210
|
+
.then(parseReadiness)
|
|
211
|
+
.catch(() => null);
|
|
212
|
+
const seen = await settleReadiness(probe, {
|
|
213
|
+
windowMs: options.settleMs,
|
|
214
|
+
pollMs: SETTLE_POLL_MS,
|
|
215
|
+
});
|
|
216
|
+
// Ahead of every other assertion about the picture: a component whose fetch went unanswered
|
|
217
|
+
// paints its own loading branch, and the picture then shows a fixture gap dressed up as a
|
|
218
|
+
// real component state. The list is the page's own, published by the seal.
|
|
219
|
+
if (seen !== null && seen.unstubbed.length > 0) {
|
|
220
|
+
throw new IslandRequestUnstubbedError({
|
|
221
|
+
island: target.island,
|
|
222
|
+
state: target.state,
|
|
223
|
+
requests: seen.unstubbed,
|
|
224
|
+
statesFile: islandStatesFile(target.island),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const fault = photographFault(target, seen);
|
|
228
|
+
if (fault !== undefined) {
|
|
229
|
+
throw new IslandUnphotographableError({
|
|
230
|
+
island: target.island,
|
|
231
|
+
state: target.state,
|
|
232
|
+
theme: target.theme,
|
|
233
|
+
...fault,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// The COMPONENT, not the viewport it happens to sit in — the crop this feature was designed
|
|
237
|
+
// around, and which nothing passed until 2026-08-26 (issue #338).
|
|
238
|
+
//
|
|
239
|
+
// The clip ALONE. `fullPage: false` beside it is accepted — `assertCaptureFraming` refuses only
|
|
240
|
+
// `=== true`, and `cdp-target.ts` sends `{ clip }` and nothing else either way — but it is a
|
|
241
|
+
// field that says nothing: the two are exclusive, and spelling out the default of the one you
|
|
242
|
+
// did not ask for reads as a choice.
|
|
243
|
+
// `??` screens null and undefined and NOTHING else, so `cropMarginPx: NaN` reached `clipFor`,
|
|
244
|
+
// where every comparison against it is false and the clamp silently answered `NaN` — a clip
|
|
245
|
+
// the browser rejects, for a margin nobody typed. Same shape as `Skeleton`'s `lines: NaN`,
|
|
246
|
+
// which rendered no placeholder at all: the screened value is what makes the default a bound.
|
|
247
|
+
const clip = clipFor(
|
|
248
|
+
seen,
|
|
249
|
+
finiteCount('x shot --island', 'cropMarginPx', options.cropMarginPx ?? ISLAND_CROP_MARGIN_PX),
|
|
250
|
+
);
|
|
251
|
+
const bytes = await page.screenshot({ clip });
|
|
252
|
+
if (bytes.byteLength < floor) {
|
|
253
|
+
throw new IslandUnphotographableError({
|
|
254
|
+
island: target.island,
|
|
255
|
+
state: target.state,
|
|
256
|
+
theme: target.theme,
|
|
257
|
+
reason: `produced ${bytes.byteLength} bytes, under the ${floor}-byte floor — that is not an image`,
|
|
258
|
+
fix: `x shot --island ${target.name} --browser /usr/bin/chromium --json`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
await Bun.write(join(options.outDir, target.file), bytes);
|
|
262
|
+
return {
|
|
263
|
+
state: target.state,
|
|
264
|
+
theme: target.theme,
|
|
265
|
+
file: target.file,
|
|
266
|
+
bytes: bytes.byteLength,
|
|
267
|
+
box: seen?.box ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
268
|
+
mounted: seen?.mounted === true,
|
|
269
|
+
unstubbed: seen?.unstubbed ?? [],
|
|
270
|
+
console: page.console(),
|
|
271
|
+
pageErrors: page.pageErrors(),
|
|
272
|
+
overflow: seen?.overflow ?? { x: false, y: false },
|
|
273
|
+
};
|
|
274
|
+
} finally {
|
|
275
|
+
const open = session;
|
|
276
|
+
if (open !== undefined) await quietly(() => open.close());
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -151,4 +151,13 @@ export const readinessProbe = (selector: string): string =>
|
|
|
151
151
|
// below the fold scrolls, and a clip taken from the raw rect then crops the wrong band with
|
|
152
152
|
// nothing to report it. The offset is returned rather than added here so `box` keeps meaning
|
|
153
153
|
// exactly what the verdict already publishes.
|
|
154
|
-
'scroll:{x:Math.round(window.scrollX||0),y:Math.round(window.scrollY||0)}
|
|
154
|
+
'scroll:{x:Math.round(window.scrollX||0),y:Math.round(window.scrollY||0)},' +
|
|
155
|
+
// Content wider or taller than the box it sits in. The same round trip the readiness answer
|
|
156
|
+
// already costs, because a second probe would be a second moment — and a fact measured after the
|
|
157
|
+
// shutter is a fact about a different page. RECORDED, never gating: `stateShotOk` reads neither.
|
|
158
|
+
'overflow:{x:box?box.scrollWidth>box.clientWidth:false,y:box?box.scrollHeight>box.clientHeight:false},' +
|
|
159
|
+
// The document's own extent, which is what a crop margin is clamped against. Read here rather
|
|
160
|
+
// than assumed from the viewport: the page is what a capture clip's coordinates are in, and a
|
|
161
|
+
// margin running off it asks for a rectangle no content is at.
|
|
162
|
+
'page:{width:Math.round(document.documentElement.scrollWidth),' +
|
|
163
|
+
'height:Math.round(document.documentElement.scrollHeight)}};})()';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `.x/shot/island/index.md` — the one file an agent opens after a capture, instead of guessing at
|
|
2
|
+
// forty PNGs. A PURE renderer over (manifests, targets, verdicts): no disk, no browser, no server,
|
|
3
|
+
// so every rule about what the index must carry is a unit test. The caller does the writing.
|
|
4
|
+
|
|
5
|
+
import type { IslandShotTarget, IslandState, IslandStatesManifest } from '@ultimat3/testing';
|
|
6
|
+
import type { IslandStateShot, IslandVerdict } from './island-verdict';
|
|
7
|
+
|
|
8
|
+
/** Markdown, because the reader is a coding agent: it greps, and it renders in a review. */
|
|
9
|
+
export const ISLAND_INDEX = 'index.md';
|
|
10
|
+
|
|
11
|
+
export interface IslandIndexPair {
|
|
12
|
+
readonly manifest: IslandStatesManifest;
|
|
13
|
+
readonly verdict: IslandVerdict;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IslandIndexInput {
|
|
17
|
+
readonly pairs: readonly IslandIndexPair[];
|
|
18
|
+
readonly capturedAt: string;
|
|
19
|
+
/**
|
|
20
|
+
* What the capture could not see, ALREADY rendered — `IslandVerdict.blind`, handed in rather
|
|
21
|
+
* than re-derived here. A blind-spot list this module worded itself would be a second answer to
|
|
22
|
+
* the question the verdict already publishes, and the two would drift the first time one moved.
|
|
23
|
+
*/
|
|
24
|
+
readonly blind: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The pictures one state was owed, in declaration order, whatever landed on disk. */
|
|
28
|
+
const targetsOf = (verdict: IslandVerdict, state: string): readonly IslandShotTarget[] =>
|
|
29
|
+
verdict.expected.filter((target) => target.state === state);
|
|
30
|
+
|
|
31
|
+
const shotsOf = (verdict: IslandVerdict, state: string): readonly IslandStateShot[] =>
|
|
32
|
+
verdict.shots.filter((shot) => shot.state === state);
|
|
33
|
+
|
|
34
|
+
const countOf = (
|
|
35
|
+
level: IslandStateShot['console'][number]['level'],
|
|
36
|
+
shot: IslandStateShot,
|
|
37
|
+
): number => shot.console.filter((line) => line.level === level).length;
|
|
38
|
+
|
|
39
|
+
const plural = (count: number, one: string): string => `${count} ${one}${count === 1 ? '' : 's'}`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What went wrong with this state, in the reader's own terms — never a code. Each clause is a
|
|
43
|
+
* different repair, so they are listed rather than collapsed into "failed".
|
|
44
|
+
*/
|
|
45
|
+
function problemsOf(verdict: IslandVerdict, state: string): readonly string[] {
|
|
46
|
+
const problems: string[] = [];
|
|
47
|
+
const taken = new Set(shotsOf(verdict, state).map((shot) => shot.file));
|
|
48
|
+
for (const target of targetsOf(verdict, state)) {
|
|
49
|
+
if (!taken.has(target.file)) problems.push(`no picture (${target.theme})`);
|
|
50
|
+
}
|
|
51
|
+
for (const shot of shotsOf(verdict, state)) {
|
|
52
|
+
if (!shot.mounted) problems.push(`never mounted (${shot.theme})`);
|
|
53
|
+
if (shot.unstubbed.length > 0) {
|
|
54
|
+
problems.push(`${plural(shot.unstubbed.length, 'request')} no stub answers`);
|
|
55
|
+
}
|
|
56
|
+
if (shot.pageErrors.length > 0) {
|
|
57
|
+
problems.push(plural(shot.pageErrors.length, 'uncaught exception'));
|
|
58
|
+
}
|
|
59
|
+
const errors = countOf('error', shot);
|
|
60
|
+
if (errors > 0) problems.push(plural(errors, 'console error'));
|
|
61
|
+
}
|
|
62
|
+
return [...new Set(problems)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Recorded and NOT gating, which is the whole reason it is a separate line: a warning that fails a
|
|
67
|
+
* run is a warning an author switches off, and an overflowing box is a fact a PNG often cannot
|
|
68
|
+
* show at all. `stateShotOk` reads neither.
|
|
69
|
+
*/
|
|
70
|
+
function notesOf(verdict: IslandVerdict, state: string): readonly string[] {
|
|
71
|
+
const shots = shotsOf(verdict, state);
|
|
72
|
+
const warnings = shots.reduce((total, shot) => total + countOf('warn', shot), 0);
|
|
73
|
+
const notes: string[] = [];
|
|
74
|
+
if (warnings > 0) notes.push(plural(warnings, 'console warning'));
|
|
75
|
+
const overflows = shots.filter((shot) => shot.overflow.x || shot.overflow.y);
|
|
76
|
+
for (const shot of overflows) {
|
|
77
|
+
const axes = [shot.overflow.x ? 'horizontally' : '', shot.overflow.y ? 'vertically' : '']
|
|
78
|
+
.filter((axis) => axis !== '')
|
|
79
|
+
.join(' and ');
|
|
80
|
+
notes.push(`content overflows the crop target ${axes} (${shot.theme})`);
|
|
81
|
+
}
|
|
82
|
+
return notes;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stateSection = (
|
|
86
|
+
manifest: IslandStatesManifest,
|
|
87
|
+
verdict: IslandVerdict,
|
|
88
|
+
state: IslandState,
|
|
89
|
+
): readonly string[] => {
|
|
90
|
+
const problems = problemsOf(verdict, state.id);
|
|
91
|
+
const notes = notesOf(verdict, state.id);
|
|
92
|
+
const lines = [`### \`${state.id}\` — ${state.title}`, ''];
|
|
93
|
+
// The note is why a reviewer knows what they are looking at: a state a running app will not
|
|
94
|
+
// produce on request has no other explanation anywhere in the artifact.
|
|
95
|
+
if (state.note !== undefined) lines.push(state.note, '');
|
|
96
|
+
for (const target of targetsOf(verdict, state.id)) {
|
|
97
|
+
lines.push(`- ${target.theme}: \`${target.file}\``);
|
|
98
|
+
}
|
|
99
|
+
lines.push(
|
|
100
|
+
`- verdict: ${problems.length === 0 ? 'ok' : `FAILED — ${problems.join('; ')}`}`,
|
|
101
|
+
...notes.map((note) => `- note: ${note}`),
|
|
102
|
+
'',
|
|
103
|
+
`- re-run: \`x shot --island ${manifest.name} --state ${state.id} --json\``,
|
|
104
|
+
'',
|
|
105
|
+
);
|
|
106
|
+
return lines;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const islandSection = (pair: IslandIndexPair): readonly string[] => {
|
|
110
|
+
const photographed = new Set(pair.verdict.expected.map((target) => target.state));
|
|
111
|
+
return [
|
|
112
|
+
`## ${pair.manifest.name} — ${pair.verdict.ok ? 'ok' : 'FAILED'}`,
|
|
113
|
+
'',
|
|
114
|
+
`source: \`${pair.manifest.island}\``,
|
|
115
|
+
'',
|
|
116
|
+
...pair.manifest.states
|
|
117
|
+
.filter((state) => photographed.has(state.id))
|
|
118
|
+
.flatMap((state) => stateSection(pair.manifest, pair.verdict, state)),
|
|
119
|
+
];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The whole index. The header states the three counts a reader needs before scrolling — how many
|
|
124
|
+
* islands, how many states, how many pictures — and the command that reproduces the run, because
|
|
125
|
+
* an artifact that cannot be regenerated is one nobody trusts a second time.
|
|
126
|
+
*/
|
|
127
|
+
export function renderIslandIndex(input: IslandIndexInput): string {
|
|
128
|
+
const states = input.pairs.reduce(
|
|
129
|
+
(total, pair) => total + new Set(pair.verdict.expected.map((one) => one.state)).size,
|
|
130
|
+
0,
|
|
131
|
+
);
|
|
132
|
+
const pictures = input.pairs.reduce((total, pair) => total + pair.verdict.expected.length, 0);
|
|
133
|
+
const failed = input.pairs.filter((pair) => !pair.verdict.ok).map((pair) => pair.manifest.name);
|
|
134
|
+
const lines = [
|
|
135
|
+
`# island states — ${plural(input.pairs.length, 'island')}, ${plural(states, 'state')}, ${plural(pictures, 'picture')}`,
|
|
136
|
+
'',
|
|
137
|
+
`Captured ${input.capturedAt}. Every picture is the component's own box, from a real browser,`,
|
|
138
|
+
'in a state a running app will not produce on request.',
|
|
139
|
+
'',
|
|
140
|
+
failed.length === 0
|
|
141
|
+
? 'Every state photographed cleanly.'
|
|
142
|
+
: `FAILED: ${failed.join(', ')} — each one names its reason below.`,
|
|
143
|
+
'',
|
|
144
|
+
'Re-run everything: `x shot --all-islands --json`',
|
|
145
|
+
'Re-run one island: `x shot --island <name> --json`',
|
|
146
|
+
'Re-run one state: `x shot --island <name> --state <id> --json`',
|
|
147
|
+
'',
|
|
148
|
+
'What this capture cannot see:',
|
|
149
|
+
'',
|
|
150
|
+
...input.blind.map((blind) => `- ${blind}`),
|
|
151
|
+
'',
|
|
152
|
+
...input.pairs.flatMap(islandSection),
|
|
153
|
+
];
|
|
154
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
155
|
+
}
|