@scenar/cli 0.1.20-rc.1 → 0.2.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/package.json +17 -5
- package/src/__tests__/publish-command.test.ts +46 -0
- package/src/__tests__/publish-flow.test.ts +220 -0
- package/src/__tests__/read-viewport.test.ts +45 -0
- package/src/__tests__/run-publish.test.ts +92 -0
- package/src/__tests__/serve-command.test.ts +44 -0
- package/src/__tests__/serve-request-path.test.ts +67 -0
- package/src/__tests__/serve-static-server.test.ts +88 -0
- package/src/api.d.ts +31 -0
- package/src/api.d.ts.map +1 -0
- package/src/api.js +28 -0
- package/src/api.js.map +1 -0
- package/src/api.ts +55 -0
- package/src/bundle/read-viewport.d.ts +23 -0
- package/src/bundle/read-viewport.d.ts.map +1 -0
- package/src/bundle/read-viewport.js +25 -0
- package/src/bundle/read-viewport.js.map +1 -0
- package/src/bundle/read-viewport.ts +36 -0
- package/src/commands/deploy.d.ts.map +1 -1
- package/src/commands/deploy.js +8 -24
- package/src/commands/deploy.js.map +1 -1
- package/src/commands/deploy.ts +10 -28
- package/src/commands/narrate.d.ts.map +1 -1
- package/src/commands/narrate.js +39 -170
- package/src/commands/narrate.js.map +1 -1
- package/src/commands/narrate.ts +48 -289
- package/src/commands/pack.d.ts.map +1 -1
- package/src/commands/pack.js +12 -112
- package/src/commands/pack.js.map +1 -1
- package/src/commands/pack.ts +12 -128
- package/src/commands/publish.d.ts +3 -0
- package/src/commands/publish.d.ts.map +1 -0
- package/src/commands/publish.js +64 -0
- package/src/commands/publish.js.map +1 -0
- package/src/commands/publish.ts +86 -0
- package/src/commands/render.d.ts.map +1 -1
- package/src/commands/render.js +12 -194
- package/src/commands/render.js.map +1 -1
- package/src/commands/render.ts +19 -286
- package/src/commands/serve.d.ts +3 -0
- package/src/commands/serve.d.ts.map +1 -0
- package/src/commands/serve.js +91 -0
- package/src/commands/serve.js.map +1 -0
- package/src/commands/serve.ts +112 -0
- package/src/index.d.ts.map +1 -1
- package/src/index.js +4 -0
- package/src/index.js.map +1 -1
- package/src/index.ts +4 -0
- package/src/narrate/run-narrate.d.ts +43 -0
- package/src/narrate/run-narrate.d.ts.map +1 -0
- package/src/narrate/run-narrate.js +174 -0
- package/src/narrate/run-narrate.js.map +1 -0
- package/src/narrate/run-narrate.ts +298 -0
- package/src/pack/run-pack.d.ts +34 -0
- package/src/pack/run-pack.d.ts.map +1 -0
- package/src/pack/run-pack.js +117 -0
- package/src/pack/run-pack.js.map +1 -0
- package/src/pack/run-pack.ts +163 -0
- package/src/publish/github.d.ts +11 -0
- package/src/publish/github.d.ts.map +1 -0
- package/src/publish/github.js +197 -0
- package/src/publish/github.js.map +1 -0
- package/src/publish/github.ts +216 -0
- package/src/publish/publish-flow.d.ts +110 -0
- package/src/publish/publish-flow.d.ts.map +1 -0
- package/src/publish/publish-flow.js +117 -0
- package/src/publish/publish-flow.js.map +1 -0
- package/src/publish/publish-flow.ts +199 -0
- package/src/publish/run-publish.d.ts +41 -0
- package/src/publish/run-publish.d.ts.map +1 -0
- package/src/publish/run-publish.js +77 -0
- package/src/publish/run-publish.js.map +1 -0
- package/src/publish/run-publish.ts +131 -0
- package/src/render/run-render.d.ts +31 -0
- package/src/render/run-render.d.ts.map +1 -0
- package/src/render/run-render.js +196 -0
- package/src/render/run-render.js.map +1 -0
- package/src/render/run-render.ts +287 -0
- package/src/serve/request-path.d.ts +23 -0
- package/src/serve/request-path.d.ts.map +1 -0
- package/src/serve/request-path.js +52 -0
- package/src/serve/request-path.js.map +1 -0
- package/src/serve/request-path.ts +55 -0
- package/src/serve/run-serve.d.ts +31 -0
- package/src/serve/run-serve.d.ts.map +1 -0
- package/src/serve/run-serve.js +35 -0
- package/src/serve/run-serve.js.map +1 -0
- package/src/serve/run-serve.ts +68 -0
- package/src/serve/static-server.d.ts +38 -0
- package/src/serve/static-server.d.ts.map +1 -0
- package/src/serve/static-server.js +91 -0
- package/src/serve/static-server.js.map +1 -0
- package/src/serve/static-server.ts +118 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scenar/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Scenar CLI — validate scenarios, generate narration, and render videos.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -26,14 +26,26 @@
|
|
|
26
26
|
},
|
|
27
27
|
"main": "./src/index.js",
|
|
28
28
|
"types": "./src/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"import": "./src/index.js",
|
|
33
|
+
"default": "./src/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./api": {
|
|
36
|
+
"types": "./src/api.d.ts",
|
|
37
|
+
"import": "./src/api.js",
|
|
38
|
+
"default": "./src/api.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
29
41
|
"dependencies": {
|
|
30
42
|
"@bufbuild/protobuf": "^2.2.2",
|
|
31
43
|
"@connectrpc/connect": "^2.0.0",
|
|
32
44
|
"@connectrpc/connect-node": "^2.0.0",
|
|
33
|
-
"@scenar/core": "0.
|
|
34
|
-
"@scenar/preview": "0.
|
|
35
|
-
"@scenar/sdk": "0.
|
|
36
|
-
"@scenar/stubs": "0.
|
|
45
|
+
"@scenar/core": "0.2.0",
|
|
46
|
+
"@scenar/preview": "0.2.0",
|
|
47
|
+
"@scenar/sdk": "0.2.0",
|
|
48
|
+
"@scenar/stubs": "0.2.0",
|
|
37
49
|
"commander": "^13.0.0",
|
|
38
50
|
"yaml": "^2.7.0"
|
|
39
51
|
},
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createProgram } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("scenar publish", () => {
|
|
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 publish command", () => {
|
|
17
|
+
const program = createProgram();
|
|
18
|
+
const cmd = program.commands.find((c) => c.name() === "publish");
|
|
19
|
+
expect(cmd).toBeDefined();
|
|
20
|
+
expect(cmd!.description()).toContain("GitHub Pages");
|
|
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() === "publish");
|
|
26
|
+
expect(cmd!.registeredArguments[0]!.name()).toBe("bundleDir");
|
|
27
|
+
const opts = cmd!.options.map((o) => o.long);
|
|
28
|
+
expect(opts).toContain("--repo");
|
|
29
|
+
expect(opts).toContain("--path");
|
|
30
|
+
expect(opts).toContain("--org");
|
|
31
|
+
expect(opts).toContain("--private");
|
|
32
|
+
expect(opts).toContain("--message");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fails clearly when the bundle directory does not exist (no GitHub calls)", async () => {
|
|
36
|
+
const program = createProgram();
|
|
37
|
+
program.exitOverride();
|
|
38
|
+
try {
|
|
39
|
+
await program.parseAsync(["node", "scenar", "publish", "/tmp/__no_bundle__"]);
|
|
40
|
+
} catch {
|
|
41
|
+
// exitOverride throws on non-zero exit; assert on exitCode below.
|
|
42
|
+
}
|
|
43
|
+
expect(process.exitCode).toBe(1);
|
|
44
|
+
expect(stderrOutput).toContain("Error");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
runPublishFlow,
|
|
4
|
+
validateRepoName,
|
|
5
|
+
validatePublishPath,
|
|
6
|
+
normalizePath,
|
|
7
|
+
pagesUrl,
|
|
8
|
+
repoUrl,
|
|
9
|
+
type PublishFlowDeps,
|
|
10
|
+
} from "../publish/publish-flow.js";
|
|
11
|
+
|
|
12
|
+
/** A deps double whose every method records its call order. */
|
|
13
|
+
function makeDeps(overrides: Partial<PublishFlowDeps> = {}): {
|
|
14
|
+
deps: PublishFlowDeps;
|
|
15
|
+
calls: string[];
|
|
16
|
+
} {
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
const deps: PublishFlowDeps = {
|
|
19
|
+
ensureToolsAvailable: vi.fn(async () => {
|
|
20
|
+
calls.push("ensureTools");
|
|
21
|
+
}),
|
|
22
|
+
resolveOwner: vi.fn(async (org) => {
|
|
23
|
+
calls.push(`resolveOwner:${org ?? "<user>"}`);
|
|
24
|
+
return org ?? "octocat";
|
|
25
|
+
}),
|
|
26
|
+
repoExists: vi.fn(async () => {
|
|
27
|
+
calls.push("repoExists");
|
|
28
|
+
return false;
|
|
29
|
+
}),
|
|
30
|
+
createRepo: vi.fn(async (_owner, _repo, opts) => {
|
|
31
|
+
calls.push(`createRepo:${opts.private ? "private" : "public"}`);
|
|
32
|
+
}),
|
|
33
|
+
pushBundleToPages: vi.fn(async () => {
|
|
34
|
+
calls.push("push");
|
|
35
|
+
}),
|
|
36
|
+
enablePages: vi.fn(async () => {
|
|
37
|
+
calls.push("enablePages");
|
|
38
|
+
}),
|
|
39
|
+
log: () => {},
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
return { deps, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("runPublishFlow", () => {
|
|
46
|
+
it("creates a new repo, pushes, and enables pages in order", async () => {
|
|
47
|
+
const { deps, calls } = makeDeps();
|
|
48
|
+
const result = await runPublishFlow(deps, {
|
|
49
|
+
bundleDir: "/tmp/bundle",
|
|
50
|
+
repo: "welcome-tour",
|
|
51
|
+
path: "",
|
|
52
|
+
private: false,
|
|
53
|
+
message: "Publish Scenar embed",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(calls).toEqual([
|
|
57
|
+
"ensureTools",
|
|
58
|
+
"resolveOwner:<user>",
|
|
59
|
+
"repoExists",
|
|
60
|
+
"createRepo:public",
|
|
61
|
+
"push",
|
|
62
|
+
"enablePages",
|
|
63
|
+
]);
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
owner: "octocat",
|
|
66
|
+
repo: "welcome-tour",
|
|
67
|
+
path: "",
|
|
68
|
+
repoUrl: "https://github.com/octocat/welcome-tour",
|
|
69
|
+
pagesUrl: "https://octocat.github.io/welcome-tour/",
|
|
70
|
+
created: true,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("publishes into a subpath and reflects it in the URL + push payload", async () => {
|
|
75
|
+
const { deps } = makeDeps({ repoExists: vi.fn(async () => true) });
|
|
76
|
+
const result = await runPublishFlow(deps, {
|
|
77
|
+
bundleDir: "/tmp/bundle",
|
|
78
|
+
repo: "scenar-embeds",
|
|
79
|
+
path: "welcome-tour",
|
|
80
|
+
private: false,
|
|
81
|
+
message: "m",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(deps.pushBundleToPages).toHaveBeenCalledWith(
|
|
85
|
+
expect.objectContaining({ repo: "scenar-embeds", path: "welcome-tour" }),
|
|
86
|
+
);
|
|
87
|
+
expect(result.path).toBe("welcome-tour");
|
|
88
|
+
expect(result.pagesUrl).toBe("https://octocat.github.io/scenar-embeds/welcome-tour/");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reuses an existing repo (no create) and reports created: false", async () => {
|
|
92
|
+
const { deps, calls } = makeDeps({
|
|
93
|
+
repoExists: vi.fn(async () => {
|
|
94
|
+
calls.push("repoExists");
|
|
95
|
+
return true;
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
// calls array is the one closed over by the override too.
|
|
99
|
+
const result = await runPublishFlow(deps, {
|
|
100
|
+
bundleDir: "/tmp/bundle",
|
|
101
|
+
repo: "welcome-tour",
|
|
102
|
+
path: "",
|
|
103
|
+
private: false,
|
|
104
|
+
message: "m",
|
|
105
|
+
});
|
|
106
|
+
expect(deps.createRepo).not.toHaveBeenCalled();
|
|
107
|
+
expect(result.created).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("passes the org through as the owner and creates a private repo when asked", async () => {
|
|
111
|
+
const { deps } = makeDeps();
|
|
112
|
+
const result = await runPublishFlow(deps, {
|
|
113
|
+
bundleDir: "/tmp/bundle",
|
|
114
|
+
repo: "demo",
|
|
115
|
+
path: "",
|
|
116
|
+
org: "acme-inc",
|
|
117
|
+
private: true,
|
|
118
|
+
message: "m",
|
|
119
|
+
});
|
|
120
|
+
expect(deps.resolveOwner).toHaveBeenCalledWith("acme-inc");
|
|
121
|
+
expect(deps.createRepo).toHaveBeenCalledWith(
|
|
122
|
+
"acme-inc",
|
|
123
|
+
"demo",
|
|
124
|
+
expect.objectContaining({ private: true }),
|
|
125
|
+
);
|
|
126
|
+
expect(result.owner).toBe("acme-inc");
|
|
127
|
+
expect(result.pagesUrl).toBe("https://acme-inc.github.io/demo/");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not push or enable pages if tooling is unavailable", async () => {
|
|
131
|
+
const { deps } = makeDeps({
|
|
132
|
+
ensureToolsAvailable: vi.fn(async () => {
|
|
133
|
+
throw new Error("gh not installed");
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
await expect(
|
|
137
|
+
runPublishFlow(deps, { bundleDir: "/x", repo: "r", path: "", private: false, message: "m" }),
|
|
138
|
+
).rejects.toThrow(/gh not installed/);
|
|
139
|
+
expect(deps.pushBundleToPages).not.toHaveBeenCalled();
|
|
140
|
+
expect(deps.enablePages).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("pagesUrl", () => {
|
|
145
|
+
it("serves a normal repo under its name as a path", () => {
|
|
146
|
+
expect(pagesUrl("octocat", "welcome-tour")).toBe("https://octocat.github.io/welcome-tour/");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("lowercases the owner in the host", () => {
|
|
150
|
+
expect(pagesUrl("OctoCat", "demo")).toBe("https://octocat.github.io/demo/");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("serves the user/org site repo at the apex", () => {
|
|
154
|
+
expect(pagesUrl("octocat", "octocat.github.io")).toBe("https://octocat.github.io/");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("appends a non-empty subpath", () => {
|
|
158
|
+
expect(pagesUrl("octocat", "scenar-embeds", "welcome-tour")).toBe(
|
|
159
|
+
"https://octocat.github.io/scenar-embeds/welcome-tour/",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("appends a subpath under the user/org site repo too", () => {
|
|
164
|
+
expect(pagesUrl("octocat", "octocat.github.io", "tour")).toBe(
|
|
165
|
+
"https://octocat.github.io/tour/",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ignores an empty/root path", () => {
|
|
170
|
+
expect(pagesUrl("octocat", "demo", "")).toBe("https://octocat.github.io/demo/");
|
|
171
|
+
expect(pagesUrl("octocat", "demo", "/")).toBe("https://octocat.github.io/demo/");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("normalizePath", () => {
|
|
176
|
+
it("strips leading/trailing/doubled slashes and dots", () => {
|
|
177
|
+
expect(normalizePath("/welcome-tour/")).toBe("welcome-tour");
|
|
178
|
+
expect(normalizePath("a//b/")).toBe("a/b");
|
|
179
|
+
expect(normalizePath("/")).toBe("");
|
|
180
|
+
expect(normalizePath(".")).toBe("");
|
|
181
|
+
expect(normalizePath(undefined)).toBe("");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("validatePublishPath", () => {
|
|
186
|
+
it("accepts root and safe segments", () => {
|
|
187
|
+
expect(validatePublishPath("")).toBeNull();
|
|
188
|
+
expect(validatePublishPath("welcome-tour")).toBeNull();
|
|
189
|
+
expect(validatePublishPath("demos/v2")).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("rejects traversal and illegal characters", () => {
|
|
193
|
+
expect(validatePublishPath("..")).toMatch(/\.\./);
|
|
194
|
+
expect(validatePublishPath("a/../b")).toMatch(/\.\./);
|
|
195
|
+
expect(validatePublishPath("has space")).toMatch(/invalid path segment/);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("repoUrl", () => {
|
|
200
|
+
it("builds the repository web URL", () => {
|
|
201
|
+
expect(repoUrl("octocat", "demo")).toBe("https://github.com/octocat/demo");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("validateRepoName", () => {
|
|
206
|
+
it("accepts valid names", () => {
|
|
207
|
+
expect(validateRepoName("welcome-tour")).toBeNull();
|
|
208
|
+
expect(validateRepoName("my_demo.embed")).toBeNull();
|
|
209
|
+
expect(validateRepoName("a")).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("rejects empty, over-long, dot, and illegal-character names", () => {
|
|
213
|
+
expect(validateRepoName("")).toMatch(/1-100/);
|
|
214
|
+
expect(validateRepoName("x".repeat(101))).toMatch(/1-100/);
|
|
215
|
+
expect(validateRepoName(".")).toMatch(/"\."/);
|
|
216
|
+
expect(validateRepoName("..")).toMatch(/"\."/);
|
|
217
|
+
expect(validateRepoName("has space")).toMatch(/invalid repo name/);
|
|
218
|
+
expect(validateRepoName("slash/name")).toMatch(/invalid repo name/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readBundleViewport } from "../bundle/read-viewport.js";
|
|
6
|
+
import { DEFAULT_VIEWPORT } from "../pack/viewport.js";
|
|
7
|
+
|
|
8
|
+
describe("readBundleViewport", () => {
|
|
9
|
+
let dir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
dir = await mkdtemp(join(tmpdir(), "scenar-viewport-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads the recorded viewport from scenario.json", async () => {
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(dir, "scenario.json"),
|
|
22
|
+
JSON.stringify({ schemaVersion: "1", id: "x", viewport: { width: 1280, height: 720 } }),
|
|
23
|
+
"utf-8",
|
|
24
|
+
);
|
|
25
|
+
expect(await readBundleViewport(dir)).toEqual({
|
|
26
|
+
viewport: { width: 1280, height: 720 },
|
|
27
|
+
recorded: true,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to the default when scenario.json is absent", async () => {
|
|
32
|
+
expect(await readBundleViewport(dir)).toEqual({
|
|
33
|
+
viewport: DEFAULT_VIEWPORT,
|
|
34
|
+
recorded: false,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to the default when the viewport is missing or malformed", async () => {
|
|
39
|
+
await writeFile(join(dir, "scenario.json"), JSON.stringify({ schemaVersion: "1" }), "utf-8");
|
|
40
|
+
expect(await readBundleViewport(dir)).toEqual({ viewport: DEFAULT_VIEWPORT, recorded: false });
|
|
41
|
+
|
|
42
|
+
await writeFile(join(dir, "scenario.json"), "{ not json", "utf-8");
|
|
43
|
+
expect(await readBundleViewport(dir)).toEqual({ viewport: DEFAULT_VIEWPORT, recorded: false });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runPublish } from "../publish/run-publish.js";
|
|
6
|
+
import type { PublishFlowDeps } from "../publish/publish-flow.js";
|
|
7
|
+
|
|
8
|
+
/** Fake gh/git deps: record calls, never touch the network. */
|
|
9
|
+
function fakeDeps(): Omit<PublishFlowDeps, "log"> {
|
|
10
|
+
return {
|
|
11
|
+
ensureToolsAvailable: vi.fn(async () => {}),
|
|
12
|
+
resolveOwner: vi.fn(async (org) => org ?? "octocat"),
|
|
13
|
+
repoExists: vi.fn(async () => false),
|
|
14
|
+
createRepo: vi.fn(async () => {}),
|
|
15
|
+
pushBundleToPages: vi.fn(async () => {}),
|
|
16
|
+
enablePages: vi.fn(async () => {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("runPublish", () => {
|
|
21
|
+
let dir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
dir = await mkdtemp(join(tmpdir(), "scenar-publish-bundle-"));
|
|
25
|
+
await writeFile(join(dir, "index.html"), "<!doctype html><title>t</title>", "utf-8");
|
|
26
|
+
await writeFile(
|
|
27
|
+
join(dir, "scenario.json"),
|
|
28
|
+
JSON.stringify({ schemaVersion: "1", id: "welcome-tour", viewport: { width: 896, height: 480 } }),
|
|
29
|
+
"utf-8",
|
|
30
|
+
);
|
|
31
|
+
await writeFile(
|
|
32
|
+
join(dir, "pack-manifest.json"),
|
|
33
|
+
JSON.stringify({ schemaVersion: 1, scenarioId: "welcome-tour", files: [{ path: "index.html", sha256: "a".repeat(64), sizeBytes: 30, contentType: "text/html" }] }),
|
|
34
|
+
"utf-8",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("defaults to the shared repo + scenario-slug subpath and returns the URL + snippet", async () => {
|
|
43
|
+
const deps = fakeDeps();
|
|
44
|
+
const { result, snippet, recordedViewport } = await runPublish({ bundleDir: dir, deps });
|
|
45
|
+
|
|
46
|
+
expect(result.repo).toBe("scenar-embeds");
|
|
47
|
+
expect(result.path).toBe("welcome-tour");
|
|
48
|
+
expect(result.pagesUrl).toBe("https://octocat.github.io/scenar-embeds/welcome-tour/");
|
|
49
|
+
expect(deps.createRepo).toHaveBeenCalledWith(
|
|
50
|
+
"octocat",
|
|
51
|
+
"scenar-embeds",
|
|
52
|
+
expect.objectContaining({ private: false }),
|
|
53
|
+
);
|
|
54
|
+
expect(deps.pushBundleToPages).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({ path: "welcome-tour" }),
|
|
56
|
+
);
|
|
57
|
+
expect(recordedViewport).toBe(true);
|
|
58
|
+
expect(snippet).toContain("https://octocat.github.io/scenar-embeds/welcome-tour/");
|
|
59
|
+
expect(snippet).toContain("aspect-ratio:896/480");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("honors an explicit repo + org, defaulting the path to the slug", async () => {
|
|
63
|
+
const deps = fakeDeps();
|
|
64
|
+
const { result } = await runPublish({ bundleDir: dir, repo: "tours", org: "stigmer", deps });
|
|
65
|
+
expect(result.pagesUrl).toBe("https://stigmer.github.io/tours/welcome-tour/");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("publishes at the repo root when --path is /", async () => {
|
|
69
|
+
const deps = fakeDeps();
|
|
70
|
+
const { result } = await runPublish({ bundleDir: dir, repo: "scenar-welcome-tour", org: "stigmer", path: "/", deps });
|
|
71
|
+
expect(result.path).toBe("");
|
|
72
|
+
expect(result.pagesUrl).toBe("https://stigmer.github.io/scenar-welcome-tour/");
|
|
73
|
+
expect(deps.pushBundleToPages).toHaveBeenCalledWith(expect.objectContaining({ path: "" }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects an invalid explicit path before any GitHub call", async () => {
|
|
77
|
+
const deps = fakeDeps();
|
|
78
|
+
await expect(runPublish({ bundleDir: dir, path: "../escape", deps })).rejects.toThrow(/\.\./);
|
|
79
|
+
expect(deps.ensureToolsAvailable).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects an invalid explicit repo name before any GitHub call", async () => {
|
|
83
|
+
const deps = fakeDeps();
|
|
84
|
+
await expect(runPublish({ bundleDir: dir, repo: "bad name", deps })).rejects.toThrow(/invalid repo name/);
|
|
85
|
+
expect(deps.ensureToolsAvailable).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("fails clearly when the bundle has no index.html", async () => {
|
|
89
|
+
await rm(join(dir, "index.html"));
|
|
90
|
+
await expect(runPublish({ bundleDir: dir, deps: fakeDeps() })).rejects.toThrow(/no index\.html/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createProgram } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("scenar serve", () => {
|
|
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 serve command", () => {
|
|
17
|
+
const program = createProgram();
|
|
18
|
+
const cmd = program.commands.find((c) => c.name() === "serve");
|
|
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() === "serve");
|
|
26
|
+
expect(cmd!.registeredArguments[0]!.name()).toBe("bundleDir");
|
|
27
|
+
const opts = cmd!.options.map((o) => o.long);
|
|
28
|
+
expect(opts).toContain("--port");
|
|
29
|
+
expect(opts).toContain("--host");
|
|
30
|
+
expect(opts).toContain("--open");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fails clearly when the bundle directory does not exist", async () => {
|
|
34
|
+
const program = createProgram();
|
|
35
|
+
program.exitOverride();
|
|
36
|
+
try {
|
|
37
|
+
await program.parseAsync(["node", "scenar", "serve", "/tmp/__no_bundle__"]);
|
|
38
|
+
} catch {
|
|
39
|
+
// exitOverride throws on a non-zero exit; the assertion is on exitCode.
|
|
40
|
+
}
|
|
41
|
+
expect(process.exitCode).toBe(1);
|
|
42
|
+
expect(stderrOutput).toContain("Error");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveStaticPath, contentTypeFor } from "../serve/request-path.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveStaticPath", () => {
|
|
5
|
+
it("maps the root and trailing-slash paths to index.html", () => {
|
|
6
|
+
expect(resolveStaticPath("/")).toBe("index.html");
|
|
7
|
+
expect(resolveStaticPath("/sub/")).toBe("sub/index.html");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns clean relative paths for file requests", () => {
|
|
11
|
+
expect(resolveStaticPath("/assets/index-abc.js")).toBe("assets/index-abc.js");
|
|
12
|
+
expect(resolveStaticPath("/scenario.json")).toBe("scenario.json");
|
|
13
|
+
expect(resolveStaticPath("/narration/step-0.mp3")).toBe("narration/step-0.mp3");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("strips a query string and fragment", () => {
|
|
17
|
+
expect(resolveStaticPath("/assets/app.js?v=2")).toBe("assets/app.js");
|
|
18
|
+
expect(resolveStaticPath("/index.html#top")).toBe("index.html");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("percent-decodes path segments", () => {
|
|
22
|
+
expect(resolveStaticPath("/assets/logo%20art.png")).toBe("assets/logo art.png");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects parent-directory traversal in every form", () => {
|
|
26
|
+
expect(resolveStaticPath("/../secret")).toBeNull();
|
|
27
|
+
expect(resolveStaticPath("/assets/../../etc/passwd")).toBeNull();
|
|
28
|
+
expect(resolveStaticPath("/%2e%2e/secret")).toBeNull();
|
|
29
|
+
expect(resolveStaticPath("/foo/%2e%2e/%2e%2e/bar")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects current-dir and empty (double-slash) segments", () => {
|
|
33
|
+
expect(resolveStaticPath("/./foo")).toBeNull();
|
|
34
|
+
expect(resolveStaticPath("/foo//bar")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects backslash and NUL injection", () => {
|
|
38
|
+
expect(resolveStaticPath("/..\\windows")).toBeNull();
|
|
39
|
+
expect(resolveStaticPath("/foo\u0000.js")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects malformed percent-encoding", () => {
|
|
43
|
+
expect(resolveStaticPath("/%zz")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects non-absolute paths", () => {
|
|
47
|
+
expect(resolveStaticPath("relative")).toBeNull();
|
|
48
|
+
expect(resolveStaticPath("")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("contentTypeFor", () => {
|
|
53
|
+
it("resolves canonical bundle content types by extension", () => {
|
|
54
|
+
expect(contentTypeFor("index.html")).toBe("text/html");
|
|
55
|
+
expect(contentTypeFor("assets/app.js")).toBe("text/javascript");
|
|
56
|
+
expect(contentTypeFor("assets/style.css")).toBe("text/css");
|
|
57
|
+
expect(contentTypeFor("scenario.json")).toBe("application/json");
|
|
58
|
+
expect(contentTypeFor("narration/step-0.mp3")).toBe("audio/mpeg");
|
|
59
|
+
expect(contentTypeFor("assets/logo.png")).toBe("image/png");
|
|
60
|
+
expect(contentTypeFor("fonts/inter.woff2")).toBe("font/woff2");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to application/octet-stream for unknown extensions", () => {
|
|
64
|
+
expect(contentTypeFor("data.bin")).toBe("application/octet-stream");
|
|
65
|
+
expect(contentTypeFor("noext")).toBe("application/octet-stream");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { request } from "node:http";
|
|
6
|
+
import { startBundleServer, type BundleServerHandle } from "../serve/static-server.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Issue a GET with a verbatim request path. Unlike fetch/URL, node:http does not
|
|
10
|
+
* normalise the path, so an encoded "%2e%2e" traversal reaches the server intact
|
|
11
|
+
* — exactly what an attacker would send. Returns the response status code.
|
|
12
|
+
*/
|
|
13
|
+
function rawGetStatus(baseUrl: string, rawPath: string): Promise<number> {
|
|
14
|
+
const base = new URL(baseUrl);
|
|
15
|
+
return new Promise<number>((resolvePromise, reject) => {
|
|
16
|
+
const req = request(
|
|
17
|
+
{ hostname: base.hostname, port: base.port, path: rawPath, method: "GET" },
|
|
18
|
+
(res) => {
|
|
19
|
+
res.resume();
|
|
20
|
+
resolvePromise(res.statusCode ?? 0);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
req.on("error", reject);
|
|
24
|
+
req.end();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("startBundleServer", () => {
|
|
29
|
+
let dir: string;
|
|
30
|
+
let server: BundleServerHandle;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
dir = await mkdtemp(join(tmpdir(), "scenar-serve-"));
|
|
34
|
+
await writeFile(join(dir, "index.html"), "<!doctype html><title>tour</title>", "utf-8");
|
|
35
|
+
await mkdir(join(dir, "assets"), { recursive: true });
|
|
36
|
+
await writeFile(join(dir, "assets", "app.js"), "console.log(1)", "utf-8");
|
|
37
|
+
// Port 0 → the OS assigns a free port (no fixed-port collisions in CI).
|
|
38
|
+
server = await startBundleServer({ rootDir: dir, port: 0 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await server.close();
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("serves index.html at the root with the right content type", async () => {
|
|
47
|
+
const res = await fetch(server.url);
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(res.headers.get("content-type")).toBe("text/html");
|
|
50
|
+
expect(await res.text()).toContain("<title>tour</title>");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("serves nested assets with the right content type", async () => {
|
|
54
|
+
const res = await fetch(new URL("assets/app.js", server.url));
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
expect(res.headers.get("content-type")).toBe("text/javascript");
|
|
57
|
+
expect(await res.text()).toBe("console.log(1)");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("sets Cache-Control: no-cache so a preview never serves stale assets", async () => {
|
|
61
|
+
const res = await fetch(server.url);
|
|
62
|
+
expect(res.headers.get("cache-control")).toBe("no-cache");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns 404 for a missing file", async () => {
|
|
66
|
+
const res = await fetch(new URL("does-not-exist.js", server.url));
|
|
67
|
+
expect(res.status).toBe(404);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("answers HEAD with headers but no body", async () => {
|
|
71
|
+
const res = await fetch(server.url, { method: "HEAD" });
|
|
72
|
+
expect(res.status).toBe(200);
|
|
73
|
+
expect(res.headers.get("content-type")).toBe("text/html");
|
|
74
|
+
expect(await res.text()).toBe("");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects non-GET/HEAD methods with 405", async () => {
|
|
78
|
+
const res = await fetch(server.url, { method: "POST" });
|
|
79
|
+
expect(res.status).toBe(405);
|
|
80
|
+
expect(res.headers.get("allow")).toBe("GET, HEAD");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects a raw encoded traversal attempt with 400 (never reaches disk)", async () => {
|
|
84
|
+
// Sent verbatim via node:http so the "%2e%2e" survives to the server.
|
|
85
|
+
expect(await rawGetStatus(server.url, "/%2e%2e/%2e%2e/secret")).toBe(400);
|
|
86
|
+
expect(await rawGetStatus(server.url, "/assets/%2e%2e/%2e%2e/etc/passwd")).toBe(400);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/api.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The programmatic API of the Scenar CLI — the same orchestration the commands
|
|
3
|
+
* run, exposed as plain functions so other tools (notably @scenar/mcp-server)
|
|
4
|
+
* can drive Scenar without shelling out. Each `run*` performs one operation and
|
|
5
|
+
* returns a structured result; none touch process.exit or the process streams
|
|
6
|
+
* (they accept an optional `onLog` for progress).
|
|
7
|
+
*/
|
|
8
|
+
export { runPack } from "./pack/run-pack.js";
|
|
9
|
+
export type { RunPackOptions, PackResult } from "./pack/run-pack.js";
|
|
10
|
+
export type { PackManifest } from "./pack/pack-manifest.js";
|
|
11
|
+
export { runNarrate } from "./narrate/run-narrate.js";
|
|
12
|
+
export type { RunNarrateOptions, NarrateResult, NarratedScenario } from "./narrate/run-narrate.js";
|
|
13
|
+
export { runRender } from "./render/run-render.js";
|
|
14
|
+
export type { RunRenderOptions, RenderResult } from "./render/run-render.js";
|
|
15
|
+
export { runServe } from "./serve/run-serve.js";
|
|
16
|
+
export type { RunServeOptions, ServeResult } from "./serve/run-serve.js";
|
|
17
|
+
export { startBundleServer, createBundleServer } from "./serve/static-server.js";
|
|
18
|
+
export type { BundleServerHandle, StartBundleServerOptions } from "./serve/static-server.js";
|
|
19
|
+
export { runPublish } from "./publish/run-publish.js";
|
|
20
|
+
export type { RunPublishOptions, PublishRunResult } from "./publish/run-publish.js";
|
|
21
|
+
export { runPublishFlow, validateRepoName, validatePublishPath, normalizePath, pagesUrl, repoUrl, } from "./publish/publish-flow.js";
|
|
22
|
+
export type { PublishFlowDeps, PublishOptions, PublishResult, } from "./publish/publish-flow.js";
|
|
23
|
+
export { createGithubPublisher } from "./publish/github.js";
|
|
24
|
+
export { validateScenario } from "./validate/scenario-validator.js";
|
|
25
|
+
export { loadScenarioYaml } from "./util/load-yaml.js";
|
|
26
|
+
export { readBundleViewport } from "./bundle/read-viewport.js";
|
|
27
|
+
export type { BundleViewport } from "./bundle/read-viewport.js";
|
|
28
|
+
export { buildEmbedSnippet } from "./deploy/embed-snippet.js";
|
|
29
|
+
export { DEFAULT_VIEWPORT, parseViewport } from "./pack/viewport.js";
|
|
30
|
+
export type { Viewport } from "./pack/viewport.js";
|
|
31
|
+
//# sourceMappingURL=api.d.ts.map
|