| 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 |