import { execSync, exec, ChildProcess } from "child_process"; import * as path from "path"; import * as fs from "fs"; const PROJECT_DIR = path.resolve(__dirname, ".."); const ENGINE_BIN = path.join(PROJECT_DIR, "engine/looper"); const CLIENT_BIN = path.join(PROJECT_DIR, "client/looper-client"); const STATUS_FIFO = "/tmp/looper_status"; const CMD_FIFO = "/tmp/looper_cmd"; let cmdFifoFd: number | null = null; function run(cmd: string, timeout_sec = 15): string { return execSync(cmd, { encoding: "utf-8", timeout: timeout_sec * 1000 }).trim(); } function runNoThrow(cmd: string): void { try { run(cmd); } catch { /* ignore */ } } function tmuxSendKeys(session: string, pane: string, keys: string) { run(`tmux send-keys -t ${session}:${pane} ${JSON.stringify(keys)}`); } function tmuxCapturePane(session: string, pane: string): string { return run(`tmux capture-pane -t ${session}:${pane} -p`); } function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Wait for a file to exist, up to `timeoutMs` milliseconds. */ 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(); }); } function waitForCommandFifo(timeoutMs = 5000): Promise { return waitForFile(CMD_FIFO, timeoutMs); } function openCmdFifo(): void { // Wait for FIFO to exist (engine creates it) let waited = 0; while (!fs.existsSync(CMD_FIFO) && waited < 5000) { const waitUntil = Date.now() + 200; require("child_process").execSync(`sleep 0.2`); waited += 200; } cmdFifoFd = fs.openSync(CMD_FIFO, 'w'); } function writeFifoCommand(cmd: string): void { if (cmdFifoFd === null) { openCmdFifo(); } fs.writeSync(cmdFifoFd, cmd + '\n'); } function setupTest() { process.stdout.write(" Killing stale processes...\n"); runNoThrow("pkill -9 -x looper"); runNoThrow("pkill -9 -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"); } function teardownTest() { if (cmdFifoFd !== null) { fs.closeSync(cmdFifoFd); cmdFifoFd = null; } runNoThrow("pkill -9 -x looper"); runNoThrow("pkill -9 -x looper-client"); runNoThrow("pkill -9 -x jack_capture"); runNoThrow("tmux kill-session -t looper"); } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } async function startEngine(): Promise { process.stdout.write(" Starting engine directly...\n"); const stderrFile = "/tmp/engine_stderr.log"; const proc = exec(`${ENGINE_BIN} 2>${stderrFile}`, { cwd: PROJECT_DIR }); // Wait for the status FIFO to appear (up to 10 seconds) try { await Promise.race([ waitForFile(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; } 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(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 */ 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) */ function readStatusNonBlock(): string { try { const fd = fs.openSync(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 "" */ 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) */ 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 */ function generateTestWav(p: string, durationSec = 1): void { run(`sox -n -r 48000 -b 16 -c 1 ${p} synth ${durationSec} sine 440`); } const GEN_TONE_BIN = "/tmp/gen_tone"; function ensureGenTone(): void { if (!fs.existsSync(GEN_TONE_BIN)) { const src = path.join(__dirname, "gen_tone.c"); execSync(`gcc -o ${GEN_TONE_BIN} ${src} -ljack -lm`, { timeout: 10000 }); } } /* Check if a WAV file contains audio (RMS > 0.001) */ function wavHasAudio(p: string): boolean { try { const stat: fs.Stats = fs.statSync(p); return stat.size > 44; } catch { return false; } } function runCmd(cmd: string): string { try { return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim(); } catch { return ""; } } /* ------------------- TESTS ------------------- */ async function testGridNavigation(): Promise { console.log("\nTest: GRID NAVIGATION"); setupTest(); const engine = await startEngine(); await startClientInTmux(); // Default location should be Row 0, Col 0 let pane = await waitForPaneText("Selected: Grid 0, Row 0, Col 0", 5000); if (pane.includes("Selected: Grid 0, Row 0, Col 0")) { console.log(" PASS: Default selection at origin"); } else { console.log(" FAIL: Expected 'Selected: Grid 0, Row 0, Col 0'"); console.log(" Actual pane content:\n" + pane); engine.kill(); teardownTest(); throw new Error("Grid navigation test failed"); } // Move right then down tmuxSendKeys("looper", "0", "l"); tmuxSendKeys("looper", "0", "j"); await wait(200); pane = tmuxCapturePane("looper", "0"); if (pane.includes("Selected: Grid 0, Row 1, Col 1")) { console.log(" PASS: Moved to Row 1, Col 1"); } else { console.log(" FAIL: Expected 'Row 1, Col 1'"); engine.kill(); teardownTest(); throw new Error("Grid navigation test failed"); } // Cycle back to origin tmuxSendKeys("looper", "0", "h"); tmuxSendKeys("looper", "0", "k"); await wait(200); pane = tmuxCapturePane("looper", "0"); if (pane.includes("Selected: Grid 0, Row 0, Col 0")) { console.log(" PASS: Returned to origin"); } else { console.log(" FAIL: Not at origin after h/k"); engine.kill(); teardownTest(); throw new Error("Grid navigation test failed"); } engine.kill(); teardownTest(); } async function testChannelAddRemove(): Promise { console.log("\nTest: CHANNEL ADD / REMOVE"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Send "add" command via FIFO writeFifoCommand("add"); await wait(1000); // Read status lines and look for CH=1 (channel 1 active) const st = readStatusNonBlock(); // Count channels by counting lines starting with "CH=" let channelCount = 0; for (const line of st.split("\n")) { if (line.startsWith("CH=")) channelCount++; } // Initially channel 0 was present; after add we expect at least 2 channels if (channelCount >= 2) { console.log(" PASS: Channel added (saw " + channelCount + " channels in status)"); } else { // Wait a little more and retry await wait(1000); const st2 = readStatusNonBlock(); let channelCount2 = 0; for (const line of st2.split("\n")) { if (line.startsWith("CH=")) channelCount2++; } if (channelCount2 >= 2) { console.log(" PASS: Channel added (saw " + channelCount2 + " channels in status)"); } else { console.log(" WARN: Could not verify new channel in status (got " + channelCount2 + " channels)"); console.log(" Status data: " + st2); } } engine.kill(); teardownTest(); } async function testToggleRecordStop(): Promise { console.log("\nTest: TOGGLE RECORD / STOP"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Send 'record 0' via FIFO to start recording writeFifoCommand("record 0"); await wait(1500); // Read status non‑blocking and look for RECORD const stAfterRecord = readStatusNonBlock(); if (stAfterRecord.includes("RECORD")) { console.log(" PASS: Status shows RECORD"); } else { // Wait a bit more and retry once await wait(500); const st2 = readStatusNonBlock(); if (st2.includes("RECORD")) { console.log(" PASS: Status shows RECORD (after delay)"); } else { console.log(" STATUS data: " + st2); console.log(" WARN: Did not see RECORD in status"); } } // Stop writeFifoCommand("stop"); await wait(1500); const stAfterStop = readStatusNonBlock(); if (stAfterStop.includes("IDLE")) { console.log(" PASS: Status shows IDLE after stop"); } else { await wait(500); const st3 = readStatusNonBlock(); if (st3.includes("IDLE")) { console.log(" PASS: Status shows IDLE after stop (after delay)"); } else { console.log(" WARN: Did not see IDLE in status"); console.log(" STATUS data: " + st3); } } engine.kill(); teardownTest(); } async function testRecordOnSelectedCell(): Promise { console.log("\nTest: RECORD ON SELECTED CELL (col 1, row 0)"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); ensureGenTone(); await wait(500); // Add a new channel so column 1 (channel 1) exists writeFifoCommand("add"); await wait(500); // Navigation: move right once to column 1 (channel 1) tmuxSendKeys("looper", "0", "l"); // right once → col 1 await wait(500); // Verify selection shows column 1 let pane = tmuxCapturePane("looper", "0"); if (!pane.includes("Selected: Grid 0, Row 0, Col 1")) { console.log(" FAIL: Could not navigate to Col 1"); engine.kill(); teardownTest(); throw new Error("Navigation to column 1 failed"); } console.log(" PASS: Successfully navigated to Col 1"); // Press 't' to start recording tmuxSendKeys("looper", "0", "t"); await wait(1000); // Send a harmless key (digit '0') to force TUI to read the updated status FIFO and redraw the grid tmuxSendKeys("looper", "0", "0"); await wait(500); // Check status FIFO: we expect RECORD on CH=1 const st = readStatusNonBlock(); const recordOnCh1 = st.includes("CH=1") && st.includes("RECORD"); if (recordOnCh1) { console.log(" PASS: Status shows RECORD on CH=1 (the selected column)"); } else { console.log(" FAIL: Status does not show RECORD on CH=1"); console.log(" Status: " + st.slice(-500)); const anyRecord = st.match(/CH=\d+[^]*?RECORD/g) || []; console.log(" All RECORD lines: " + anyRecord.join(" | ")); engine.kill(); teardownTest(); throw new Error("Selected column recording did not target correct channel"); } // Verify that CH=0 (first column) is NOT in RECORD const lineForCh0 = st.split("\n").find(line => line.startsWith("CH=0")); if (lineForCh0 && lineForCh0.includes("RECORD")) { console.log(" FAIL: CH=0 also shows RECORD (unexpected cross‑talk)"); engine.kill(); teardownTest(); throw new Error("First channel incorrectly changed"); } console.log(" PASS: CH=0 remains idle (no cross‑talk)"); // Verify grid indicator 'R' appears near cell 1 pane = tmuxCapturePane("looper", "0"); // Use a simple presence check with approximate proximity const paneLines = pane.split("\n"); let cell1Line = -1, recordLine = -1; for (let i = 0; i < paneLines.length; i++) { if (paneLines[i].includes(" 1")) cell1Line = i; if (paneLines[i].includes("R")) recordLine = i; } if (cell1Line !== -1 && recordLine !== -1 && Math.abs(recordLine - cell1Line) <= 2) { console.log(" PASS: Grid shows 'R' indicator near cell 1"); } else { console.log(" FAIL: Could not find 'R' indicator near cell 1 in pane"); console.log(" Cell1 line: " + cell1Line + ", R line: " + recordLine); console.log(" Pane excerpt:\n" + pane.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("Grid did not show 'R' indicator for selected cell"); } engine.kill(); teardownTest(); } /** Wait until the status FIFO contains the given substring or timeout */ 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(); } async function testTUIRecordAndLoop(): Promise { console.log("\nTest: TUI RECORD AND LOOP (T key)"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); ensureGenTone(); await wait(500); // press 't' to start recording on default cell (col 0, row 0) tmuxSendKeys("looper", "0", "t"); await wait(1000); // 1) Check status FIFO shows RECORD const statusRec = await waitForStatusContaining("RECORD", 5000); if (!statusRec.includes("RECORD")) { console.log(" FAIL: Status FIFO did not show RECORD after pressing t"); engine.kill(); teardownTest(); throw new Error("RECORD state not achieved via TUI"); } console.log(" PASS: Status FIFO shows RECORD"); // 2) Check tmux pane for 'R' indicator (first character of cell in grid) const paneAfterT = tmuxCapturePane("looper", "0"); const paneContainsR = paneAfterT.includes("R"); if (!paneContainsR) { console.log(" FAIL: TUI grid does not show 'R' indicator after pressing t"); console.log(" Pane excerpt (maybe): " + paneAfterT.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("Grid indicator not updated"); } console.log(" PASS: TUI grid shows 'R' indicator"); // Play tone into looper:input (3 seconds) execSync(`${GEN_TONE_BIN} 3.0 "looper:input"`, { timeout: 8000 }); // press 't' again to stop recording -> loop tmuxSendKeys("looper", "0", "t"); const statusLoop = await waitForStatusContaining("LOOPING", 8000); if (!statusLoop.includes("LOOPING")) { console.log(" WARN: Did not see LOOPING in status, continuing"); } else { console.log(" PASS: Status FIFO shows LOOPING after second t"); } // Check pane for 'L' indicator const paneAfterLoop = tmuxCapturePane("looper", "0"); const paneContainsL = paneAfterLoop.includes("L"); if (!paneContainsL) { console.log(" FAIL: TUI grid does not show 'L' indicator after loop"); engine.kill(); teardownTest(); throw new Error("Grid indicator not updated for LOOPING"); } console.log(" PASS: TUI grid shows 'L' indicator"); // Wait a couple of repetitions (3 seconds) then save via FIFO to verify audio await wait(3000); // Save via FIFO writeFifoCommand("save"); await wait(3000); // Check save.wav exists and has audio const savePath = path.join(PROJECT_DIR, "save.wav"); let saveOk = false; if (fs.existsSync(savePath)) { const stat = fs.statSync(savePath); if (stat.size > 44) { saveOk = true; console.log(` PASS: save.wav created (${stat.size} bytes) – loop has audio`); } } if (!saveOk) { console.log(" FAIL: save.wav not created or too small – loop not producing audio"); engine.kill(); teardownTest(); throw new Error("Loop playback not producing audio"); } engine.kill(); teardownTest(); } async function testSaveLoad(): Promise { console.log("\nTest: SAVE / LOAD"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(1000); ensureGenTone(); // Start recording via FIFO with retry let recordAttempts = 0; while (recordAttempts < 3) { writeFifoCommand("record 0"); const st1 = await waitForStatusContaining("RECORD", 3000); if (st1.includes("RECORD")) { console.log(" DEBUG: RECORD confirmed after attempt " + (recordAttempts + 1)); break; } recordAttempts++; console.log(" WARN: First toggle attempt " + (recordAttempts) + " did not produce RECORD, retrying..."); } if (recordAttempts >= 3) { console.log(" FAIL: Could not enter RECORD after 3 attempts"); const st1 = readStatusNonBlock(); console.log(" DEBUG status after first toggle:", st1.slice(0, 200)); engine.kill(); teardownTest(); throw new Error("Could not enter RECORD"); } // Play tone into looper:input using gen_tone (synchronous, blocks until done) execSync(`${GEN_TONE_BIN} 3.0 "looper:input"`, { timeout: 8000 }); // 3 seconds tone // Stop recording (toggle again -> loop) writeFifoCommand("record 0"); const loopState = await waitForStatusContaining("LOOPING", 8000); if (!loopState.includes("LOOPING")) { console.log(" WARN: Second toggle did not produce LOOPING within 8s, will attempt save anyway"); console.log(" DEBUG status after second toggle:", loopState.slice(0, 200)); } else { console.log(" DEBUG: LOOPING confirmed"); } // Save via FIFO writeFifoCommand("save"); await wait(6000); // wait for synchronous save // Print engine stderr log for save debug try { const stderrLog = execSync("tail -5 /tmp/engine_stderr.log", { encoding: "utf-8" }); console.log(" Engine stderr:", stderrLog.trim()); } catch {} // Look for save file in project directory (engine writes there) const files = fs.readdirSync(PROJECT_DIR); const saveFile = files.find(f => f === "save.wav"); if (saveFile) { const stat = fs.statSync(path.join(PROJECT_DIR, saveFile)); if (stat.size > 44) { console.log(` PASS: save.wav created (${stat.size} bytes)`); } else { console.log(` FAIL: save.wav exists but header may be incomplete (size=${stat.size})`); engine.kill(); teardownTest(); throw new Error("save.wav too short"); } } else { console.log(" FAIL: save.wav not found in project directory"); console.log(" Directory listing: " + fs.readdirSync(PROJECT_DIR).filter(f => f.endsWith(".wav")).join(",")); engine.kill(); teardownTest(); throw new Error("save.wav not created"); } // Load into channel 0 const testWavPath = path.join(PROJECT_DIR, "loop.wav"); generateTestWav(testWavPath, 3.0); // 3 seconds loop to capture await wait(500); // Send load command (no path argument – engine will read loop.wav) execSync("echo 'load' > /tmp/looper_cmd", { timeout: 1000 }); await wait(1000); // Wait for LOOPING state after load const loadState = await waitForStatusContaining("LOOPING", 5000); if (!loadState.includes("LOOPING")) { console.log(" WARN: Status did not show LOOPING after load command"); console.log(" Status: " + loadState.slice(0,200)); } // Check engine stderr for load success line let loadSucceeded = false; try { const stderrLog = execSync("tail -5 /tmp/engine_stderr.log", { encoding: "utf-8" }); console.log(" Engine stderr:", stderrLog.trim()); if (stderrLog.includes("LOAD: success")) { loadSucceeded = true; console.log(" PASS: Loaded sample acknowledged by engine"); } } catch (e) { console.log(" Could not read engine stderr, continuing"); } if (!loadSucceeded) { console.log(" FAIL: Engine did not report successful load"); engine.kill(); teardownTest(); throw new Error("Engine load reported failure"); } engine.kill(); teardownTest(); } async function testRecordOnMissingChannel(): Promise { console.log("\nTest: RECORD ON MISSING CHANNEL (col 2, row 2)"); setupTest(); const engine = await startEngine(); await startClientInTmux(); // Do NOT add any channels – only channel 0 exists await wait(500); // Navigate to row 2, col 2 (two rights, two downs) tmuxSendKeys("looper", "0", "l"); tmuxSendKeys("looper", "0", "l"); await wait(200); tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); await wait(200); // Verify selection line let pane = tmuxCapturePane("looper", "0"); if (!pane.includes("Selected: Grid 0, Row 2, Col 2")) { console.log(" FAIL: Could not navigate to Row 2, Col 2"); engine.kill(); teardownTest(); throw new Error("Navigation to (2,2) failed"); } // Press 't' to start recording tmuxSendKeys("looper", "0", "t"); // Trigger a status read by sending a harmless key tmuxSendKeys("looper", "0", "0"); await wait(1000); // Read status FIFO – expect RECORD on CH=2 with SC=2 const st = readStatusNonBlock(); const expectedLine = "CH=2 SC=2 STATE=RECORD"; if (!st.includes(expectedLine)) { console.log(" FAIL: Status does not show \"CH=2 SC=2 STATE=RECORD\""); console.log(" Status: " + st.slice(-500)); engine.kill(); teardownTest(); throw new Error("Expected RECORD on channel 2, scene 2"); } console.log(" PASS: Status shows RECORD on CH=2 SC=2"); // Also verify the grid shows 'R' near cell (2,2) pane = tmuxCapturePane("looper", "0"); const paneLines = pane.split("\n"); let cellLine = -1, recordLine = -1; for (let i = 0; i < paneLines.length; i++) { if (paneLines[i].includes(" 2")) cellLine = i; if (paneLines[i].includes("R")) recordLine = i; } if (cellLine !== -1 && recordLine !== -1 && Math.abs(recordLine - cellLine) <= 2) { console.log(" PASS: Grid shows 'R' indicator near cell 2"); } else { console.log(" FAIL: Could not find 'R' near cell 2 in pane"); console.log(" Pane excerpt:\n" + pane.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("Grid indicator missing for col 2"); } engine.kill(); teardownTest(); } async function main(): Promise { console.log("=== Looper E2E Tests ===\n"); const tests = [testGridNavigation, testChannelAddRemove, testToggleRecordStop, testTUIRecordAndLoop, testRecordOnSelectedCell, testSaveLoad, testRecordOnMissingChannel]; let passCount = 0; let failCount = 0; for (const testFn of tests) { process.stdout.write("\n"); try { await Promise.race([ testFn(), new Promise((_, reject) => setTimeout(() => reject(new Error("Test timed out")), 60000)) ]); passCount++; } catch (e: any) { console.log(` ERROR: ${e.message}`); failCount++; } } console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`); if (failCount > 0) { process.exit(1); } process.exit(0); } main().catch((e) => { console.error("Unhandled error:", e); process.exit(1); });