Files
looper/e2e/test.ts

1051 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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();
});
}
function waitForCommandFifo(timeoutMs = 5000): Promise<void> {
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 -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");
}
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");
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function startEngine(): Promise<ChildProcess> {
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<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(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 nonblocking. 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<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 */
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<void> {
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<void> {
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<void> {
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 nonblocking 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<void> {
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' (crosstalk)");
engine.kill(); teardownTest();
throw new Error("Crosstalk detected on cell 0");
}
console.log(" PASS: Cell (0,0) does not show 'R' (no crosstalk)");
engine.kill();
teardownTest();
}
/** Wait until the status FIFO contains the given substring or timeout */
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();
}
async function testTUIRecordAndLoop(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 preadd engine must autocreate 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<void> {
console.log("\nTest: STRESS RANDOM USAGE (10,000 keys, stability check)");
setupTest();
const engine = await startEngine();
await startClientInTmux();
openCmdFifo();
await wait(500);
// Preadd 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 (nonempty, at least has header and a cell)
let pane = tmuxCapturePane("looper", "0");
if (!pane || pane.trim() === "") {
await wait(200);
pane = tmuxCapturePane("looper", "0");
}
if (!pane || !pane.includes("JACK Looper") || !pane.includes(" 0")) {
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 testKeyPressLatency(): Promise<void> {
console.log("\nTest: KEY PRESS LATENCY (50 toggles, check for exponential slowdown)");
setupTest();
const engine = await startEngine();
await startClientInTmux();
openCmdFifo();
await wait(500);
const ITERATIONS = 50;
const LATENCY_WARN = 500; // warn if >500ms
const LATENCY_FAIL = 5000; // fail if >5s
let latencies: number[] = [];
let prevState = "IDLE";
for (let i = 0; i < ITERATIONS; i++) {
// Determine which state we expect after toggle
const expectNext = (prevState === "IDLE") ? "R" : "L";
const startTime = Date.now();
tmuxSendKeys("looper", "0", "t");
const pane = await waitForPaneText(expectNext, 10000);
const elapsed = Date.now() - startTime;
latencies.push(elapsed);
// Log periodic summary
if (i % 10 === 9) {
const avg = latencies.slice(i-9, i+1).reduce((a,b)=>a+b,0) / 10;
console.log(` Iteration ${i+1}: avg last 10 = ${avg.toFixed(0)} ms, last = ${elapsed} ms`);
}
if (elapsed > LATENCY_FAIL) {
console.log(` FAIL: Iteration ${i+1} latency ${elapsed} ms exceeds ${LATENCY_FAIL} ms`);
engine.kill(); teardownTest();
throw new Error(`Latency exceeded fail threshold at iteration ${i+1}`);
}
if (elapsed > LATENCY_WARN) {
console.log(` WARN: Iteration ${i+1} latency ${elapsed} ms > ${LATENCY_WARN} ms (possible slowdown)`);
}
// Toggle state for next expectation
prevState = (prevState === "IDLE") ? "LOOPING" : "IDLE";
await wait(200); // brief cooldown
}
// Check for trend: if last 10 avg > 3x first 10 avg → exponential
const first10Avg = latencies.slice(0,10).reduce((a,b)=>a+b,0) / 10;
const last10Avg = latencies.slice(-10).reduce((a,b)=>a+b,0) / 10;
console.log(` First 10 avg: ${first10Avg.toFixed(0)} ms, Last 10 avg: ${last10Avg.toFixed(0)} ms`);
if (last10Avg > 3 * first10Avg && last10Avg > 500) {
console.log(` FAIL: Latency grew from ${first10Avg.toFixed(0)} ms to ${last10Avg.toFixed(0)} ms (exponential pattern)`);
engine.kill(); teardownTest();
throw new Error("Exponential latency increase");
}
console.log(" PASS: No exponential latency growth");
engine.kill();
teardownTest();
}
async function main(): Promise<void> {
console.log("=== Looper E2E Tests ===\n");
const tests = [
/*
testGridNavigation,
testChannelAddRemove,
testToggleRecordStop,
testTUIRecordAndLoop,
testRecordOnSelectedCell,
testSaveLoad,
testRecordOnMissingChannel,
testRapidKeyMashConsistency,
*/
testRecordOnHighRow,
testRecordMoveRecord,
testStressRandomUsage,
testKeyPressLatency
];
let passCount = 0;
let failCount = 0;
for (const testFn of tests) {
process.stdout.write("\n");
try {
await Promise.race([
testFn(),
new Promise<void>((_, 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);
});