#!/usr/bin/env node // Restart the daemon and verify the core, pattern and tempo survived. import { spawn } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const PORT = 4318; const DAEMON = './target/debug/zhila-daemon'; const project = mkdtempSync(join(tmpdir(), 'zhila-persist-')); const fixture = readFileSync('fixtures/clicks-01.wav'); let child = null; function start() { return new Promise((resolve, reject) => { child = spawn(DAEMON, ['--port', String(PORT), '--project', project, '--no-audio'], { stdio: ['ignore', 'ignore', 'pipe'] }); child.stderr.on('data', (data) => process.stderr.write(data)); child.on('exit', (code) => { if (code && code !== 0) console.error('daemon exited', code); }); const attempt = () => { const socket = new WebSocket(`ws://127.0.0.1:${PORT}`); socket.onopen = () => { socket.close(); resolve(); }; socket.onerror = () => setTimeout(attempt, 150); }; setTimeout(attempt, 200); setTimeout(() => reject(new Error('daemon did not start')), 10_000); }); } async function connect() { const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); ws.binaryType = 'arraybuffer'; const pending = new Map(); let snapshot = null; let imported = null; const waiters = []; const waitFor = (predicate) => new Promise((resolve) => waiters.push({ predicate, resolve })); ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: 'dev', client: 'persistence-smoke' })); ws.onmessage = (message) => { if (typeof message.data !== 'string') return; const parsed = JSON.parse(message.data); if (parsed.type === 'snapshot') { snapshot = parsed.state; for (const waiter of [...waiters]) if (waiter.predicate(parsed)) waiters.splice(waiters.indexOf(waiter), 1), waiter.resolve(parsed); } else if (parsed.type === 'imported') { imported = parsed; for (const waiter of [...waiters]) if (waiter.predicate(parsed)) waiters.splice(waiters.indexOf(waiter), 1), waiter.resolve(parsed); } else if (parsed.type === 'result') { const entry = pending.get(parsed.id); if (entry) { pending.delete(parsed.id); if (parsed.result.state) snapshot = parsed.result.state; entry.resolve(parsed.result); } } else if (parsed.type === 'error') { const entry = parsed.id != null ? pending.get(parsed.id) : null; if (entry) { pending.delete(parsed.id); entry.reject(new Error(parsed.message)); } } }; await waitFor((message) => message.type === 'snapshot'); const command = (id, command) => new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ type: 'command', id, command })); }); const importFixture = async () => { ws.send(JSON.stringify({ type: 'import_core', id: 10, name: 'clicks-01.wav', seal: 'found' })); ws.send(fixture); await waitFor((message) => message.type === 'imported'); return imported.core; }; return { ws, command, importFixture, get snapshot() { return snapshot; } }; } try { await start(); let client = await connect(); const first = client.snapshot; if (!first.project_name) throw new Error('no project name in the snapshot'); const core = await client.importFixture(); if (core.faces.length !== 16) throw new Error('no sixteen faces'); const top = core.faces.find((face) => face.rank === 0); await client.command(20, { type: 'set_pattern_face', row: 0, core: core.id, face: top.index }); await client.command(21, { type: 'undo' }); if (client.snapshot.pattern.rows[0].face) throw new Error('undo did not revert the face'); await client.command(22, { type: 'redo' }); const assigned = client.snapshot.pattern.rows[0].face; if (!assigned) throw new Error('redo did not restore the face'); await client.command(23, { type: 'set_pattern_step', row: 0, step: 0, velocity: 96 }); await client.command(24, { type: 'set_tempo', bpm: 132 }); client.ws.close(); await new Promise((resolve) => { child.once('exit', resolve); child.kill('SIGTERM'); }); await start(); client = await connect(); const second = client.snapshot; const restored = second.pattern.rows[0]; if (second.cores.length !== 1) throw new Error('core did not survive the restart'); if (second.cores[0].name !== 'clicks-01') throw new Error('core name changed'); if (!restored.face || restored.face.face !== assigned.face) throw new Error('pattern face did not survive'); if (restored.velocities[0] !== 96) throw new Error('step did not survive'); if (second.transport.bpm !== 132) throw new Error('tempo did not survive'); if (second.project_name !== first.project_name) throw new Error('project name changed'); console.log(JSON.stringify({ project: second.project_name, path: project, cores: second.cores.length, faces: second.cores[0].faces.length, face: restored.face, step: restored.velocities[0], bpm: second.transport.bpm, journal: second.journal_len, db: existsSync(join(project, 'project.db')) }, null, 2)); client.ws.close(); child.kill('SIGTERM'); rmSync(project, { recursive: true, force: true }); console.log('persistence smoke passed'); process.exit(0); } catch (error) { if (child) child.kill('SIGTERM'); rmSync(project, { recursive: true, force: true }); console.error('FAIL', error.message); process.exit(1); }