feat: add comprehensive end-to-end test suite

This commit is contained in:
Loic Coenen
2026-06-06 17:46:52 +00:00
committed by Loic Coenen (aider)
parent af7588b832
commit 18eb27e9c8
20 changed files with 1261 additions and 0 deletions

229
e2e/test_utils.ts Normal file
View File

@@ -0,0 +1,229 @@
import { execSync, exec, ChildProcess } from "child_process";
import * as path from "path";
import * as fs from "fs";
import * as globals from "./test_globals";
let cmdFifoFd: number | null = null;
export function run(cmd: string, timeout_sec = 15): string {
return execSync(cmd, { encoding: "utf-8", timeout: timeout_sec * 1000 }).trim();
}
export function runNoThrow(cmd: string): void {
try { run(cmd); } catch { /* ignore */ }
}
export function tmuxSendKeys(session: string, pane: string, keys: string) {
run(`tmux send-keys -t ${session}:${pane} ${JSON.stringify(keys)}`);
}
export function tmuxCapturePane(session: string, pane: string): string {
return run(`tmux capture-pane -t ${session}:${pane} -p`);
}
export function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Wait for a file to exist, up to `timeoutMs` milliseconds. */
export function waitForFile(filepath: string, timeoutMs: number): Promise<void> {
const start = Date.now();
return new Promise<void>((resolve, reject) => {
const check = () => {
if (fs.existsSync(filepath)) {
resolve();
} else if (Date.now() - start > timeoutMs) {
reject(new Error(`Timeout waiting for ${filepath}`));
} else {
setTimeout(check, 200);
}
};
check();
});
}
export function waitForCommandFifo(timeoutMs = 5000): Promise<void> {
return waitForFile(globals.CMD_FIFO, timeoutMs);
}
export function openCmdFifo(): void {
// Wait for FIFO to exist (engine creates it)
let waited = 0;
while (!fs.existsSync(globals.CMD_FIFO) && waited < 5000) {
const waitUntil = Date.now() + 200;
require("child_process").execSync(`sleep 0.2`);
waited += 200;
}
cmdFifoFd = fs.openSync(globals.CMD_FIFO, 'w');
}
export function writeFifoCommand(cmd: string): void {
if (cmdFifoFd === null) {
openCmdFifo();
}
fs.writeSync(cmdFifoFd!, cmd + '\n');
}
export function setupTest() {
process.stdout.write(" Killing stale processes...\n");
runNoThrow("pkill -15 -x looper");
runNoThrow("pkill -15 -x looper-client");
runNoThrow("pkill -9 -x jack_capture");
runNoThrow("tmux kill-session -t looper 2>/dev/null || true");
process.stdout.write(" Checking JACK...\n");
try {
run("jack_wait -c -t 5", 10);
} catch {
console.warn(" JACK server is not running. Tests may fail.");
}
process.stdout.write(" Removing old temp files...\n");
run("rm -f /tmp/looper_cmd /tmp/looper_status /tmp/test.wav /tmp/captured.wav /tmp/loop.wav /tmp/save.wav /tmp/load_test.wav /tmp/loaded.wav save_ch*.wav save.wav loop.wav");
}
export function teardownTest() {
if (cmdFifoFd !== null) {
fs.closeSync(cmdFifoFd);
cmdFifoFd = null;
}
runNoThrow("pkill -15 -x looper");
runNoThrow("pkill -15 -x looper-client");
runNoThrow("pkill -9 -x jack_capture");
runNoThrow("tmux kill-session -t looper");
}
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export async function startEngine(): Promise<ChildProcess> {
process.stdout.write(" Starting engine directly...\n");
const stderrFile = "/tmp/engine_stderr.log";
const proc = exec(`${globals.ENGINE_BIN} 2>${stderrFile}`, { cwd: globals.PROJECT_DIR });
// Wait for the status FIFO to appear (up to 10 seconds)
try {
await Promise.race([
waitForFile(globals.STATUS_FIFO, 10000),
wait(11000),
]);
} catch {
process.stdout.write(" Engine status FIFO did not appear within timeout\n");
if (proc.pid && !isProcessAlive(proc.pid)) {
process.stdout.write(" Engine process died prematurely. stderr log:\n");
const stderr = execSync(`cat ${stderrFile}`, { encoding: "utf-8" }).trim();
process.stdout.write(stderr + "\n");
}
}
if (proc.pid && isProcessAlive(proc.pid)) {
process.stdout.write(" Engine started (pid " + proc.pid + ")\n");
} else {
process.stdout.write(" Engine process is not alive after start attempt\n");
}
return proc;
}
export async function startClientInTmux(): Promise<void> {
// Kill any stale session (silently)
runNoThrow("tmux kill-session -t looper 2>/dev/null");
run("tmux new-session -d -s looper");
// Resize the window (width x height) so we can see the full grid and status line
run("tmux resize-window -t looper:0 -x 120 -y 50 2>/dev/null || true");
// Launch the client
run(`tmux send-keys -t looper:0 ${JSON.stringify(globals.CLIENT_BIN)} Enter`);
// Wait for the client to draw the initial frame (max 10 seconds, poll every 500 ms)
const deadline = Date.now() + 10000;
let pane = "";
while (Date.now() < deadline) {
await wait(500);
pane = tmuxCapturePane("looper", "0");
if (pane.includes("[online]") || pane.includes("Selected:")) {
break;
}
}
// Final short extra wait to ensure status lines are updated
await wait(500);
}
/* Check if the tmux pane contains a given substring */
export function tmuxContains(text: string): boolean {
const pane = tmuxCapturePane("looper", "0");
return pane.includes(text);
}
/* Read the status FIFO nonblocking. Returns the data read (may be empty) */
export function readStatusNonBlock(): string {
try {
const fd = fs.openSync(globals.STATUS_FIFO, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
const buf = Buffer.alloc(2000);
const bytesRead = fs.readSync(fd, buf, 0, 2000, null);
fs.closeSync(fd);
return buf.slice(0, bytesRead).toString('utf-8').trim();
} catch {
return "";
}
}
/* Read the status FIFO and return the first line that matches a pattern, or "" */
export function readStatusLineMatching(pattern: string): string {
const data = readStatusNonBlock();
for (const line of data.split("\n")) {
if (line.includes(pattern)) return line;
}
return "";
}
/* Wait until the tmux pane contains the given substring (optional, used by tests) */
export async function waitForPaneText(text: string, timeoutMs = 5000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const pane = tmuxCapturePane("looper", "0");
if (pane.includes(text)) return pane;
await wait(300);
}
return tmuxCapturePane("looper", "0");
}
/* Generate a test WAV with a 440 Hz sine wave */
export function generateTestWav(p: string, durationSec = 1): void {
run(`sox -n -r 48000 -b 16 -c 1 ${p} synth ${durationSec} sine 440`);
}
export function ensureGenTone(): void {
if (!fs.existsSync(globals.GEN_TONE_BIN)) {
const src = path.join(__dirname, "gen_tone.c");
execSync(`gcc -o ${globals.GEN_TONE_BIN} ${src} -ljack -lm`, { timeout: 10000 });
}
}
/* Check if a WAV file contains audio (RMS > 0.001) */
export function wavHasAudio(p: string): boolean {
try {
const stat: fs.Stats = fs.statSync(p);
return stat.size > 44;
} catch {
return false;
}
}
export function runCmd(cmd: string): string {
try {
return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim();
} catch { return ""; }
}
/** Wait until the status FIFO contains the given substring or timeout */
export async function waitForStatusContaining(substr: string, timeoutMs = 8000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const data = readStatusNonBlock();
if (data.includes(substr)) return data;
await wait(200);
}
return readStatusNonBlock();
}