747 lines
24 KiB
TypeScript
747 lines
24 KiB
TypeScript
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 -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<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 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<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 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<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
|
||
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<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 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
|
||
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<void> {
|
||
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<void>((_, 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);
|
||
});
|