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>
This commit is contained in:
1 parent
46f9f32aef
commit
518d43855b
41 files changed
+3907
-86
No files matched your search
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const optional = args.includes('--optional')
|
||||
const sourceIndex = args.indexOf('--source')
|
||||
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const targetDir = path.join(root, 'bin', process.platform)
|
||||
const target = path.join(targetDir, exe)
|
||||
|
||||
try {
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
|
||||
if (existsSync(target) && statSync(target).isFile()) {
|
||||
chmodIfNeeded(target)
|
||||
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
|
||||
if (!source) {
|
||||
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
|
||||
}
|
||||
|
||||
const resolvedSource = path.resolve(source)
|
||||
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
|
||||
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
|
||||
}
|
||||
|
||||
copyFileSync(resolvedSource, target)
|
||||
chmodIfNeeded(target)
|
||||
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
|
||||
} catch (err) {
|
||||
if (optional) {
|
||||
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
function findOnPath() {
|
||||
const command = process.platform === 'win32' ? 'where' : 'which'
|
||||
const result = spawnSync(command, [exe], { encoding: 'utf8' })
|
||||
if (result.status !== 0) return ''
|
||||
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
|
||||
}
|
||||
|
||||
function chmodIfNeeded(file) {
|
||||
if (process.platform !== 'win32') chmodSync(file, 0o755)
|
||||
}
|
||||
|
||||
function finishWithoutBinary(message) {
|
||||
if (optional) {
|
||||
console.warn(`warning: ${message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Phase B verification of the render-window network block (finding #5):
|
||||
// - a local data: image asset still loads and draws (block must not break it)
|
||||
// - an http: image asset is blocked and skipped without hanging the render
|
||||
//
|
||||
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
|
||||
// window built the same way, so it exercises the actual webRequest filter.
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
// 40x40 green square as an inline SVG data URL (no network needed).
|
||||
const greenSvg =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
|
||||
|
||||
function makeScene(src) {
|
||||
return {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
duration: 1,
|
||||
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'img1',
|
||||
visible: true,
|
||||
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderWindow(scene) {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
},
|
||||
})
|
||||
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
||||
cb({ cancel: true }),
|
||||
)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||
return win
|
||||
}
|
||||
|
||||
function countGreen(image) {
|
||||
const bmp = image.getBitmap() // BGRA
|
||||
let green = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
|
||||
}
|
||||
return green
|
||||
}
|
||||
|
||||
async function renderOnce(src) {
|
||||
const scene = makeScene(src)
|
||||
const win = createRenderWindow(scene)
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
|
||||
const initDone = win.webContents.executeJavaScript(
|
||||
`window.__animatrInit(${JSON.stringify(scene)})`,
|
||||
true,
|
||||
)
|
||||
const timedOut = await Promise.race([
|
||||
initDone.then(() => false),
|
||||
new Promise((r) => setTimeout(() => r(true), 5000)),
|
||||
])
|
||||
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
||||
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
||||
const green = countGreen(img)
|
||||
if (!win.isDestroyed()) win.close()
|
||||
return { green, timedOut }
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const local = await renderOnce(greenSvg)
|
||||
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
// ~1600 green px expected for a 40x40 image; allow for AA.
|
||||
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
|
||||
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
|
||||
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
|
||||
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
|
||||
|
||||
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
|
||||
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// Phase B verification of transparent-region flattening (finding #4): a scene
|
||||
// with backgroundVideo set makes render.html skip the background fill, leaving
|
||||
// transparent areas. With the opaque-white render window those areas must
|
||||
// capture as white (~255), matching the server's omitBackground:false, not
|
||||
// black. Builds the window exactly like renderWorker.createRenderWindow.
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
// backgroundVideo present -> render.html does NOT fill canvas.background.
|
||||
const scene = {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
|
||||
duration: 1,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function createRenderWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
},
|
||||
})
|
||||
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
||||
cb({ cancel: true }),
|
||||
)
|
||||
return win
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = createRenderWindow()
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
||||
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
||||
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
||||
const bmp = img.getBitmap() // BGRA
|
||||
|
||||
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
|
||||
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
|
||||
let white = 0
|
||||
let black = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
|
||||
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
|
||||
}
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
|
||||
pass = false
|
||||
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
|
||||
}
|
||||
if (black > 100) {
|
||||
pass = false
|
||||
notes.push(`${black} near-black px present (transparent flattened to black?)`)
|
||||
}
|
||||
|
||||
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
if (!win.isDestroyed()) win.close()
|
||||
app.exit(pass ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
|
||||
// rejects, leaves no partial output file, and the ffmpeg child is gone.
|
||||
//
|
||||
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
|
||||
|
||||
const { app } = require('electron')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const fs = require('node:fs')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const fps = 24
|
||||
const duration = 6 // long enough that we can cancel before it finishes
|
||||
const scene = {
|
||||
canvas: { width: 480, height: 360, background: '#202030' },
|
||||
duration,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 40 } },
|
||||
{ time: 6, props: { x: 420 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
|
||||
const controller = new AbortController()
|
||||
let progressAtCancel = null
|
||||
|
||||
const job = renderMp4Job({
|
||||
jobId: 'cancel',
|
||||
scene,
|
||||
options,
|
||||
outputPath: outPath,
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
// Cancel once we've made real progress but are nowhere near done.
|
||||
if (progressAtCancel === null && p > 0.1) {
|
||||
progressAtCancel = p
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let rejected = false
|
||||
let message = ''
|
||||
try {
|
||||
await job
|
||||
} catch (err) {
|
||||
rejected = true
|
||||
message = err.message
|
||||
}
|
||||
|
||||
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (progressAtCancel === null) {
|
||||
pass = false
|
||||
notes.push('never reached a progress point to cancel at (job too fast)')
|
||||
}
|
||||
if (!rejected) {
|
||||
pass = false
|
||||
notes.push('job resolved instead of rejecting after abort')
|
||||
} else if (!/abort/i.test(message)) {
|
||||
notes.push(`rejected, but message was: ${message}`)
|
||||
}
|
||||
if (fs.existsSync(outPath)) {
|
||||
pass = false
|
||||
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
|
||||
}
|
||||
|
||||
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
|
||||
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
// Suppress Electron's default "quit when all windows close": renderMp4Job
|
||||
// closes its render window, and the auto-quit would race our result printing.
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
// Compares Electron capturePage output against render.html's own canvas pixels
|
||||
// for deterministic fixture scenes. This guards the capture step itself:
|
||||
// wrong scale-factor normalization, stale frames, and clipping show up here.
|
||||
|
||||
const { app, BrowserWindow, nativeImage } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
]
|
||||
|
||||
function createRenderWindow(scene) {
|
||||
return new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
backgroundThrottling: false,
|
||||
partition: 'ruwio-capture-fidelity',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let pass = true
|
||||
const notes = []
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
|
||||
const win = createRenderWindow(scene)
|
||||
win.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['*://*/*'] },
|
||||
(_details, callback) => callback({ cancel: true }),
|
||||
)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
|
||||
try {
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
||||
|
||||
for (const t of fixture.times) {
|
||||
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
|
||||
const capture = await win.webContents.capturePage({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
})
|
||||
const canvasDataUrl = await win.webContents.executeJavaScript(
|
||||
`document.querySelector('canvas').toDataURL('image/png')`,
|
||||
true,
|
||||
)
|
||||
const expected = nativeImage.createFromDataURL(canvasDataUrl)
|
||||
const captureSize = capture.getSize()
|
||||
const expectedSize = expected.getSize()
|
||||
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
|
||||
pass = false
|
||||
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
|
||||
continue
|
||||
}
|
||||
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
|
||||
pass = false
|
||||
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
|
||||
continue
|
||||
}
|
||||
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
|
||||
// capturePage composites the hidden window, which introduces
|
||||
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
|
||||
// versus the raw canvas toDataURL. Tolerate that; genuine render
|
||||
// drift (wrong color, missing or stale shape, off-by-one frame)
|
||||
// produces much larger deltas over a contiguous region and still
|
||||
// trips the ratio gate. The explicit size checks above catch #10/#11.
|
||||
perChannelTolerance: 8,
|
||||
maxMismatchedRatio: 0.02,
|
||||
})
|
||||
console.log(
|
||||
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
|
||||
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
|
||||
)
|
||||
if (!diff.ok) {
|
||||
pass = false
|
||||
notes.push(
|
||||
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
|
||||
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!win.isDestroyed()) win.close()
|
||||
}
|
||||
}
|
||||
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
// Phase A verification harness (electron-rendering-integration-plan_2.md, finding #1/#2).
|
||||
//
|
||||
// Reproduces renderWorker.cts's window-creation + capturePage path against a
|
||||
// scene with one moving + fading layer, then checks that captured frames are
|
||||
// (a) non-blank and (b) actually advance over time. This validates or refutes
|
||||
// "capturePage on a hidden, non-offscreen window returns blank/stale frames".
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-capture.cjs
|
||||
// Set OFFSCREEN=1 to exercise the offscreen+paint-settle code path instead.
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const OFFSCREEN = process.env.OFFSCREEN === '1'
|
||||
|
||||
// A 320x240 scene: a red rect slides left->right and fades over 2s on a dark bg.
|
||||
const scene = {
|
||||
canvas: { width: 320, height: 240, background: '#202030' },
|
||||
duration: 2,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 20 } },
|
||||
{ time: 2, props: { x: 260 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function createRenderWindow() {
|
||||
return new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
offscreen: OFFSCREEN,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Cheap fingerprint of a NativeImage: total pixels, count of non-(near)background
|
||||
// pixels, and a coarse sum so we can tell frames apart.
|
||||
function fingerprint(image) {
|
||||
const bmp = image.getBitmap() // BGRA
|
||||
let nonBg = 0
|
||||
let redish = 0
|
||||
// Position-weighted hash: changes under pure translation (an order-independent
|
||||
// sum would not), so two frames that differ only in where the rect sits hash
|
||||
// differently.
|
||||
let hash = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
const b = bmp[i]
|
||||
const g = bmp[i + 1]
|
||||
const r = bmp[i + 2]
|
||||
const px = i / 4
|
||||
if (Math.abs(r - 0x20) + Math.abs(g - 0x20) + Math.abs(b - 0x30) > 60) nonBg++
|
||||
if (r > 150 && g < 120 && b < 120) {
|
||||
redish++
|
||||
hash = (hash + px * 2654435761) >>> 0
|
||||
}
|
||||
}
|
||||
return { pixels: bmp.length / 4, nonBg, redish, hash, size: image.getSize() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const win = createRenderWindow()
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
||||
|
||||
const captureRect = { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height }
|
||||
const times = [0, 0.5, 1, 1.5, 2]
|
||||
const frames = []
|
||||
|
||||
for (const t of times) {
|
||||
// __animatrSeek now resolves only after a compositor paint settle, so the
|
||||
// worker just awaits it — no extra settle here (mirrors renderWorker.cts).
|
||||
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
|
||||
const image = await win.webContents.capturePage(captureRect)
|
||||
frames.push({ t, ...fingerprint(image) })
|
||||
}
|
||||
|
||||
// Analysis
|
||||
let pass = true
|
||||
const notes = []
|
||||
for (const f of frames) {
|
||||
if (f.size.width !== scene.canvas.width || f.size.height !== scene.canvas.height) {
|
||||
pass = false
|
||||
notes.push(`frame t=${f.t}: wrong size ${f.size.width}x${f.size.height}`)
|
||||
}
|
||||
if (f.nonBg < 50) {
|
||||
pass = false
|
||||
notes.push(`frame t=${f.t}: looks blank (nonBg=${f.nonBg})`)
|
||||
}
|
||||
}
|
||||
// Frames must differ (motion advanced, not frozen on frame 0).
|
||||
const hashes = new Set(frames.map((f) => f.hash))
|
||||
if (hashes.size < frames.length) {
|
||||
pass = false
|
||||
notes.push(`frames not all distinct: ${frames.length} captured, ${hashes.size} unique hashes`)
|
||||
}
|
||||
|
||||
console.log('OFFSCREEN =', OFFSCREEN)
|
||||
console.log('captures:')
|
||||
for (const f of frames) {
|
||||
console.log(
|
||||
` t=${f.t} nonBg=${f.nonBg} redish=${f.redish} hash=${f.hash} size=${f.size.width}x${f.size.height}`,
|
||||
)
|
||||
}
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
|
||||
if (!win.isDestroyed()) win.close()
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
// Runs the desktop-render verification harnesses (Phase A/B of
|
||||
// electron-rendering-integration-plan_2.md) under Electron and aggregates the
|
||||
// results. Each harness exits 0 on PASS, non-zero on FAIL.
|
||||
//
|
||||
// npm run test:render-desktop (compiles electron TS first, then runs this)
|
||||
//
|
||||
// Requires a display (DISPLAY set), ffmpeg + ffprobe on PATH, and a built
|
||||
// dist-electron/ (the npm script's `tsc` step handles the latter).
|
||||
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const electron = require('electron') // resolves to the electron binary path
|
||||
const harnesses = [
|
||||
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
|
||||
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
|
||||
['export', 'verify-export.cjs', 'real worker renders a playable H.264 MP4 with motion'],
|
||||
['resolution', 'verify-resolution.cjs', 'MP4 dimensions match the requested canvas on HiDPI (#10)'],
|
||||
['odd-dims', 'verify-odd-dims.cjs', 'odd canvas dimensions pad to an even MP4 instead of failing (#13)'],
|
||||
['cancel', 'verify-cancel.cjs', 'cancel mid-export aborts cleanly, no partial file'],
|
||||
['timeout', 'verify-timeout.cjs', 'inactivity watchdog aborts a wedged render (#3)'],
|
||||
['assets', 'verify-assets.cjs', 'local data: assets load; remote http: blocked, no hang (#5)'],
|
||||
['background', 'verify-bg.cjs', 'transparent regions flatten to white, not black (#4)'],
|
||||
]
|
||||
|
||||
let failures = 0
|
||||
for (const [name, file, desc] of harnesses) {
|
||||
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
|
||||
const res = spawnSync(electron, [path.join(__dirname, file)], {
|
||||
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
failures += 1
|
||||
process.stdout.write(`*** ${name} FAILED (exit ${res.status})\n`)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${failures === 0 ? 'ALL DESKTOP RENDER CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`)
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,125 @@
|
||||
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
|
||||
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
|
||||
//
|
||||
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
|
||||
|
||||
const { app } = require('electron')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const fps = 12
|
||||
const duration = 2
|
||||
const scene = {
|
||||
canvas: { width: 320, height: 240, background: '#202030' },
|
||||
duration,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 20 } },
|
||||
{ time: 2, props: { x: 260 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const options = {
|
||||
fps,
|
||||
duration,
|
||||
totalFrames: fps * duration + 1,
|
||||
crf: '20',
|
||||
preset: 'ultrafast',
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
|
||||
const controller = new AbortController()
|
||||
let lastProgress = 0
|
||||
const startedAt = Date.now()
|
||||
await renderMp4Job({
|
||||
jobId: 'verify',
|
||||
scene,
|
||||
options,
|
||||
outputPath: outPath,
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
lastProgress = p
|
||||
},
|
||||
})
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const msPerFrame = elapsedMs / options.totalFrames
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!fs.existsSync(outPath)) {
|
||||
pass = false
|
||||
notes.push('output file missing')
|
||||
} else {
|
||||
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
|
||||
const probe = JSON.parse(
|
||||
execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
|
||||
'-count_frames',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString(),
|
||||
)
|
||||
const s = probe.streams[0]
|
||||
console.log(`output: ${outPath} (${sizeKb} KB)`)
|
||||
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
|
||||
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
|
||||
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
|
||||
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
|
||||
if (Number(s.nb_read_frames) !== options.totalFrames) {
|
||||
pass = false
|
||||
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
|
||||
}
|
||||
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
|
||||
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
|
||||
if (msPerFrame > maxMsPerFrame) {
|
||||
pass = false
|
||||
notes.push(
|
||||
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
|
||||
'(hidden render window may be throttled)',
|
||||
)
|
||||
}
|
||||
|
||||
// Decode first & last frame, confirm the rect moved (motion preserved).
|
||||
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
|
||||
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
|
||||
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
|
||||
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
|
||||
const sz0 = fs.statSync(f0).size
|
||||
const sz1 = fs.statSync(f1).size
|
||||
const b0 = fs.readFileSync(f0)
|
||||
const b1 = fs.readFileSync(f1)
|
||||
const same = b0.length === b1.length && b0.equals(b1)
|
||||
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
|
||||
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
|
||||
}
|
||||
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
// renderMp4Job closes its render window; suppress the default auto-quit so it
|
||||
// doesn't race result printing.
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
|
||||
// dimensions instead of failing with "height not divisible by 2".
|
||||
|
||||
const { app } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const scene = {
|
||||
canvas: { width: 101, height: 101, background: '#111827' },
|
||||
duration: 0.5,
|
||||
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'layer-rect',
|
||||
assetId: 'rect',
|
||||
visible: true,
|
||||
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 38 } },
|
||||
{ time: 0.5, props: { x: 62 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
await renderMp4Job({
|
||||
jobId: 'verify-odd-dims',
|
||||
scene,
|
||||
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
|
||||
outputPath: outPath,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
})
|
||||
|
||||
const probe = JSON.parse(execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
|
||||
'-count_frames',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString())
|
||||
const stream = probe.streams[0]
|
||||
const pass =
|
||||
stream.codec_name === 'h264' &&
|
||||
stream.width === 102 &&
|
||||
stream.height === 102 &&
|
||||
Number(stream.nb_read_frames) === 3
|
||||
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
// Regression for capture resolution normalization (#10). On scale-factor-1
|
||||
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
|
||||
|
||||
const { app } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const scene = {
|
||||
canvas: { width: 320, height: 240, background: '#ffffff' },
|
||||
duration: 0.25,
|
||||
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'layer-rect',
|
||||
assetId: 'rect',
|
||||
visible: true,
|
||||
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [{ time: 0, props: {} }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
await renderMp4Job({
|
||||
jobId: 'verify-resolution',
|
||||
scene,
|
||||
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
|
||||
outputPath: outPath,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
})
|
||||
|
||||
const probe = JSON.parse(execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString())
|
||||
const stream = probe.streams[0]
|
||||
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
|
||||
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in new issue
Block a user