@ultimat3/cli 11.1.0 → 11.2.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,303 @@
1
+ // `x shot --island <name>` — one component, in the states it declares, photographed one address at
2
+ // a time. The order matters and is the whole design: the complete expected picture list is
3
+ // computed from the states file BEFORE a browser exists, and what landed on disk is diffed against
4
+ // that list afterwards, so "produced nothing and exited 0" is a state this command can refuse.
5
+
6
+ // why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
7
+ import { join } from 'node:path';
8
+ import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
9
+ import { systemScrapeClock } from '@ultimat3/scraping';
10
+ import type { IslandShotTarget, IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
11
+ import { islandShotTargets, islandStatesFile } from '@ultimat3/testing';
12
+ import { ISLAND_HARNESS_PATH } from './island-harness';
13
+ import { readinessProbe } from './island-harness-script';
14
+ import {
15
+ IslandRequestUnstubbedError,
16
+ IslandShotsMissingError,
17
+ IslandUnphotographableError,
18
+ } from './island-shot-errors';
19
+ import type { IslandArtifacts, IslandReadiness, IslandStateShot } from './island-verdict';
20
+ import { buildIslandVerdict, islandVerdictJson, parseReadiness } from './island-verdict';
21
+ import type { ShotServer } from './shot-server';
22
+ import { allowHostsFrom } from './shot-server';
23
+ import { SETTLE_POLL_MS, settleReadiness } from './shot-settle';
24
+
25
+ /** Where a component's pictures land, under the same `.x/shot` tree a route's picture does. */
26
+ export const ISLAND_SHOT_DIR = 'island';
27
+ export const ISLAND_VERDICT = 'verdict.json';
28
+
29
+ /**
30
+ * A backstop and not a quality bar: it catches the answers that are not an image at all — a driver
31
+ * that hands back a handshake, an empty buffer, a PNG signature with nothing behind it. A real
32
+ * capture of any viewport clears it by an order of magnitude.
33
+ */
34
+ export const MIN_SHOT_BYTES = 512;
35
+
36
+ /**
37
+ * A browser sized to one viewport. A FUNCTION and not a driver, because the shipped browser port
38
+ * takes the viewport as a LAUNCH option (`LocalBrowserOptions.options`) and a state declares its
39
+ * own — so "photograph this state at 480x320" is a different browser, not a different call.
40
+ */
41
+ export type IslandBrowser = (viewport: IslandViewport) => Promise<ScrapeDriver>;
42
+
43
+ export interface IslandShotRun {
44
+ readonly manifest: IslandStatesManifest;
45
+ /**
46
+ * `--state`, or every declared state when absent. The caller validates it: `defineIslandStates`
47
+ * refuses a manifest with no states, so the only way this expansion comes back empty is a filter
48
+ * naming a state that does not exist — which is a typo, and belongs to the flag that made it.
49
+ */
50
+ readonly state?: string | undefined;
51
+ readonly outDir: string;
52
+ readonly driver: IslandBrowser;
53
+ readonly boot: () => Promise<ShotServer>;
54
+ readonly settleMs: number;
55
+ readonly timeoutMs: number;
56
+ readonly extraHosts?: string | undefined;
57
+ readonly minBytes?: number | undefined;
58
+ readonly now?: (() => Date) | undefined;
59
+ }
60
+
61
+ const quietly = async (stop: () => Promise<void>): Promise<void> => {
62
+ await stop().catch(() => undefined);
63
+ };
64
+
65
+ /**
66
+ * Every assertion that has to hold before a shutter opens, in the order a failure is most useful
67
+ * in. Each one names a fact the picture would have hidden rather than shown: an absent harness is
68
+ * a dev server that does not know this island, an unattached host photographs the frame's
69
+ * background, a zero box photographs whatever is behind it, and an empty box is a component that
70
+ * mounted and rendered nothing — every one of which comes out as a plausible image of the wrong
71
+ * thing.
72
+ */
73
+ interface Refusal {
74
+ readonly reason: string;
75
+ readonly fix: string;
76
+ }
77
+
78
+ /**
79
+ * The first assertion that does not hold, in the order a failure is most useful in — or
80
+ * `undefined`, which is the only way a shutter opens. Each clause names a fact the picture would
81
+ * have hidden rather than shown: an absent harness is a dev server that does not know this island,
82
+ * an unattached host photographs the frame's background, a zero box photographs whatever is behind
83
+ * it, and an empty box is a component that mounted and rendered nothing. Every one of them comes
84
+ * out as a plausible image of the wrong thing.
85
+ *
86
+ * A value and not a throw, so the whole ladder is one pure function a test can walk.
87
+ */
88
+ export function photographFault(
89
+ target: IslandShotTarget,
90
+ seen: IslandReadiness | null,
91
+ ): Refusal | undefined {
92
+ const settle = `x shot --island ${target.name} --settle 8000 --json`;
93
+ if (seen === null) {
94
+ return {
95
+ reason: 'answered no readiness probe at all, so nothing about the page can be asserted',
96
+ fix: `x shot --island ${target.name} --state ${target.state} --timeout 60000 --json`,
97
+ };
98
+ }
99
+ if (!seen.harness) {
100
+ return {
101
+ reason:
102
+ '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',
103
+ fix: 'restart x dev, then run this command again',
104
+ };
105
+ }
106
+ if (!seen.attached) {
107
+ return { reason: 'rendered no [data-x-island] host element', fix: hostFix(target) };
108
+ }
109
+ if (seen.failed !== null) {
110
+ return {
111
+ reason: `mounted and its mount() REJECTED: ${seen.failed}`,
112
+ fix: `x shot --island ${target.name} --state ${target.state} --json # the verdict carries the throw and its frame`,
113
+ };
114
+ }
115
+ if (!seen.mounted) {
116
+ return { reason: 'did not finish mounting inside the settle window', fix: settle };
117
+ }
118
+ if (!seen.ready) {
119
+ return {
120
+ reason:
121
+ 'never went quiet: something kept starting or settling requests for the whole settle window',
122
+ fix: settle,
123
+ };
124
+ }
125
+ if (seen.box.width === 0 || seen.box.height === 0) {
126
+ return {
127
+ reason: `has a ${seen.box.width}x${seen.box.height} bounding box, so the picture would be of whatever is behind it`,
128
+ fix: cropFix(target),
129
+ };
130
+ }
131
+ if (!seen.filled) {
132
+ return {
133
+ reason:
134
+ 'has a box with no child elements and no text in it — it mounted and rendered nothing',
135
+ fix: cropFix(target),
136
+ };
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ const hostFix = (target: IslandShotTarget): string =>
142
+ `in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
143
+
144
+ const cropFix = (target: IslandShotTarget): string =>
145
+ `in ${islandStatesFile(target.island)} set target to a selector the component really renders, or widen the state's props`;
146
+
147
+ /**
148
+ * One address, one full page load, one picture. Never a client-side switch between states: the
149
+ * previous state's fixtures, its resolved resources and its mounted DOM would ride into the next
150
+ * picture, which is the one way a screenshot tool can lie about its own subject.
151
+ *
152
+ * A session PER TARGET, and it costs a browser launch each: `page.console()` and `page.pageErrors()`
153
+ * are bounded rings over the whole SESSION, so a shared one would file state A's console errors
154
+ * under state B — and per-state attribution is the half of this artifact that gates.
155
+ */
156
+ async function captureOne(
157
+ options: IslandShotRun,
158
+ server: ShotServer,
159
+ target: IslandShotTarget,
160
+ ): Promise<IslandStateShot> {
161
+ const url = new URL(`${ISLAND_HARNESS_PATH}${target.query}`, server.url).toString();
162
+ let session: ScrapeSession | undefined;
163
+ try {
164
+ const driver = await options.driver(target.viewport);
165
+ session = await driver.open({
166
+ name: 'x shot --island',
167
+ rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
168
+ clock: systemScrapeClock,
169
+ timeoutMs: options.timeoutMs,
170
+ });
171
+ const page = session.page;
172
+ await page.goto(url, { timeout: options.timeoutMs });
173
+ const expression = readinessProbe(target.target ?? '[data-x-island]');
174
+ const probe = (): Promise<IslandReadiness | null> =>
175
+ page
176
+ .evaluate(expression)
177
+ .then(parseReadiness)
178
+ .catch(() => null);
179
+ const seen = await settleReadiness(probe, {
180
+ windowMs: options.settleMs,
181
+ pollMs: SETTLE_POLL_MS,
182
+ });
183
+ // Ahead of every other assertion about the picture: a component whose fetch went unanswered
184
+ // paints its own loading branch, and the picture then shows a fixture gap dressed up as a
185
+ // real component state. The list is the page's own, published by the seal.
186
+ if (seen !== null && seen.unstubbed.length > 0) {
187
+ throw new IslandRequestUnstubbedError({
188
+ island: target.island,
189
+ state: target.state,
190
+ requests: seen.unstubbed,
191
+ statesFile: islandStatesFile(target.island),
192
+ });
193
+ }
194
+ const fault = photographFault(target, seen);
195
+ if (fault !== undefined) {
196
+ throw new IslandUnphotographableError({
197
+ island: target.island,
198
+ state: target.state,
199
+ theme: target.theme,
200
+ ...fault,
201
+ });
202
+ }
203
+ // Never `fullPage`: the frame is the state's own declared viewport, and a full-page capture
204
+ // would grow with whatever the component scrolled.
205
+ const bytes = await page.screenshot({ fullPage: false });
206
+ const floor = options.minBytes ?? MIN_SHOT_BYTES;
207
+ if (bytes.byteLength < floor) {
208
+ throw new IslandUnphotographableError({
209
+ island: target.island,
210
+ state: target.state,
211
+ theme: target.theme,
212
+ reason: `produced ${bytes.byteLength} bytes, under the ${floor}-byte floor — that is not an image`,
213
+ fix: `x shot --island ${target.name} --browser /usr/bin/chromium --json`,
214
+ });
215
+ }
216
+ await Bun.write(join(options.outDir, target.file), bytes);
217
+ return {
218
+ state: target.state,
219
+ theme: target.theme,
220
+ file: target.file,
221
+ bytes: bytes.byteLength,
222
+ box: seen?.box ?? { x: 0, y: 0, width: 0, height: 0 },
223
+ mounted: seen?.mounted === true,
224
+ unstubbed: seen?.unstubbed ?? [],
225
+ console: page.console(),
226
+ pageErrors: page.pageErrors(),
227
+ };
228
+ } finally {
229
+ const open = session;
230
+ if (open !== undefined) await quietly(() => open.close());
231
+ }
232
+ }
233
+
234
+ /** Declared pictures that are not on disk. Read from the EXPANSION, never from the loop's beliefs. */
235
+ export async function missingShots(
236
+ outDir: string,
237
+ expected: readonly IslandShotTarget[],
238
+ ): Promise<readonly string[]> {
239
+ const missing: string[] = [];
240
+ for (const target of expected) {
241
+ if (!(await Bun.file(join(outDir, target.file)).exists())) missing.push(target.file);
242
+ }
243
+ return missing;
244
+ }
245
+
246
+ /**
247
+ * Boot (or find) the server, photograph every declared state, write the pictures and the verdict,
248
+ * then refuse if any declared picture is absent. The driver and the boot are ARGUMENTS for the
249
+ * reason `runShot`'s are: `bun test` drives this with a fake browser and a stub server, so the
250
+ * whole command is proved on a machine with no Chrome.
251
+ */
252
+ export async function runIslandShot(options: IslandShotRun): Promise<IslandArtifacts> {
253
+ // A Set and not an `===`: `bun run secret-compare` reads the NAME of a comparison's operands and
254
+ // `state` is on its list, because an OAuth handshake state is compared under exactly that name.
255
+ // This one is a screenshot filename stem, and the membership test says so.
256
+ const chosen = options.state === undefined ? null : new Set([options.state]);
257
+ const targets = islandShotTargets(options.manifest).filter(
258
+ (target) => chosen === null || chosen.has(target.state),
259
+ );
260
+ const server = await options.boot();
261
+ const shots: IslandStateShot[] = [];
262
+ const failures: unknown[] = [];
263
+ try {
264
+ for (const target of targets) {
265
+ // A state that cannot be photographed does not stop the run: the reader wants every picture
266
+ // the app CAN produce plus a named reason for each one it cannot, and the missing-shot gate
267
+ // below is what turns those reasons into a non-zero exit.
268
+ try {
269
+ shots.push(await captureOne(options, server, target));
270
+ } catch (error) {
271
+ failures.push(error);
272
+ }
273
+ }
274
+ } finally {
275
+ await quietly(() => server.stop());
276
+ }
277
+ const missing = await missingShots(options.outDir, targets);
278
+ const verdict = buildIslandVerdict({
279
+ island: options.manifest.island,
280
+ name: options.manifest.name,
281
+ server: server.origin,
282
+ capturedAt: (options.now ?? (() => new Date()))().toISOString(),
283
+ expected: targets,
284
+ shots,
285
+ missing,
286
+ });
287
+ const dir = join(options.outDir, options.manifest.name);
288
+ const verdictFile = join(dir, ISLAND_VERDICT);
289
+ await Bun.write(verdictFile, `${JSON.stringify(islandVerdictJson(verdict), null, 2)}\n`);
290
+ // The first failure is re-thrown ONLY when it explains a missing picture. A run that took every
291
+ // declared picture and also logged a failure is a contradiction; the artifact is what decides.
292
+ if (missing.length > 0) {
293
+ const first = failures[0];
294
+ if (first !== undefined) throw first;
295
+ throw new IslandShotsMissingError({
296
+ island: options.manifest.name,
297
+ missing,
298
+ expected: targets.length,
299
+ dir,
300
+ });
301
+ }
302
+ return { verdict, dir, verdictFile };
303
+ }
@@ -0,0 +1,78 @@
1
+ // Step one of photographing an island: find every `*.island.states.ts` in the app, prove each is
2
+ // pure data BEFORE importing it, and hand back the manifests. It happens with no browser and no
3
+ // dev server on purpose — the complete expected picture list has to exist before a capture starts,
4
+ // or "produced nothing and exited 0" is a result nobody can tell from success.
5
+
6
+ // why: no Bun native joins a path or relativises one; `Bun.file` and `import()` both take one joined.
7
+ import { join, relative, sep } from 'node:path';
8
+ import { ISLAND_EXTENSION } from '@ultimat3/render';
9
+ import type { IslandStatesManifest } from '@ultimat3/testing';
10
+ import {
11
+ assertIslandFiles,
12
+ assertIslandStatesPure,
13
+ assertUniqueIslandStates,
14
+ isIslandStatesManifest,
15
+ islandStatesFile,
16
+ } from '@ultimat3/testing';
17
+ import { ISLAND_GLOB } from './island-bundle';
18
+ import { IslandStatesFileEmptyError } from './island-shot-errors';
19
+
20
+ /**
21
+ * `.island.states.ts`, built from the two constants that own its halves and restated as neither:
22
+ * `@ultimat3/render` owns the island extension and `@ultimat3/testing` owns what a states file
23
+ * beside one is called. A third spelling here is the drift the derivation exists to prevent.
24
+ */
25
+ export const ISLAND_STATES_SUFFIX = islandStatesFile(ISLAND_EXTENSION);
26
+
27
+ /**
28
+ * `ISLAND_GLOB` with the island extension swapped for the states suffix, so a states file is
29
+ * discovered exactly where its island is and nowhere else. One shape, derived, never a second
30
+ * glob: a states file the discovery misses is a state nobody photographs and nothing says so —
31
+ * which is what `findIslandStates` listing every declared name is the backstop for.
32
+ */
33
+ export const ISLAND_STATES_GLOB = ISLAND_GLOB.replace(ISLAND_EXTENSION, ISLAND_STATES_SUFFIX);
34
+
35
+ /** App-root-relative POSIX paths of every states file, sorted, so a run is reproducible. */
36
+ export async function discoverIslandStates(root: string): Promise<readonly string[]> {
37
+ const files: string[] = [];
38
+ const scan = new Bun.Glob(ISLAND_STATES_GLOB).scan({ cwd: root, absolute: true });
39
+ for await (const absolute of scan) {
40
+ if (absolute.includes('node_modules')) continue;
41
+ files.push(relative(root, absolute).split(sep).join('/'));
42
+ }
43
+ return files.sort();
44
+ }
45
+
46
+ /**
47
+ * One file's manifests. The purity check runs against the file's TEXT and BEFORE the import,
48
+ * which is the whole reason the rule is static: a states file that imports Solid evaluates
49
+ * perfectly well under Bun, so nothing about loading it can notice, and by the time it has been
50
+ * loaded the damage — a module graph that needs a browser — is already done.
51
+ */
52
+ async function manifestsIn(root: string, file: string): Promise<readonly IslandStatesManifest[]> {
53
+ const absolute = join(root, file);
54
+ assertIslandStatesPure(file, await Bun.file(absolute).text());
55
+ const module: unknown = await import(absolute);
56
+ const exported = typeof module === 'object' && module !== null ? Object.values(module) : [];
57
+ const found = exported.filter(isIslandStatesManifest);
58
+ // A file named `*.island.states.ts` that exports no manifest is the same silence one level up:
59
+ // it expands to no picture, and a run over it reports success having photographed nothing.
60
+ if (found.length === 0) throw new IslandStatesFileEmptyError({ file });
61
+ return found;
62
+ }
63
+
64
+ /**
65
+ * Every manifest this app declares, checked as a SET: two islands answering to one name share a
66
+ * shot directory (`assertUniqueIslandStates`), and a manifest naming a file that is not on disk
67
+ * expands to pictures that can never be taken (`assertIslandFiles`). Both are asked once here
68
+ * rather than discovered by whichever lookup happens to be made.
69
+ */
70
+ export async function loadIslandStates(root: string): Promise<readonly IslandStatesManifest[]> {
71
+ const all: IslandStatesManifest[] = [];
72
+ for (const file of await discoverIslandStates(root)) {
73
+ all.push(...(await manifestsIn(root, file)));
74
+ }
75
+ assertUniqueIslandStates(all);
76
+ await assertIslandFiles(all, root);
77
+ return all;
78
+ }
@@ -0,0 +1,194 @@
1
+ // What a run of `x shot --island` CLAIMS, and what it refuses to claim. The route verdict's shape
2
+ // extended rather than forked: `ok` is still "nothing logged an error, nothing threw, no mount
3
+ // rejected", with the two facts only a component capture has — the requests nobody stubbed, and
4
+ // the declared pictures that never landed on disk.
5
+
6
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { t, validate } from '@ultimat3/schema';
8
+ import type { ConsoleLine, PageError } from '@ultimat3/scraping';
9
+ import type { IslandShotTarget } from '@ultimat3/testing';
10
+ import { msg } from './messages';
11
+ import type { JsonValue } from './output';
12
+
13
+ /** Every key this path renders. `msg()` answers `⟦key⟧` for a miss, which no build can see. */
14
+ export const ISLAND_SHOT_MESSAGE_KEYS = [
15
+ 'cli.shot.island.ok',
16
+ 'cli.shot.island.failed',
17
+ 'cli.shot.island.missing',
18
+ 'cli.shot.island.picture',
19
+ 'cli.shot.island.verdict',
20
+ 'cli.shot.island.state',
21
+ 'cli.shot.island.blind.crop',
22
+ 'cli.shot.island.blind.locale',
23
+ ] as const;
24
+
25
+ /**
26
+ * What a component picture cannot see, named every time. `errors: 0` read without them is a claim
27
+ * this tool cannot support — and both are properties of the port rather than of a run, which is
28
+ * why they are a constant and not a per-run list.
29
+ *
30
+ * The crop one is the honest limit of the shipped browser port: `CaptureRequest` is `fullPage`
31
+ * alone (`packages/scraping/src/page.ts`), so a picture is the VIEWPORT and the framing knob is the
32
+ * state's own `viewport`, not a clip rectangle. The locale one is the reach of a page-side clock
33
+ * patch: `date.toLocaleString()` resolves the zone inside the engine and never through the patched
34
+ * `Intl.DateTimeFormat`.
35
+ */
36
+ export const ISLAND_BLIND_SPOTS = [
37
+ 'cli.shot.island.blind.crop',
38
+ 'cli.shot.island.blind.locale',
39
+ ] as const;
40
+
41
+ export interface IslandBox {
42
+ readonly x: number;
43
+ readonly y: number;
44
+ readonly width: number;
45
+ readonly height: number;
46
+ }
47
+
48
+ /** What `readinessProbe` answers. Parsed, never cast: a page can return anything at all. */
49
+ export interface IslandReadiness {
50
+ readonly harness: boolean;
51
+ readonly ready: boolean;
52
+ readonly unstubbed: readonly string[];
53
+ readonly attached: boolean;
54
+ readonly mounted: boolean;
55
+ readonly failed: string | null;
56
+ readonly filled: boolean;
57
+ readonly box: IslandBox;
58
+ }
59
+
60
+ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
61
+ harness: t.boolean,
62
+ ready: t.boolean,
63
+ unstubbed: t.array(t.string),
64
+ attached: t.boolean,
65
+ mounted: t.boolean,
66
+ failed: t.nullable(t.string),
67
+ filled: t.boolean,
68
+ box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
69
+ }) as unknown as StandardSchemaV1<unknown, IslandReadiness>;
70
+
71
+ /**
72
+ * `null` for anything that does not fit, and the caller treats that as "the page answered no
73
+ * probe" — never as a page that is ready. A malformed probe reported as readiness would be the
74
+ * capture asserting nothing while looking like it asserted everything.
75
+ */
76
+ export function parseReadiness(value: unknown): IslandReadiness | null {
77
+ const result = validate(readinessSchema, value);
78
+ return result.issues === undefined ? result.value : null;
79
+ }
80
+
81
+ export interface IslandStateShot {
82
+ readonly state: string;
83
+ readonly theme: string;
84
+ /** `<name>/<state>-<theme>.png`, relative to the run's output directory. */
85
+ readonly file: string;
86
+ readonly bytes: number;
87
+ readonly box: IslandBox;
88
+ readonly mounted: boolean;
89
+ readonly unstubbed: readonly string[];
90
+ readonly console: readonly ConsoleLine[];
91
+ readonly pageErrors: readonly PageError[];
92
+ }
93
+
94
+ /** One state's picture is clean when nothing on the page logged, threw, or went unanswered. */
95
+ export const stateShotOk = (shot: IslandStateShot): boolean =>
96
+ shot.mounted &&
97
+ shot.unstubbed.length === 0 &&
98
+ shot.pageErrors.length === 0 &&
99
+ shot.console.every((line) => line.level !== 'error');
100
+
101
+ export interface IslandVerdictInput {
102
+ readonly island: string;
103
+ readonly name: string;
104
+ readonly server: 'booted' | 'reused';
105
+ readonly capturedAt: string;
106
+ /** Computed BEFORE a browser existed — the complete picture list this run owes. */
107
+ readonly expected: readonly IslandShotTarget[];
108
+ readonly shots: readonly IslandStateShot[];
109
+ /** Declared `target.file`s that are not on disk. The gate the browser cannot influence. */
110
+ readonly missing: readonly string[];
111
+ }
112
+
113
+ export interface IslandVerdict extends IslandVerdictInput {
114
+ readonly ok: boolean;
115
+ readonly blind: readonly string[];
116
+ }
117
+
118
+ export function buildIslandVerdict(input: IslandVerdictInput): IslandVerdict {
119
+ return {
120
+ ...input,
121
+ // The missing list gates on its own, and that is the whole point of computing it from the
122
+ // expansion rather than from what the loop believes it did: a capture that produced nothing
123
+ // and threw nothing would otherwise be a run with no shots, no failures and `ok: true`.
124
+ ok: input.missing.length === 0 && input.shots.every(stateShotOk),
125
+ blind: ISLAND_BLIND_SPOTS.map((key) => msg(key)),
126
+ };
127
+ }
128
+
129
+ const shotJson = (shot: IslandStateShot): JsonValue => ({
130
+ state: shot.state,
131
+ theme: shot.theme,
132
+ file: shot.file,
133
+ bytes: shot.bytes,
134
+ box: { x: shot.box.x, y: shot.box.y, width: shot.box.width, height: shot.box.height },
135
+ mounted: shot.mounted,
136
+ ok: stateShotOk(shot),
137
+ unstubbed: [...shot.unstubbed],
138
+ console: shot.console.map((line) => ({ level: line.level, text: line.text, at: line.at })),
139
+ pageErrors: shot.pageErrors.map((error) => ({
140
+ message: error.message,
141
+ stack: error.stack ?? null,
142
+ at: error.at,
143
+ })),
144
+ });
145
+
146
+ /** The artifact, and the same object `--json` carries under `data.verdict`. One shape, two files. */
147
+ export function islandVerdictJson(verdict: IslandVerdict): JsonValue {
148
+ return {
149
+ ok: verdict.ok,
150
+ island: verdict.island,
151
+ name: verdict.name,
152
+ server: verdict.server,
153
+ capturedAt: verdict.capturedAt,
154
+ expected: verdict.expected.map((target) => target.file),
155
+ missing: [...verdict.missing],
156
+ states: verdict.shots.map(shotJson),
157
+ blind: [...verdict.blind],
158
+ };
159
+ }
160
+
161
+ export interface IslandArtifacts {
162
+ readonly verdict: IslandVerdict;
163
+ /** Absolute path of the directory the pictures and the verdict were written to. */
164
+ readonly dir: string;
165
+ readonly verdictFile: string;
166
+ }
167
+
168
+ export function islandShotLines(artifacts: IslandArtifacts): readonly string[] {
169
+ const verdict = artifacts.verdict;
170
+ return [
171
+ ...verdict.shots.map((shot) =>
172
+ msg('cli.shot.island.state', {
173
+ state: shot.state,
174
+ theme: shot.theme,
175
+ width: shot.box.width,
176
+ height: shot.box.height,
177
+ file: shot.file,
178
+ }),
179
+ ),
180
+ ...verdict.missing.map((file) => msg('cli.shot.island.missing', { file })),
181
+ msg('cli.shot.island.picture', { path: artifacts.dir }),
182
+ msg('cli.shot.island.verdict', { path: artifacts.verdictFile }),
183
+ ];
184
+ }
185
+
186
+ /** The one line a reader sees first, and it names the gating fact rather than the file count. */
187
+ export const islandShotSummary = (verdict: IslandVerdict): string =>
188
+ verdict.ok
189
+ ? msg('cli.shot.island.ok', { island: verdict.name, pictures: verdict.shots.length })
190
+ : msg('cli.shot.island.failed', {
191
+ island: verdict.name,
192
+ taken: verdict.shots.length,
193
+ expected: verdict.expected.length,
194
+ });
package/src/mcp-errors.ts CHANGED
@@ -53,6 +53,17 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
53
53
  X_WORKSPACE_DEP_UNDECLARED:
54
54
  'x verify --json # the package-shape finding carries the dependency line to add',
55
55
  X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
56
+ // The four island-capture codes. Each one's real repair is an edit to the app's own states file
57
+ // or component, which no command can perform — so each names the command that REPRODUCES it with
58
+ // the file and the reason attached, which is the runnable half.
59
+ X_SHOT_ISLAND_STATES_EMPTY:
60
+ 'x help shot --json # then export the manifest from the states file the cause names',
61
+ X_SHOT_ISLAND_UNPHOTOGRAPHABLE:
62
+ 'x help shot --json # the cause names the assertion that did not hold; --settle buys the slow ones more time',
63
+ X_SHOT_ISLAND_UNSTUBBED_REQUEST:
64
+ 'x help shot --json # the cause lists every request the state must answer under routes',
65
+ X_SHOT_ISLAND_MISSING:
66
+ 'x help shot --json # every absent picture carries its own named refusal in the run above',
56
67
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
57
68
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
58
69
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
package/src/messages.ts CHANGED
@@ -196,6 +196,20 @@ const CATALOG = {
196
196
  'cli.shot.verdict': ' verdict {path}',
197
197
  'cli.shot.blind.status':
198
198
  'HTTP response status is not observed — the port records requests, never responses',
199
+ // `--island`. A component capture reports per STATE, so the summary counts pictures and the
200
+ // lines name one state each; nothing here restates a fact `--json` does not carry.
201
+ 'cli.shot.island.ok':
202
+ '{island} clean — {pictures} picture(s), every one mounted, nothing logged and nothing threw',
203
+ 'cli.shot.island.failed':
204
+ '{island}: {taken} of {expected} declared picture(s) taken — verdict.json names each refusal',
205
+ 'cli.shot.island.state': ' {state} {theme} {width}x{height} {file}',
206
+ 'cli.shot.island.missing': ' missing {file} — no picture was taken for this declared state',
207
+ 'cli.shot.island.picture': ' pictures {path}',
208
+ 'cli.shot.island.verdict': ' verdict {path}',
209
+ 'cli.shot.island.blind.crop':
210
+ 'the picture is the viewport, not a crop — the browser port takes no clip rectangle, so a state sizes its own frame with viewport',
211
+ 'cli.shot.island.blind.locale':
212
+ 'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
199
213
  'cli.ci.failed':
200
214
  '{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
201
215
  'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
@@ -0,0 +1,62 @@
1
+ // Whether a file is a pure re-export manifest — every statement in it an `import` or an `export`
2
+ // that declares nothing. The line ceiling is a rule about REVIEWABLE LOGIC, and such a file has
3
+ // none: its length is a function of the package's API size, so the ceiling measures the wrong
4
+ // thing there. One added statement of logic disqualifies it and re-arms the ceiling on the spot.
5
+
6
+ import { CLOSERS, maskLiterals, OPENERS } from './ts-scan';
7
+
8
+ /** Every statement a manifest may hold begins with one of these two words. */
9
+ const IMPORT_OR_EXPORT = /^(?:import|export)\b/;
10
+
11
+ /**
12
+ * An `export` that DECLARES rather than re-exports. `export const LIMIT = 1` is a value with an
13
+ * initialiser, `export function` is logic outright, and both are exactly what the ceiling is for —
14
+ * so a file holding one is an ordinary source file that happens to start with re-exports.
15
+ */
16
+ const DECLARES =
17
+ /^export\s+(?:default|declare|abstract|async|const|let|var|function|class|enum|namespace|module|interface)\b/;
18
+
19
+ /**
20
+ * `export type { Ctx } from './ctx'` is a re-export; `export type Ctx = { … }` is a type alias, and
21
+ * an alias is a declaration a reviewer reads. The brace is the whole distinction.
22
+ */
23
+ const TYPE_ALIAS = /^export\s+type\s+[A-Za-z_$]/;
24
+
25
+ /**
26
+ * Top-level statements, split at the `;` that ends each one at bracket depth 0, over MASKED source
27
+ * — comments and string contents blanked — so a `;` inside a specifier or a comment is not read as
28
+ * a boundary. `undefined` when the file ends in something this cannot read as a statement: a scan
29
+ * that guesses would exempt a file on the strength of not understanding it.
30
+ */
31
+ function topLevelStatements(masked: string): readonly string[] | undefined {
32
+ const statements: string[] = [];
33
+ let depth = 0;
34
+ let start = 0;
35
+ for (let i = 0; i < masked.length; i += 1) {
36
+ const ch = masked[i] as string;
37
+ if (OPENERS.has(ch)) depth += 1;
38
+ // Clamped, because an unbalanced closer would otherwise put every later `;` at a negative
39
+ // depth and the whole file would read as one unterminated statement.
40
+ else if (CLOSERS.has(ch)) depth = Math.max(0, depth - 1);
41
+ else if (ch === ';' && depth === 0) {
42
+ statements.push(masked.slice(start, i));
43
+ start = i + 1;
44
+ }
45
+ }
46
+ return masked.slice(start).trim() === '' ? statements : undefined;
47
+ }
48
+
49
+ /**
50
+ * True when every statement in `source` is an import or a declaration-free export. A file with no
51
+ * statement at all is NOT a manifest: an exemption has to be earned by what a file holds, and
52
+ * "this scan found nothing" is the one answer that must never grant one.
53
+ */
54
+ export function isReExportManifest(source: string): boolean {
55
+ const statements = topLevelStatements(maskLiterals(source));
56
+ if (statements === undefined || statements.length === 0) return false;
57
+ return statements.every((statement) => {
58
+ const text = statement.trim();
59
+ if (text === '') return true;
60
+ return IMPORT_OR_EXPORT.test(text) && !DECLARES.test(text) && !TYPE_ALIAS.test(text);
61
+ });
62
+ }