@scenar/cli 0.8.0 → 0.9.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/example-bundle/assets/index-BoIR0_nN.js +147 -0
- package/example-bundle/assets/style-YRMeJPPX.css +1 -0
- package/example-bundle/index.html +2 -2
- package/example-bundle/pack-manifest.json +7 -7
- package/package.json +9 -5
- package/src/__tests__/pack-generate-embed-entry.test.ts +68 -3
- package/src/__tests__/run-shoot.test.ts +270 -0
- package/src/api.d.ts +3 -0
- package/src/api.d.ts.map +1 -1
- package/src/api.js +2 -0
- package/src/api.js.map +1 -1
- package/src/api.ts +5 -0
- package/src/commands/shoot.d.ts +3 -0
- package/src/commands/shoot.d.ts.map +1 -0
- package/src/commands/shoot.js +52 -0
- package/src/commands/shoot.js.map +1 -0
- package/src/commands/shoot.ts +72 -0
- package/src/index.d.ts.map +1 -1
- package/src/index.js +2 -0
- package/src/index.js.map +1 -1
- package/src/index.ts +2 -0
- package/src/pack/generate-embed-entry.d.ts +11 -0
- package/src/pack/generate-embed-entry.d.ts.map +1 -1
- package/src/pack/generate-embed-entry.js +82 -1
- package/src/pack/generate-embed-entry.js.map +1 -1
- package/src/pack/generate-embed-entry.ts +83 -1
- package/src/shoot/playwright-browser.d.ts +9 -0
- package/src/shoot/playwright-browser.d.ts.map +1 -0
- package/src/shoot/playwright-browser.js +92 -0
- package/src/shoot/playwright-browser.js.map +1 -0
- package/src/shoot/playwright-browser.ts +121 -0
- package/src/shoot/run-shoot.d.ts +52 -0
- package/src/shoot/run-shoot.d.ts.map +1 -0
- package/src/shoot/run-shoot.js +172 -0
- package/src/shoot/run-shoot.js.map +1 -0
- package/src/shoot/run-shoot.ts +251 -0
- package/src/shoot/types.d.ts +40 -0
- package/src/shoot/types.d.ts.map +1 -0
- package/src/shoot/types.js +2 -0
- package/src/shoot/types.js.map +1 -0
- package/src/shoot/types.ts +40 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/example-bundle/assets/index-CBaiZ4ek.js +0 -147
- package/example-bundle/assets/style-Ci-h7jik.css +0 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ShotBrowser, ShotCaptureInfo } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chromium launch flags for byte-deterministic rasterization — every one
|
|
5
|
+
* proven necessary by the 2026-07-28 determinism spike (without them,
|
|
6
|
+
* ±1-LSB full-frame compositing jitter across sessions):
|
|
7
|
+
*
|
|
8
|
+
* - `--disable-gpu`: software rasterization, no GPU-dependent output.
|
|
9
|
+
* - `--force-color-profile=srgb`: pins color conversion.
|
|
10
|
+
* - `--disable-lcd-text`: subpixel AA depends on the (virtual) display.
|
|
11
|
+
* - `--hide-scrollbars`: platform scrollbar chrome is not content.
|
|
12
|
+
*/
|
|
13
|
+
const DETERMINISM_LAUNCH_FLAGS = [
|
|
14
|
+
"--disable-gpu",
|
|
15
|
+
"--force-color-profile=srgb",
|
|
16
|
+
"--disable-lcd-text",
|
|
17
|
+
"--hide-scrollbars",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const PLAYWRIGHT_INSTALL_HINT =
|
|
21
|
+
"Install it in your demos project: npm install -D playwright\n" +
|
|
22
|
+
"Then install the browser binary: npx playwright install chromium";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The real {@link ShotBrowser}: Playwright Chromium with the full spike
|
|
26
|
+
* determinism recipe. Playwright is an optional peer dependency (the
|
|
27
|
+
* remotion/echogarden pattern) — loaded lazily so every other command works
|
|
28
|
+
* without it, and failing with install instructions when absent.
|
|
29
|
+
*/
|
|
30
|
+
export async function createPlaywrightShotBrowser(): Promise<ShotBrowser> {
|
|
31
|
+
const playwright = await import("playwright").catch(() => {
|
|
32
|
+
throw new Error(`Could not load playwright.\n${PLAYWRIGHT_INSTALL_HINT}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const browser = await playwright.chromium
|
|
36
|
+
.launch({ args: DETERMINISM_LAUNCH_FLAGS })
|
|
37
|
+
.catch((error: unknown) => {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Could not launch Chromium.\n${message}\n\n${PLAYWRIGHT_INSTALL_HINT}`,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async newSession({ theme, viewport }) {
|
|
46
|
+
// `reducedMotion: "reduce"` is safe under a TimeSource: step index
|
|
47
|
+
// derives from time, not from the reduced-motion shortcut — and it
|
|
48
|
+
// keeps framer-motion from animating on wall-clock time.
|
|
49
|
+
// `colorScheme` emulation resolves the stage backdrop's light-dark()
|
|
50
|
+
// colors; the `?theme=` param handles the scenar token class — both
|
|
51
|
+
// halves are required for a correct dark variant (DD-02 D2).
|
|
52
|
+
const context = await browser.newContext({
|
|
53
|
+
viewport,
|
|
54
|
+
deviceScaleFactor: 2,
|
|
55
|
+
reducedMotion: "reduce",
|
|
56
|
+
colorScheme: theme,
|
|
57
|
+
});
|
|
58
|
+
const page = await context.newPage();
|
|
59
|
+
let frameSelector = "";
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
async open(url, timeoutMs) {
|
|
63
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
64
|
+
await page.waitForFunction(
|
|
65
|
+
() =>
|
|
66
|
+
(window as { __scenarShot?: unknown }).__scenarShot !== undefined ||
|
|
67
|
+
(window as { __scenarShotError?: unknown }).__scenarShotError !== undefined,
|
|
68
|
+
undefined,
|
|
69
|
+
{ timeout: timeoutMs },
|
|
70
|
+
);
|
|
71
|
+
const result: { error: string | null } & ShotCaptureInfo = await page.evaluate(() => {
|
|
72
|
+
const w = window as unknown as {
|
|
73
|
+
__scenarShot?: { shots: ShotCaptureInfo["shots"]; frameSelector: string };
|
|
74
|
+
__scenarShotError?: string;
|
|
75
|
+
};
|
|
76
|
+
if (w.__scenarShotError !== undefined) {
|
|
77
|
+
return { error: String(w.__scenarShotError), shots: [], frameSelector: "" };
|
|
78
|
+
}
|
|
79
|
+
const driver = w.__scenarShot!;
|
|
80
|
+
return { error: null, shots: driver.shots, frameSelector: driver.frameSelector };
|
|
81
|
+
});
|
|
82
|
+
if (result.error !== null) {
|
|
83
|
+
throw new Error(`the capture page reported: ${result.error}`);
|
|
84
|
+
}
|
|
85
|
+
// Webfont rasterization differs from the fallback font — never
|
|
86
|
+
// shoot before every declared font face has loaded.
|
|
87
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
|
88
|
+
frameSelector = result.frameSelector;
|
|
89
|
+
return { shots: result.shots, frameSelector: result.frameSelector };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async walkTo(timeMs) {
|
|
93
|
+
await page.evaluate(
|
|
94
|
+
(ms) =>
|
|
95
|
+
(
|
|
96
|
+
window as unknown as {
|
|
97
|
+
__scenarShot: { walkTo: (t: number) => Promise<void> };
|
|
98
|
+
}
|
|
99
|
+
).__scenarShot.walkTo(ms),
|
|
100
|
+
timeMs,
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async screenshotFrame() {
|
|
105
|
+
// `animations: "disabled"` is load-bearing: the shells crossfade
|
|
106
|
+
// content on wall-clock CSS time, and this alone caused whole-pane
|
|
107
|
+
// noise in the spike until disabled at screenshot time.
|
|
108
|
+
return page.locator(frameSelector).screenshot({ animations: "disabled" });
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async close() {
|
|
112
|
+
await context.close();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async close() {
|
|
118
|
+
await browser.close();
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ShotBrowser, ShotCaptureInfo, ShotTheme } from "./types.js";
|
|
2
|
+
/** Directory inside the bundle where stills land (and deploy from). */
|
|
3
|
+
export declare const STILLS_DIR = "stills";
|
|
4
|
+
/** Options for {@link runShoot}. Paths may be relative; they are resolved here. */
|
|
5
|
+
export interface RunShootOptions {
|
|
6
|
+
/** A packed bundle directory (a `scenar pack` output with a ?shot entry). */
|
|
7
|
+
readonly bundleDir: string;
|
|
8
|
+
/** Themes to capture (default: both — DD-02 D2). */
|
|
9
|
+
readonly themes?: readonly ShotTheme[];
|
|
10
|
+
/**
|
|
11
|
+
* Capture everything twice in fresh browser sessions and byte-compare —
|
|
12
|
+
* the determinism gate. A mismatch means some component renders
|
|
13
|
+
* nondeterministically and the stills cannot be trusted.
|
|
14
|
+
*/
|
|
15
|
+
readonly verify?: boolean;
|
|
16
|
+
/** Per-page readiness timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
/** Progress sink for mid-operation messages. */
|
|
19
|
+
readonly onLog?: (message: string) => void;
|
|
20
|
+
/** Test seam: swap the real Playwright browser for a fake. */
|
|
21
|
+
readonly browserFactory?: () => Promise<ShotBrowser>;
|
|
22
|
+
}
|
|
23
|
+
/** The outcome of a successful shoot. */
|
|
24
|
+
export interface ShootResult {
|
|
25
|
+
readonly scenarioId: string;
|
|
26
|
+
readonly bundleDir: string;
|
|
27
|
+
/** The bundle's declared shots, in timeline order (empty = nothing to do). */
|
|
28
|
+
readonly shots: ShotCaptureInfo["shots"];
|
|
29
|
+
/** Bundle-relative paths of the stills written, in write order. */
|
|
30
|
+
readonly files: readonly string[];
|
|
31
|
+
readonly themes: readonly ShotTheme[];
|
|
32
|
+
/** True when `verify` ran and every still was byte-identical. */
|
|
33
|
+
readonly verified: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Render a packed bundle's declared shots to still images — the pure
|
|
37
|
+
* orchestration behind `scenar shoot`, with no process/exit coupling so both
|
|
38
|
+
* the CLI command and the MCP server can call it.
|
|
39
|
+
*
|
|
40
|
+
* Bundle-centric by design: stills are captured from (and written into) the
|
|
41
|
+
* exact bundle that deploys, so they can never depict anything but what
|
|
42
|
+
* ships. The pack manifest is rebuilt afterwards so `publish`/deploy pick
|
|
43
|
+
* the stills up with zero pipeline changes.
|
|
44
|
+
*
|
|
45
|
+
* `stills/` is cleared up front, unconditionally — even when the scenario
|
|
46
|
+
* declares no shots — and the manifest is rebuilt in every path. A removed
|
|
47
|
+
* or renamed shot must never linger in a deployed bundle, and a manifest
|
|
48
|
+
* listing deleted files would fail its own existence check at the next
|
|
49
|
+
* publish.
|
|
50
|
+
*/
|
|
51
|
+
export declare function runShoot(options: RunShootOptions): Promise<ShootResult>;
|
|
52
|
+
//# sourceMappingURL=run-shoot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-shoot.d.ts","sourceRoot":"","sources":["../../../src/shoot/run-shoot.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAe,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvF,uEAAuE;AACvE,eAAO,MAAM,UAAU,WAAW,CAAC;AAWnC,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC;CACtD;AAED,yCAAyC;AACzC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IACzC,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IACtC,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,WAAW,CAAC,CA0E7E"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { PACK_MANIFEST_FILE, SCENARIO_JSON_FILE, buildPackManifest, verifyManifestFilesExist, writePackManifest, } from "../pack/pack-manifest.js";
|
|
4
|
+
import { readBundleViewport } from "../bundle/read-viewport.js";
|
|
5
|
+
import { startBundleServer } from "../serve/static-server.js";
|
|
6
|
+
import { createPlaywrightShotBrowser } from "./playwright-browser.js";
|
|
7
|
+
/** Directory inside the bundle where stills land (and deploy from). */
|
|
8
|
+
export const STILLS_DIR = "stills";
|
|
9
|
+
/** How long to wait for the capture page's driver before failing. */
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
|
+
/**
|
|
12
|
+
* Extra room around the canonical frame so the element screenshot never has
|
|
13
|
+
* to scroll-stitch (stitching re-rasterizes and would break byte-exactness).
|
|
14
|
+
*/
|
|
15
|
+
const VIEWPORT_MARGIN_PX = 64;
|
|
16
|
+
/**
|
|
17
|
+
* Render a packed bundle's declared shots to still images — the pure
|
|
18
|
+
* orchestration behind `scenar shoot`, with no process/exit coupling so both
|
|
19
|
+
* the CLI command and the MCP server can call it.
|
|
20
|
+
*
|
|
21
|
+
* Bundle-centric by design: stills are captured from (and written into) the
|
|
22
|
+
* exact bundle that deploys, so they can never depict anything but what
|
|
23
|
+
* ships. The pack manifest is rebuilt afterwards so `publish`/deploy pick
|
|
24
|
+
* the stills up with zero pipeline changes.
|
|
25
|
+
*
|
|
26
|
+
* `stills/` is cleared up front, unconditionally — even when the scenario
|
|
27
|
+
* declares no shots — and the manifest is rebuilt in every path. A removed
|
|
28
|
+
* or renamed shot must never linger in a deployed bundle, and a manifest
|
|
29
|
+
* listing deleted files would fail its own existence check at the next
|
|
30
|
+
* publish.
|
|
31
|
+
*/
|
|
32
|
+
export async function runShoot(options) {
|
|
33
|
+
const onLog = options.onLog ?? (() => { });
|
|
34
|
+
const bundleDir = resolve(options.bundleDir);
|
|
35
|
+
const themes = options.themes ?? ["light", "dark"];
|
|
36
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
|
+
const scenarioId = await validateBundle(bundleDir);
|
|
38
|
+
const { viewport } = await readBundleViewport(bundleDir);
|
|
39
|
+
onLog(`Scenario: ${scenarioId}`);
|
|
40
|
+
onLog(`Bundle: ${bundleDir}`);
|
|
41
|
+
onLog(`Viewport: ${viewport.width}x${viewport.height} (DPR 2)`);
|
|
42
|
+
onLog(`Themes: ${themes.join(", ")}`);
|
|
43
|
+
await rm(join(bundleDir, STILLS_DIR), { recursive: true, force: true });
|
|
44
|
+
const server = await startBundleServer({ rootDir: bundleDir, port: 0 });
|
|
45
|
+
let browser;
|
|
46
|
+
try {
|
|
47
|
+
browser = await (options.browserFactory ?? createPlaywrightShotBrowser)();
|
|
48
|
+
const sessionViewport = {
|
|
49
|
+
width: viewport.width + VIEWPORT_MARGIN_PX,
|
|
50
|
+
height: viewport.height + VIEWPORT_MARGIN_PX,
|
|
51
|
+
};
|
|
52
|
+
const captures = new Map();
|
|
53
|
+
for (const theme of themes) {
|
|
54
|
+
captures.set(theme, await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs));
|
|
55
|
+
}
|
|
56
|
+
const first = captures.get(themes[0]).info;
|
|
57
|
+
assertShotsAgreeAcrossThemes(captures);
|
|
58
|
+
if (first.shots.length === 0) {
|
|
59
|
+
onLog("No steps declare a `shot` — nothing to capture.");
|
|
60
|
+
await rebuildManifest(bundleDir, scenarioId);
|
|
61
|
+
return { scenarioId, bundleDir, shots: [], files: [], themes, verified: false };
|
|
62
|
+
}
|
|
63
|
+
if (options.verify) {
|
|
64
|
+
onLog("Verifying determinism (second capture in fresh sessions)...");
|
|
65
|
+
for (const theme of themes) {
|
|
66
|
+
const rerun = await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs);
|
|
67
|
+
assertByteIdentical(theme, captures.get(theme), rerun);
|
|
68
|
+
}
|
|
69
|
+
onLog(`Determinism verified: every still byte-identical across sessions.`);
|
|
70
|
+
}
|
|
71
|
+
await mkdir(join(bundleDir, STILLS_DIR), { recursive: true });
|
|
72
|
+
const files = [];
|
|
73
|
+
for (const theme of themes) {
|
|
74
|
+
const { info, images } = captures.get(theme);
|
|
75
|
+
for (const [i, shot] of info.shots.entries()) {
|
|
76
|
+
const relative = `${STILLS_DIR}/${shot.name}.${theme}.png`;
|
|
77
|
+
await writeFile(join(bundleDir, ...relative.split("/")), images[i]);
|
|
78
|
+
files.push(relative);
|
|
79
|
+
onLog(`Captured: ${relative}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await rebuildManifest(bundleDir, scenarioId);
|
|
83
|
+
return {
|
|
84
|
+
scenarioId,
|
|
85
|
+
bundleDir,
|
|
86
|
+
shots: first.shots,
|
|
87
|
+
files,
|
|
88
|
+
themes,
|
|
89
|
+
verified: options.verify ?? false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await browser?.close().catch(() => { });
|
|
94
|
+
await server.close().catch(() => { });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Walk one themed session through every shot, collecting screenshots. */
|
|
98
|
+
async function captureTheme(browser, baseUrl, theme, viewport, timeoutMs) {
|
|
99
|
+
// `?shot` (bare) switches the packed entry into capture mode; the scenar
|
|
100
|
+
// token class needs `theme=dark` on top of the context's colorScheme
|
|
101
|
+
// emulation — both halves of a dark variant (DD-02 D2).
|
|
102
|
+
const url = `${baseUrl}?shot${theme === "dark" ? "&theme=dark" : ""}`;
|
|
103
|
+
const session = await browser.newSession({ theme, viewport });
|
|
104
|
+
try {
|
|
105
|
+
const info = await session.open(url, timeoutMs);
|
|
106
|
+
const images = [];
|
|
107
|
+
// Sequential walk in timeline order — never a cold jump (cross-step
|
|
108
|
+
// React state does not survive one; spike-proven, DD-02).
|
|
109
|
+
for (const shot of info.shots) {
|
|
110
|
+
await session.walkTo(shot.timeMs);
|
|
111
|
+
images.push(await session.screenshotFrame());
|
|
112
|
+
}
|
|
113
|
+
return { info, images };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await session.close().catch(() => { });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** A bundle is shootable when it has the three files `scenar pack` writes. */
|
|
120
|
+
async function validateBundle(bundleDir) {
|
|
121
|
+
const info = await stat(bundleDir).catch(() => null);
|
|
122
|
+
if (!info || !info.isDirectory()) {
|
|
123
|
+
throw new Error(`${bundleDir} is not a directory. Pass a bundle produced by \`scenar pack\`.`);
|
|
124
|
+
}
|
|
125
|
+
for (const required of ["index.html", SCENARIO_JSON_FILE, PACK_MANIFEST_FILE]) {
|
|
126
|
+
const fileInfo = await stat(join(bundleDir, required)).catch(() => null);
|
|
127
|
+
if (!fileInfo || !fileInfo.isFile()) {
|
|
128
|
+
throw new Error(`no ${required} in ${bundleDir}. Run \`scenar pack\` to produce a shootable bundle first.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const scenarioJson = JSON.parse(await readFile(join(bundleDir, SCENARIO_JSON_FILE), "utf-8"));
|
|
132
|
+
if (typeof scenarioJson.id !== "string" || scenarioJson.id.length === 0) {
|
|
133
|
+
throw new Error(`${SCENARIO_JSON_FILE} in ${bundleDir} has no scenario id.`);
|
|
134
|
+
}
|
|
135
|
+
return scenarioJson.id;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The shot list is derived from the bundled steps, so every theme must
|
|
139
|
+
* report the identical list — a divergence means the bundle itself renders
|
|
140
|
+
* nondeterministically and nothing downstream can be trusted.
|
|
141
|
+
*/
|
|
142
|
+
function assertShotsAgreeAcrossThemes(captures) {
|
|
143
|
+
const lists = [...captures.entries()].map(([theme, { info }]) => [theme, JSON.stringify(info.shots)]);
|
|
144
|
+
const [, reference] = lists[0];
|
|
145
|
+
for (const [theme, list] of lists) {
|
|
146
|
+
if (list !== reference) {
|
|
147
|
+
throw new Error(`internal error: the ${theme} capture reported a different shot list than ${lists[0][0]}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Byte-compare two capture passes of the same theme (the `--verify` gate). */
|
|
152
|
+
function assertByteIdentical(theme, first, second) {
|
|
153
|
+
const differing = first.info.shots
|
|
154
|
+
.filter((_, i) => !first.images[i].equals(second.images[i]))
|
|
155
|
+
.map((shot) => shot.name);
|
|
156
|
+
if (differing.length > 0) {
|
|
157
|
+
throw new Error(`determinism check failed (${theme}): shot(s) ${differing.join(", ")} differed ` +
|
|
158
|
+
`across fresh sessions. Some component renders nondeterministically — ` +
|
|
159
|
+
`fix that before trusting these stills (see DD-02's determinism recipe).`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Re-list the bundle after stills changed. `buildPackManifest` validates
|
|
164
|
+
* every file against the deploy allowlist, so a bad shot filename fails
|
|
165
|
+
* here, locally, rather than mid-upload.
|
|
166
|
+
*/
|
|
167
|
+
async function rebuildManifest(bundleDir, scenarioId) {
|
|
168
|
+
const manifest = await buildPackManifest(bundleDir, scenarioId);
|
|
169
|
+
await verifyManifestFilesExist(bundleDir, manifest);
|
|
170
|
+
await writePackManifest(bundleDir, manifest);
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=run-shoot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-shoot.js","sourceRoot":"","sources":["../../../src/shoot/run-shoot.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
PACK_MANIFEST_FILE,
|
|
5
|
+
SCENARIO_JSON_FILE,
|
|
6
|
+
buildPackManifest,
|
|
7
|
+
verifyManifestFilesExist,
|
|
8
|
+
writePackManifest,
|
|
9
|
+
} from "../pack/pack-manifest.js";
|
|
10
|
+
import { readBundleViewport } from "../bundle/read-viewport.js";
|
|
11
|
+
import { startBundleServer } from "../serve/static-server.js";
|
|
12
|
+
import { createPlaywrightShotBrowser } from "./playwright-browser.js";
|
|
13
|
+
import type { ShotBrowser, ShotCaptureInfo, ShotSession, ShotTheme } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Directory inside the bundle where stills land (and deploy from). */
|
|
16
|
+
export const STILLS_DIR = "stills";
|
|
17
|
+
|
|
18
|
+
/** How long to wait for the capture page's driver before failing. */
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extra room around the canonical frame so the element screenshot never has
|
|
23
|
+
* to scroll-stitch (stitching re-rasterizes and would break byte-exactness).
|
|
24
|
+
*/
|
|
25
|
+
const VIEWPORT_MARGIN_PX = 64;
|
|
26
|
+
|
|
27
|
+
/** Options for {@link runShoot}. Paths may be relative; they are resolved here. */
|
|
28
|
+
export interface RunShootOptions {
|
|
29
|
+
/** A packed bundle directory (a `scenar pack` output with a ?shot entry). */
|
|
30
|
+
readonly bundleDir: string;
|
|
31
|
+
/** Themes to capture (default: both — DD-02 D2). */
|
|
32
|
+
readonly themes?: readonly ShotTheme[];
|
|
33
|
+
/**
|
|
34
|
+
* Capture everything twice in fresh browser sessions and byte-compare —
|
|
35
|
+
* the determinism gate. A mismatch means some component renders
|
|
36
|
+
* nondeterministically and the stills cannot be trusted.
|
|
37
|
+
*/
|
|
38
|
+
readonly verify?: boolean;
|
|
39
|
+
/** Per-page readiness timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */
|
|
40
|
+
readonly timeoutMs?: number;
|
|
41
|
+
/** Progress sink for mid-operation messages. */
|
|
42
|
+
readonly onLog?: (message: string) => void;
|
|
43
|
+
/** Test seam: swap the real Playwright browser for a fake. */
|
|
44
|
+
readonly browserFactory?: () => Promise<ShotBrowser>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The outcome of a successful shoot. */
|
|
48
|
+
export interface ShootResult {
|
|
49
|
+
readonly scenarioId: string;
|
|
50
|
+
readonly bundleDir: string;
|
|
51
|
+
/** The bundle's declared shots, in timeline order (empty = nothing to do). */
|
|
52
|
+
readonly shots: ShotCaptureInfo["shots"];
|
|
53
|
+
/** Bundle-relative paths of the stills written, in write order. */
|
|
54
|
+
readonly files: readonly string[];
|
|
55
|
+
readonly themes: readonly ShotTheme[];
|
|
56
|
+
/** True when `verify` ran and every still was byte-identical. */
|
|
57
|
+
readonly verified: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render a packed bundle's declared shots to still images — the pure
|
|
62
|
+
* orchestration behind `scenar shoot`, with no process/exit coupling so both
|
|
63
|
+
* the CLI command and the MCP server can call it.
|
|
64
|
+
*
|
|
65
|
+
* Bundle-centric by design: stills are captured from (and written into) the
|
|
66
|
+
* exact bundle that deploys, so they can never depict anything but what
|
|
67
|
+
* ships. The pack manifest is rebuilt afterwards so `publish`/deploy pick
|
|
68
|
+
* the stills up with zero pipeline changes.
|
|
69
|
+
*
|
|
70
|
+
* `stills/` is cleared up front, unconditionally — even when the scenario
|
|
71
|
+
* declares no shots — and the manifest is rebuilt in every path. A removed
|
|
72
|
+
* or renamed shot must never linger in a deployed bundle, and a manifest
|
|
73
|
+
* listing deleted files would fail its own existence check at the next
|
|
74
|
+
* publish.
|
|
75
|
+
*/
|
|
76
|
+
export async function runShoot(options: RunShootOptions): Promise<ShootResult> {
|
|
77
|
+
const onLog = options.onLog ?? (() => {});
|
|
78
|
+
const bundleDir = resolve(options.bundleDir);
|
|
79
|
+
const themes = options.themes ?? (["light", "dark"] as const);
|
|
80
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
81
|
+
|
|
82
|
+
const scenarioId = await validateBundle(bundleDir);
|
|
83
|
+
const { viewport } = await readBundleViewport(bundleDir);
|
|
84
|
+
|
|
85
|
+
onLog(`Scenario: ${scenarioId}`);
|
|
86
|
+
onLog(`Bundle: ${bundleDir}`);
|
|
87
|
+
onLog(`Viewport: ${viewport.width}x${viewport.height} (DPR 2)`);
|
|
88
|
+
onLog(`Themes: ${themes.join(", ")}`);
|
|
89
|
+
|
|
90
|
+
await rm(join(bundleDir, STILLS_DIR), { recursive: true, force: true });
|
|
91
|
+
|
|
92
|
+
const server = await startBundleServer({ rootDir: bundleDir, port: 0 });
|
|
93
|
+
let browser: ShotBrowser | undefined;
|
|
94
|
+
try {
|
|
95
|
+
browser = await (options.browserFactory ?? createPlaywrightShotBrowser)();
|
|
96
|
+
|
|
97
|
+
const sessionViewport = {
|
|
98
|
+
width: viewport.width + VIEWPORT_MARGIN_PX,
|
|
99
|
+
height: viewport.height + VIEWPORT_MARGIN_PX,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const captures = new Map<ShotTheme, { info: ShotCaptureInfo; images: Buffer[] }>();
|
|
103
|
+
for (const theme of themes) {
|
|
104
|
+
captures.set(theme, await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const first = captures.get(themes[0]!)!.info;
|
|
108
|
+
assertShotsAgreeAcrossThemes(captures);
|
|
109
|
+
|
|
110
|
+
if (first.shots.length === 0) {
|
|
111
|
+
onLog("No steps declare a `shot` — nothing to capture.");
|
|
112
|
+
await rebuildManifest(bundleDir, scenarioId);
|
|
113
|
+
return { scenarioId, bundleDir, shots: [], files: [], themes, verified: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.verify) {
|
|
117
|
+
onLog("Verifying determinism (second capture in fresh sessions)...");
|
|
118
|
+
for (const theme of themes) {
|
|
119
|
+
const rerun = await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs);
|
|
120
|
+
assertByteIdentical(theme, captures.get(theme)!, rerun);
|
|
121
|
+
}
|
|
122
|
+
onLog(`Determinism verified: every still byte-identical across sessions.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await mkdir(join(bundleDir, STILLS_DIR), { recursive: true });
|
|
126
|
+
const files: string[] = [];
|
|
127
|
+
for (const theme of themes) {
|
|
128
|
+
const { info, images } = captures.get(theme)!;
|
|
129
|
+
for (const [i, shot] of info.shots.entries()) {
|
|
130
|
+
const relative = `${STILLS_DIR}/${shot.name}.${theme}.png`;
|
|
131
|
+
await writeFile(join(bundleDir, ...relative.split("/")), images[i]!);
|
|
132
|
+
files.push(relative);
|
|
133
|
+
onLog(`Captured: ${relative}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await rebuildManifest(bundleDir, scenarioId);
|
|
138
|
+
return {
|
|
139
|
+
scenarioId,
|
|
140
|
+
bundleDir,
|
|
141
|
+
shots: first.shots,
|
|
142
|
+
files,
|
|
143
|
+
themes,
|
|
144
|
+
verified: options.verify ?? false,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
await browser?.close().catch(() => {});
|
|
148
|
+
await server.close().catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Walk one themed session through every shot, collecting screenshots. */
|
|
153
|
+
async function captureTheme(
|
|
154
|
+
browser: ShotBrowser,
|
|
155
|
+
baseUrl: string,
|
|
156
|
+
theme: ShotTheme,
|
|
157
|
+
viewport: { width: number; height: number },
|
|
158
|
+
timeoutMs: number,
|
|
159
|
+
): Promise<{ info: ShotCaptureInfo; images: Buffer[] }> {
|
|
160
|
+
// `?shot` (bare) switches the packed entry into capture mode; the scenar
|
|
161
|
+
// token class needs `theme=dark` on top of the context's colorScheme
|
|
162
|
+
// emulation — both halves of a dark variant (DD-02 D2).
|
|
163
|
+
const url = `${baseUrl}?shot${theme === "dark" ? "&theme=dark" : ""}`;
|
|
164
|
+
const session: ShotSession = await browser.newSession({ theme, viewport });
|
|
165
|
+
try {
|
|
166
|
+
const info = await session.open(url, timeoutMs);
|
|
167
|
+
const images: Buffer[] = [];
|
|
168
|
+
// Sequential walk in timeline order — never a cold jump (cross-step
|
|
169
|
+
// React state does not survive one; spike-proven, DD-02).
|
|
170
|
+
for (const shot of info.shots) {
|
|
171
|
+
await session.walkTo(shot.timeMs);
|
|
172
|
+
images.push(await session.screenshotFrame());
|
|
173
|
+
}
|
|
174
|
+
return { info, images };
|
|
175
|
+
} finally {
|
|
176
|
+
await session.close().catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A bundle is shootable when it has the three files `scenar pack` writes. */
|
|
181
|
+
async function validateBundle(bundleDir: string): Promise<string> {
|
|
182
|
+
const info = await stat(bundleDir).catch(() => null);
|
|
183
|
+
if (!info || !info.isDirectory()) {
|
|
184
|
+
throw new Error(`${bundleDir} is not a directory. Pass a bundle produced by \`scenar pack\`.`);
|
|
185
|
+
}
|
|
186
|
+
for (const required of ["index.html", SCENARIO_JSON_FILE, PACK_MANIFEST_FILE]) {
|
|
187
|
+
const fileInfo = await stat(join(bundleDir, required)).catch(() => null);
|
|
188
|
+
if (!fileInfo || !fileInfo.isFile()) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`no ${required} in ${bundleDir}. Run \`scenar pack\` to produce a shootable bundle first.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const scenarioJson = JSON.parse(
|
|
195
|
+
await readFile(join(bundleDir, SCENARIO_JSON_FILE), "utf-8"),
|
|
196
|
+
) as { id?: unknown };
|
|
197
|
+
if (typeof scenarioJson.id !== "string" || scenarioJson.id.length === 0) {
|
|
198
|
+
throw new Error(`${SCENARIO_JSON_FILE} in ${bundleDir} has no scenario id.`);
|
|
199
|
+
}
|
|
200
|
+
return scenarioJson.id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The shot list is derived from the bundled steps, so every theme must
|
|
205
|
+
* report the identical list — a divergence means the bundle itself renders
|
|
206
|
+
* nondeterministically and nothing downstream can be trusted.
|
|
207
|
+
*/
|
|
208
|
+
function assertShotsAgreeAcrossThemes(
|
|
209
|
+
captures: Map<ShotTheme, { info: ShotCaptureInfo; images: Buffer[] }>,
|
|
210
|
+
): void {
|
|
211
|
+
const lists = [...captures.entries()].map(
|
|
212
|
+
([theme, { info }]) => [theme, JSON.stringify(info.shots)] as const,
|
|
213
|
+
);
|
|
214
|
+
const [, reference] = lists[0]!;
|
|
215
|
+
for (const [theme, list] of lists) {
|
|
216
|
+
if (list !== reference) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`internal error: the ${theme} capture reported a different shot list than ${lists[0]![0]}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Byte-compare two capture passes of the same theme (the `--verify` gate). */
|
|
225
|
+
function assertByteIdentical(
|
|
226
|
+
theme: ShotTheme,
|
|
227
|
+
first: { info: ShotCaptureInfo; images: Buffer[] },
|
|
228
|
+
second: { info: ShotCaptureInfo; images: Buffer[] },
|
|
229
|
+
): void {
|
|
230
|
+
const differing = first.info.shots
|
|
231
|
+
.filter((_, i) => !first.images[i]!.equals(second.images[i]!))
|
|
232
|
+
.map((shot) => shot.name);
|
|
233
|
+
if (differing.length > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`determinism check failed (${theme}): shot(s) ${differing.join(", ")} differed ` +
|
|
236
|
+
`across fresh sessions. Some component renders nondeterministically — ` +
|
|
237
|
+
`fix that before trusting these stills (see DD-02's determinism recipe).`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Re-list the bundle after stills changed. `buildPackManifest` validates
|
|
244
|
+
* every file against the deploy allowlist, so a bad shot filename fails
|
|
245
|
+
* here, locally, rather than mid-upload.
|
|
246
|
+
*/
|
|
247
|
+
async function rebuildManifest(bundleDir: string, scenarioId: string): Promise<void> {
|
|
248
|
+
const manifest = await buildPackManifest(bundleDir, scenarioId);
|
|
249
|
+
await verifyManifestFilesExist(bundleDir, manifest);
|
|
250
|
+
await writePackManifest(bundleDir, manifest);
|
|
251
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ScenarioShot } from "@scenar/core";
|
|
2
|
+
/** The two capture themes — every shot is rendered once per theme (DD-02 D2). */
|
|
3
|
+
export type ShotTheme = "light" | "dark";
|
|
4
|
+
/** What the capture page reports once its driver is ready. */
|
|
5
|
+
export interface ShotCaptureInfo {
|
|
6
|
+
/** The bundle's declared shots, validated and in timeline order. */
|
|
7
|
+
readonly shots: readonly ScenarioShot[];
|
|
8
|
+
/** Selector for the element to screenshot (owned by the capture mount). */
|
|
9
|
+
readonly frameSelector: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One themed browser page driving a capture — the narrow seam between
|
|
13
|
+
* run-shoot's orchestration (unit-tested against a fake) and Playwright
|
|
14
|
+
* (exercised by the real end-to-end capture, not by unit tests).
|
|
15
|
+
*/
|
|
16
|
+
export interface ShotSession {
|
|
17
|
+
/**
|
|
18
|
+
* Navigate to the capture URL and wait for the page to report either a
|
|
19
|
+
* ready driver or an error (`window.__scenarShot` / `__scenarShotError`).
|
|
20
|
+
* Throws with the page's own message on a reported error or on timeout.
|
|
21
|
+
*/
|
|
22
|
+
open(url: string, timeoutMs: number): Promise<ShotCaptureInfo>;
|
|
23
|
+
/** Advance the page's TimeSource walk to `timeMs` (sequential, never back). */
|
|
24
|
+
walkTo(timeMs: number): Promise<void>;
|
|
25
|
+
/** Screenshot the capture frame, animations disabled. */
|
|
26
|
+
screenshotFrame(): Promise<Buffer>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** A launched capture browser that can open themed sessions. */
|
|
30
|
+
export interface ShotBrowser {
|
|
31
|
+
newSession(options: {
|
|
32
|
+
theme: ShotTheme;
|
|
33
|
+
viewport: {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
};
|
|
37
|
+
}): Promise<ShotSession>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/shoot/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,iFAAiF;AACjF,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;AAEzC,8DAA8D;AAC9D,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IACxC,2EAA2E;IAC3E,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IAC/D,+EAA+E;IAC/E,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,yDAAyD;IACzD,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACnC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,OAAO,EAAE;QAClB,KAAK,EAAE,SAAS,CAAC;QACjB,QAAQ,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,CAAC;KAC7C,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/shoot/types.ts"],"names":[],"mappings":""}
|