@scenar/cli 0.8.0 → 0.9.1

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 (78) hide show
  1. package/example-bundle/assets/index-DlmG1Uh7.js +388 -0
  2. package/example-bundle/assets/style-BjquDnTE.css +1 -0
  3. package/example-bundle/index.html +2 -2
  4. package/example-bundle/pack-manifest.json +9 -9
  5. package/example-bundle/scenario.json +6 -2
  6. package/package.json +9 -5
  7. package/src/__tests__/collect-pack-shots.test.ts +97 -0
  8. package/src/__tests__/pack-generate-embed-entry.test.ts +68 -3
  9. package/src/__tests__/pack-manifest.test.ts +17 -0
  10. package/src/__tests__/read-shots.test.ts +57 -0
  11. package/src/__tests__/run-shoot.test.ts +319 -0
  12. package/src/api.d.ts +3 -0
  13. package/src/api.d.ts.map +1 -1
  14. package/src/api.js +2 -0
  15. package/src/api.js.map +1 -1
  16. package/src/api.ts +5 -0
  17. package/src/bundle/read-shots.d.ts +28 -0
  18. package/src/bundle/read-shots.d.ts.map +1 -0
  19. package/src/bundle/read-shots.js +28 -0
  20. package/src/bundle/read-shots.js.map +1 -0
  21. package/src/bundle/read-shots.ts +38 -0
  22. package/src/commands/shoot.d.ts +3 -0
  23. package/src/commands/shoot.d.ts.map +1 -0
  24. package/src/commands/shoot.js +52 -0
  25. package/src/commands/shoot.js.map +1 -0
  26. package/src/commands/shoot.ts +72 -0
  27. package/src/index.d.ts.map +1 -1
  28. package/src/index.js +2 -0
  29. package/src/index.js.map +1 -1
  30. package/src/index.ts +2 -0
  31. package/src/pack/build.d.ts +33 -1
  32. package/src/pack/build.d.ts.map +1 -1
  33. package/src/pack/build.js +35 -10
  34. package/src/pack/build.js.map +1 -1
  35. package/src/pack/build.ts +63 -17
  36. package/src/pack/collect-pack-shots.d.ts +40 -0
  37. package/src/pack/collect-pack-shots.d.ts.map +1 -0
  38. package/src/pack/collect-pack-shots.js +59 -0
  39. package/src/pack/collect-pack-shots.js.map +1 -0
  40. package/src/pack/collect-pack-shots.ts +76 -0
  41. package/src/pack/generate-embed-entry.d.ts +18 -0
  42. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  43. package/src/pack/generate-embed-entry.js +92 -3
  44. package/src/pack/generate-embed-entry.js.map +1 -1
  45. package/src/pack/generate-embed-entry.ts +94 -3
  46. package/src/pack/pack-manifest.d.ts +12 -3
  47. package/src/pack/pack-manifest.d.ts.map +1 -1
  48. package/src/pack/pack-manifest.js +13 -3
  49. package/src/pack/pack-manifest.js.map +1 -1
  50. package/src/pack/pack-manifest.ts +13 -2
  51. package/src/pack/run-pack.d.ts +6 -0
  52. package/src/pack/run-pack.d.ts.map +1 -1
  53. package/src/pack/run-pack.js +41 -14
  54. package/src/pack/run-pack.js.map +1 -1
  55. package/src/pack/run-pack.ts +55 -14
  56. package/src/shoot/playwright-browser.d.ts +9 -0
  57. package/src/shoot/playwright-browser.d.ts.map +1 -0
  58. package/src/shoot/playwright-browser.js +92 -0
  59. package/src/shoot/playwright-browser.js.map +1 -0
  60. package/src/shoot/playwright-browser.ts +121 -0
  61. package/src/shoot/run-shoot.d.ts +58 -0
  62. package/src/shoot/run-shoot.d.ts.map +1 -0
  63. package/src/shoot/run-shoot.js +190 -0
  64. package/src/shoot/run-shoot.js.map +1 -0
  65. package/src/shoot/run-shoot.ts +270 -0
  66. package/src/shoot/types.d.ts +40 -0
  67. package/src/shoot/types.d.ts.map +1 -0
  68. package/src/shoot/types.js +2 -0
  69. package/src/shoot/types.js.map +1 -0
  70. package/src/shoot/types.ts +40 -0
  71. package/src/util/load-ts.d.ts +12 -2
  72. package/src/util/load-ts.d.ts.map +1 -1
  73. package/src/util/load-ts.js +25 -10
  74. package/src/util/load-ts.js.map +1 -1
  75. package/src/util/load-ts.ts +25 -11
  76. package/tsconfig.tsbuildinfo +1 -1
  77. package/example-bundle/assets/index-CBaiZ4ek.js +0 -147
  78. package/example-bundle/assets/style-Ci-h7jik.css +0 -1
@@ -0,0 +1,270 @@
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 { readBundleShots } from "../bundle/read-shots.js";
12
+ import { startBundleServer } from "../serve/static-server.js";
13
+ import { createPlaywrightShotBrowser } from "./playwright-browser.js";
14
+ import type { ShotBrowser, ShotCaptureInfo, ShotSession, ShotTheme } from "./types.js";
15
+
16
+ /** Directory inside the bundle where stills land (and deploy from). */
17
+ export const STILLS_DIR = "stills";
18
+
19
+ /** How long to wait for the capture page's driver before failing. */
20
+ const DEFAULT_TIMEOUT_MS = 30_000;
21
+
22
+ /**
23
+ * Extra room around the canonical frame so the element screenshot never has
24
+ * to scroll-stitch (stitching re-rasterizes and would break byte-exactness).
25
+ */
26
+ const VIEWPORT_MARGIN_PX = 64;
27
+
28
+ /** Options for {@link runShoot}. Paths may be relative; they are resolved here. */
29
+ export interface RunShootOptions {
30
+ /** A packed bundle directory (a `scenar pack` output with a ?shot entry). */
31
+ readonly bundleDir: string;
32
+ /** Themes to capture (default: both — DD-02 D2). */
33
+ readonly themes?: readonly ShotTheme[];
34
+ /**
35
+ * Capture everything twice in fresh browser sessions and byte-compare —
36
+ * the determinism gate. A mismatch means some component renders
37
+ * nondeterministically and the stills cannot be trusted.
38
+ */
39
+ readonly verify?: boolean;
40
+ /** Per-page readiness timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */
41
+ readonly timeoutMs?: number;
42
+ /** Progress sink for mid-operation messages. */
43
+ readonly onLog?: (message: string) => void;
44
+ /** Test seam: swap the real Playwright browser for a fake. */
45
+ readonly browserFactory?: () => Promise<ShotBrowser>;
46
+ }
47
+
48
+ /** The outcome of a successful shoot. */
49
+ export interface ShootResult {
50
+ readonly scenarioId: string;
51
+ readonly bundleDir: string;
52
+ /** The bundle's declared shots, in timeline order (empty = nothing to do). */
53
+ readonly shots: ShotCaptureInfo["shots"];
54
+ /** Bundle-relative paths of the stills written, in write order. */
55
+ readonly files: readonly string[];
56
+ readonly themes: readonly ShotTheme[];
57
+ /** True when `verify` ran and every still was byte-identical. */
58
+ readonly verified: boolean;
59
+ }
60
+
61
+ /**
62
+ * Render a packed bundle's declared shots to still images — the pure
63
+ * orchestration behind `scenar shoot`, with no process/exit coupling so both
64
+ * the CLI command and the MCP server can call it.
65
+ *
66
+ * Bundle-centric by design: stills are captured from (and written into) the
67
+ * exact bundle that deploys, so they can never depict anything but what
68
+ * ships. The pack manifest is rebuilt afterwards so `publish`/deploy pick
69
+ * the stills up with zero pipeline changes.
70
+ *
71
+ * `stills/` is cleared up front, unconditionally — even when the scenario
72
+ * declares no shots — and the manifest is rebuilt in every path. A removed
73
+ * or renamed shot must never linger in a deployed bundle, and a manifest
74
+ * listing deleted files would fail its own existence check at the next
75
+ * publish.
76
+ *
77
+ * When scenario.json records an authoritatively empty shot list (pack ≥
78
+ * generator 0.0.2), the whole server + browser boot is skipped — both
79
+ * invariants above still hold on that path. A recorded non-empty list does
80
+ * NOT bypass the capture page: the running bundle stays the runtime truth
81
+ * for what gets shot; the record only proves there is something to boot for.
82
+ */
83
+ export async function runShoot(options: RunShootOptions): Promise<ShootResult> {
84
+ const onLog = options.onLog ?? (() => {});
85
+ const bundleDir = resolve(options.bundleDir);
86
+ const themes = options.themes ?? (["light", "dark"] as const);
87
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
+
89
+ const scenarioId = await validateBundle(bundleDir);
90
+ const { viewport } = await readBundleViewport(bundleDir);
91
+
92
+ onLog(`Scenario: ${scenarioId}`);
93
+ onLog(`Bundle: ${bundleDir}`);
94
+ onLog(`Viewport: ${viewport.width}x${viewport.height} (DPR 2)`);
95
+ onLog(`Themes: ${themes.join(", ")}`);
96
+
97
+ await rm(join(bundleDir, STILLS_DIR), { recursive: true, force: true });
98
+
99
+ // Short-circuit on pack's authority: a recorded-and-empty shot list means
100
+ // there is provably nothing to capture, so don't pay for a static server
101
+ // and a Chromium launch just to learn that. Absent record = unknown — fall
102
+ // through and let the capture page answer, exactly as before the record
103
+ // existed.
104
+ const recordedShots = await readBundleShots(bundleDir);
105
+ if (recordedShots.recorded && recordedShots.shots.length === 0) {
106
+ onLog("No steps declare a `shot` — nothing to capture.");
107
+ await rebuildManifest(bundleDir, scenarioId);
108
+ return { scenarioId, bundleDir, shots: [], files: [], themes, verified: false };
109
+ }
110
+
111
+ const server = await startBundleServer({ rootDir: bundleDir, port: 0 });
112
+ let browser: ShotBrowser | undefined;
113
+ try {
114
+ browser = await (options.browserFactory ?? createPlaywrightShotBrowser)();
115
+
116
+ const sessionViewport = {
117
+ width: viewport.width + VIEWPORT_MARGIN_PX,
118
+ height: viewport.height + VIEWPORT_MARGIN_PX,
119
+ };
120
+
121
+ const captures = new Map<ShotTheme, { info: ShotCaptureInfo; images: Buffer[] }>();
122
+ for (const theme of themes) {
123
+ captures.set(theme, await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs));
124
+ }
125
+
126
+ const first = captures.get(themes[0]!)!.info;
127
+ assertShotsAgreeAcrossThemes(captures);
128
+
129
+ if (first.shots.length === 0) {
130
+ onLog("No steps declare a `shot` — nothing to capture.");
131
+ await rebuildManifest(bundleDir, scenarioId);
132
+ return { scenarioId, bundleDir, shots: [], files: [], themes, verified: false };
133
+ }
134
+
135
+ if (options.verify) {
136
+ onLog("Verifying determinism (second capture in fresh sessions)...");
137
+ for (const theme of themes) {
138
+ const rerun = await captureTheme(browser, server.url, theme, sessionViewport, timeoutMs);
139
+ assertByteIdentical(theme, captures.get(theme)!, rerun);
140
+ }
141
+ onLog(`Determinism verified: every still byte-identical across sessions.`);
142
+ }
143
+
144
+ await mkdir(join(bundleDir, STILLS_DIR), { recursive: true });
145
+ const files: string[] = [];
146
+ for (const theme of themes) {
147
+ const { info, images } = captures.get(theme)!;
148
+ for (const [i, shot] of info.shots.entries()) {
149
+ const relative = `${STILLS_DIR}/${shot.name}.${theme}.png`;
150
+ await writeFile(join(bundleDir, ...relative.split("/")), images[i]!);
151
+ files.push(relative);
152
+ onLog(`Captured: ${relative}`);
153
+ }
154
+ }
155
+
156
+ await rebuildManifest(bundleDir, scenarioId);
157
+ return {
158
+ scenarioId,
159
+ bundleDir,
160
+ shots: first.shots,
161
+ files,
162
+ themes,
163
+ verified: options.verify ?? false,
164
+ };
165
+ } finally {
166
+ await browser?.close().catch(() => {});
167
+ await server.close().catch(() => {});
168
+ }
169
+ }
170
+
171
+ /** Walk one themed session through every shot, collecting screenshots. */
172
+ async function captureTheme(
173
+ browser: ShotBrowser,
174
+ baseUrl: string,
175
+ theme: ShotTheme,
176
+ viewport: { width: number; height: number },
177
+ timeoutMs: number,
178
+ ): Promise<{ info: ShotCaptureInfo; images: Buffer[] }> {
179
+ // `?shot` (bare) switches the packed entry into capture mode; the scenar
180
+ // token class needs `theme=dark` on top of the context's colorScheme
181
+ // emulation — both halves of a dark variant (DD-02 D2).
182
+ const url = `${baseUrl}?shot${theme === "dark" ? "&theme=dark" : ""}`;
183
+ const session: ShotSession = await browser.newSession({ theme, viewport });
184
+ try {
185
+ const info = await session.open(url, timeoutMs);
186
+ const images: Buffer[] = [];
187
+ // Sequential walk in timeline order — never a cold jump (cross-step
188
+ // React state does not survive one; spike-proven, DD-02).
189
+ for (const shot of info.shots) {
190
+ await session.walkTo(shot.timeMs);
191
+ images.push(await session.screenshotFrame());
192
+ }
193
+ return { info, images };
194
+ } finally {
195
+ await session.close().catch(() => {});
196
+ }
197
+ }
198
+
199
+ /** A bundle is shootable when it has the three files `scenar pack` writes. */
200
+ async function validateBundle(bundleDir: string): Promise<string> {
201
+ const info = await stat(bundleDir).catch(() => null);
202
+ if (!info || !info.isDirectory()) {
203
+ throw new Error(`${bundleDir} is not a directory. Pass a bundle produced by \`scenar pack\`.`);
204
+ }
205
+ for (const required of ["index.html", SCENARIO_JSON_FILE, PACK_MANIFEST_FILE]) {
206
+ const fileInfo = await stat(join(bundleDir, required)).catch(() => null);
207
+ if (!fileInfo || !fileInfo.isFile()) {
208
+ throw new Error(
209
+ `no ${required} in ${bundleDir}. Run \`scenar pack\` to produce a shootable bundle first.`,
210
+ );
211
+ }
212
+ }
213
+ const scenarioJson = JSON.parse(
214
+ await readFile(join(bundleDir, SCENARIO_JSON_FILE), "utf-8"),
215
+ ) as { id?: unknown };
216
+ if (typeof scenarioJson.id !== "string" || scenarioJson.id.length === 0) {
217
+ throw new Error(`${SCENARIO_JSON_FILE} in ${bundleDir} has no scenario id.`);
218
+ }
219
+ return scenarioJson.id;
220
+ }
221
+
222
+ /**
223
+ * The shot list is derived from the bundled steps, so every theme must
224
+ * report the identical list — a divergence means the bundle itself renders
225
+ * nondeterministically and nothing downstream can be trusted.
226
+ */
227
+ function assertShotsAgreeAcrossThemes(
228
+ captures: Map<ShotTheme, { info: ShotCaptureInfo; images: Buffer[] }>,
229
+ ): void {
230
+ const lists = [...captures.entries()].map(
231
+ ([theme, { info }]) => [theme, JSON.stringify(info.shots)] as const,
232
+ );
233
+ const [, reference] = lists[0]!;
234
+ for (const [theme, list] of lists) {
235
+ if (list !== reference) {
236
+ throw new Error(
237
+ `internal error: the ${theme} capture reported a different shot list than ${lists[0]![0]}`,
238
+ );
239
+ }
240
+ }
241
+ }
242
+
243
+ /** Byte-compare two capture passes of the same theme (the `--verify` gate). */
244
+ function assertByteIdentical(
245
+ theme: ShotTheme,
246
+ first: { info: ShotCaptureInfo; images: Buffer[] },
247
+ second: { info: ShotCaptureInfo; images: Buffer[] },
248
+ ): void {
249
+ const differing = first.info.shots
250
+ .filter((_, i) => !first.images[i]!.equals(second.images[i]!))
251
+ .map((shot) => shot.name);
252
+ if (differing.length > 0) {
253
+ throw new Error(
254
+ `determinism check failed (${theme}): shot(s) ${differing.join(", ")} differed ` +
255
+ `across fresh sessions. Some component renders nondeterministically — ` +
256
+ `fix that before trusting these stills (see DD-02's determinism recipe).`,
257
+ );
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Re-list the bundle after stills changed. `buildPackManifest` validates
263
+ * every file against the deploy allowlist, so a bad shot filename fails
264
+ * here, locally, rather than mid-upload.
265
+ */
266
+ async function rebuildManifest(bundleDir: string, scenarioId: string): Promise<void> {
267
+ const manifest = await buildPackManifest(bundleDir, scenarioId);
268
+ await verifyManifestFilesExist(bundleDir, manifest);
269
+ await writePackManifest(bundleDir, manifest);
270
+ }
@@ -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":""}
@@ -0,0 +1,40 @@
1
+ import type { ScenarioShot } from "@scenar/core";
2
+
3
+ /** The two capture themes — every shot is rendered once per theme (DD-02 D2). */
4
+ export type ShotTheme = "light" | "dark";
5
+
6
+ /** What the capture page reports once its driver is ready. */
7
+ export interface ShotCaptureInfo {
8
+ /** The bundle's declared shots, validated and in timeline order. */
9
+ readonly shots: readonly ScenarioShot[];
10
+ /** Selector for the element to screenshot (owned by the capture mount). */
11
+ readonly frameSelector: string;
12
+ }
13
+
14
+ /**
15
+ * One themed browser page driving a capture — the narrow seam between
16
+ * run-shoot's orchestration (unit-tested against a fake) and Playwright
17
+ * (exercised by the real end-to-end capture, not by unit tests).
18
+ */
19
+ export interface ShotSession {
20
+ /**
21
+ * Navigate to the capture URL and wait for the page to report either a
22
+ * ready driver or an error (`window.__scenarShot` / `__scenarShotError`).
23
+ * Throws with the page's own message on a reported error or on timeout.
24
+ */
25
+ open(url: string, timeoutMs: number): Promise<ShotCaptureInfo>;
26
+ /** Advance the page's TimeSource walk to `timeMs` (sequential, never back). */
27
+ walkTo(timeMs: number): Promise<void>;
28
+ /** Screenshot the capture frame, animations disabled. */
29
+ screenshotFrame(): Promise<Buffer>;
30
+ close(): Promise<void>;
31
+ }
32
+
33
+ /** A launched capture browser that can open themed sessions. */
34
+ export interface ShotBrowser {
35
+ newSession(options: {
36
+ theme: ShotTheme;
37
+ viewport: { width: number; height: number };
38
+ }): Promise<ShotSession>;
39
+ close(): Promise<void>;
40
+ }
@@ -7,10 +7,20 @@ export interface ImportedStep {
7
7
  delayMs: number;
8
8
  narration?: string;
9
9
  }
10
+ /**
11
+ * Find the steps array in a loaded module's exports by duck-typing: the
12
+ * first exported array whose first element has a `delayMs` property.
13
+ * Returns null when the module exports no such array.
14
+ *
15
+ * This is THE steps-discovery rule, shared by every Node-side loader and
16
+ * mirrored verbatim by the browser-side `_findSteps` in the generated
17
+ * pack entry (see generate-embed-entry.ts) — the mirrors must agree, or
18
+ * pack-time tooling would see different steps than the packed bundle.
19
+ */
20
+ export declare function findStepsArray(exports: Record<string, unknown>): ImportedStep[] | null;
10
21
  /**
11
22
  * Dynamically import a TypeScript steps file and extract the
12
- * steps array by duck-typing (looks for the first exported array
13
- * whose elements have a `delayMs` property).
23
+ * steps array by duck-typing ({@link findStepsArray}).
14
24
  *
15
25
  * Requires the caller's Node process to have a TypeScript loader
16
26
  * active (e.g. running via `tsx`). The CLI itself does not depend
@@ -1 +1 @@
1
- {"version":3,"file":"load-ts.d.ts","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAiB/E"}
1
+ {"version":3,"file":"load-ts.d.ts","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,EAAE,GAAG,IAAI,CAatF;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAO/E"}
@@ -1,16 +1,15 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  /**
3
- * Dynamically import a TypeScript steps file and extract the
4
- * steps array by duck-typing (looks for the first exported array
5
- * whose elements have a `delayMs` property).
3
+ * Find the steps array in a loaded module's exports by duck-typing: the
4
+ * first exported array whose first element has a `delayMs` property.
5
+ * Returns null when the module exports no such array.
6
6
  *
7
- * Requires the caller's Node process to have a TypeScript loader
8
- * active (e.g. running via `tsx`). The CLI itself does not depend
9
- * on any TS compilation tool.
7
+ * This is THE steps-discovery rule, shared by every Node-side loader and
8
+ * mirrored verbatim by the browser-side `_findSteps` in the generated
9
+ * pack entry (see generate-embed-entry.ts) — the mirrors must agree, or
10
+ * pack-time tooling would see different steps than the packed bundle.
10
11
  */
11
- export async function loadStepsFromTs(filePath) {
12
- const mod = await import(pathToFileURL(filePath).href);
13
- const exports = mod.default ?? mod;
12
+ export function findStepsArray(exports) {
14
13
  for (const value of Object.values(exports)) {
15
14
  if (Array.isArray(value) &&
16
15
  value.length > 0 &&
@@ -20,6 +19,22 @@ export async function loadStepsFromTs(filePath) {
20
19
  return value;
21
20
  }
22
21
  }
23
- throw new Error(`No steps array found in ${filePath}`);
22
+ return null;
23
+ }
24
+ /**
25
+ * Dynamically import a TypeScript steps file and extract the
26
+ * steps array by duck-typing ({@link findStepsArray}).
27
+ *
28
+ * Requires the caller's Node process to have a TypeScript loader
29
+ * active (e.g. running via `tsx`). The CLI itself does not depend
30
+ * on any TS compilation tool.
31
+ */
32
+ export async function loadStepsFromTs(filePath) {
33
+ const mod = await import(pathToFileURL(filePath).href);
34
+ const steps = findStepsArray(mod.default ?? mod);
35
+ if (steps === null) {
36
+ throw new Error(`No steps array found in ${filePath}`);
37
+ }
38
+ return steps;
24
39
  }
25
40
  //# sourceMappingURL=load-ts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"load-ts.js","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAYzC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB;IACpD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC;IAEnC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3C,IACE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YACpB,KAAK,CAAC,MAAM,GAAG,CAAC;YAChB,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,QAAQ;YAC5B,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;YACjB,SAAS,IAAI,KAAK,CAAC,CAAC,CAAC,EACrB,CAAC;YACD,OAAO,KAAuB,CAAC;QACjC,CAAC;IACH,CAAC;IAED,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,EAAE,CAAC,CAAC;AACzD,CAAC"}
1
+ {"version":3,"file":"load-ts.js","sourceRoot":"","sources":["../../../src/util/load-ts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAYzC;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,OAAgC;IAC7D,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3C,IACE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YACpB,KAAK,CAAC,MAAM,GAAG,CAAC;YAChB,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,QAAQ;YAC5B,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;YACjB,SAAS,IAAI,KAAK,CAAC,CAAC,CAAC,EACrB,CAAC;YACD,OAAO,KAAuB,CAAC;QACjC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB;IACpD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,KAAK,GAAG,cAAc,CAAC,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,CAAC;IACjD,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,EAAE,CAAC,CAAC;IACzD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -11,18 +11,16 @@ export interface ImportedStep {
11
11
  }
12
12
 
13
13
  /**
14
- * Dynamically import a TypeScript steps file and extract the
15
- * steps array by duck-typing (looks for the first exported array
16
- * whose elements have a `delayMs` property).
14
+ * Find the steps array in a loaded module's exports by duck-typing: the
15
+ * first exported array whose first element has a `delayMs` property.
16
+ * Returns null when the module exports no such array.
17
17
  *
18
- * Requires the caller's Node process to have a TypeScript loader
19
- * active (e.g. running via `tsx`). The CLI itself does not depend
20
- * on any TS compilation tool.
18
+ * This is THE steps-discovery rule, shared by every Node-side loader and
19
+ * mirrored verbatim by the browser-side `_findSteps` in the generated
20
+ * pack entry (see generate-embed-entry.ts) — the mirrors must agree, or
21
+ * pack-time tooling would see different steps than the packed bundle.
21
22
  */
22
- export async function loadStepsFromTs(filePath: string): Promise<ImportedStep[]> {
23
- const mod = await import(pathToFileURL(filePath).href);
24
- const exports = mod.default ?? mod;
25
-
23
+ export function findStepsArray(exports: Record<string, unknown>): ImportedStep[] | null {
26
24
  for (const value of Object.values(exports)) {
27
25
  if (
28
26
  Array.isArray(value) &&
@@ -34,6 +32,22 @@ export async function loadStepsFromTs(filePath: string): Promise<ImportedStep[]>
34
32
  return value as ImportedStep[];
35
33
  }
36
34
  }
35
+ return null;
36
+ }
37
37
 
38
- throw new Error(`No steps array found in ${filePath}`);
38
+ /**
39
+ * Dynamically import a TypeScript steps file and extract the
40
+ * steps array by duck-typing ({@link findStepsArray}).
41
+ *
42
+ * Requires the caller's Node process to have a TypeScript loader
43
+ * active (e.g. running via `tsx`). The CLI itself does not depend
44
+ * on any TS compilation tool.
45
+ */
46
+ export async function loadStepsFromTs(filePath: string): Promise<ImportedStep[]> {
47
+ const mod = await import(pathToFileURL(filePath).href);
48
+ const steps = findStepsArray(mod.default ?? mod);
49
+ if (steps === null) {
50
+ throw new Error(`No steps array found in ${filePath}`);
51
+ }
52
+ return steps;
39
53
  }