返回 html-video
smoke.ts
根目录 / packages / cli / src / smoke.ts
1 /**
2 * End-to-end smoke test for project-centric workflow (RFC-05).
3 * Asserts: bootstrap → create project → add assets → set template → preview → render
4 */
5
6 import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
7 import { tmpdir } from 'node:os';
8 import { join, resolve } from 'node:path';
9 import { existsSync } from 'node:fs';
10 import { bootstrap } from './context.js';
11
12 const log = (msg: string) => process.stdout.write(`▸ ${msg}\n`);
13 const ok = (msg: string) => process.stdout.write(` ✓ ${msg}\n`);
14
15 async function main() {
16 const projectRoot = await mkdtemp(join(tmpdir(), 'html-video-smoke-'));
17 await mkdir(join(projectRoot, '.html-video'), { recursive: true });
18 log(`workdir: ${projectRoot}`);
19
20 const monorepoRoot = resolve(__dirname_polyfill(), '..', '..', '..');
21
22 const fakeLogoPath = join(projectRoot, 'fake-logo.png');
23 const PNG_1x1 = Buffer.from(
24 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=',
25 'base64',
26 );
27 await writeFile(fakeLogoPath, PNG_1x1);
28
29 log('bootstrap context');
30 const ctx = await bootstrap({ cwd: projectRoot });
31 if (ctx.templates.list().length === 0) {
32 await ctx.templates.scan(join(monorepoRoot, 'templates'));
33 }
34 ok(`engines: ${ctx.engines.list().map((e) => e.id).join(', ')}`);
35 ok(`templates: ${ctx.templates.list().map((t) => t.id).join(', ')}`);
36
37 // 1. Create a project
38 log('project create');
39 const project1 = await ctx.orchestrator.create({
40 name: 'OD Plugin Library Demo',
41 intent: 'Show OD plugin library distribution',
42 preferences: { aspect: '16:9', commercial: true },
43 });
44 ok(`project ${project1.id} status=${project1.status}`);
45
46 // 2. Add assets
47 log('add image asset');
48 let p = await ctx.orchestrator.addFileAsset(project1.id, fakeLogoPath, 'OD logo');
49 ok(`assets=${p.assets.length}`);
50
51 log('add inline text asset');
52 p = await ctx.orchestrator.addInlineAsset(project1.id, 'Design that evolves itself', 'text');
53 ok(`assets=${p.assets.length}`);
54
55 log('add inline data asset');
56 const chartData = JSON.stringify([
57 { label: 'Templates', value: 231, color: '#ffb84d' },
58 { label: 'Skills', value: 15, color: '#9b87f5' },
59 { label: 'Systems', value: 150, color: '#6dd99c' },
60 { label: 'Craft', value: 11, color: '#ff8a4d' },
61 ]);
62 p = await ctx.orchestrator.addInlineAsset(project1.id, chartData, 'data');
63 ok(`assets=${p.assets.length}`);
64
65 // 3. Pick a template
66 log('set template = frame-data-chart-nyt');
67 p = await ctx.orchestrator.setTemplate(project1.id, 'frame-data-chart-nyt');
68 ok(`templateId=${p.templateId} variables(after-defaults)=${JSON.stringify(p.variables).slice(0, 80)}…`);
69
70 // 4. Set variables (use the chart data we just added)
71 log('set variables');
72 p = await ctx.orchestrator.setVariables(project1.id, {
73 title: 'OD Plugin Library Distribution',
74 subtitle: '2026-05-27',
75 data: JSON.parse(chartData),
76 value_format: 'number',
77 duration_sec: 8,
78 });
79 ok('variables saved');
80
81 // 5. Render preview HTML
82 log('render preview html');
83 const { project: previewedProj, htmlPath } = await ctx.orchestrator.renderPreviewHtml(project1.id);
84 if (!existsSync(htmlPath)) throw new Error('Preview HTML missing: ' + htmlPath);
85 const content = await readFile(htmlPath, 'utf8');
86 if (!content.includes('<html')) throw new Error('Preview HTML malformed');
87 ok(`status=${previewedProj.status} html=${htmlPath}`);
88
89 // 6. Switch template to test variable preservation
90 log('switch template to frame-glitch-title');
91 p = await ctx.orchestrator.setTemplate(project1.id, 'frame-glitch-title');
92 ok(`now templateId=${p.templateId} kept-vars=${JSON.stringify(p.variables)}`);
93
94 // 7. Switch back + render again
95 log('switch back to frame-data-chart-nyt');
96 p = await ctx.orchestrator.setTemplate(project1.id, 'frame-data-chart-nyt');
97 p = await ctx.orchestrator.setVariables(project1.id, {
98 title: 'OD Plugin Library Distribution',
99 data: JSON.parse(chartData),
100 duration_sec: 8,
101 });
102
103 // 8. Export MP4 (stub)
104 log('export MP4 (stub)');
105 const { project: rendered, outputPath } = await ctx.orchestrator.exportMp4({
106 projectId: project1.id,
107 onProgress: (pct, stage) => {
108 if (pct === 0 || pct === 100 || pct % 25 === 0) ok(`render ${stage} ${pct}%`);
109 },
110 });
111 if (!existsSync(outputPath)) throw new Error('MP4 output missing');
112 ok(`status=${rendered.status} mp4=${outputPath}`);
113
114 // 9. v0.8: ContentGraph + multi-frame self-test
115 log('v0.8 multi-frame: write content-graph + 3 frames');
116 const project2 = await ctx.orchestrator.create({
117 name: 'Multi-frame explainer demo',
118 intent: 'Test content-graph + frames pipeline',
119 preferences: {},
120 });
121 const graph = {
122 schemaVersion: 1 as const,
123 intent: 'explainer' as const,
124 synopsis: 'Smoke-test explainer with three frames',
125 nodes: [
126 { id: 'intro', kind: 'text' as const, text: 'Hello world', durationSec: 2 },
127 { id: 'middle', kind: 'data' as const, data: { v: 42 }, durationSec: 4 },
128 { id: 'outro', kind: 'entity' as const, props: { logo: 'OD' }, durationSec: 3 },
129 ],
130 edges: [
131 { from: 'intro', to: 'middle', kind: 'sequence' as const },
132 { from: 'middle', to: 'outro', kind: 'dependency' as const },
133 ],
134 };
135 await ctx.orchestrator.writeContentGraph(project2.id, graph);
136 ok('graph persisted + validated');
137
138 for (const node of graph.nodes) {
139 const html = `<!doctype html><html><head><title>${node.id}</title></head><body data-hv-text="${node.id}">${node.id}</body></html>`;
140 const { frame } = await ctx.orchestrator.writeFrameHtml(project2.id, node.id, html);
141 ok(`frame written: ${frame.graphNodeId} order=${frame.order} dur=${frame.durationSec}s path=${frame.htmlPath.split('/').slice(-3).join('/')}`);
142 }
143
144 const finalProject = await ctx.orchestrator.load(project2.id);
145 if (!finalProject.frames || finalProject.frames.length !== 3) {
146 throw new Error(`expected 3 frames, got ${finalProject.frames?.length}`);
147 }
148 // Order should be: intro (no deps), middle (after intro by sequence + before outro), outro (depends on middle)
149 const order = finalProject.frames.map((f) => f.graphNodeId).join(',');
150 if (order !== 'intro,middle,outro') {
151 throw new Error(`unexpected play order: ${order}`);
152 }
153 ok(`play order: ${order}`);
154
155 // 10. Verify project list works
156 log('list projects');
157 const all = await ctx.orchestrator.list();
158 ok(`${all.length} project(s) in store`);
159
160 process.stdout.write('\n✅ smoke test passed\n');
161 }
162
163 function __dirname_polyfill(): string {
164 const url = import.meta.url;
165 const path = url.replace(/^file:\/\//, '');
166 return path.replace(/\/[^/]*$/, '');
167 }
168
169 main().catch((err) => {
170 process.stderr.write(`\n❌ smoke test failed: ${err.message ?? err}\n`);
171 if (err.stack) process.stderr.write(err.stack + '\n');
172 process.exit(1);
173 });
174
174 lines TYPESCRIPT