Files
RUWIO/scripts/verify-timeout.cjs
T
thandClaude Opus 4.8 518d43855b Add desktop/server MP4 export pipeline, hardening, and render tests (v1.3.0)
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>
2026-06-06 20:41:25 +02:00

89 lines
3.2 KiB
JavaScript

// Phase B verification of the inactivity watchdog (finding #3): point the
// worker at a render page whose __animatrSeek never resolves, with a short
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
// and leaves no partial file.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
// Must be set BEFORE requiring the worker — the timeout is read at module load.
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
// Build a temp project dir with a server/render.html that hangs on seek, then
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
// picks it up.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
fs.mkdirSync(path.join(tmpDir, 'server'))
fs.writeFileSync(
path.join(tmpDir, 'server', 'render.html'),
`<!doctype html><html><body><script>
window.__animatrInit = function(){ return Promise.resolve(true) }
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
</script></body></html>`,
)
process.chdir(tmpDir)
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const outPath = path.join(tmpDir, 'out.mp4')
const controller = new AbortController()
const startedAt = process.hrtime.bigint()
let rejected = false
let message = ''
try {
await renderMp4Job({
jobId: 'timeout',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: () => {},
})
} catch (err) {
rejected = true
message = err.message
}
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
await new Promise((r) => setTimeout(r, 200))
const notes = []
let pass = true
if (!rejected) { pass = false; notes.push('job did not reject') }
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
fs.rmSync(tmpDir, { recursive: true, force: true })
app.exit(pass ? 0 : 1)
})