@ultimat3/cli 11.1.0 → 11.3.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 +62 -1
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +91 -9
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +151 -0
- package/src/cmd-shot.ts +66 -95
- package/src/compile-externals.ts +7 -4
- package/src/error-codes.ts +11 -0
- package/src/island-bundle.ts +18 -7
- 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-browser.ts +84 -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
- package/src/island-solid-production.ts +0 -129
package/src/cmd-shot.ts
CHANGED
|
@@ -11,17 +11,17 @@ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
|
11
11
|
import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
12
12
|
import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
|
|
13
13
|
import { requireAppRoot } from './app-root';
|
|
14
|
-
import { appBrowser
|
|
15
|
-
import {
|
|
14
|
+
import { appBrowser } from './browser-launcher';
|
|
15
|
+
import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
|
|
16
16
|
import type { CliCommand, CommandContext } from './command';
|
|
17
|
-
import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
|
|
18
|
-
import { DEV_BINDING } from './dev-roles';
|
|
19
|
-
import { resolveServices } from './dev-services';
|
|
20
17
|
import { BadFlagError, MissingPositionalError } from './errors';
|
|
21
18
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
22
19
|
import type { CommandResult } from './output';
|
|
23
20
|
import type { ParsedArgs } from './parse';
|
|
24
21
|
import { flagBool, flagString } from './parse';
|
|
22
|
+
import { shotBrowserChoice } from './shot-browser';
|
|
23
|
+
import type { BootDevServer, ShotServer } from './shot-server';
|
|
24
|
+
import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
|
|
25
25
|
import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
|
|
26
26
|
import type { IslandCount, ShotArtifacts } from './shot-verdict';
|
|
27
27
|
import {
|
|
@@ -45,7 +45,11 @@ const DEFAULT_PORT = 0;
|
|
|
45
45
|
*/
|
|
46
46
|
export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// Re-exported, not re-declared: `cmd-shot.test.ts` and the island path both name them, and a
|
|
49
|
+
// second declaration of a path or an allow-list rule is a second answer.
|
|
50
|
+
export type { BootDevServer, ShotServer };
|
|
51
|
+
export { allowHostsFrom, devServerFor, SHOT_DIR };
|
|
52
|
+
|
|
49
53
|
export const SHOT_IMAGE = 'shot.png';
|
|
50
54
|
export const SHOT_VERDICT = 'verdict.json';
|
|
51
55
|
|
|
@@ -164,24 +168,6 @@ const intFlag = (
|
|
|
164
168
|
fallback,
|
|
165
169
|
);
|
|
166
170
|
|
|
167
|
-
/**
|
|
168
|
-
* How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
|
|
169
|
-
* call, for the reason every `Runner` in this package is one: the failure path below — a boot that
|
|
170
|
-
* throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
|
|
171
|
-
*/
|
|
172
|
-
export type BootDevServer = (input: {
|
|
173
|
-
readonly root: string;
|
|
174
|
-
readonly port: number;
|
|
175
|
-
readonly env: Readonly<Record<string, string | undefined>>;
|
|
176
|
-
}) => Promise<{ readonly url: string; stop(): Promise<void> }>;
|
|
177
|
-
|
|
178
|
-
export interface ShotServer {
|
|
179
|
-
readonly url: string;
|
|
180
|
-
/** Which server the picture is of. Reported, because the two have different failure modes. */
|
|
181
|
-
readonly origin: 'booted' | 'reused';
|
|
182
|
-
stop(): Promise<void>;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
171
|
export interface ShotRun {
|
|
186
172
|
readonly route: string;
|
|
187
173
|
readonly outDir: string;
|
|
@@ -274,65 +260,6 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
274
260
|
}
|
|
275
261
|
}
|
|
276
262
|
|
|
277
|
-
/**
|
|
278
|
-
* One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
|
|
279
|
-
* Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
|
|
280
|
-
* on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
|
|
281
|
-
*/
|
|
282
|
-
export async function devServerFor(
|
|
283
|
-
root: string,
|
|
284
|
-
env: Readonly<Record<string, string | undefined>>,
|
|
285
|
-
port: number,
|
|
286
|
-
boot: BootDevServer = (input) => startDev(input),
|
|
287
|
-
): Promise<ShotServer> {
|
|
288
|
-
const services = resolveServices(root, env);
|
|
289
|
-
const file = Bun.file(lockPath(services.stateDir));
|
|
290
|
-
if (await file.exists()) {
|
|
291
|
-
const lock = parseLock(await file.text());
|
|
292
|
-
if (lock !== null && isProcessAlive(lock.pid)) {
|
|
293
|
-
return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
const { release } = await preflight({
|
|
297
|
-
stateDir: services.stateDir,
|
|
298
|
-
port,
|
|
299
|
-
hostname: DEV_BINDING.hostname,
|
|
300
|
-
embeddedDb: services.db.mode === 'embedded',
|
|
301
|
-
});
|
|
302
|
-
// The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
|
|
303
|
-
// looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
|
|
304
|
-
// same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
|
|
305
|
-
// checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
|
|
306
|
-
// teardown must never replace the failure it is cleaning up after.
|
|
307
|
-
const dev = await boot({ root, port, env }).catch((error: unknown) => {
|
|
308
|
-
release();
|
|
309
|
-
throw error;
|
|
310
|
-
});
|
|
311
|
-
await writeLock(services.stateDir, {
|
|
312
|
-
pid: process.pid,
|
|
313
|
-
port,
|
|
314
|
-
url: dev.url,
|
|
315
|
-
startedAt: new Date().toISOString(),
|
|
316
|
-
});
|
|
317
|
-
return {
|
|
318
|
-
url: dev.url,
|
|
319
|
-
origin: 'booted',
|
|
320
|
-
async stop() {
|
|
321
|
-
clearLock(services.stateDir);
|
|
322
|
-
await dev.stop();
|
|
323
|
-
},
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
|
|
328
|
-
export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
|
|
329
|
-
const named = (extra ?? '')
|
|
330
|
-
.split(',')
|
|
331
|
-
.map((host) => host.trim())
|
|
332
|
-
.filter((host) => host.length > 0);
|
|
333
|
-
return [new URL(url).hostname, ...named];
|
|
334
|
-
};
|
|
335
|
-
|
|
336
263
|
export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
337
264
|
ok: artifacts.verdict.ok,
|
|
338
265
|
command: 'shot',
|
|
@@ -348,8 +275,9 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
|
348
275
|
export const shotCommand: CliCommand = {
|
|
349
276
|
spec: {
|
|
350
277
|
name: 'shot',
|
|
351
|
-
summary: 'photograph one route
|
|
352
|
-
usage:
|
|
278
|
+
summary: 'photograph one route, or one island in a state it declares, from a real browser',
|
|
279
|
+
usage:
|
|
280
|
+
'x shot <route> | --island <name> [--state <id>] [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
353
281
|
requiresApp: true,
|
|
354
282
|
flags: [
|
|
355
283
|
{ name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
|
|
@@ -358,33 +286,76 @@ export const shotCommand: CliCommand = {
|
|
|
358
286
|
{ name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
|
|
359
287
|
{ name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
|
|
360
288
|
{ name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
|
|
289
|
+
{
|
|
290
|
+
name: 'cdp-url',
|
|
291
|
+
type: 'string',
|
|
292
|
+
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
293
|
+
},
|
|
361
294
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
295
|
+
// A FLAG on `x shot` and never a second command: photographing a route and photographing a
|
|
296
|
+
// component are one job with two subjects, and a parallel command would be the second path
|
|
297
|
+
// axiom 1 refuses.
|
|
298
|
+
{
|
|
299
|
+
name: 'island',
|
|
300
|
+
type: 'string',
|
|
301
|
+
summary: 'photograph one island in every state it declares',
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'state',
|
|
305
|
+
type: 'string',
|
|
306
|
+
summary: 'one declared state of that island, not all of them',
|
|
307
|
+
},
|
|
362
308
|
],
|
|
363
309
|
},
|
|
364
310
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
365
311
|
const root = requireAppRoot('shot', ctx.cwd).dir;
|
|
366
312
|
// Every value read before anything boots: a typo must not cost a browser and a dev server to
|
|
367
313
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
368
|
-
const
|
|
314
|
+
const island = flagString(ctx.args, 'island');
|
|
315
|
+
const positional = ctx.args.positionals[0];
|
|
316
|
+
if (island !== undefined && island !== '' && positional !== undefined) {
|
|
317
|
+
refuseRouteWithIsland(positional, island);
|
|
318
|
+
}
|
|
319
|
+
const route = island === undefined || island === '' ? readRoute(positional) : '';
|
|
369
320
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
370
321
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
371
322
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
323
|
+
// Which browser this run gets — start one here, or attach to one somebody else is running.
|
|
324
|
+
// Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
|
|
325
|
+
// provider session exists to pay for a typo.
|
|
326
|
+
const { cdpUrl, executablePath } = shotBrowserChoice({
|
|
327
|
+
cdpFlag: flagString(ctx.args, 'cdp-url'),
|
|
328
|
+
browserFlag: flagString(ctx.args, 'browser'),
|
|
329
|
+
env: ctx.env,
|
|
330
|
+
});
|
|
381
331
|
const out = flagString(ctx.args, 'out');
|
|
382
332
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
333
|
+
if (island !== undefined && island !== '') {
|
|
334
|
+
return islandShotResult(
|
|
335
|
+
await islandShot({
|
|
336
|
+
root,
|
|
337
|
+
island,
|
|
338
|
+
...(flagString(ctx.args, 'state') === undefined
|
|
339
|
+
? {}
|
|
340
|
+
: { state: flagString(ctx.args, 'state') }),
|
|
341
|
+
...(out === undefined ? {} : { out }),
|
|
342
|
+
settleMs,
|
|
343
|
+
timeoutMs,
|
|
344
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
345
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
346
|
+
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
347
|
+
? {}
|
|
348
|
+
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
349
|
+
boot,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
383
353
|
// Resolved before the boot for the same reason: an app with no browser installed must not pay
|
|
384
354
|
// an embedded Postgres to be told to run `bun add -d puppeteer-core`.
|
|
385
355
|
const driver = await appBrowser({
|
|
386
356
|
root,
|
|
387
357
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
358
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
388
359
|
});
|
|
389
360
|
return shotResult(
|
|
390
361
|
await runShot({
|
package/src/compile-externals.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
|
|
9
9
|
* loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
|
|
10
10
|
* config file is ever loaded and neither line is reachable at run time. The bundler walks them
|
|
11
|
-
* anyway, and that is the whole failure: Bun 1.3
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* anyway, and that is the whole failure: Bun 1.3 refuses the build with
|
|
12
|
+
* `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
|
|
13
|
+
* unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
|
|
14
|
+
* That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
|
|
15
|
+
* `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
|
|
16
|
+
* 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
|
|
17
|
+
* `--compile` graph that must not reach an unresolvable `require`, on either Bun.
|
|
15
18
|
*
|
|
16
19
|
* Marking the dead specifier external rather than the two live ones: `serve.ts` calls
|
|
17
20
|
* `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
|
package/src/error-codes.ts
CHANGED
|
@@ -114,6 +114,13 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
114
114
|
'X_SECRETS_EDIT_FAILED',
|
|
115
115
|
'X_WORKSPACE_DEP_UNDECLARED',
|
|
116
116
|
'X_SHOT_BROWSER_MISSING',
|
|
117
|
+
// `x shot --island` — one code per way a component's named state fails to become a picture.
|
|
118
|
+
// The last of the four is the one that gates: it is checked against the expansion computed
|
|
119
|
+
// before a browser existed, so a capture loop that swallowed a failure cannot exit 0.
|
|
120
|
+
'X_SHOT_ISLAND_STATES_EMPTY',
|
|
121
|
+
'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
|
|
122
|
+
'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
|
|
123
|
+
'X_SHOT_ISLAND_MISSING',
|
|
117
124
|
'X_GH_UNAVAILABLE',
|
|
118
125
|
'X_GH_NOT_AUTHENTICATED',
|
|
119
126
|
'X_GH_COMMAND_FAILED',
|
|
@@ -218,6 +225,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
218
225
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
219
226
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
220
227
|
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
228
|
+
X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
|
|
229
|
+
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
|
230
|
+
X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
|
|
231
|
+
X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
|
|
221
232
|
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
222
233
|
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
223
234
|
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
package/src/island-bundle.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { renderThrowable } from '@ultimat3/core';
|
|
|
10
10
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
11
11
|
import { contentHash } from '@ultimat3/render/server';
|
|
12
12
|
import { IslandBuildFailedError } from './errors';
|
|
13
|
-
import { solidProductionPlugin } from './island-solid-production';
|
|
14
13
|
import { islandStylesPlugin } from './island-styles';
|
|
15
14
|
import { solidJsxPlugin } from './solid-loader';
|
|
16
15
|
|
|
@@ -84,12 +83,24 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
84
83
|
// classic `React.createElement` — emitted into a browser chunk that imports no React, with
|
|
85
84
|
// `success: true` and no log. Every island shipped that way through five majors.
|
|
86
85
|
//
|
|
87
|
-
// The
|
|
88
|
-
// `success: true`: without
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
86
|
+
// The second closes the same shape of failure — a wrong answer `Bun.build` reports as
|
|
87
|
+
// `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
|
|
88
|
+
// PATH, so `styles['x']` is `undefined` and every element renders unclassed.
|
|
89
|
+
plugins: [solidJsxPlugin, islandStylesPlugin],
|
|
90
|
+
// The third one, and it is a `define` rather than the plugin this used to be: Bun selects
|
|
91
|
+
// the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
|
|
92
|
+
// and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
|
|
93
|
+
// `solid-js/web` plus `solid-js/store`: unset → dev build, `test` → dev build, `production`
|
|
94
|
+
// → production build, this line → production build in all three, byte for byte.
|
|
95
|
+
//
|
|
96
|
+
// So without it a chunk built anywhere a container did not run — `x dev`, `x build` on a
|
|
97
|
+
// laptop, `bun test` — ships Solid's development build, and the island's own
|
|
98
|
+
// `process.env.NODE_ENV` reads `"development"` in the file a browser downloads.
|
|
99
|
+
//
|
|
100
|
+
// Pinned rather than inherited, because an island chunk is only ever built to be shipped:
|
|
101
|
+
// `x dev` serves the same chunk the container does, and bytes that depend on the ambient
|
|
102
|
+
// NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
|
|
103
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
93
104
|
});
|
|
94
105
|
} catch (error) {
|
|
95
106
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
@@ -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
|
+
}
|