const assert = require('node:assert/strict') const { spawn } = require('node:child_process') const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises') const { join, resolve } = require('node:path') const { tmpdir } = require('node:os') const { fileHash } = require('./release.cjs') const metadata = require('../package.json') const root = resolve(__dirname, '..') const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) async function until(check, label) { for (let i = 0; i < 100; i++) { const result = await check() if (result) return result await delay(100) } throw new Error(`Timeout: ${label}`) } async function launch(binary, profile, check) { const child = spawn( binary, [ `--user-data-dir=${profile}`, '--remote-debugging-port=0', '--remote-debugging-address=127.0.0.1', '--no-sandbox', '--mute-audio', '--disable-gpu', ], { stdio: ['ignore', 'ignore', 'pipe'] }, ) let stderr = '' let spawnError = null let exited = false child.on('error', (error) => { spawnError = error }) const exit = new Promise((resolve) => child.on('close', () => { exited = true resolve() }), ) child.stderr.on('data', (bytes) => { stderr = (stderr + bytes.toString()).slice(-32768) }) let socket try { const port = await until(() => { if (spawnError) throw spawnError if (exited) throw new Error('Packaged application exited before startup') const match = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//.exec(stderr) return match?.[1] }, 'DevTools endpoint') const target = await until(async () => { const response = await fetch(`http://127.0.0.1:${port}/json`) const targets = await response.json() return targets.find((page) => page.url === 'screen-app://app/index.html') }, 'control target') socket = new WebSocket(target.webSocketDebuggerUrl) await new Promise((resolve, reject) => { socket.onopen = resolve socket.onerror = reject }) let id = 0 const pending = new Map() socket.onmessage = ({ data }) => { const result = JSON.parse(data) const entry = pending.get(result.id) if (entry) { pending.delete(result.id) clearTimeout(entry.timer) entry.resolve(result) } } socket.onclose = () => { for (const entry of pending.values()) { clearTimeout(entry.timer) entry.reject(new Error('Debugger disconnected')) } pending.clear() } const request = (method, params = {}) => new Promise((resolve, reject) => { const callId = ++id const timer = setTimeout(() => { pending.delete(callId) reject(new Error('Debugger request timeout')) }, 5000) pending.set(callId, { resolve, reject, timer }) socket.send(JSON.stringify({ id: callId, method, params })) }) const evaluate = async (expression) => { const result = await request('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }) assert.ok(!result.error && !result.result?.exceptionDetails, 'Packaged evaluation failed') return result.result.result.value } await until( () => evaluate('!!window.screenBridge && !!document.querySelector("h1")'), 'packaged UI', ) await check(evaluate) void request('Page.close').catch(() => {}) await until(() => exited, 'normal application exit') } finally { socket?.close() if (!exited) { child.kill('SIGKILL') await exit } } } async function main() { const binary = resolve(process.argv[2] || 'release/linux-unpacked/evening-detective-screen') const previous = process.argv[3] ? resolve(process.argv[3]) : null const profile = await mkdtemp(join(tmpdir(), 'screen-packaged-')) const { PackageStore } = require('../dist-electron/electron/package-store.js') const { buildScreenPackage } = require('../dist-electron/electron/screen-package.js') try { const store = new PackageStore(join(profile, 'packages'), { fetch: async (input) => { const video = String(input).endsWith('.mp4') return new Response( await readFile(join(root, 'fixtures', video ? 'video.mp4' : 'audio.wav')), { headers: { 'Content-Type': video ? 'video/mp4' : 'audio/wav' } }, ) }, }) await store.initialize() const description = buildScreenPackage('https://packaged.test', 7, { game: { game: { ID: 7, name: 'Packaged offline', scenario: { story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'video.mp4' } }, }, }, }, plan: {}, music: {}, }) const pkg = await store.download(description) const connection = join(profile, 'connection') await mkdir(connection) await writeFile( join(connection, 'selection.json'), JSON.stringify({ server: description.server, key: pkg.key }), ) const results = { packageKey: pkg.key, capturedAt: '2026-10-17T10:00:00.000Z', questions: [{ code: 'A', text: 'Question' }], teams: [ { id: 't', name: 'Team', place: 1, totalScore: 3, actionsCount: 1, answers: [{ questionCode: 'A', score: 3 }], }, ], } const resultsFile = join(connection, `results-${pkg.key}.json`) await writeFile(resultsFile, JSON.stringify(results)) await writeFile( join(profile, 'windows.json'), JSON.stringify({ version: 1, control: { bounds: { x: 30, y: 30, width: 900, height: 650 }, maximized: false }, display: null, }), ) const manifest = join(profile, 'packages', pkg.key, `v-${pkg.version}`, 'manifest.json') const before = await fileHash(manifest) const resultHash = await fileHash(resultsFile) if (previous) await launch(previous, profile, async (evaluate) => { assert.equal(await evaluate('typeof window.require'), 'undefined') }) for (let attempt = 0; attempt < 2; attempt++) await launch(binary, profile, async (evaluate) => { assert.equal(await evaluate('typeof window.require'), 'undefined') const state = await evaluate('window.screenBridge.state()') assert.equal(state.catalog.key, pkg.key) assert.equal(state.results.teams[0].totalScore, 3) assert.equal(state.outputOpen, false) assert.equal(state.timer.status, 'stopped') assert.equal(state.channels.main.desired, 'stopped') assert.equal(state.channels.music.desired, 'stopped') assert.equal(await evaluate('document.querySelectorAll("audio,video").length'), 0) for (const url of [ 'screen-app://app/media/video', state.catalog.media.find((m) => m.kind === 'video').url, ]) { const range = await evaluate( `fetch(${JSON.stringify(url)}, {headers:{Range:'bytes=100-199'}}).then(async r=>({status:r.status,size:(await r.arrayBuffer()).byteLength}))`, ) assert.deepEqual(range, { status: 206, size: 100 }) } const logs = await readFile(join(profile, 'logs', 'diagnostic.jsonl'), 'utf8') const starts = logs .trim() .split('\n') .map((line) => JSON.parse(line)) .filter((row) => row.event === 'app-start') assert.equal(starts.at(-1).version, metadata.version) assert.equal(await fileHash(manifest), before) assert.equal(await fileHash(resultsFile), resultHash) assert.equal((await evaluate('window.screenBridge.host()')).sleepBlocked, false) }) const log = (await readFile(join(profile, 'logs', 'diagnostic.jsonl'), 'utf8')) .trim() .split('\n') .map((line) => JSON.parse(line)) assert.equal(log.at(-1).event, 'app-stop') const restored = new PackageStore(join(profile, 'packages')) await restored.initialize() assert.equal(restored.list()[0].version, pkg.version) const windows = JSON.parse(await readFile(join(profile, 'windows.json'), 'utf8')) assert.equal(windows.control.maximized, false) assert.ok(windows.control.bounds.width <= 900 && windows.control.bounds.height <= 650) console.log( 'PACKAGED_SMOKE_PASS', JSON.stringify({ version: metadata.version, previous: !!previous, restarts: 2, offlineFiles: pkg.files.length, preservedResults: true, }), ) } finally { await rm(profile, { recursive: true, force: true }) } } main().catch((error) => { console.error(error.message) process.exitCode = 1 })