151 lines
7.5 KiB
JavaScript
151 lines
7.5 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 });
|
|
await client.command(28, { type: 'select_section', index: 1 });
|
|
await client.command(29, { type: 'set_pattern_step', row: 0, step: 0, velocity: 0 });
|
|
await client.command(30, { type: 'copy_section', from: 0, to: 2 });
|
|
const sortedFaces = [...core.faces].sort((a, b) => a.index - b.index);
|
|
const boundaries = sortedFaces.map((face) => face.start_sec);
|
|
boundaries.push(sortedFaces[sortedFaces.length - 1].end_sec);
|
|
const beforeCut = boundaries[8];
|
|
boundaries[8] += 0.02;
|
|
await client.command(25, { type: 'set_core_faces', core: core.id, boundaries });
|
|
const cutEdge = client.snapshot.cores[0].faces[7].end_sec;
|
|
if (Math.abs(cutEdge - boundaries[8]) > 0.002) throw new Error('manual face did not apply');
|
|
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.arrangement[0].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');
|
|
const loadedEdge = second.cores[0].faces[7].end_sec;
|
|
if (Math.abs(loadedEdge - cutEdge) > 0.002) throw new Error('manual face did not survive');
|
|
if (second.project_name !== first.project_name) throw new Error('project name changed');
|
|
if (second.arrangement.length !== 4) throw new Error('arrangement did not survive');
|
|
if (second.active_section !== 1) throw new Error('active section did not survive');
|
|
if (second.arrangement[1].rows[0].velocities[0] !== 0) throw new Error('section B edit did not survive');
|
|
if (JSON.stringify(second.arrangement[2]) !== JSON.stringify(second.arrangement[0]))
|
|
throw new Error('copied section did not survive');
|
|
if (!second.can_undo) throw new Error('undo history did not survive');
|
|
await client.command(26, { type: 'undo' });
|
|
const undoneEdge = client.snapshot.cores[0].faces[7].end_sec;
|
|
if (Math.abs(undoneEdge - beforeCut) > 0.002) throw new Error('undo after reload did not revert the cut');
|
|
if (!client.snapshot.can_redo) throw new Error('redo did not become available');
|
|
await client.command(27, { type: 'redo' });
|
|
const redoneEdge = client.snapshot.cores[0].faces[7].end_sec;
|
|
if (Math.abs(redoneEdge - cutEdge) > 0.002) throw new Error('redo after reload did not restore the cut');
|
|
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,
|
|
cut: { before: beforeCut, after: loadedEdge },
|
|
sections: { count: second.arrangement.length, active: second.active_section, bNotes: second.arrangement[1].rows[0].velocities[0] },
|
|
history: { restored: second.can_undo, undone: undoneEdge, redone: redoneEdge },
|
|
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);
|
|
}
|