feat: add comprehensive end-to-end test suite
This commit is contained in:
committed by
Loic Coenen (aider)
parent
af7588b832
commit
18eb27e9c8
229
e2e/test_utils.ts
Normal file
229
e2e/test_utils.ts
Normal 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 non‑blocking. 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();
|
||||
}
|
||||
Reference in New Issue
Block a user