@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.
- package/CLAUDE.md +60 -0
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +12 -0
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +146 -0
- package/src/cmd-shot.ts +50 -86
- package/src/error-codes.ts +11 -0
- package/src/island-harness-route.ts +91 -0
- package/src/island-harness-script.ts +147 -0
- package/src/island-harness.ts +98 -0
- package/src/island-shot-errors.ts +94 -0
- package/src/island-shot.ts +303 -0
- package/src/island-states-load.ts +78 -0
- package/src/island-verdict.ts +194 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +14 -0
- package/src/reexport-manifest.ts +62 -0
- package/src/shot-server.ts +90 -0
- package/src/shot-settle.ts +33 -3
- package/src/ts-scan.ts +34 -5
- package/src/workspace-checks.ts +23 -7
|
@@ -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
|
+
}
|