@scenar/cli 0.1.19 → 0.1.20

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 (73) hide show
  1. package/package.json +17 -5
  2. package/src/__tests__/deploy-command.test.ts +45 -0
  3. package/src/__tests__/deploy-flow.test.ts +125 -0
  4. package/src/__tests__/deploy-upload.test.ts +39 -0
  5. package/src/__tests__/embed-snippet.test.ts +56 -0
  6. package/src/__tests__/pack-bundle-contract.test.ts +135 -0
  7. package/src/__tests__/pack-command.test.ts +51 -0
  8. package/src/__tests__/pack-generate-embed-entry.test.ts +90 -0
  9. package/src/__tests__/pack-manifest.test.ts +138 -0
  10. package/src/__tests__/viewport.test.ts +33 -0
  11. package/src/commands/deploy.d.ts +3 -0
  12. package/src/commands/deploy.d.ts.map +1 -0
  13. package/src/commands/deploy.js +166 -0
  14. package/src/commands/deploy.js.map +1 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/pack.d.ts +3 -0
  17. package/src/commands/pack.d.ts.map +1 -0
  18. package/src/commands/pack.js +156 -0
  19. package/src/commands/pack.js.map +1 -0
  20. package/src/commands/pack.ts +189 -0
  21. package/src/deploy/client.d.ts +20 -0
  22. package/src/deploy/client.d.ts.map +1 -0
  23. package/src/deploy/client.js +22 -0
  24. package/src/deploy/client.js.map +1 -0
  25. package/src/deploy/client.ts +28 -0
  26. package/src/deploy/deploy-flow.d.ts +78 -0
  27. package/src/deploy/deploy-flow.d.ts.map +1 -0
  28. package/src/deploy/deploy-flow.js +53 -0
  29. package/src/deploy/deploy-flow.js.map +1 -0
  30. package/src/deploy/deploy-flow.ts +115 -0
  31. package/src/deploy/embed-snippet.d.ts +27 -0
  32. package/src/deploy/embed-snippet.d.ts.map +1 -0
  33. package/src/deploy/embed-snippet.js +40 -0
  34. package/src/deploy/embed-snippet.js.map +1 -0
  35. package/src/deploy/embed-snippet.ts +52 -0
  36. package/src/deploy/upload.d.ts +10 -0
  37. package/src/deploy/upload.d.ts.map +1 -0
  38. package/src/deploy/upload.js +23 -0
  39. package/src/deploy/upload.js.map +1 -0
  40. package/src/deploy/upload.ts +29 -0
  41. package/src/index.d.ts.map +1 -1
  42. package/src/index.js +5 -1
  43. package/src/index.js.map +1 -1
  44. package/src/index.ts +5 -1
  45. package/src/pack/build.d.ts +17 -0
  46. package/src/pack/build.d.ts.map +1 -0
  47. package/src/pack/build.js +42 -0
  48. package/src/pack/build.js.map +1 -0
  49. package/src/pack/build.ts +59 -0
  50. package/src/pack/bundle-contract.d.ts +61 -0
  51. package/src/pack/bundle-contract.d.ts.map +1 -0
  52. package/src/pack/bundle-contract.js +116 -0
  53. package/src/pack/bundle-contract.js.map +1 -0
  54. package/src/pack/bundle-contract.ts +128 -0
  55. package/src/pack/generate-embed-entry.d.ts +42 -0
  56. package/src/pack/generate-embed-entry.d.ts.map +1 -0
  57. package/src/pack/generate-embed-entry.js +125 -0
  58. package/src/pack/generate-embed-entry.js.map +1 -0
  59. package/src/pack/generate-embed-entry.ts +162 -0
  60. package/src/pack/pack-manifest.d.ts +45 -0
  61. package/src/pack/pack-manifest.d.ts.map +1 -0
  62. package/src/pack/pack-manifest.js +95 -0
  63. package/src/pack/pack-manifest.js.map +1 -0
  64. package/src/pack/pack-manifest.ts +146 -0
  65. package/src/pack/viewport.d.ts +30 -0
  66. package/src/pack/viewport.d.ts.map +1 -0
  67. package/src/pack/viewport.js +19 -0
  68. package/src/pack/viewport.js.map +1 -0
  69. package/src/pack/viewport.ts +40 -0
  70. package/src/util/load-yaml.d.ts +1 -1
  71. package/src/util/load-yaml.js +1 -1
  72. package/src/util/load-yaml.ts +1 -1
  73. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenar/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Scenar CLI — validate scenarios, generate narration, and render videos.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,9 +27,13 @@
27
27
  "main": "./src/index.js",
28
28
  "types": "./src/index.d.ts",
29
29
  "dependencies": {
30
- "@scenar/core": "0.1.19",
31
- "@scenar/preview": "0.1.19",
32
- "@scenar/sdk": "0.1.19",
30
+ "@bufbuild/protobuf": "^2.2.2",
31
+ "@connectrpc/connect": "^2.0.0",
32
+ "@connectrpc/connect-node": "^2.0.0",
33
+ "@scenar/core": "0.1.20",
34
+ "@scenar/preview": "0.1.20",
35
+ "@scenar/sdk": "0.1.20",
36
+ "@scenar/stubs": "0.1.20",
33
37
  "commander": "^13.0.0",
34
38
  "yaml": "^2.7.0"
35
39
  },
@@ -38,7 +42,9 @@
38
42
  "edge-tts-universal": ">=1.0.0",
39
43
  "remotion": "4.0.448",
40
44
  "@remotion/renderer": "4.0.448",
41
- "@remotion/bundler": "4.0.448"
45
+ "@remotion/bundler": "4.0.448",
46
+ "vite": ">=6.0.0",
47
+ "@vitejs/plugin-react": ">=4.3.0"
42
48
  },
43
49
  "peerDependenciesMeta": {
44
50
  "echogarden": {
@@ -55,6 +61,12 @@
55
61
  },
56
62
  "@remotion/bundler": {
57
63
  "optional": true
64
+ },
65
+ "vite": {
66
+ "optional": true
67
+ },
68
+ "@vitejs/plugin-react": {
69
+ "optional": true
58
70
  }
59
71
  }
60
72
  }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createProgram } from "../index.js";
3
+
4
+ describe("scenar deploy", () => {
5
+ let stderrOutput: string;
6
+
7
+ beforeEach(() => {
8
+ stderrOutput = "";
9
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
10
+ stderrOutput += String(chunk);
11
+ return true;
12
+ });
13
+ process.exitCode = undefined;
14
+ });
15
+
16
+ it("registers the deploy command", () => {
17
+ const program = createProgram();
18
+ const cmd = program.commands.find((c) => c.name() === "deploy");
19
+ expect(cmd).toBeDefined();
20
+ expect(cmd!.description()).toContain("embed URL");
21
+ });
22
+
23
+ it("accepts a bundle directory argument and exposes its options", () => {
24
+ const program = createProgram();
25
+ const cmd = program.commands.find((c) => c.name() === "deploy");
26
+ expect(cmd!.registeredArguments[0]!.name()).toBe("bundleDir");
27
+ const opts = cmd!.options.map((o) => o.long);
28
+ expect(opts).toContain("--backend");
29
+ expect(opts).toContain("--org");
30
+ expect(opts).toContain("--slug");
31
+ expect(opts).toContain("--name");
32
+ });
33
+
34
+ it("fails clearly when the bundle directory does not exist", async () => {
35
+ const program = createProgram();
36
+ program.exitOverride();
37
+ try {
38
+ await program.parseAsync(["node", "scenar", "deploy", "/tmp/__no_bundle__", "--org", "acme"]);
39
+ } catch {
40
+ // exitOverride
41
+ }
42
+ expect(process.exitCode).toBe(1);
43
+ expect(stderrOutput).toContain("Error");
44
+ });
45
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ runDeployFlow,
4
+ toDeclaredFiles,
5
+ localViewUrl,
6
+ type DeployFlowDeps,
7
+ type UploadTarget,
8
+ } from "../deploy/deploy-flow.js";
9
+ import type { PackManifest } from "../pack/pack-manifest.js";
10
+
11
+ const MANIFEST: PackManifest = {
12
+ schemaVersion: 1,
13
+ scenarioId: "welcome-tour",
14
+ files: [
15
+ { path: "index.html", sha256: "a".repeat(64), sizeBytes: 397, contentType: "text/html" },
16
+ { path: "assets/app.js", sha256: "b".repeat(64), sizeBytes: 356965, contentType: "text/javascript" },
17
+ { path: "scenario.json", sha256: "c".repeat(64), sizeBytes: 91, contentType: "application/json" },
18
+ ],
19
+ };
20
+
21
+ describe("toDeclaredFiles", () => {
22
+ it("maps manifest files to declared files with bigint sizes and lowercase-hex sha256", () => {
23
+ const declared = toDeclaredFiles(MANIFEST);
24
+ expect(declared).toHaveLength(3);
25
+ expect(declared[0]).toEqual({
26
+ relativePath: "index.html",
27
+ sha256: "a".repeat(64),
28
+ sizeBytes: 397n,
29
+ contentType: "text/html",
30
+ });
31
+ expect(typeof declared[1]!.sizeBytes).toBe("bigint");
32
+ expect(declared[1]!.sizeBytes).toBe(356965n);
33
+ });
34
+ });
35
+
36
+ describe("runDeployFlow", () => {
37
+ it("ensures the scenario before creating the session, then uploads then completes", async () => {
38
+ const calls: string[] = [];
39
+ const targets: UploadTarget[] = [
40
+ {
41
+ relativePath: "index.html",
42
+ presignedPutUrl: "https://store/idx",
43
+ requiredHeaders: { "Content-Type": "text/html", "x-amz-checksum-sha256": "AAAA" },
44
+ },
45
+ {
46
+ relativePath: "assets/app.js",
47
+ presignedPutUrl: "https://store/js",
48
+ requiredHeaders: { "Content-Type": "text/javascript", "x-amz-checksum-sha256": "BBBB" },
49
+ },
50
+ ];
51
+
52
+ const uploaded: Array<{ target: UploadTarget; bytes: number }> = [];
53
+
54
+ const deps: DeployFlowDeps = {
55
+ applyScenario: vi.fn(async (input) => {
56
+ calls.push("apply");
57
+ expect(input).toEqual({ org: "acme", slug: "welcome-tour", name: "Welcome" });
58
+ return "scn_123";
59
+ }),
60
+ createSession: vi.fn(async (scenarioId, files) => {
61
+ calls.push("create");
62
+ expect(scenarioId).toBe("scn_123");
63
+ expect(files).toHaveLength(3);
64
+ return { deployId: "dep_456", uploadTargets: targets };
65
+ }),
66
+ readBundleFile: vi.fn(async (rel) => new Uint8Array(rel.length)),
67
+ uploadFile: vi.fn(async (target, bytes) => {
68
+ calls.push(`upload:${target.relativePath}`);
69
+ uploaded.push({ target, bytes: bytes.byteLength });
70
+ }),
71
+ completeSession: vi.fn(async (deployId) => {
72
+ calls.push("complete");
73
+ expect(deployId).toBe("dep_456");
74
+ return "https://d-dep_456.localhost:8787/";
75
+ }),
76
+ log: () => {},
77
+ };
78
+
79
+ const result = await runDeployFlow(deps, {
80
+ manifest: MANIFEST,
81
+ org: "acme",
82
+ slug: "welcome-tour",
83
+ name: "Welcome",
84
+ });
85
+
86
+ expect(result).toEqual({ deployId: "dep_456", embedUrl: "https://d-dep_456.localhost:8787/" });
87
+ // Ordering: ensure -> create -> upload each (in target order) -> complete.
88
+ expect(calls).toEqual(["apply", "create", "upload:index.html", "upload:assets/app.js", "complete"]);
89
+ // Required headers are passed through verbatim to each upload.
90
+ expect(uploaded[0]!.target.requiredHeaders).toEqual(targets[0]!.requiredHeaders);
91
+ expect(uploaded[1]!.target.requiredHeaders).toEqual(targets[1]!.requiredHeaders);
92
+ });
93
+
94
+ it("propagates a missing-embed-url failure from completion", async () => {
95
+ const deps: DeployFlowDeps = {
96
+ applyScenario: async () => "scn_1",
97
+ createSession: async () => ({ deployId: "dep_1", uploadTargets: [] }),
98
+ readBundleFile: async () => new Uint8Array(),
99
+ uploadFile: async () => {},
100
+ completeSession: async () => {
101
+ throw new Error("deploy completed but returned no embed_url");
102
+ },
103
+ log: () => {},
104
+ };
105
+
106
+ await expect(
107
+ runDeployFlow(deps, { manifest: MANIFEST, org: "acme", slug: "welcome-tour", name: "x" }),
108
+ ).rejects.toThrow(/embed_url/);
109
+ });
110
+ });
111
+
112
+ describe("localViewUrl", () => {
113
+ it("downgrades https to http for *.localhost hosts (wrangler dev serves http)", () => {
114
+ expect(localViewUrl("https://d-abc.localhost:8787/")).toBe("http://d-abc.localhost:8787/");
115
+ expect(localViewUrl("https://localhost:8787/")).toBe("http://localhost:8787/");
116
+ });
117
+
118
+ it("leaves production https URLs untouched", () => {
119
+ expect(localViewUrl("https://d-abc.scenar.ai/")).toBe("https://d-abc.scenar.ai/");
120
+ });
121
+
122
+ it("returns non-URL input unchanged", () => {
123
+ expect(localViewUrl("not a url")).toBe("not a url");
124
+ });
125
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { putFile } from "../deploy/upload.js";
3
+
4
+ const realFetch = globalThis.fetch;
5
+
6
+ afterEach(() => {
7
+ globalThis.fetch = realFetch;
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ describe("putFile", () => {
12
+ it("PUTs the raw bytes and replays required headers verbatim", async () => {
13
+ const seen: { url?: string; init?: RequestInit } = {};
14
+ globalThis.fetch = vi.fn(async (url: any, init: any) => {
15
+ seen.url = String(url);
16
+ seen.init = init;
17
+ return new Response(null, { status: 200 });
18
+ }) as unknown as typeof fetch;
19
+
20
+ const bytes = new Uint8Array([1, 2, 3]);
21
+ const headers = { "Content-Type": "text/html", "x-amz-checksum-sha256": "AAAA" };
22
+ await putFile("https://store/key", headers, bytes);
23
+
24
+ expect(seen.url).toBe("https://store/key");
25
+ expect(seen.init!.method).toBe("PUT");
26
+ expect(seen.init!.headers).toEqual(headers);
27
+ expect(seen.init!.body).toBe(bytes);
28
+ });
29
+
30
+ it("throws with the status and body detail on a non-2xx response", async () => {
31
+ globalThis.fetch = vi.fn(async () =>
32
+ new Response("SignatureDoesNotMatch", { status: 403, statusText: "Forbidden" }),
33
+ ) as unknown as typeof fetch;
34
+
35
+ await expect(putFile("https://store/key", {}, new Uint8Array())).rejects.toThrow(
36
+ /upload failed \(403 Forbidden\): SignatureDoesNotMatch/,
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildEmbedSnippet } from "../deploy/embed-snippet.js";
3
+
4
+ describe("buildEmbedSnippet", () => {
5
+ const embedUrl = "https://d-dep1.scenarusercontent.net/";
6
+
7
+ it("pins the responsive box to the canonical aspect ratio and max width", () => {
8
+ const snippet = buildEmbedSnippet({ embedUrl, viewport: { width: 896, height: 480 } });
9
+ expect(snippet).toContain("aspect-ratio:896/480");
10
+ expect(snippet).toContain("max-width:896px");
11
+ expect(snippet).toContain("width:100%");
12
+ });
13
+
14
+ it("embeds the URL as the iframe src and lazy-loads", () => {
15
+ const snippet = buildEmbedSnippet({ embedUrl, viewport: { width: 896, height: 480 } });
16
+ expect(snippet).toContain(`src="${embedUrl}"`);
17
+ expect(snippet).toContain('loading="lazy"');
18
+ });
19
+
20
+ it("delegates autoplay and fullscreen (the host half of the permission pair)", () => {
21
+ const snippet = buildEmbedSnippet({ embedUrl, viewport: { width: 896, height: 480 } });
22
+ expect(snippet).toContain('allow="autoplay; fullscreen"');
23
+ expect(snippet).toContain("allowfullscreen");
24
+ });
25
+
26
+ it("uses a default title and honors an explicit one", () => {
27
+ const def = buildEmbedSnippet({ embedUrl, viewport: { width: 896, height: 480 } });
28
+ expect(def).toContain('title="Scenar embed"');
29
+
30
+ const named = buildEmbedSnippet({
31
+ embedUrl,
32
+ viewport: { width: 896, height: 480 },
33
+ title: "Welcome tour",
34
+ });
35
+ expect(named).toContain('title="Welcome tour"');
36
+ });
37
+
38
+ it("escapes attribute-breaking characters in the URL and title", () => {
39
+ const snippet = buildEmbedSnippet({
40
+ embedUrl: 'https://x.test/?a=1&b="2"',
41
+ viewport: { width: 800, height: 600 },
42
+ title: '<script>"&',
43
+ });
44
+ expect(snippet).toContain("https://x.test/?a=1&amp;b=&quot;2&quot;");
45
+ expect(snippet).toContain('title="&lt;script&gt;&quot;&amp;"');
46
+ // No raw quote/angle bracket leaked into an attribute value.
47
+ expect(snippet).not.toContain('b="2"');
48
+ expect(snippet).not.toContain("<script>");
49
+ });
50
+
51
+ it("reflects different viewports in the aspect ratio", () => {
52
+ const snippet = buildEmbedSnippet({ embedUrl, viewport: { width: 1280, height: 720 } });
53
+ expect(snippet).toContain("aspect-ratio:1280/720");
54
+ expect(snippet).toContain("max-width:1280px");
55
+ });
56
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ ALLOWED_EXTENSIONS,
4
+ CONTENT_TYPE_BY_EXTENSION,
5
+ finalExtension,
6
+ validateRelativePath,
7
+ validateScenarioJson,
8
+ MAX_PATH_DEPTH,
9
+ } from "../pack/bundle-contract.js";
10
+
11
+ describe("bundle-contract: finalExtension", () => {
12
+ it("returns the lowercase final extension", () => {
13
+ expect(finalExtension("index.html")).toBe("html");
14
+ expect(finalExtension("assets/app-ABC123.JS")).toBe("js");
15
+ expect(finalExtension("a/b/c.min.css")).toBe("css");
16
+ });
17
+
18
+ it("returns empty string for dotfiles or no extension", () => {
19
+ expect(finalExtension("Makefile")).toBe("");
20
+ expect(finalExtension(".gitignore")).toBe("");
21
+ expect(finalExtension("dir/.keep")).toBe("");
22
+ });
23
+ });
24
+
25
+ describe("bundle-contract: validateRelativePath", () => {
26
+ it("accepts clean allowlisted paths", () => {
27
+ expect(validateRelativePath("index.html")).toBeNull();
28
+ expect(validateRelativePath("assets/index-abc.js")).toBeNull();
29
+ expect(validateRelativePath("assets/index-abc.css")).toBeNull();
30
+ expect(validateRelativePath("scenario.json")).toBeNull();
31
+ expect(validateRelativePath("narration/0.mp3")).toBeNull();
32
+ });
33
+
34
+ it("accepts raster images and modern fonts (the Vite asset case)", () => {
35
+ expect(validateRelativePath("assets/logo-abc.png")).toBeNull();
36
+ expect(validateRelativePath("assets/photo-abc.jpg")).toBeNull();
37
+ expect(validateRelativePath("assets/photo-abc.jpeg")).toBeNull();
38
+ expect(validateRelativePath("assets/anim-abc.gif")).toBeNull();
39
+ expect(validateRelativePath("assets/img-abc.webp")).toBeNull();
40
+ expect(validateRelativePath("assets/img-abc.avif")).toBeNull();
41
+ expect(validateRelativePath("assets/font-abc.woff2")).toBeNull();
42
+ expect(validateRelativePath("assets/font-abc.woff")).toBeNull();
43
+ });
44
+
45
+ it("normalizes the extension case before the allowlist check", () => {
46
+ expect(validateRelativePath("assets/LOGO-ABC.PNG")).toBeNull();
47
+ });
48
+
49
+ it("rejects disallowed extensions (svg active content, source maps, legacy fonts, binaries)", () => {
50
+ expect(validateRelativePath("assets/icon-abc.svg")).toMatch(/disallowed extension/);
51
+ expect(validateRelativePath("assets/app-abc.js.map")).toMatch(/disallowed extension/);
52
+ expect(validateRelativePath("assets/font-abc.ttf")).toMatch(/disallowed extension/);
53
+ expect(validateRelativePath("payload.exe")).toMatch(/disallowed extension/);
54
+ });
55
+
56
+ it("rejects unclean paths", () => {
57
+ expect(validateRelativePath("/index.html")).toMatch(/clean relative path/);
58
+ expect(validateRelativePath("a//b.js")).toMatch(/clean relative path/);
59
+ });
60
+
61
+ it("rejects traversal segments the charset regex would otherwise admit", () => {
62
+ expect(validateRelativePath("../escape.js")).toMatch(/segments/);
63
+ expect(validateRelativePath("a/../b.js")).toMatch(/segments/);
64
+ expect(validateRelativePath("./a.js")).toMatch(/segments/);
65
+ });
66
+
67
+ it("rejects dotfiles with no real extension", () => {
68
+ expect(validateRelativePath("a/b/.js")).toMatch(/disallowed extension/);
69
+ });
70
+
71
+ it("rejects paths deeper than the max depth", () => {
72
+ const deep = Array.from({ length: MAX_PATH_DEPTH + 1 }, (_, i) => `d${i}`).join("/") + ".js";
73
+ expect(validateRelativePath(deep)).toMatch(/exceeds the maximum depth/);
74
+ });
75
+ });
76
+
77
+ describe("bundle-contract: validateScenarioJson", () => {
78
+ it("accepts a valid object with a non-empty schemaVersion", () => {
79
+ expect(validateScenarioJson('{"schemaVersion":"1"}')).toBeNull();
80
+ expect(validateScenarioJson('{"schemaVersion":"1","id":"x"}')).toBeNull();
81
+ });
82
+
83
+ it("rejects non-objects", () => {
84
+ expect(validateScenarioJson("[]")).toMatch(/must be a JSON object/);
85
+ expect(validateScenarioJson('"x"')).toMatch(/must be a JSON object/);
86
+ expect(validateScenarioJson("null")).toMatch(/must be a JSON object/);
87
+ });
88
+
89
+ it("rejects invalid JSON", () => {
90
+ expect(validateScenarioJson("{not json}")).toMatch(/not valid JSON/);
91
+ });
92
+
93
+ it("requires a non-empty string schemaVersion", () => {
94
+ expect(validateScenarioJson("{}")).toMatch(/schemaVersion/);
95
+ expect(validateScenarioJson('{"schemaVersion":""}')).toMatch(/schemaVersion/);
96
+ expect(validateScenarioJson('{"schemaVersion":1}')).toMatch(/schemaVersion/);
97
+ });
98
+
99
+ it("rejects prototype-pollution keys", () => {
100
+ expect(validateScenarioJson('{"schemaVersion":"1","__proto__":{}}')).toMatch(/forbidden key/);
101
+ expect(validateScenarioJson('{"schemaVersion":"1","prototype":{}}')).toMatch(/forbidden key/);
102
+ });
103
+
104
+ it("rejects content over 1 MiB", () => {
105
+ const big = `{"schemaVersion":"1","pad":"${"x".repeat(1024 * 1024)}"}`;
106
+ expect(validateScenarioJson(big)).toMatch(/1 MiB/);
107
+ });
108
+ });
109
+
110
+ describe("bundle-contract: content types", () => {
111
+ it("maps every allowed extension to a type/subtype", () => {
112
+ for (const ext of ALLOWED_EXTENSIONS) {
113
+ const ct = CONTENT_TYPE_BY_EXTENSION[ext];
114
+ expect(ct).toMatch(/^[^\s/]+\/[^\s/]+$/);
115
+ }
116
+ });
117
+
118
+ it("maps each extension to its canonical MIME type (lockstep with the backend)", () => {
119
+ expect(CONTENT_TYPE_BY_EXTENSION).toEqual({
120
+ html: "text/html",
121
+ js: "text/javascript",
122
+ css: "text/css",
123
+ json: "application/json",
124
+ mp3: "audio/mpeg",
125
+ png: "image/png",
126
+ jpg: "image/jpeg",
127
+ jpeg: "image/jpeg",
128
+ gif: "image/gif",
129
+ webp: "image/webp",
130
+ avif: "image/avif",
131
+ woff2: "font/woff2",
132
+ woff: "font/woff",
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createProgram } from "../index.js";
3
+
4
+ describe("scenar pack", () => {
5
+ let stderrOutput: string;
6
+
7
+ beforeEach(() => {
8
+ stderrOutput = "";
9
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
10
+ stderrOutput += String(chunk);
11
+ return true;
12
+ });
13
+ process.exitCode = undefined;
14
+ });
15
+
16
+ it("registers the pack command", () => {
17
+ const program = createProgram();
18
+ const cmd = program.commands.find((c) => c.name() === "pack");
19
+ expect(cmd).toBeDefined();
20
+ expect(cmd!.description()).toContain("hosted embed");
21
+ });
22
+
23
+ it("accepts a directory argument", () => {
24
+ const program = createProgram();
25
+ const cmd = program.commands.find((c) => c.name() === "pack");
26
+ expect(cmd!.registeredArguments).toHaveLength(1);
27
+ expect(cmd!.registeredArguments[0]!.name()).toBe("dir");
28
+ });
29
+
30
+ it("exposes its options", () => {
31
+ const program = createProgram();
32
+ const cmd = program.commands.find((c) => c.name() === "pack");
33
+ const opts = cmd!.options.map((o) => o.long);
34
+ expect(opts).toContain("--out");
35
+ expect(opts).toContain("--width");
36
+ expect(opts).toContain("--shell-height");
37
+ expect(opts).toContain("--keep-temp");
38
+ });
39
+
40
+ it("sets exit code 1 when given a nonexistent path", async () => {
41
+ const program = createProgram();
42
+ program.exitOverride();
43
+ try {
44
+ await program.parseAsync(["node", "scenar", "pack", "/tmp/__no_such_scenar_dir__"]);
45
+ } catch {
46
+ // commander throws under exitOverride
47
+ }
48
+ expect(process.exitCode).toBe(1);
49
+ expect(stderrOutput).toContain("Error");
50
+ });
51
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ generateEmbedEntry,
4
+ generateEmbedHtml,
5
+ } from "../pack/generate-embed-entry.js";
6
+
7
+ const BASE = {
8
+ scenarioDir: "/proj/scenarios/welcome-tour",
9
+ renderFilePath: "/proj/scenarios/welcome-tour/index.tsx",
10
+ scenarioId: "welcome-tour",
11
+ canonicalWidth: 896,
12
+ shellHeight: 480,
13
+ } as const;
14
+
15
+ describe("generateEmbedEntry", () => {
16
+ it("mounts ScenarioPlayer into #root with the renderStep contract", () => {
17
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
18
+ expect(src).toContain('import { createRoot } from "react-dom/client";');
19
+ expect(src).toContain('import { ScenarioPlayer, DemoViewport, SCENAR_CLASS } from "@scenar/react";');
20
+ expect(src).toContain('import { renderStep } from "/proj/scenarios/welcome-tour/index";');
21
+ expect(src).toContain("<ScenarioPlayer bundle={_bundle} embed>");
22
+ expect(src).toContain("renderStep(data, stepIndex)");
23
+ expect(src).toContain('getElementById("root")');
24
+ });
25
+
26
+ it("imports the @scenar/react theme + styles so the bundle is self-contained", () => {
27
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
28
+ expect(src).toContain('import "@scenar/react/theme.css";');
29
+ expect(src).toContain('import "@scenar/react/styles.css";');
30
+ });
31
+
32
+ it("scopes the tree with SCENAR_CLASS + DemoViewport sizing", () => {
33
+ const src = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
34
+ expect(src).toContain("className={SCENAR_CLASS}");
35
+ expect(src).toContain("canonicalWidth={896}");
36
+ expect(src).toContain("shellHeight={480}");
37
+ });
38
+
39
+ it("fetches the narration manifest at runtime only when present", () => {
40
+ const without = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
41
+ expect(without).toContain("narrationManifest: undefined,");
42
+ expect(without).not.toContain("useNarrationManifest");
43
+ expect(without).not.toContain("import _manifest");
44
+
45
+ const withNarration = generateEmbedEntry({ ...BASE, hasNarration: true, providersPath: null });
46
+ // Never inlines the manifest as a build-time JSON import.
47
+ expect(withNarration).not.toContain("import _manifest");
48
+ // Imports the hook and fetches the manifest from its own relative location,
49
+ // so clip src values resolve against ./narration/ at runtime.
50
+ expect(withNarration).toContain(
51
+ 'import { ScenarioPlayer, DemoViewport, SCENAR_CLASS, useNarrationManifest } from "@scenar/react";',
52
+ );
53
+ expect(withNarration).toContain('const _resolveManifestUrl = () => "./narration/manifest.json";');
54
+ expect(withNarration).toContain(
55
+ 'const _manifest = useNarrationManifest("welcome-tour", _resolveManifestUrl);',
56
+ );
57
+ expect(withNarration).toContain("narrationManifest: _manifest,");
58
+ });
59
+
60
+ it("wraps in PreviewProviders only when a providers file exists", () => {
61
+ const without = generateEmbedEntry({ ...BASE, hasNarration: false, providersPath: null });
62
+ expect(without).not.toContain("_Providers");
63
+
64
+ const withProviders = generateEmbedEntry({
65
+ ...BASE,
66
+ hasNarration: false,
67
+ providersPath: "/proj/.scenar/providers.tsx",
68
+ });
69
+ expect(withProviders).toContain(
70
+ 'import { PreviewProviders as _Providers } from "/proj/.scenar/providers";',
71
+ );
72
+ expect(withProviders).toContain("<_Providers>");
73
+ expect(withProviders).toContain("</_Providers>");
74
+ });
75
+ });
76
+
77
+ describe("generateEmbedHtml", () => {
78
+ it("references the entry as an external module script (CSP-safe, no inline code)", () => {
79
+ const html = generateEmbedHtml("welcome-tour", "entry.tsx");
80
+ expect(html).toContain('<script type="module" src="./entry.tsx"></script>');
81
+ expect(html).toContain('<div id="root"></div>');
82
+ // No inline executable script body — only the external src reference.
83
+ expect(html).not.toMatch(/<script(?![^>]*\bsrc=)[^>]*>[^<]+<\/script>/);
84
+ });
85
+
86
+ it("escapes the title", () => {
87
+ const html = generateEmbedHtml("a<b>&c", "entry.tsx");
88
+ expect(html).toContain("<title>a&lt;b&gt;&amp;c</title>");
89
+ });
90
+ });