@ultimat3/cli 19.4.0 → 20.0.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.
@@ -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
+ }