Files
zhila/scripts/persistence-smoke.mjs
T

122 lines
5.4 KiB
JavaScript

#!/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);
}