@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,91 @@
1
+ // Serving the harness. It is a `x dev` route and not a second server, for the reason `x shot`
2
+ // reuses a running dev server at all: the chunks, the app's stylesheet registry and the island
3
+ // bundle all live in the process that built them, and embedded Postgres is single-writer so there
4
+ // can only be one of those per checkout.
5
+
6
+ import type { Route, UltimateRequest } from '@ultimat3/http';
7
+ import { html, json } from '@ultimat3/http';
8
+ import type { IslandStatesManifest } from '@ultimat3/testing';
9
+ import {
10
+ islandShotFile,
11
+ islandShotTargets,
12
+ islandStatesMatching,
13
+ islandStatesNames,
14
+ parseIslandAddress,
15
+ } from '@ultimat3/testing';
16
+ import { harnessPage, ISLAND_HARNESS_PATH } from './island-harness';
17
+ import type { IslandSource } from './island-routes';
18
+
19
+ /**
20
+ * A getter, never the manifests: `x dev` rebuilds on the watcher tick, and a set captured when the
21
+ * route was mounted would serve a state the author has since edited for the rest of the session.
22
+ * It is async because reading a states file is an `import()`, and one that throws is answered as a
23
+ * refusal rather than taking the dev server down.
24
+ */
25
+ export type IslandStatesSource = () => Promise<readonly IslandStatesManifest[]>;
26
+
27
+ const refused = (cause: string, fix: string): Response =>
28
+ json(
29
+ { ok: false, error: { code: 'X_SHOT_ISLAND_UNPHOTOGRAPHABLE', cause, fix } },
30
+ { status: 404 },
31
+ );
32
+
33
+ export interface HarnessRouteInput {
34
+ readonly islands: IslandSource;
35
+ readonly states: IslandStatesSource;
36
+ }
37
+
38
+ /**
39
+ * `GET /_x/island?island=…&state=…&theme=…`. The address is parsed by the vocabulary's own
40
+ * `parseIslandAddress`, which is `islandAddress`'s inverse and is TOTAL — a mistyped theme falls
41
+ * back rather than throwing, because a page that renders an error over a typo turns a typo into a
42
+ * screenshot of the framework.
43
+ *
44
+ * An island or a state this process does not know is the one case that IS refused, and it can only
45
+ * mean the two processes disagree: `x shot` computed its picture list from the states files on disk
46
+ * and this server was booted against an older set.
47
+ */
48
+ export function islandHarnessRoutes(input: HarnessRouteInput): readonly Route[] {
49
+ return [
50
+ {
51
+ method: 'GET',
52
+ path: ISLAND_HARNESS_PATH,
53
+ meta: { name: 'dev._x.island', auth: 'public', tags: ['dev'] },
54
+ handler: async (request: UltimateRequest): Promise<Response> => {
55
+ const address = parseIslandAddress(request.url.search);
56
+ const all = await input.states();
57
+ const manifest = islandStatesMatching(all, address.island)[0];
58
+ if (manifest === undefined) {
59
+ return refused(
60
+ `this dev server declares no island states for ${address.island === '' ? '(no island named)' : address.island} — it knows ${islandStatesNames(all).join(', ') || 'none'}`,
61
+ 'restart x dev, then: x shot --island <name> --json',
62
+ );
63
+ }
64
+ // `wanted`, never a local called `state`: `bun run secret-compare` reads the NAME of a
65
+ // comparison's operands and `state` is on its list — an OAuth handshake state is compared
66
+ // under exactly that name, and this one is a screenshot filename stem.
67
+ const wanted = address.state;
68
+ const declared = manifest.states.find((one) => one.id === wanted);
69
+ const file = islandShotFile(manifest.name, wanted, address.theme);
70
+ const target = islandShotTargets(manifest).find((one) => one.file === file);
71
+ if (declared === undefined || target === undefined) {
72
+ return refused(
73
+ `${manifest.island} declares no state ${wanted === '' ? '(none named)' : wanted} in theme ${address.theme}`,
74
+ `x shot --island ${manifest.name} --state ${manifest.states[0]?.id ?? '<id>'} --json`,
75
+ );
76
+ }
77
+ // The chunk is looked up rather than built here: `x dev` already built every island at
78
+ // boot and rebuilds on the watcher tick, so a second build would be a second answer to
79
+ // "what code does this island run".
80
+ const chunk = input.islands().chunks.find((one) => one.file === manifest.island);
81
+ if (chunk === undefined) {
82
+ return refused(
83
+ `${manifest.island} is declared as an island's states file but this build has no chunk for it`,
84
+ `x g island ${manifest.name} --at ${manifest.island.split('/').slice(0, -1).join('/')}`,
85
+ );
86
+ }
87
+ return html(harnessPage({ target, state: declared, entry: chunk.url }));
88
+ },
89
+ },
90
+ ];
91
+ }
@@ -0,0 +1,147 @@
1
+ // The inline script the harness page runs BEFORE the island's chunk: the sealed network, the
2
+ // pinned clock and the readiness signal. A classic `<script>` in `<head>` evaluates before any
3
+ // module script, which is the only ordering in which a component's first `fetch` can be caught.
4
+ //
5
+ // A string and not a module, deliberately: this runs in the browser and the CLI has no bundler in
6
+ // the path that serves a page. `@ultimat3/testing`'s `sealNetwork()` patches THIS process's
7
+ // `globalThis.fetch` and cannot reach the page's realm.
8
+
9
+ import type { IslandRouteStub } from '@ultimat3/testing';
10
+
11
+ /**
12
+ * Consecutive animation frames with an unchanged activity counter before a page is called ready.
13
+ * QUIET, never zero in flight: a state whose fixture is deliberately `pending` has a request that
14
+ * never settles, so waiting for zero hangs forever — and a fixed sleep photographs whatever a slow
15
+ * machine had painted by then.
16
+ */
17
+ export const QUIET_FRAMES = 3;
18
+
19
+ /** The one global the CLI reads back. Namespaced so an app's own page state can never collide. */
20
+ export const HARNESS_GLOBAL = '__xShot';
21
+
22
+ /**
23
+ * Embedded as a JS string literal, so `<` is escaped: a `</script` anywhere inside a stub body
24
+ * would otherwise end the tag and the rest of the page would be parsed as markup. Escaping the
25
+ * character rather than the sequence is the total form — there is no second spelling of it.
26
+ */
27
+ const embed = (value: unknown): string => JSON.stringify(value ?? null).replaceAll('<', '\\u003c');
28
+
29
+ export interface HarnessScriptOptions {
30
+ readonly stubs: readonly IslandRouteStub[];
31
+ /** The frozen instant, with an explicit offset — the manifest's `now`. */
32
+ readonly now: string;
33
+ /** The IANA zone every unzoned `Intl.DateTimeFormat` in the page is given. */
34
+ readonly timeZone: string;
35
+ }
36
+
37
+ /**
38
+ * The seal. Four egress surfaces, and an unmatched request on any of them REJECTS while recording
39
+ * itself: a component whose fetch quietly hangs paints its own loading branch, and the picture then
40
+ * shows a fixture gap dressed up as a real component state.
41
+ *
42
+ * `activity` counts a request STARTING and a request SETTLING, which is what lets readiness be
43
+ * "nothing changed for N frames" rather than "nothing is in flight" — the second is unreachable for
44
+ * a `pending` fixture, which is a state an author declares on purpose.
45
+ */
46
+ const sealScript = (stubs: readonly IslandRouteStub[]): string => `
47
+ var W=window.${HARNESS_GLOBAL};var STUBS=${embed(stubs)};
48
+ function bump(){W.activity+=1}
49
+ function stubFor(method,path){var k=method.toUpperCase()+' '+path;
50
+ for(var i=0;i<STUBS.length;i+=1){if(k.indexOf(STUBS[i].match)===0)return STUBS[i].respond}
51
+ W.unstubbed.push(k);return null}
52
+ // Pathname AND query: the vocabulary says a stub's \`match\` is a PREFIX, so \`'GET /api/quota'\`
53
+ // catching \`/api/quota?window=day\` is a property of the key carrying the query, not of the match.
54
+ function pathOf(url){try{var u=new URL(url,location.href);return u.pathname+u.search}catch(e){return String(url)}}
55
+ function refuse(k){return new Error('x shot: no stub answers '+k+' — declare it in the state\\'s routes')}
56
+ function answer(respond,k){
57
+ if(respond===null)return Promise.reject(refuse(k));
58
+ if(respond.kind==='pending')return new Promise(function(){});
59
+ if(respond.kind==='offline')return Promise.reject(new TypeError('x shot: offline fixture for '+k));
60
+ return Promise.resolve(new Response(JSON.stringify(respond.body===undefined?null:respond.body),
61
+ {status:respond.status||200,headers:{'content-type':'application/json'}}))}
62
+ window.fetch=function(input,init){
63
+ var url=typeof input==='string'?input:(input&&input.url)||String(input);
64
+ var method=(init&&init.method)||(typeof input==='object'&&input&&input.method)||'GET';
65
+ var path=pathOf(url);var k=method.toUpperCase()+' '+path;
66
+ var respond=stubFor(method,path);bump();
67
+ return answer(respond,k).then(function(r){bump();return r},function(e){bump();throw e})};
68
+ // A socket and an event stream have no stub vocabulary at all, so both are refused outright and
69
+ // recorded: a live component that opened one would otherwise sit in its loading branch forever.
70
+ window.WebSocket=function(url){W.unstubbed.push('WS '+url);throw refuse('WS '+url)};
71
+ window.EventSource=function(url){W.unstubbed.push('SSE '+url);throw refuse('SSE '+url)};
72
+ var RealXHR=window.XMLHttpRequest;
73
+ window.XMLHttpRequest=function(){var xhr=new RealXHR();var open=xhr.open;
74
+ xhr.open=function(method,url){W.unstubbed.push(String(method).toUpperCase()+' '+pathOf(url));
75
+ return open.apply(xhr,arguments)};return xhr};
76
+ // Nothing here serves a service worker, and one an earlier page registered would answer requests
77
+ // this seal never sees. A no-op registration keeps a component that asks from throwing.
78
+ if(navigator.serviceWorker)navigator.serviceWorker.register=function(){return Promise.resolve(undefined)};
79
+ `;
80
+
81
+ /**
82
+ * The clock, pinned in both halves. A harness that freezes the INSTANT and leaves the zone ambient
83
+ * renders `12:00` on one machine and `14:00` on the next, and the review diff then reports a
84
+ * component change that never happened — so the zone is filled in for every `Intl.DateTimeFormat`
85
+ * built without one, and the instant replaces the argumentless `new Date()`.
86
+ *
87
+ * `toLocaleString` on a Date is NOT covered: it reaches the engine's own Intl and not this global.
88
+ * The framework's rule is that no date is formatted without an explicit `timeZone`, so a component
89
+ * obeying it is pinned; one that does not is a blind spot the verdict names rather than hides.
90
+ */
91
+ const clockScript = (now: string, timeZone: string): string => `
92
+ var FIXED=${embed(Date.parse(now))};var ZONE=${embed(timeZone)};
93
+ class ShotDate extends Date{constructor(){if(arguments.length===0)super(FIXED);else super(...arguments)}
94
+ static now(){return FIXED}}
95
+ window.Date=ShotDate;
96
+ var RealDTF=Intl.DateTimeFormat;
97
+ function zoned(options){return options&&options.timeZone?options:Object.assign({},options,{timeZone:ZONE})}
98
+ function ShotDTF(locales,options){return new RealDTF(locales,zoned(options))}
99
+ ShotDTF.prototype=RealDTF.prototype;
100
+ ShotDTF.supportedLocalesOf=RealDTF.supportedLocalesOf.bind(RealDTF);
101
+ Intl.DateTimeFormat=ShotDTF;
102
+ `;
103
+
104
+ /**
105
+ * Ready is quiet, not idle. Fonts first — a picture taken mid-swap photographs the fallback face —
106
+ * then `QUIET_FRAMES` consecutive frames in which nothing started and nothing settled.
107
+ */
108
+ const readyScript = (): string => `
109
+ var last=-1;var still=0;
110
+ function tick(){var seen=W.activity;
111
+ if(seen===last)still+=1;else{still=0;last=seen}
112
+ if(still>=${QUIET_FRAMES}){W.ready=true;return}
113
+ requestAnimationFrame(tick)}
114
+ (document.fonts?document.fonts.ready:Promise.resolve()).then(function(){requestAnimationFrame(tick)});
115
+ `;
116
+
117
+ /** The whole prelude, in the one order that works: state, seal, clock, then the readiness watch. */
118
+ export function harnessScript(options: HarnessScriptOptions): string {
119
+ return [
120
+ `window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[]};`,
121
+ sealScript(options.stubs),
122
+ clockScript(options.now, options.timeZone),
123
+ readyScript(),
124
+ ].join('\n');
125
+ }
126
+
127
+ /**
128
+ * What the CLI evaluates before every capture, as one expression — `CdpPageLike.evaluate` takes
129
+ * the string form only. Every clause is a fact a picture cannot carry: a host that never attached,
130
+ * a mount that rejected, a box of zero pixels, a box holding nothing, a request nobody stubbed.
131
+ *
132
+ * `selector` is the crop target the manifest declared; the island's own host element when absent.
133
+ */
134
+ export const readinessProbe = (selector: string): string =>
135
+ `(function(){var W=window.${HARNESS_GLOBAL}||{};` +
136
+ 'var host=document.querySelector("[data-x-island]");' +
137
+ `var box=document.querySelector(${JSON.stringify(selector)})||host;` +
138
+ 'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
139
+ 'return{harness:W.harness===true,ready:W.ready===true,' +
140
+ 'unstubbed:(W.unstubbed||[]).slice(),' +
141
+ 'attached:host!==null&&document.body.contains(host),' +
142
+ 'mounted:host!==null&&host.hasAttribute("data-x-mounted"),' +
143
+ 'failed:host&&host.hasAttribute("data-x-failed")?host.getAttribute("data-x-failed"):null,' +
144
+ // Children OR text: a component that renders one text node has painted, and one that mounted
145
+ // and rendered nothing is the silence a non-zero box would otherwise read as success.
146
+ 'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
147
+ 'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}};})()';
@@ -0,0 +1,98 @@
1
+ // The harness document: one island, one state, one theme, mounted over the SEAM the framework
2
+ // already has — `data-x-entry` for the chunk, `data-x-props` for the props, and
3
+ // `@ultimat3/render`'s own hydration runtime to boot it. A second mounting mechanism here would be
4
+ // a picture of something no page ever renders.
5
+
6
+ // why: no Bun native takes a path apart; the surface is a segment of an app-root-relative path.
7
+ import { basename } from 'node:path';
8
+ import type { IslandDirective, Surface } from '@ultimat3/render';
9
+ import {
10
+ emitIslandAttributes,
11
+ emitIslandProps,
12
+ hydrateRuntime,
13
+ islandModuleId,
14
+ SURFACES,
15
+ } from '@ultimat3/render';
16
+ import { stylesFor } from '@ultimat3/render/server';
17
+ import type { IslandShotTarget, IslandState } from '@ultimat3/testing';
18
+ import { harnessScript } from './island-harness-script';
19
+
20
+ /** Where the harness lives in `x dev`'s own namespace, so no app route can shadow it. */
21
+ export const ISLAND_HARNESS_PATH = '/_x/island';
22
+
23
+ /**
24
+ * `idle`, and not because the picture should wait: it is the only strategy whose runtime boots an
25
+ * island nothing has scrolled to or clicked, and reusing a shipped strategy is what keeps the
26
+ * `data-x-mounted` / `data-x-failed` markers — the two facts a picture cannot carry — landing here
27
+ * exactly as they land on a real page.
28
+ */
29
+ const HARNESS_STRATEGY = 'idle';
30
+
31
+ /**
32
+ * `apps/web/app/settings/settings.island.tsx` → `app`. The CSS a document carries is per surface
33
+ * (axiom 6 applied to bytes the browser parses), so a `site/` island photographed against `app/`'s
34
+ * stylesheet would be a picture of styling that page never receives.
35
+ */
36
+ export function surfaceOf(island: string): Surface | null {
37
+ const segment = island.split('/')[2];
38
+ return SURFACES.find((surface) => surface === segment) ?? null;
39
+ }
40
+
41
+ /**
42
+ * The frame, and every rule in it is about what a REVIEWER sees. Animations and transitions are
43
+ * off because a picture taken mid-transition is a picture of a moment no user experiences; the
44
+ * caret is invisible because a focused input blinks and two otherwise identical runs then differ.
45
+ * Colours are semantic tokens, never literals — the app's own global layer defines them.
46
+ */
47
+ const FRAME_STYLE = `
48
+ *,*::before,*::after{animation:none !important;transition:none !important;
49
+ scroll-behavior:auto !important;caret-color:transparent !important}
50
+ html{background:rgb(var(--color-bg) / 1)}
51
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
52
+ background:rgb(var(--color-bg) / 1)}
53
+ #x-shot-frame{padding:var(--space-4, 16px);max-width:100%;box-sizing:border-box}
54
+ `.trim();
55
+
56
+ export interface HarnessPageInput {
57
+ readonly target: IslandShotTarget;
58
+ readonly state: IslandState;
59
+ /** The built chunk's immutable URL — what `data-x-entry` carries and what the runtime imports. */
60
+ readonly entry: string;
61
+ }
62
+
63
+ /**
64
+ * One address, one document, and every address is a FULL PAGE LOAD. Switching islands or states
65
+ * client-side would carry the previous state's fixtures, its resolved resources and its mounted
66
+ * DOM into the next picture, which is the one way a screenshot tool can lie about its own subject.
67
+ */
68
+ export function harnessPage(input: HarnessPageInput): string {
69
+ const moduleId = islandModuleId(basename(input.target.island));
70
+ const directive: IslandDirective = {
71
+ islandId: moduleId,
72
+ moduleId,
73
+ strategy: HARNESS_STRATEGY,
74
+ entry: input.entry,
75
+ props: input.state.props,
76
+ };
77
+ const css = stylesFor(surfaceOf(input.target.island));
78
+ return [
79
+ '<!doctype html>',
80
+ `<html lang="en" data-theme="${input.target.theme}">`,
81
+ '<head><meta charset="utf-8">',
82
+ `<title>${input.target.name} · ${input.target.state} · ${input.target.theme}</title>`,
83
+ css.length === 0 ? '' : `<style>${css}</style>`,
84
+ `<style>${FRAME_STYLE}</style>`,
85
+ // Before the body and before every module script, which is the only ordering in which the
86
+ // seal can catch a component's first request.
87
+ `<script>${harnessScript({
88
+ stubs: input.state.routes,
89
+ now: input.target.now,
90
+ timeZone: input.target.timeZone,
91
+ })}</script>`,
92
+ '</head><body>',
93
+ `<div id="x-shot-frame"><div ${emitIslandAttributes(directive)}></div></div>`,
94
+ emitIslandProps(directive),
95
+ hydrateRuntime([directive]),
96
+ '</body></html>',
97
+ ].join('');
98
+ }
@@ -0,0 +1,94 @@
1
+ // The four ways `x shot --island` refuses, beside their one thrower rather than in `errors.ts` —
2
+ // which is at the 500-line ceiling. The codes, their titles and the single `registerErrorCodes`
3
+ // call stay in `error-codes.ts`: one owner, one registration, one place a duplicate can surface.
4
+
5
+ import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
6
+
7
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`.
8
+
9
+ /** Neither path is the framework's: both arrive from an app's own declaration. */
10
+ const STATES_PLACEHOLDER = '<the states file the cause names>';
11
+
12
+ /**
13
+ * A `*.island.states.ts` that exports no manifest — the author wrote `defineIslandStates(…)` and
14
+ * did not `export` the result, or exported it from a file nothing else names. It expands to no
15
+ * picture, so a run over it photographs nothing and reports success, which is the one outcome this
16
+ * whole command exists to make impossible.
17
+ */
18
+ export class IslandStatesFileEmptyError extends UltimateError {
19
+ constructor(input: { readonly file: string }) {
20
+ super({
21
+ code: 'X_SHOT_ISLAND_STATES_EMPTY',
22
+ cause: `${renderCauseValue(input.file)} exports no defineIslandStates() result, so it declares no picture`,
23
+ fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} write: export const states = defineIslandStates({ island: '…', states: [...] })`,
24
+ });
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The page could not be photographed, and the reason is named. Every clause is something a picture
30
+ * would have hidden rather than shown: a host element that never attached photographs the harness
31
+ * chrome, a zero-sized box photographs whatever is behind it, and a box with no children is a
32
+ * component that mounted and rendered nothing — each of which comes out as a plausible-looking
33
+ * image of the wrong thing.
34
+ */
35
+ export class IslandUnphotographableError extends UltimateError {
36
+ constructor(input: {
37
+ readonly island: string;
38
+ readonly state: string;
39
+ readonly theme: string;
40
+ readonly reason: string;
41
+ readonly fix: string;
42
+ }) {
43
+ super({
44
+ code: 'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
45
+ cause: `${input.island} in state ${renderCauseValue(input.state)} (${input.theme}) ${input.reason}`,
46
+ fix: input.fix,
47
+ meta: { island: input.island, state: input.state, theme: input.theme },
48
+ });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The component asked the network for something no stub answers. Refused rather than allowed
54
+ * through, because the alternative is the failure this command was built to prevent: a `fetch`
55
+ * that quietly hangs leaves the component painting its own loading branch, and the picture then
56
+ * shows a fixture gap dressed up as a real component state.
57
+ */
58
+ export class IslandRequestUnstubbedError extends UltimateError {
59
+ constructor(input: {
60
+ readonly island: string;
61
+ readonly state: string;
62
+ readonly requests: readonly string[];
63
+ readonly statesFile: string;
64
+ }) {
65
+ super({
66
+ code: 'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
67
+ cause: `${input.island} in state ${renderCauseValue(input.state)} made ${input.requests.length} request(s) no stub answers: ${input.requests.join(', ')}`,
68
+ fix: `in ${renderFixLiteral(input.statesFile, STATES_PLACEHOLDER)} add to that state: routes: [{ match: '${input.requests[0] ?? 'GET /api/x'}', respond: { kind: 'json', body: {} } }]`,
69
+ meta: { island: input.island, state: input.state, requests: [...input.requests] },
70
+ });
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The gate that does not depend on the browser's own verdict: every declared state expands to a
76
+ * file, and a file that is not on disk when the run ends is a picture nobody took. A capture loop
77
+ * that swallowed one failure and exited 0 is exactly what this refuses, and it is checked against
78
+ * the expansion computed before a browser existed rather than against what the loop believes it did.
79
+ */
80
+ export class IslandShotsMissingError extends UltimateError {
81
+ constructor(input: {
82
+ readonly island: string;
83
+ readonly missing: readonly string[];
84
+ readonly expected: number;
85
+ readonly dir: string;
86
+ }) {
87
+ super({
88
+ code: 'X_SHOT_ISLAND_MISSING',
89
+ cause: `${input.missing.length} of ${input.expected} declared picture(s) are not on disk under ${renderCauseValue(input.dir)}: ${input.missing.join(', ')}`,
90
+ fix: `x shot --island ${input.island} --json # the run above names why each one was refused`,
91
+ meta: { island: input.island, missing: [...input.missing] },
92
+ });
93
+ }
94
+ }