tui-tester
Version:
End-to-end testing framework for terminal user interfaces
1,634 lines (1,625 loc) • 77.1 kB
JavaScript
import 'path';
import 'url';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import { exec, spawn } from 'child_process';
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var init_esm_shims = __esm({
"node_modules/tsup/assets/esm_shims.js"() {
}
});
// src/core/utils.ts
function stripAnsi(text) {
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "").replace(/\x1b[PX^_].*?\x1b\\/g, "").replace(/\x1b[=><!~]/g, "").replace(/\x1b\[[0-9;]*[mGKHfJ]/g, "").replace(/\x1b\[[\d;]*\d*[A-Za-z]/g, "").replace(/\x1b\(\w/g, "").replace(/\x1b\[\?[\d;]*[hlc]/g, "").replace(/\x08/g, "");
}
function normalizeText(text, options = {}) {
let normalized = text;
if (options.ignoreAnsi) {
normalized = stripAnsi(normalized);
}
if (options.normalizeLineEndings) {
normalized = normalized.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
if (options.trimLines) {
normalized = normalized.split("\n").map((line) => line.trim()).join("\n");
}
if (options.ignoreWhitespace) {
normalized = normalized.replace(/\s+/g, " ").trim();
}
if (options.ignoreCase) {
normalized = normalized.toLowerCase();
}
return normalized;
}
function compareScreens(actual, expected, options = {}) {
const normalizedActual = normalizeText(actual, options);
const normalizedExpected = normalizeText(expected, options);
return normalizedActual === normalizedExpected;
}
function screenDiff(actual, expected) {
const actualLines = actual.split("\n");
const expectedLines = expected.split("\n");
const diff = [];
const maxLines = Math.max(actualLines.length, expectedLines.length);
for (let i = 0; i < maxLines; i++) {
const actualLine = actualLines[i] || "";
const expectedLine = expectedLines[i] || "";
if (actualLine !== expectedLine) {
diff.push(`Line ${i + 1}:`);
diff.push(` Expected: "${expectedLine}"`);
diff.push(` Actual: "${actualLine}"`);
}
}
return diff.join("\n");
}
async function waitFor(fn, options = {}) {
const timeout = options.timeout ?? 5e3;
const interval = options.interval ?? 100;
const message = options.message ?? "Timeout waiting for condition";
const startTime = Date.now();
while (true) {
const result = await fn();
if (result !== void 0) {
return result;
}
if (Date.now() - startTime > timeout) {
throw new Error(`${message} (timeout: ${timeout}ms)`);
}
await sleep(interval);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function generateSessionName(prefix) {
const p = "tui-test";
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${p}-${timestamp}-${random}`;
}
function escapeShellArg(arg) {
return `'${arg.replace(/'/g, "'\\''")}'`;
}
function parseTmuxKey(key, modifiers) {
const keyMap = {
"enter": "Enter",
"return": "Enter",
"tab": "Tab",
"escape": "Escape",
"esc": "Escape",
"space": "Space",
"backspace": "BSpace",
"delete": "Delete",
"up": "Up",
"down": "Down",
"left": "Left",
"right": "Right",
"home": "Home",
"end": "End",
"pageup": "PageUp",
"pagedown": "PageDown",
"insert": "Insert",
"f1": "F1",
"f2": "F2",
"f3": "F3",
"f4": "F4",
"f5": "F5",
"f6": "F6",
"f7": "F7",
"f8": "F8",
"f9": "F9",
"f10": "F10",
"f11": "F11",
"f12": "F12"
};
let tmuxKey = keyMap[key.toLowerCase()] || key;
if (modifiers) {
const prefixes = [];
if (modifiers.ctrl) prefixes.push("C-");
if (modifiers.alt) prefixes.push("M-");
if (modifiers.shift) prefixes.push("S-");
if (prefixes.length > 0 && tmuxKey.length === 1) {
tmuxKey = prefixes.join("") + tmuxKey.toLowerCase();
} else if (prefixes.length > 0) {
tmuxKey = prefixes.join("") + tmuxKey;
}
}
return tmuxKey;
}
function parseTmuxMouse(x, y, button = "left") {
const buttonMap = {
"left": 0,
"middle": 1,
"right": 2
};
return `\x1B[<${buttonMap[button]};${x + 1};${y + 1}M`;
}
function splitLines(text) {
return text.split(/\r?\n/);
}
var init_utils = __esm({
"src/core/utils.ts"() {
init_esm_shims();
}
});
// src/adapters/base.ts
var BaseRuntimeAdapter;
var init_base = __esm({
"src/adapters/base.ts"() {
init_esm_shims();
BaseRuntimeAdapter = class {
/**
* Cross-platform sleep implementation
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Execute command with timeout
*/
async execWithTimeout(command, timeoutMs = 3e4) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Command timeout: ${command}`)), timeoutMs);
});
return Promise.race([
this.exec(command),
timeoutPromise
]);
}
/**
* Try to execute command, return null on failure
*/
async tryExec(command) {
try {
return await this.exec(command);
} catch {
return null;
}
}
/**
* Check if command is available
*/
async commandExists(command) {
const result = await this.tryExec(`which ${command} 2>/dev/null`);
return result !== null && result.code === 0;
}
/**
* Get environment variable
*/
getEnv(key) {
if (typeof process !== "undefined") {
return process.env[key];
}
if (typeof Deno !== "undefined") {
return Deno.env.get(key);
}
if (typeof Bun !== "undefined") {
return Bun.env[key];
}
return void 0;
}
/**
* Set environment variable
*/
setEnv(key, value) {
if (typeof process !== "undefined") {
process.env[key] = value;
}
if (typeof Deno !== "undefined") {
Deno.env.set(key, value);
}
if (typeof Bun !== "undefined") {
Bun.env[key] = value;
}
}
/**
* Get current working directory
*/
getCwd() {
if (typeof process !== "undefined") {
return process.cwd();
}
if (typeof Deno !== "undefined") {
return Deno.cwd();
}
if (typeof Bun !== "undefined") {
return globalThis.process?.cwd() || "/";
}
return "/";
}
/**
* Get platform
*/
getPlatform() {
if (typeof process !== "undefined") {
return process.platform;
}
if (typeof Deno !== "undefined") {
return Deno.build.os;
}
if (typeof Bun !== "undefined") {
return globalThis.process?.platform || "unknown";
}
return "unknown";
}
/**
* Check if running on Windows
*/
isWindows() {
const platform = this.getPlatform();
return platform === "win32" || platform === "windows";
}
/**
* Check if running in CI environment
*/
isCI() {
return !!(this.getEnv("CI") || this.getEnv("CONTINUOUS_INTEGRATION") || this.getEnv("GITHUB_ACTIONS") || this.getEnv("GITLAB_CI") || this.getEnv("CIRCLECI") || this.getEnv("TRAVIS") || this.getEnv("JENKINS_URL"));
}
};
}
});
// src/adapters/bun.ts
var BunChildProcess, BunAdapter;
var init_bun = __esm({
"src/adapters/bun.ts"() {
init_esm_shims();
init_base();
BunChildProcess = class {
process;
// Bun.Subprocess
_pid;
outputBuffer = "";
errorBuffer = "";
constructor(command, args, options) {
this.process = Bun.spawn([command, ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: options?.env ? { ...process.env, ...options.env } : process.env,
cwd: options?.cwd || process.cwd()
});
this._pid = this.process.pid;
this.startReading().catch(() => {
});
}
async startReading() {
this.readStream(this.process.stdout, (data) => {
this.outputBuffer += data;
});
this.readStream(this.process.stderr, (data) => {
this.errorBuffer += data;
});
}
async readStream(stream, callback) {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
callback(decoder.decode(value));
}
}
} catch (error) {
} finally {
reader.releaseLock();
}
}
get pid() {
return this._pid;
}
get stdin() {
return this.process.stdin;
}
get stdout() {
return this.process.stdout;
}
get stderr() {
return this.process.stderr;
}
kill(signal) {
this.process.kill(signal);
}
async wait() {
const code = await this.process.exited;
return { code };
}
};
BunAdapter = class extends BaseRuntimeAdapter {
processes = /* @__PURE__ */ new Set();
async exec(command) {
try {
const env = {
...process.env,
PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`
};
const proc = Bun.spawn(["sh", "-c", command], {
stdout: "pipe",
stderr: "pipe",
env
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const code = await proc.exited;
return { stdout, stderr, code };
} catch (error) {
return {
stdout: "",
stderr: error.message,
code: 1
};
}
}
async spawn(command, args, options) {
try {
const proc = new BunChildProcess(command, args, options);
this.processes.add(proc);
proc.wait().then(() => {
this.processes.delete(proc);
}).catch(() => {
this.processes.delete(proc);
});
return proc;
} catch (error) {
throw new Error(`Failed to spawn process: ${error.message}`);
}
}
async kill(proc, signal) {
try {
if (!proc) return false;
if (proc instanceof BunChildProcess) {
this.processes.delete(proc);
}
if (typeof proc.kill === "function") {
proc.kill(signal || "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
return true;
}
return false;
} catch {
return false;
}
}
async write(proc, data) {
try {
if (proc && proc.stdin) {
const stream = proc.stdin;
const writer = stream.getWriter();
try {
let dataToWrite;
if (typeof data === "string") {
if (data === "") {
await writer.close();
return true;
}
const encoder = new TextEncoder();
dataToWrite = encoder.encode(data);
} else {
dataToWrite = data;
}
await writer.write(dataToWrite);
await writer.ready;
} finally {
writer.releaseLock();
}
return true;
}
return false;
} catch (error) {
console.error("Write error:", error);
return false;
}
}
async read(proc, timeout = 1e3) {
if (!proc) return "";
if (proc instanceof BunChildProcess) {
await new Promise((resolve) => setTimeout(resolve, 200));
const output = proc.outputBuffer;
return output;
}
if (!proc.stdout) return "";
try {
const stream = proc.stdout;
const reader = stream.getReader();
const decoder = new TextDecoder();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => resolve(""), timeout);
});
const readPromise = reader.read().then(({ value, done }) => {
reader.releaseLock();
if (done || !value) return "";
return decoder.decode(value);
});
return await Promise.race([readPromise, timeoutPromise]);
} catch {
return "";
}
}
async resize(_proc, _cols, _rows) {
return true;
}
async isAlive(proc) {
if (!proc) return false;
try {
const p = proc.process;
if (p && p.exitCode === null) {
return true;
}
} catch {
}
return false;
}
async cleanup() {
const procs = Array.from(this.processes);
this.processes.clear();
await Promise.all(
procs.map(async (proc) => {
try {
proc.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
} catch {
}
})
);
}
async readFile(path2) {
const file = Bun.file(path2);
return await file.text();
}
async writeFile(path2, content) {
await Bun.write(path2, content);
}
async exists(path2) {
const file = Bun.file(path2);
return await file.exists();
}
async mkdir(path2, options) {
const recursive = options?.recursive ? "-p" : "";
await this.exec(`mkdir ${recursive} "${path2}"`);
}
async rmdir(path2, options) {
const recursive = options?.recursive ? "-rf" : "";
await this.exec(`rm ${recursive} "${path2}"`);
}
};
}
});
var execAsync, NodeChildProcess, NodeAdapter;
var init_node = __esm({
"src/adapters/node.ts"() {
init_esm_shims();
init_base();
execAsync = promisify(exec);
NodeChildProcess = class {
process;
outputBuffer = "";
errorBuffer = "";
constructor(command, args, options) {
this.process = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
env: options?.env ? { ...process.env, ...options.env } : process.env,
cwd: options?.cwd,
...options?.pty ? {} : {}
});
if (this.process.stdout) {
this.process.stdout.on("data", (chunk) => {
this.outputBuffer += chunk.toString();
});
}
if (this.process.stderr) {
this.process.stderr.on("data", (chunk) => {
this.errorBuffer += chunk.toString();
});
}
}
get output() {
return this.outputBuffer;
}
get error() {
return this.errorBuffer;
}
clearOutput() {
this.outputBuffer = "";
this.errorBuffer = "";
}
get pid() {
return this.process.pid || -1;
}
get stdin() {
return this.process.stdin;
}
get stdout() {
return this.process.stdout;
}
get stderr() {
return this.process.stderr;
}
kill(signal) {
this.process.kill(signal);
}
wait() {
return new Promise((resolve, reject) => {
if (this.process.exitCode !== null) {
resolve({ code: this.process.exitCode });
return;
}
const onExit = (code) => {
cleanup();
resolve({ code: code || 0 });
};
const onError = (err) => {
cleanup();
reject(err);
};
const cleanup = () => {
this.process.removeListener("exit", onExit);
this.process.removeListener("error", onError);
};
this.process.once("exit", onExit);
this.process.once("error", onError);
});
}
};
NodeAdapter = class extends BaseRuntimeAdapter {
processes = /* @__PURE__ */ new Set();
/**
* Check if command is available with proper PATH
*/
async commandExists(command) {
try {
const env = {
...process.env,
PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`
};
const { stdout } = await execAsync(`which ${command}`, {
encoding: "utf8",
env
});
return stdout.trim().length > 0;
} catch {
return false;
}
}
async exec(command) {
try {
const env = {
...process.env,
PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`
};
const { stdout, stderr } = await execAsync(command, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
// 10MB buffer
env
});
return { stdout, stderr, code: 0 };
} catch (error) {
return {
stdout: error.stdout || "",
stderr: error.stderr || error.message,
code: error.code || 1
};
}
}
async spawn(command, args, options) {
try {
const proc = new NodeChildProcess(command, args, options);
this.processes.add(proc);
proc.wait().then(() => {
this.processes.delete(proc);
}).catch(() => {
this.processes.delete(proc);
});
return proc;
} catch (error) {
throw new Error(`Failed to spawn process: ${error.message}`);
}
}
async kill(proc, signal) {
try {
if (!proc) return false;
if (proc instanceof NodeChildProcess) {
this.processes.delete(proc);
}
if (typeof proc.kill === "function") {
proc.kill(signal || "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
return true;
}
return false;
} catch {
return false;
}
}
async write(proc, data) {
try {
if (proc && proc.stdin) {
const stream = proc.stdin;
return new Promise((resolve, reject) => {
stream.write(data, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
return false;
} catch {
return false;
}
}
async read(proc, timeout = 1e3) {
if (!proc) return "";
if (proc instanceof NodeChildProcess) {
const output = proc.output;
proc.clearOutput();
return output;
}
if (!proc.stdout) return "";
const stream = proc.stdout;
return new Promise((resolve) => {
let data = "";
const handler = (chunk) => {
data += chunk.toString();
};
stream.on("data", handler);
setTimeout(() => {
stream.off("data", handler);
resolve(data);
}, timeout);
});
}
async resize(_proc, _cols, _rows) {
return true;
}
async isAlive(proc) {
if (!proc) return false;
try {
if (proc.pid > 0) {
process.kill(proc.pid, 0);
return true;
}
} catch {
}
return false;
}
async cleanup() {
const procs = Array.from(this.processes);
this.processes.clear();
await Promise.all(
procs.map(async (proc) => {
try {
proc.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
} catch {
}
})
);
}
async readFile(filePath) {
return fs.readFile(filePath, "utf8");
}
async writeFile(filePath, content) {
await fs.writeFile(filePath, content, "utf8");
}
async exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async mkdir(dirPath, options) {
await fs.mkdir(dirPath, options);
}
async rmdir(dirPath, options) {
await fs.rm(dirPath, { recursive: options?.recursive, force: true });
}
};
}
});
// src/adapters/deno.ts
var DenoChildProcess, DenoAdapter;
var init_deno = __esm({
"src/adapters/deno.ts"() {
init_esm_shims();
init_base();
DenoChildProcess = class {
process;
// Deno.ChildProcess
_stdin;
_stdout;
_stderr;
outputBuffer = "";
errorBuffer = "";
constructor(command, args, options) {
const currentEnv = globalThis.Deno?.env?.toObject?.() || (typeof process !== "undefined" ? process.env : {});
this.process = new Deno.Command(command, {
args,
stdin: "piped",
stdout: "piped",
stderr: "piped",
env: options?.env ? { ...currentEnv, ...options.env } : currentEnv,
cwd: options?.cwd
}).spawn();
this._stdin = this.process.stdin;
this._stdout = this.process.stdout;
this._stderr = this.process.stderr;
this.startReading();
}
async startReading() {
this.readStream(this._stdout, (data) => {
this.outputBuffer += data;
});
this.readStream(this._stderr, (data) => {
this.errorBuffer += data;
});
}
async readStream(stream, callback) {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
callback(decoder.decode(value));
}
}
} catch (error) {
} finally {
reader.releaseLock();
}
}
get pid() {
return this.process.pid;
}
get stdin() {
return this._stdin;
}
get stdout() {
return this._stdout;
}
get stderr() {
return this._stderr;
}
kill(signal) {
this.process.kill(signal);
}
async wait() {
const status = await this.process.status;
return { code: status.code };
}
};
DenoAdapter = class extends BaseRuntimeAdapter {
processes = /* @__PURE__ */ new Set();
async exec(command) {
try {
const currentEnv = globalThis.Deno?.env?.toObject?.() || (typeof process !== "undefined" ? process.env : {});
const currentPath = globalThis.Deno?.env?.get?.("PATH") || (typeof process !== "undefined" ? process.env.PATH : "");
const extendedEnv = {
...currentEnv,
PATH: `/opt/homebrew/bin:/usr/local/bin:${currentPath}`
};
const cmd = new Deno.Command("sh", {
args: ["-c", command],
stdout: "piped",
stderr: "piped",
env: extendedEnv
});
const { code, stdout, stderr } = await cmd.output();
return {
stdout: new TextDecoder().decode(stdout),
stderr: new TextDecoder().decode(stderr),
code
};
} catch (error) {
return {
stdout: "",
stderr: error.message,
code: 1
};
}
}
async spawn(command, args, options) {
try {
const proc = new DenoChildProcess(command, args, options);
this.processes.add(proc);
proc.wait().then(() => {
this.processes.delete(proc);
}).catch(() => {
this.processes.delete(proc);
});
return proc;
} catch (error) {
throw new Error(`Failed to spawn process: ${error.message}`);
}
}
async kill(proc, signal) {
try {
if (!proc) return false;
if (proc instanceof DenoChildProcess) {
this.processes.delete(proc);
}
if (typeof proc.kill === "function") {
proc.kill(signal || "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
return true;
}
return false;
} catch {
return false;
}
}
async write(proc, data) {
try {
if (proc && proc.stdin) {
const stream = proc.stdin;
const writer = stream.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode(data));
writer.releaseLock();
return true;
}
return false;
} catch {
return false;
}
}
async read(proc, timeout = 1e3) {
if (!proc) return "";
if (proc instanceof DenoChildProcess) {
await new Promise((resolve) => setTimeout(resolve, 50));
const output = proc.outputBuffer;
proc.outputBuffer = "";
return output;
}
if (!proc.stdout) return "";
try {
const stream = proc.stdout;
const reader = stream.getReader();
const decoder = new TextDecoder();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => resolve(""), timeout);
});
const readPromise = reader.read().then(({ value, done }) => {
reader.releaseLock();
if (done || !value) return "";
return decoder.decode(value);
});
return await Promise.race([readPromise, timeoutPromise]);
} catch {
return "";
}
}
async resize(_proc, _cols, _rows) {
return true;
}
async isAlive(proc) {
if (!proc) return false;
try {
const p = proc.process;
if (p && typeof p.status === "function") {
return true;
}
} catch {
}
return false;
}
async cleanup() {
const procs = Array.from(this.processes);
this.processes.clear();
await Promise.all(
procs.map(async (proc) => {
try {
proc.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 100));
if (await this.isAlive(proc)) {
proc.kill("SIGKILL");
}
} catch {
}
})
);
}
async readFile(path2) {
return await Deno.readTextFile(path2);
}
async writeFile(path2, content) {
await Deno.writeTextFile(path2, content);
}
async exists(path2) {
try {
await Deno.stat(path2);
return true;
} catch {
return false;
}
}
async mkdir(path2, options) {
await Deno.mkdir(path2, options);
}
async rmdir(path2, options) {
await Deno.remove(path2, { recursive: options?.recursive });
}
};
}
});
// src/adapters/index.ts
function detectRuntime() {
if (typeof process !== "undefined" && process.env?.TUI_TESTER_ADAPTER) {
const envAdapter = process.env.TUI_TESTER_ADAPTER.toLowerCase();
if (envAdapter === "node" || envAdapter === "deno" || envAdapter === "bun") {
return envAdapter;
}
}
if (typeof Deno !== "undefined" && typeof Deno.version !== "undefined") {
return "deno";
}
if (typeof Bun !== "undefined" && typeof Bun.version !== "undefined") {
return "bun";
}
if (typeof process !== "undefined" && process.versions?.node) {
return "node";
}
return "node";
}
function createAdapter(runtime) {
if (defaultAdapterName) {
const customAdapter = adapterRegistry.get(defaultAdapterName);
if (customAdapter) {
return new customAdapter.adapterClass();
}
if (defaultAdapterName === "node" || defaultAdapterName === "deno" || defaultAdapterName === "bun") {
runtime = defaultAdapterName;
}
}
if (!runtime) {
const sortedAdapters = Array.from(adapterRegistry.entries()).filter(([, config]) => config.detect).sort(([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0));
for (const [, config] of sortedAdapters) {
if (config.detect && config.detect()) {
return new config.adapterClass();
}
}
}
if (typeof runtime === "string" && adapterRegistry.has(runtime.toLowerCase())) {
const customAdapter = adapterRegistry.get(runtime.toLowerCase());
return new customAdapter.adapterClass();
}
const detectedRuntime = typeof runtime === "string" ? runtime : runtime || detectRuntime();
switch (detectedRuntime) {
case "node":
return new NodeAdapter();
case "deno":
return new DenoAdapter();
case "bun":
return new BunAdapter();
default:
return new NodeAdapter();
}
}
function getAdapter() {
if (!currentAdapter) {
currentAdapter = createAdapter();
}
return currentAdapter;
}
var adapterRegistry, defaultAdapterName, currentAdapter;
var init_adapters = __esm({
"src/adapters/index.ts"() {
init_esm_shims();
init_bun();
init_node();
init_deno();
init_base();
adapterRegistry = /* @__PURE__ */ new Map();
defaultAdapterName = null;
currentAdapter = null;
}
});
// src/snapshot/snapshot-manager.ts
var snapshot_manager_exports = {};
__export(snapshot_manager_exports, {
SnapshotManager: () => SnapshotManager,
getSnapshotManager: () => getSnapshotManager,
resetSnapshotManager: () => resetSnapshotManager
});
function getSnapshotManager(options) {
if (!globalSnapshotManager) {
globalSnapshotManager = new SnapshotManager(options);
} else if (options) {
globalSnapshotManager.configure(options);
}
return globalSnapshotManager;
}
function resetSnapshotManager() {
globalSnapshotManager = null;
}
var SnapshotManager, globalSnapshotManager;
var init_snapshot_manager = __esm({
"src/snapshot/snapshot-manager.ts"() {
init_esm_shims();
init_adapters();
init_utils();
SnapshotManager = class {
adapter;
options;
snapshots = /* @__PURE__ */ new Map();
snapshotCounter = 0;
snapshotDir;
constructor(snapshotDir) {
this.adapter = getAdapter();
if (typeof snapshotDir === "string") {
this.snapshotDir = snapshotDir;
this.options = {
updateSnapshots: false,
snapshotDir,
diffOptions: {},
format: "text"
};
} else {
const options = snapshotDir || {};
this.snapshotDir = options.snapshotDir ?? "./__snapshots__";
this.options = {
updateSnapshots: options.updateSnapshots ?? false,
snapshotDir: this.snapshotDir,
diffOptions: options.diffOptions ?? {},
format: options.format ?? "text"
};
}
}
/**
* Configure the snapshot manager
*/
configure(options) {
if (options.updateSnapshots !== void 0) {
this.options.updateSnapshots = options.updateSnapshots;
}
if (options.snapshotDir !== void 0) {
this.options.snapshotDir = options.snapshotDir;
this.snapshotDir = options.snapshotDir;
}
if (options.diffOptions !== void 0) {
this.options.diffOptions = options.diffOptions;
}
if (options.format !== void 0) {
this.options.format = options.format;
}
if (options.stripAnsi !== void 0 && this.options.diffOptions) {
this.options.diffOptions.ignoreAnsi = options.stripAnsi;
}
if (options.trim !== void 0 && this.options.diffOptions) {
this.options.diffOptions.ignoreWhitespace = options.trim;
}
}
/**
* Create a snapshot from screen capture
*/
createSnapshot(capture, name) {
const snapshotName = name || `snapshot-${++this.snapshotCounter}`;
const snapshot = {
id: this.generateSnapshotId(snapshotName),
name: snapshotName,
capture,
metadata: {
createdAt: Date.now(),
format: this.options.format
}
};
this.snapshots.set(snapshot.id, snapshot);
return snapshot;
}
/**
* Match a capture against a snapshot
*/
async matchSnapshot(capture, snapshotName, testPath) {
const snapshotPath = this.getSnapshotPath(snapshotName, testPath);
try {
const existingSnapshot = await this.loadSnapshot(snapshotPath);
const pass = this.compareCaptures(capture, existingSnapshot.capture);
if (!pass) {
const diff = this.generateDiff(capture, existingSnapshot.capture);
return {
pass: false,
message: `Snapshot mismatch for "${snapshotName}"`,
diff
};
}
return { pass: true };
} catch (error) {
if (this.options.updateSnapshots) {
const snapshot = this.createSnapshot(capture, snapshotName);
await this.saveSnapshot(snapshot, snapshotPath);
return {
pass: true,
message: `New snapshot created for "${snapshotName}"`
};
} else {
return {
pass: false,
message: `Snapshot "${snapshotName}" does not exist. Run with updateSnapshots=true to create it.`
};
}
}
}
/**
* Compare two screen captures
*/
compareCaptures(actual, expected) {
switch (this.options.format) {
case "ansi":
return compareScreens(actual.raw, expected.raw, this.options.diffOptions);
case "text":
return compareScreens(actual.text, expected.text, this.options.diffOptions);
case "json":
default: {
const actualNorm = normalizeText(actual.text, this.options.diffOptions);
const expectedNorm = normalizeText(expected.text, this.options.diffOptions);
return actualNorm === expectedNorm;
}
}
}
/**
* Generate diff between captures
*/
generateDiff(actual, expected) {
const actualText = this.options.format === "ansi" ? actual.raw : actual.text;
const expectedText = this.options.format === "ansi" ? expected.raw : expected.text;
return screenDiff(actualText, expectedText);
}
/**
* Save snapshot to file
*/
async saveSnapshot(snapshot, path2) {
const snapshotPath = path2 || this.getSnapshotPath(snapshot.name);
const dir = snapshotPath.substring(0, snapshotPath.lastIndexOf("/"));
await this.adapter.mkdir(dir, { recursive: true });
let content;
switch (this.options.format) {
case "text":
content = snapshot.capture.text;
break;
case "ansi":
content = snapshot.capture.raw;
break;
case "json":
default:
content = JSON.stringify(snapshot, null, 2);
break;
}
await this.adapter.writeFile(snapshotPath, content);
}
/**
* Load snapshot from file
*/
async loadSnapshot(path2) {
const content = await this.adapter.readFile(path2);
let snapshot;
switch (this.options.format) {
case "text":
case "ansi":
const lines = content.split("\n");
snapshot = {
id: path2,
name: path2.substring(path2.lastIndexOf("/") + 1),
capture: {
raw: this.options.format === "ansi" ? content : "",
text: this.options.format === "text" ? content : "",
lines,
timestamp: 0,
size: { cols: 0, rows: lines.length }
}
};
break;
case "json":
default:
snapshot = JSON.parse(content);
break;
}
this.snapshots.set(snapshot.id, snapshot);
return snapshot;
}
/**
* Update existing snapshot
*/
async updateSnapshot(snapshotName, capture, testPath) {
const snapshot = this.createSnapshot(capture, snapshotName);
const snapshotPath = this.getSnapshotPath(snapshotName, testPath);
await this.saveSnapshot(snapshot, snapshotPath);
}
/**
* Remove snapshot
*/
async removeSnapshot(snapshotName, testPath) {
const snapshotPath = this.getSnapshotPath(snapshotName, testPath);
const snapshot = Array.from(this.snapshots.values()).find((s) => s.name === snapshotName);
if (snapshot) {
this.snapshots.delete(snapshot.id);
}
if (await this.adapter.exists(snapshotPath)) {
await this.adapter.exec(`rm "${snapshotPath}"`);
}
}
/**
* List all snapshots
*/
async listSnapshots() {
const extension = this.getFileExtension();
const result = await this.adapter.exec(
`find "${this.options.snapshotDir}" -name "*${extension}" 2>/dev/null || true`
);
return result.stdout.split("\n").filter((line) => line.trim()).map((path2) => path2.substring(path2.lastIndexOf("/") + 1));
}
/**
* Clear all snapshots
*/
async clearSnapshots() {
this.snapshots.clear();
if (await this.adapter.exists(this.options.snapshotDir)) {
await this.adapter.exec(`rm -rf "${this.options.snapshotDir}"`);
}
}
/**
* Get snapshot by name
*/
getSnapshot(name) {
return Array.from(this.snapshots.values()).find((s) => s.name === name);
}
/**
* Check if snapshot exists
*/
async snapshotExists(snapshotName, testPath) {
const snapshotPath = this.getSnapshotPath(snapshotName, testPath);
return this.adapter.exists(snapshotPath);
}
// Simple API methods for backward compatibility
/**
* Save a simple text snapshot
*/
async save(name, content) {
const sanitizedName = this.sanitizeName(name);
const filePath = `${this.snapshotDir}/${sanitizedName}.snap`;
await this.adapter.mkdir(this.snapshotDir, { recursive: true });
await this.adapter.writeFile(filePath, content);
}
/**
* Load a simple text snapshot
*/
async load(name) {
try {
const sanitizedName = this.sanitizeName(name);
const filePath = `${this.snapshotDir}/${sanitizedName}.snap`;
return await this.adapter.readFile(filePath);
} catch (error) {
return null;
}
}
/**
* Compare two snapshots
*/
async compare(name1, name2) {
const content1 = await this.load(name1);
const content2 = await this.load(name2);
if (content1 === null || content2 === null) {
return {
identical: false,
differences: [],
error: `Snapshot does not exist: ${content1 === null ? name1 : name2}`
};
}
const lines1 = content1.split("\n");
const lines2 = content2.split("\n");
const differences = [];
const maxLines = Math.max(lines1.length, lines2.length);
for (let i = 0; i < maxLines; i++) {
const line1 = lines1[i];
const line2 = lines2[i];
if (line1 !== line2) {
differences.push({
line: i + 1,
expected: line1,
actual: line2
});
}
}
return {
identical: differences.length === 0,
differences
};
}
/**
* Compare with options
*/
async compareWithOptions(name1, name2, options) {
const content1 = await this.load(name1);
const content2 = await this.load(name2);
if (content1 === null || content2 === null) {
return { identical: false, match: false, diff: `Snapshot does not exist: ${content1 === null ? name1 : name2}` };
}
let normalized1 = content1;
let normalized2 = content2;
if (options.ignoreWhitespace) {
normalized1 = normalized1.replace(/\s+/g, " ").trim();
normalized2 = normalized2.replace(/\s+/g, " ").trim();
}
if (options.ignoreAnsi) {
const ansiRegex = /\x1b\[[0-9;]*m/g;
normalized1 = normalized1.replace(ansiRegex, "");
normalized2 = normalized2.replace(ansiRegex, "");
}
const match = normalized1 === normalized2;
if (!match) {
const diff = this.createDiff(normalized2, normalized1);
return { identical: false, match: false, diff };
}
return { identical: true, match: true };
}
/**
* Delete a snapshot
*/
async delete(name) {
try {
const sanitizedName = this.sanitizeName(name);
const filePath = `${this.snapshotDir}/${sanitizedName}.snap`;
if (await this.adapter.exists(filePath)) {
await this.adapter.exec(`rm "${filePath}"`);
return true;
}
return false;
} catch (error) {
return false;
}
}
/**
* Delete all snapshots
*/
async deleteAll() {
try {
if (await this.adapter.exists(this.snapshotDir)) {
await this.adapter.exec(`rm -rf "${this.snapshotDir}"`);
await this.adapter.mkdir(this.snapshotDir, { recursive: true });
}
} catch (error) {
}
}
/**
* List all snapshot names
*/
async list(pattern) {
try {
if (!await this.adapter.exists(this.snapshotDir)) {
return [];
}
const result = await this.adapter.exec(
`find "${this.snapshotDir}" -name "*.snap" 2>/dev/null || true`
);
const files = result.stdout.split("\n").filter((line) => line.trim() && line.endsWith(".snap")).map((path2) => {
const filename = path2.substring(path2.lastIndexOf("/") + 1);
return filename.replace(".snap", "");
});
if (pattern) {
const regex = new RegExp(pattern);
return files.filter((name) => regex.test(name));
}
return files;
} catch (error) {
return [];
}
}
/**
* List by pattern (alias for list with pattern)
*/
async listByPattern(pattern) {
return this.list(pattern);
}
/**
* Check if update is needed
*/
async needsUpdate(name, content) {
const existing = await this.load(name);
if (existing === null) {
return true;
}
return existing !== content;
}
/**
* Save with metadata
*/
async saveWithMetadata(name, content, metadata) {
const data = JSON.stringify({
content,
metadata: {
...metadata,
timestamp: Date.now()
}
}, null, 2);
const sanitizedName = this.sanitizeName(name);
const filePath = `${this.snapshotDir}/${sanitizedName}.snap`;
await this.adapter.mkdir(this.snapshotDir, { recursive: true });
await this.adapter.writeFile(filePath, data);
}
/**
* Load with metadata
*/
async loadWithMetadata(name) {
try {
const sanitizedName = this.sanitizeName(name);
const filePath = `${this.snapshotDir}/${sanitizedName}.snap`;
const data = await this.adapter.readFile(filePath);
try {
const parsed = JSON.parse(data);
if (parsed.content !== void 0) {
return parsed;
}
return { content: data };
} catch {
return { content: data };
}
} catch (error) {
return null;
}
}
/**
* Format snapshot for display
*/
async format(name) {
const content = await this.load(name);
if (!content) return null;
const lines = content.split("\n");
return lines.map((line, i) => `${i + 1}: ${line}`).join("\n");
}
/**
* Format with options
*/
async formatWithOptions(name, options) {
const content = await this.load(name);
if (!content) return null;
const lines = content.split("\n");
if (options.lineNumbers) {
return lines.map((line, i) => `${(i + 1).toString().padStart(3, " ")} | ${line}`).join("\n");
}
return content;
}
/**
* Generate unified diff between two snapshots
*/
async diff(name1, name2) {
const content1 = await this.load(name1);
const content2 = await this.load(name2);
if (!content1 || !content2) {
return null;
}
return this.createDiff(content1, content2);
}
/**
* Sanitize snapshot name
*/
sanitizeName(name) {
return name.replace(/\//g, "_slash_").replace(/\.\./g, "_dot_").replace(/\s+/g, "_space_").replace(/[^a-zA-Z0-9-_]/g, "");
}
/**
* Create a simple diff
*/
createDiff(expected, actual) {
const expectedLines = expected.split("\n");
const actualLines = actual.split("\n");
const maxLines = Math.max(expectedLines.length, actualLines.length);
const diff = [];
for (let i = 0; i < maxLines; i++) {
const expectedLine = expectedLines[i];
const actualLine = actualLines[i];
if (expectedLine !== actualLine) {
if (expectedLine !== void 0) {
diff.push(`-${expectedLine}`);
}
if (actualLine !== void 0) {
diff.push(`+${actualLine}`);
}
}
}
return diff.join("\n");
}
// Private helper methods
generateSnapshotId(name) {
return `${name}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
}
getSnapshotPath(snapshotName, testPath) {
const extension = this.getFileExtension();
if (testPath) {
const testDir = testPath.substring(0, testPath.lastIndexOf("/"));
const testFile = testPath.substring(testPath.lastIndexOf("/") + 1);
const testName = testFile.replace(/\.(test|spec)\.(ts|js)$/, "");
return `${testDir}/__snapshots__/${testName}.${snapshotName}${extension}`;
} else {
return `${this.options.snapshotDir}/${snapshotName}${extension}`;
}
}
getFileExtension() {
switch (this.options.format) {
case "text":
return ".txt";
case "ansi":
return ".ansi";
case "json":
default:
return ".json";
}
}
};
globalSnapshotManager = null;
}
});
// src/helpers/test-runner.ts
init_esm_shims();
init_utils();
// src/tmux-tester.ts
init_esm_shims();
init_adapters();
init_utils();
var TmuxTester = class {
config;
adapter;
sessionName;
running = false;
_outputBuffer = "";
// Store last captured output
recording = null;
snapshots = /* @__PURE__ */ new Map();
debugMode;
constructor(config) {
this.config = {
command: config.command,
size: config.size || { cols: 80, rows: 24 },
env: config.env || {},
cwd: config.cwd || process.cwd(),
shell: config.shell || "sh",
sessionName: config.sessionName || generateSessionName(),
debug: config.debug || false,
recordingEnabled: config.recordingEnabled || false,
snapshotDir: config.snapshotDir || "./snapshots"
};
this.adapter = getAdapter();
this.sessionName = this.config.sessionName;
this.debugMode = this.config.debug;
}
/**
* Start the tmux session and launch the application
*/
async start() {
if (this.running) {
throw new Error("Tester is already running");
}
const tmuxAvailable = this.adapter.commandExists ? await this.adapter.commandExists("tmux") : true;
if (!tmuxAvailable) {
throw new Error("tmux is not installed. Please install tmux to use the terminal tester.");
}
if (this.adapter.tryExec) {
await this.adapter.tryExec(`tmux kill-session -t ${this.sessionName} 2>/dev/null`);
}
const { cols, rows } = this.config.size;
const envVars = Object.entries(this.config.env).map(([key, value]) => `-e ${key}=${escapeShellArg(value)}`).join(" ");
const needsShell = this.config.command.length > 0 && !this.config.command[0].match(/^(bash|sh|zsh|fis