const assert = require('node:assert/strict') const { spawn } = require('node:child_process') const { mkdtemp, readFile, rm } = require('node:fs/promises') const net = require('node:net') const os = require('node:os') const path = require('node:path') const { test } = require('node:test') const HOST = '127.0.0.1' const TOKEN = 'server-e2e-token' const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' test('render server produces a playable MP4 for a tiny scene', async (t) => { const ffmpeg = await commandPath('ffmpeg') const ffprobe = await commandPath('ffprobe') if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) { t.skip('requires ffmpeg, ffprobe, and Chromium') return } const port = await freePort() const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-')) const server = spawn(process.execPath, ['server.js'], { cwd: __dirname, env: { ...process.env, HOST, PORT: String(port), RENDER_TOKEN: TOKEN, RENDER_JOB_TTL_MS: '1000', RENDER_JOB_TIMEOUT_MS: '30000', PUPPETEER_EXECUTABLE_PATH: CHROME, CHROME_NO_SANDBOX: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }) let output = '' server.stdout.on('data', (chunk) => { output += chunk.toString() }) server.stderr.on('data', (chunk) => { output += chunk.toString() }) t.after(async () => { if (server.exitCode === null) { server.kill('SIGTERM') await waitForExit(server) } await rm(tmp, { recursive: true, force: true }) }) await waitForServer(server, port, () => output) const base = `http://${HOST}:${port}` const start = await fetch(`${base}/render`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }), }) const startText = await start.text() assert.equal(start.status, 200, startText) const { jobId, accessToken } = JSON.parse(startText) assert.equal(typeof jobId, 'string') assert.equal(typeof accessToken, 'string') const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`) assert.equal(done.done, true) assert.equal(done.error, null) assert.equal(done.progress, 1) assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`) const file = await fetch(`${base}${done.url}`) assert.equal(file.status, 200) const outPath = path.join(tmp, 'tiny.mp4') await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer())) const metadata = await ffprobeJson(outPath) const video = metadata.streams.find((stream) => stream.codec_type === 'video') assert.ok(video, 'expected a video stream') assert.equal(video.codec_name, 'h264') assert.equal(video.width, 64) assert.equal(video.height, 64) }) test('render server pads odd canvas dimensions to an even MP4', async (t) => { const ffmpeg = await commandPath('ffmpeg') const ffprobe = await commandPath('ffprobe') if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) { t.skip('requires ffmpeg, ffprobe, and Chromium') return } const port = await freePort() const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-')) const server = spawn(process.execPath, ['server.js'], { cwd: __dirname, env: { ...process.env, HOST, PORT: String(port), RENDER_TOKEN: TOKEN, RENDER_JOB_TTL_MS: '1000', RENDER_JOB_TIMEOUT_MS: '30000', PUPPETEER_EXECUTABLE_PATH: CHROME, CHROME_NO_SANDBOX: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }) let output = '' server.stdout.on('data', (chunk) => { output += chunk.toString() }) server.stderr.on('data', (chunk) => { output += chunk.toString() }) t.after(async () => { if (server.exitCode === null) { server.kill('SIGTERM') await waitForExit(server) } await rm(tmp, { recursive: true, force: true }) }) await waitForServer(server, port, () => output) const base = `http://${HOST}:${port}` const start = await fetch(`${base}/render`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }), }) const startText = await start.text() assert.equal(start.status, 200, startText) const { jobId, accessToken } = JSON.parse(startText) const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`) assert.equal(done.done, true) assert.equal(done.error, null) assert.equal(done.progress, 1) const file = await fetch(`${base}${done.url}`) assert.equal(file.status, 200) const outPath = path.join(tmp, 'odd.mp4') await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer())) const metadata = await ffprobeJson(outPath) const video = metadata.streams.find((stream) => stream.codec_type === 'video') assert.ok(video, 'expected a video stream') assert.equal(video.codec_name, 'h264') assert.equal(video.width, 102) assert.equal(video.height, 102) }) function tinyScene() { return { canvas: { width: 64, height: 64, background: '#ffffff' }, duration: 1, fps: 2, assets: [ { id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' }, ], layers: [ { id: 'layer-rect', name: 'Box', assetId: 'asset-rect', visible: true, base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, keyframes: [ { id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } }, { id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } }, ], }, ], } } function oddScene() { const s = tinyScene() s.canvas.width = 101 s.canvas.height = 101 s.layers[0].base.x = 50 s.layers[0].base.y = 50 s.layers[0].keyframes = [ { id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } }, { id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } }, ] return s } async function waitForDone(url) { const res = await fetch(url) assert.equal(res.status, 200) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' const deadline = Date.now() + 30000 while (Date.now() < deadline) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const messages = buffer.split('\n\n') buffer = messages.pop() || '' for (const message of messages) { const line = message.split('\n').find((entry) => entry.startsWith('data: ')) if (!line) continue const payload = JSON.parse(line.slice(6)) if (payload.done) return payload } } throw new Error('render did not finish before timeout') } function ffprobeJson(file) { return new Promise((resolve, reject) => { const child = spawn('ffprobe', [ '-v', 'error', '-show_streams', '-of', 'json', file, ]) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => { stdout += chunk.toString() }) child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) child.on('error', reject) child.on('exit', (code) => { if (code === 0) resolve(JSON.parse(stdout)) else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`)) }) }) } function commandPath(command) { return new Promise((resolve) => { const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command]) probe.on('error', () => resolve('')) probe.on('exit', (code) => resolve(code === 0 ? command : '')) }) } async function canAccess(file) { try { await readFile(file) return true } catch { return false } } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer() server.once('error', reject) server.listen(0, HOST, () => { const address = server.address() server.close(() => { if (!address || typeof address === 'string') reject(new Error('could not allocate test port')) else resolve(address.port) }) }) }) } async function waitForServer(server, port, output) { const deadline = Date.now() + 5000 while (Date.now() < deadline) { if (server.exitCode !== null) { throw new Error(`server exited early with code ${server.exitCode}\n${output()}`) } try { const res = await fetch(`http://${HOST}:${port}/health`) if (res.ok) return } catch { /* retry */ } await delay(50) } throw new Error(`server did not start\n${output()}`) } function waitForExit(child) { return new Promise((resolve) => { child.once('exit', () => resolve()) setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL') resolve() }, 1000).unref() }) } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) }