Files
looper/e2e/test_utils.ts
2026-06-06 17:46:52 +00:00

230 lines
7.3 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";
import * as globals from "./test_globals";
let cmdFifoFd: number | null = null;
export function run(cmd: string, timeout_sec = 15): string {
return execSync(cmd, { encoding: "utf-8", timeout: timeout_sec * 1000 }).trim();
}
export function runNoThrow(cmd: string): void {
try { run(cmd); } catch { /* ignore */ }
}
export function tmuxSendKeys(session: string, pane: string, keys: string) {
run(`tmux send-keys -t ${session}:${pane} ${JSON.stringify(keys)}`);
}
export function tmuxCapturePane(session: string, pane: string): string {
return run(`tmux capture-pane -t ${session}:${pane} -p`);
}
export function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Wait for a file to exist, up to `timeoutMs` milliseconds. */
export function waitForFile(filepath: string, timeoutMs: number): Promise<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();
});
}
export function waitForCommandFifo(timeoutMs = 5000): Promise<void> {
return waitForFile(globals.CMD_FIFO, timeoutMs);
}
export function openCmdFifo(): void {
// Wait for FIFO to exist (engine creates it)
let waited = 0;
while (!fs.existsSync(globals.CMD_FIFO) && waited < 5000) {
const waitUntil = Date.now() + 200;
require("child_process").execSync(`sleep 0.2`);
waited += 200;
}
cmdFifoFd = fs.openSync(globals.CMD_FIFO, 'w');
}
export function writeFifoCommand(cmd: string): void {
if (cmdFifoFd === null) {
openCmdFifo();
}
fs.writeSync(cmdFifoFd!, cmd + '\n');
}
export function setupTest() {
process.stdout.write(" Killing stale processes...\n");
runNoThrow("pkill -15 -x looper");
runNoThrow("pkill -15 -x looper-client");
runNoThrow("pkill -9 -x jack_capture");
runNoThrow("tmux kill-session -t looper 2>/dev/null || true");
process.stdout.write(" Checking JACK...\n");
try {
run("jack_wait -c -t 5", 10);
} catch {
console.warn(" JACK server is not running. Tests may fail.");
}
process.stdout.write(" Removing old temp files...\n");
run("rm -f /tmp/looper_cmd /tmp/looper_status /tmp/test.wav /tmp/captured.wav /tmp/loop.wav /tmp/save.wav /tmp/load_test.wav /tmp/loaded.wav save_ch*.wav save.wav loop.wav");
}
export function teardownTest() {
if (cmdFifoFd !== null) {
fs.closeSync(cmdFifoFd);
cmdFifoFd = null;
}
runNoThrow("pkill -15 -x looper");
runNoThrow("pkill -15 -x looper-client");
runNoThrow("pkill -9 -x jack_capture");
runNoThrow("tmux kill-session -t looper");
}
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export async function startEngine(): Promise<ChildProcess> {
process.stdout.write(" Starting engine directly...\n");
const stderrFile = "/tmp/engine_stderr.log";
const proc = exec(`${globals.ENGINE_BIN} 2>${stderrFile}`, { cwd: globals.PROJECT_DIR });
// Wait for the status FIFO to appear (up to 10 seconds)
try {
await Promise.race([
waitForFile(globals.STATUS_FIFO, 10000),
wait(11000),
]);
} catch {
process.stdout.write(" Engine status FIFO did not appear within timeout\n");
if (proc.pid && !isProcessAlive(proc.pid)) {
process.stdout.write(" Engine process died prematurely. stderr log:\n");
const stderr = execSync(`cat ${stderrFile}`, { encoding: "utf-8" }).trim();
process.stdout.write(stderr + "\n");
}
}
if (proc.pid && isProcessAlive(proc.pid)) {
process.stdout.write(" Engine started (pid " + proc.pid + ")\n");
} else {
process.stdout.write(" Engine process is not alive after start attempt\n");
}
return proc;
}
export async function startClientInTmux(): Promise<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(globals.CLIENT_BIN)} Enter`);
// Wait for the client to draw the initial frame (max 10 seconds, poll every 500 ms)
const deadline = Date.now() + 10000;
let pane = "";
while (Date.now() < deadline) {
await wait(500);
pane = tmuxCapturePane("looper", "0");
if (pane.includes("[online]") || pane.includes("Selected:")) {
break;
}
}
// Final short extra wait to ensure status lines are updated
await wait(500);
}
/* Check if the tmux pane contains a given substring */
export function tmuxContains(text: string): boolean {
const pane = tmuxCapturePane("looper", "0");
return pane.includes(text);
}
/* Read the status FIFO nonblocking. Returns the data read (may be empty) */
export function readStatusNonBlock(): string {
try {
const fd = fs.openSync(globals.STATUS_FIFO, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
const buf = Buffer.alloc(2000);
const bytesRead = fs.readSync(fd, buf, 0, 2000, null);
fs.closeSync(fd);
return buf.slice(0, bytesRead).toString('utf-8').trim();
} catch {
return "";
}
}
/* Read the status FIFO and return the first line that matches a pattern, or "" */
export function readStatusLineMatching(pattern: string): string {
const data = readStatusNonBlock();
for (const line of data.split("\n")) {
if (line.includes(pattern)) return line;
}
return "";
}
/* Wait until the tmux pane contains the given substring (optional, used by tests) */
export async function waitForPaneText(text: string, timeoutMs = 5000): Promise<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 */
export function generateTestWav(p: string, durationSec = 1): void {
run(`sox -n -r 48000 -b 16 -c 1 ${p} synth ${durationSec} sine 440`);
}
export function ensureGenTone(): void {
if (!fs.existsSync(globals.GEN_TONE_BIN)) {
const src = path.join(__dirname, "gen_tone.c");
execSync(`gcc -o ${globals.GEN_TONE_BIN} ${src} -ljack -lm`, { timeout: 10000 });
}
}
/* Check if a WAV file contains audio (RMS > 0.001) */
export function wavHasAudio(p: string): boolean {
try {
const stat: fs.Stats = fs.statSync(p);
return stat.size > 44;
} catch {
return false;
}
}
export function runCmd(cmd: string): string {
try {
return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim();
} catch { return ""; }
}
/** Wait until the status FIFO contains the given substring or timeout */
export async function waitForStatusContaining(substr: string, timeoutMs = 8000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const data = readStatusNonBlock();
if (data.includes(substr)) return data;
await wait(200);
}
return readStatusNonBlock();
}