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 { const start = Date.now(); return new Promise((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 { 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 { 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 { // 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 { 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 { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const data = readStatusNonBlock(); if (data.includes(substr)) return data; await wait(200); } return readStatusNonBlock(); }