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 – no extra key, TUI should redraw on its own tmuxSendKeys("looper", "0", "t"); await wait(1500); // Capture the pane once – this is the TUI's state const paneAfter = tmuxCapturePane("looper", "0"); // 1. The grid should show 'R' near cell 1 (col 1, row 0) const paneLines = paneAfter.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(" FAIL: Grid did not show 'R' near cell 1"); console.log(" Pane excerpt:\n" + paneAfter.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("Grid indicator not updated for selected cell"); } console.log(" PASS: Grid shows 'R' indicator near cell 1 after single 't'"); // 2. Verify that cell (row 0, col 0) does NOT show 'R' via pane char position // Cell (0,0) has its state character at line 5, column 4 (based on grid layout) const cell00StateCh = (paneLines.length > 5 && paneLines[5].length > 4) ? paneLines[5][4] : '?'; if (cell00StateCh === 'R') { console.log(" FAIL: Cell (0,0) shows 'R' (cross‑talk)"); engine.kill(); teardownTest(); throw new Error("Cross‑talk detected on cell 0"); } console.log(" PASS: Cell (0,0) does not show 'R' (no cross‑talk)"); 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 testRapidKeyMashConsistency(): Promise { console.log("\nTest: RAPID KEY MASH CONSISTENCY (burst of 10 keys, verify pane)"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Add channels up to column 5 for (let i = 1; i <= 5; i++) { writeFifoCommand("add"); await wait(100); } const ITERATIONS = 20; for (let iter = 0; iter < ITERATIONS; iter++) { const seed = iter * 7; let keys = ""; for (let k = 0; k < 10; k++) { const dir = (seed + k) % 4; switch (dir) { case 0: keys += "l"; break; case 1: keys += "h"; break; case 2: keys += "j"; break; case 3: keys += "k"; break; } } keys += "t"; // record tmuxSendKeys("looper", "0", keys); await wait(500); // Capture pane const pane = tmuxCapturePane("looper", "0"); // 1. Selected line must be present const selMatch = pane.match(/Selected: Grid \d+, Row (\d+), Col (\d+)/); if (!selMatch) { console.log(` FAIL at iteration ${iter}: No selected line in pane`); console.log(" Pane:\n" + pane.slice(0, 500)); engine.kill(); teardownTest(); throw new Error("Selected line missing after burst"); } const row = parseInt(selMatch[1]); const col = parseInt(selMatch[2]); if (row < 0 || row > 7 || col < 0 || col > 7) { console.log(` FAIL at iteration ${iter}: selected (${row},${col}) out of bounds`); engine.kill(); teardownTest(); throw new Error("Selected cell out of bounds after burst"); } // 2. At least one 'R' must appear in the pane const hasR = pane.includes("R"); if (!hasR) { console.log(` FAIL at iteration ${iter}: No 'R' found after burst`); console.log(" Pane:\n" + pane.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("No 'R' indicator after rapid key mash"); } // 3. Toggle back to idle for next iteration tmuxSendKeys("looper", "0", "t"); await wait(300); } console.log(" PASS: Rapid key mash consistency maintained over " + ITERATIONS + " iterations"); 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 (no extra key – TUI polls itself) tmuxSendKeys("looper", "0", "t"); await wait(1500); // Check 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 testRecordOnHighRow(): Promise { console.log("\nTest: RECORD ON HIGH ROW (row 5, col 0) – verifies engine & TUI"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Add a few channels so column 0 is usable for (let i = 1; i <= 3; i++) { writeFifoCommand("add"); await wait(100); } // Navigate to row 5, col 0 tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); await wait(500); // Verify selection shows row 5 let pane = tmuxCapturePane("looper", "0"); if (!pane.includes("Selected: Grid 0, Row 5, Col 0")) { console.log(" FAIL: Could not navigate to Row 5, Col 0"); engine.kill(); teardownTest(); throw new Error("Navigation to row 5 failed"); } console.log(" PASS: Navigated to Row 5, Col 0"); // Press 't' to start recording tmuxSendKeys("looper", "0", "t"); // Check the TUI pane – wait until it shows 'R' near row 5 const paneWithR = await waitForPaneText("R", 5000); const paneLines = paneWithR.split("\n"); let cell5Line = -1, recordLine = -1; for (let i = 0; i < paneLines.length; i++) { if (paneLines[i].includes(" 5")) cell5Line = i; if (paneLines[i].includes("R")) recordLine = i; } if (cell5Line !== -1 && recordLine !== -1 && Math.abs(recordLine - cell5Line) <= 2) { console.log(" PASS: TUI grid shows 'R' near row 5"); } else { console.log(" FAIL: TUI grid does not show 'R' near row 5"); console.log(" Pane:\n" + paneWithR.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("TUI indicator missing for row 5"); } engine.kill(); teardownTest(); } async function testRecordMoveRecord(): Promise { console.log("\nTest: RECORD ON ROW2 COL0, THEN MOVE RIGHT AND RECORD AGAIN"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Do NOT pre‑add – engine must auto‑create channel 1 on demand // Navigate down twice to row2, col0 tmuxSendKeys("looper", "0", "j"); tmuxSendKeys("looper", "0", "j"); await wait(500); // Verify selection let pane = tmuxCapturePane("looper", "0"); if (!pane.includes("Row 2, Col 0")) { console.log(" FAIL: Could not navigate to Row2, Col0"); engine.kill(); teardownTest(); throw new Error("Navigation failed"); } // First trigger: record on cell (row2, col0) tmuxSendKeys("looper", "0", "t"); await wait(1000); pane = tmuxCapturePane("looper", "0"); const gridArea1 = pane.split("Selected:")[0] || pane; const rCount1 = (gridArea1.match(/R/g) || []).length; if (rCount1 !== 1) { console.log(` FAIL: Expected 1 'R' after first trigger, got ${rCount1}`); console.log(" Pane:\n" + pane.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("First trigger not reflected"); } console.log(" PASS: First trigger produced exactly one 'R'"); // Move right to col1 tmuxSendKeys("looper", "0", "l"); await wait(500); // Second trigger tmuxSendKeys("looper", "0", "t"); await wait(1000); pane = tmuxCapturePane("looper", "0"); const gridArea2 = pane.split("Selected:")[0] || pane; const rCount2 = (gridArea2.match(/R/g) || []).length; if (rCount2 !== 2) { console.log(` FAIL: Expected 2 'R's after second trigger on col1, got ${rCount2}`); console.log(" Pane:\n" + pane.slice(0, 1000)); engine.kill(); teardownTest(); throw new Error("Second trigger did not create another recording indicator"); } console.log(" PASS: Second trigger produced a second 'R'"); engine.kill(); teardownTest(); } async function testStressRandomUsage(): Promise { console.log("\nTest: STRESS RANDOM USAGE (10,000 keys, stability check)"); setupTest(); const engine = await startEngine(); await startClientInTmux(); openCmdFifo(); await wait(500); // Pre‑add channels for more variety for (let i = 0; i < 7; i++) { writeFifoCommand("add"); await wait(100); } const KEY_ACTIONS = ['h','j','k','l','t','d','s','S','a','A','r','b','u']; const TOTAL = 5000; const KEY_DELAY_MS = 20; const CHECK_INTERVAL = 500; console.log(` Starting stress loop: ${TOTAL} keys at ~20 keys/second...`); let keysSent = 0; const startTime = Date.now(); for (let i = 0; i < TOTAL; i++) { const key = KEY_ACTIONS[Math.floor(Math.random() * KEY_ACTIONS.length)]; tmuxSendKeys("looper", "0", key); await wait(KEY_DELAY_MS); keysSent++; if (keysSent % CHECK_INTERVAL === 0) { // Wait a little for TUI to settle await wait(300); // Check engine alive if (engine.pid && !isProcessAlive(engine.pid)) { console.log(` FAIL: Engine died at key ${keysSent}`); try { const stderr = execSync("tail -20 /tmp/engine_stderr.log", { encoding: "utf-8" }).trim(); console.log(" Engine stderr:", stderr); } catch {} teardownTest(); throw new Error("Engine crash during stress test"); } // Check TUI pane integrity (non‑empty, has selection line) let pane = tmuxCapturePane("looper", "0"); if (!pane || pane.trim() === "") { await wait(200); pane = tmuxCapturePane("looper", "0"); } if (!pane || !pane.includes("Selected:")) { console.log(` FAIL: TUI pane appears corrupted at key ${keysSent}`); console.log(" Pane:\n" + (pane ? pane.slice(0, 1000) : "(empty)")); teardownTest(); throw new Error("TUI corruption during stress test"); } } } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(` Stress loop finished in ${elapsed}s`); await wait(500); if (engine.pid && !isProcessAlive(engine.pid)) { console.log(" FAIL: Engine died after test"); teardownTest(); throw new Error("Engine crash"); } console.log(" PASS: Stress test completed (no crash or corruption)"); engine.kill(); teardownTest(); } async function main(): Promise { console.log("=== Looper E2E Tests ===\n"); const tests = [ /* testGridNavigation, testChannelAddRemove, testToggleRecordStop, testTUIRecordAndLoop, testRecordOnSelectedCell, testSaveLoad, testRecordOnMissingChannel, testRapidKeyMashConsistency, */ testRecordOnHighRow, testRecordMoveRecord, testStressRandomUsage ]; 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")), 600000)) ]); 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); });