Desktop MP4 export renders frames from a hidden BrowserWindow via capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart), and writes the file through an atomic temp-file swap. It shares scene validation (shared/renderValidation.cjs) and the render.html renderer with the standalone Puppeteer server, which is hardened in parallel. Render worker & main (electron/): - abortable render loop with an inactivity watchdog and cancel plumbing - atomic temp-file + rename so a cancel/failure never truncates a pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang handled (#8) - HiDPI frames normalized to canvas size (#10); odd dimensions padded to even (#13, also server); backgroundThrottling disabled (#12) - reject canvases larger than the display (#11); single-render guard; isolated render session that blocks network requests (#5) Tests: - Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel, timeout, assets, background, capture-fidelity, resolution, odd-dims, aggregated by verify-desktop-render.cjs - server smoke + e2e and shared validation unit tests - headless hook (src/devtools/headless.ts) to run the in-app suite under browser/electron hosts; pixel-diff util + capture fixtures (test/) - dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
3.8 KiB
JavaScript
140 lines
3.8 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const { spawn } = require('node:child_process')
|
|
const net = require('node:net')
|
|
const { test } = require('node:test')
|
|
|
|
const HOST = '127.0.0.1'
|
|
const TOKEN = 'server-smoke-token'
|
|
|
|
test('render server health, auth, and validation routes work', async (t) => {
|
|
const port = await freePort()
|
|
const server = spawn(process.execPath, ['server.js'], {
|
|
cwd: __dirname,
|
|
env: {
|
|
...process.env,
|
|
HOST,
|
|
PORT: String(port),
|
|
RENDER_TOKEN: TOKEN,
|
|
RENDER_MAX_BODY: '1mb',
|
|
RENDER_RATE_MAX: '100',
|
|
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
|
|
},
|
|
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 waitForServer(server, port, () => output)
|
|
|
|
const base = `http://${HOST}:${port}`
|
|
|
|
const health = await fetch(`${base}/health`)
|
|
assert.equal(health.status, 200)
|
|
assert.deepEqual(await health.json(), { ok: true })
|
|
|
|
const missingToken = await postRender(base, null, invalidRenderBody())
|
|
assert.equal(missingToken.status, 401)
|
|
assert.equal((await missingToken.json()).error, 'invalid render token')
|
|
|
|
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
|
|
assert.equal(badToken.status, 401)
|
|
assert.equal((await badToken.json()).error, 'invalid render token')
|
|
|
|
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
|
|
assert.equal(invalidRender.status, 400)
|
|
assert.match((await invalidRender.json()).error, /canvas must be/)
|
|
|
|
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
|
|
assert.equal(missingJobEvents.status, 404)
|
|
|
|
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
|
|
assert.equal(missingJobFile.status, 404)
|
|
|
|
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
|
|
assert.equal(missingJobDelete.status, 404)
|
|
})
|
|
|
|
function invalidRenderBody() {
|
|
return {
|
|
scene: {
|
|
canvas: { width: 0, height: 100 },
|
|
duration: 1,
|
|
fps: 30,
|
|
layers: [],
|
|
assets: [],
|
|
},
|
|
options: { fps: 30 },
|
|
}
|
|
}
|
|
|
|
function postRender(base, token, body) {
|
|
const headers = { 'Content-Type': 'application/json' }
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
return fetch(`${base}/render`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
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
|
|
let lastError = null
|
|
|
|
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 (err) {
|
|
lastError = err
|
|
}
|
|
await delay(50)
|
|
}
|
|
|
|
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\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))
|
|
}
|