@scenar/cli 0.7.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.
Files changed (44) hide show
  1. package/example-bundle/assets/index-BoIR0_nN.js +147 -0
  2. package/example-bundle/assets/style-YRMeJPPX.css +1 -0
  3. package/example-bundle/index.html +2 -2
  4. package/example-bundle/pack-manifest.json +7 -7
  5. package/package.json +9 -5
  6. package/src/__tests__/pack-generate-embed-entry.test.ts +68 -3
  7. package/src/__tests__/run-shoot.test.ts +270 -0
  8. package/src/api.d.ts +3 -0
  9. package/src/api.d.ts.map +1 -1
  10. package/src/api.js +2 -0
  11. package/src/api.js.map +1 -1
  12. package/src/api.ts +5 -0
  13. package/src/commands/shoot.d.ts +3 -0
  14. package/src/commands/shoot.d.ts.map +1 -0
  15. package/src/commands/shoot.js +52 -0
  16. package/src/commands/shoot.js.map +1 -0
  17. package/src/commands/shoot.ts +72 -0
  18. package/src/index.d.ts.map +1 -1
  19. package/src/index.js +2 -0
  20. package/src/index.js.map +1 -1
  21. package/src/index.ts +2 -0
  22. package/src/pack/generate-embed-entry.d.ts +11 -0
  23. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  24. package/src/pack/generate-embed-entry.js +82 -1
  25. package/src/pack/generate-embed-entry.js.map +1 -1
  26. package/src/pack/generate-embed-entry.ts +83 -1
  27. package/src/shoot/playwright-browser.d.ts +9 -0
  28. package/src/shoot/playwright-browser.d.ts.map +1 -0
  29. package/src/shoot/playwright-browser.js +92 -0
  30. package/src/shoot/playwright-browser.js.map +1 -0
  31. package/src/shoot/playwright-browser.ts +121 -0
  32. package/src/shoot/run-shoot.d.ts +52 -0
  33. package/src/shoot/run-shoot.d.ts.map +1 -0
  34. package/src/shoot/run-shoot.js +172 -0
  35. package/src/shoot/run-shoot.js.map +1 -0
  36. package/src/shoot/run-shoot.ts +251 -0
  37. package/src/shoot/types.d.ts +40 -0
  38. package/src/shoot/types.d.ts.map +1 -0
  39. package/src/shoot/types.js +2 -0
  40. package/src/shoot/types.js.map +1 -0
  41. package/src/shoot/types.ts +40 -0
  42. package/tsconfig.tsbuildinfo +1 -1
  43. package/example-bundle/assets/index-CC5MZqKr.js +0 -147
  44. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/shoot/types.ts"],"names":[],"mappings":""}