tui-tester
Version:
End-to-end testing framework for terminal user interfaces
2,320 lines • 69.3 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/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/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/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/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|fish|dash)$/);
let createCmd;
if (needsShell) {
createCmd = `tmux new-session -d -s ${this.sessionName} -x ${cols} -y ${rows} ${envVars} -c ${escapeShellArg(this.config.cwd)} bash`;
} else {
const shell = this.config.command[0] || "bash";
createCmd = `tmux new-session -d -s ${this.sessionName} -x ${cols} -y ${rows} ${envVars} -c ${escapeShellArg(this.config.cwd)} ${shell}`;
}
this.debug(`Creating tmux session: ${createCmd}`);
const result = await this.adapter.exec(createCmd);
if (result.code !== 0) {
throw new Error(`Failed to create tmux session: ${result.stderr}`);
}
this.running = true;
if (needsShell) {
await this.sleep(1e3);
const appCmd = this.config.command.join(" ");
await this.sendCommand(appCmd);
}
await this.sleep(500);
this.debug("Tmux session started successfully");
if (this.config.recordingEnabled) {
this.startRecording();
}
}
/**
* Stop the tmux session
*/
async stop() {
if (!this.running) {
return;
}
if (this.recording) {
this.stopRecording();
}
const result = await this.adapter.exec(`tmux kill-session -t ${this.sessionName}`);
if (result.code !== 0) {
this.debug(`Warning: Failed to kill tmux session: ${result.stderr}`);
}
this.running = false;
this.debug("Tmux session stopped");
}
/**
* Restart the session
*/
async restart() {
await this.stop();
await this.start();
}
/**
* Check if the session is running
*/
isRunning() {
return this.running;
}
/**
* Send text to the terminal
*/
async sendText(text) {
this.ensureRunning();
const escaped = escapeShellArg(text);
const cmd = `tmux send-keys -t ${this.sessionName} ${escaped}`;
this.debug(`Sending text: ${text}`);
await this.adapter.exec(cmd);
this.recordEvent("input", { type: "text", text });
await this.sleep(100);
}
/**
* Send a key with optional modifiers
*/
async sendKey(key, modifiers) {
this.ensureRunning();
const tmuxKey = parseTmuxKey(key, modifiers);
const cmd = `tmux send-keys -t ${this.sessionName} ${tmuxKey}`;
this.debug(`Sending key: ${key} (tmux: ${tmuxKey})`);
await this.adapter.exec(cmd);
this.recordEvent("key", { key, modifiers });
await this.sleep(100);
}
/**
* Send multiple keys
*/
async sendKeys(keys) {
for (const key of keys) {
await this.sendKey(key);
}
}
/**
* Enable mouse support in the terminal
*/
async enableMouse() {
this.ensureRunning();
await this.adapter.exec(`tmux set -t ${this.sessionName} mouse on`);
this.debug("Mouse support enabled");
}
/**
* Disable mouse support in the terminal
*/
async disableMouse() {
this.ensureRunning();
await this.adapter.exec(`tmux set -t ${this.sessionName} mouse off`);
this.debug("Mouse support disabled");
}
/**
* Click at specific position
*/
async click(x, y) {
await this.sendMouse({
type: "click",
position: { x, y },
button: "left"
});
}
/**
* Click on text in the terminal
*/
async clickText(text) {
const capture = await this.captureScreen();
const lines = capture.lines;
for (let y = 0; y < lines.length; y++) {
const x = lines[y].indexOf(text);
if (x !== -1) {
await this.click(x, y);
return;
}
}
throw new Error(`Text "${text}" not found on screen`);
}
/**
* Double click at specific position
*/
async doubleClick(x, y) {
await this.click(x, y);
await this.sleep(50);
await this.click(x, y);
}
/**
* Right click at specific position
*/
async rightClick(x, y) {
await this.sendMouse({
type: "click",
position: { x, y },
button: "right"
});
}
/**
* Drag from one position to another
*/
async drag(from, to) {
await this.sendMouse({
type: "click",
position: from,
button: "left"
});
await this.sendMouse({
type: "move",
position: to,
button: "left"
});
await this.sendMouse({
type: "click",
position: to,
button: "left"
});
}
/**
* Scroll up or down
*/
async scroll(direction, lines = 1) {
const currentPos = await this.getCursor();
for (let i = 0; i < lines; i++) {
await this.sendMouse({
type: "scroll",
position: currentPos,
button: direction
});
await this.sleep(50);
}
}
/**
* Send a mouse event
*/
async sendMouse(event) {
this.ensureRunning();
await this.adapter.exec(`tmux set -t ${this.sessionName} mouse on`);
let cmd;
switch (event.type) {
case "click":
const button = event.button === "left" || event.button === "middle" || event.button === "right" ? event.button : "left";
const sequence = parseTmuxMouse(event.position.x, event.position.y, button);
cmd = `tmux send-keys -t ${this.sessionName} -H ${sequence}`;
break;
case "move":
cmd = `tmux send-keys -t ${this.sessionName} -H '\x1B[<35;${event.position.x + 1};${event.position.y + 1}M'`;
break;
case "scroll":
const scrollButton = event.button === "up" ? 4 : 5;
cmd = `tmux send-keys -t ${this.sessionName} -H '\x1B[<${scrollButton};${event.position.x + 1};${event.position.y + 1}M'`;
break;
default:
this.debug(`Unsupported mouse event type: ${event.type}`);
return;
}
this.debug(`Sending mouse event: ${event.type} at (${event.position.x}, ${event.position.y})`);
await this.adapter.exec(cmd);
this.recordEvent("mouse", event);
await this.sleep(100);
}
/**
* Paste text (using bracketed paste mode)
*/
async paste(text) {
this.ensureRunning();
await this.adapter.exec(`tmux send-keys -t ${this.sessionName} '\x1B[200~'`);
await this.sendText(text);
await this.adapter.exec(`tmux send-keys -t ${this.sessionName} '\x1B[201~'`);
this.recordEvent("input", { type: "paste", text });
}
/**
* Type text with optional delay between characters
*/
async typeText(text, delayMs = 50) {
for (const char of text) {
await this.sendText(char);
await this.sleep(delayMs);
}
}
/**
* Capture the current screen
*/
async captureScreen() {
this.ensureRunning();
const result = await this.adapter.exec(`tmux capture-pane -t ${this.sessionName} -p -e`);
if (result.code !== 0) {
throw new Error(`Failed to capture screen: ${result.stderr}`);
}
const raw = result.stdout;
const text = stripAnsi(raw);
const lines = splitLines(text);
const capture = {
raw,
text,
lines,
timestamp: Date.now(),
size: this.config.size
};
this._outputBuffer = raw;
if (this.recording) {
this.recording.captures.push(capture);
}
return capture;
}
/**
* Get screen text without ANSI codes
*/
async getScreenText() {
const capture = await this.captureScreen();
return capture.text;
}
/**
* Get screen lines without ANSI codes
*/
async getScreenLines() {
const capture = await this.captureScreen();
return capture.lines;
}
/**
* Get screen content (alias for getScreenText for backward compatibility)
*/
async getScreenContent() {
return this.getScreenText();
}
/**
* Get screen content with options
*/
async getScreen(options) {
if (options?.stripAnsi) {
return this.getScreenText();
}
const capture = await this.captureScreen();
return capture.raw;
}
/**
* Get screen lines (alias for getScreenLines)
*/
async getLines() {
return this.getScreenLines();
}
/**
* Wait for text to appear on screen
*/
async waitForText(text, options) {
await waitFor(
async () => {
const screenText = await this.getScreenText();
return screenText.includes(text) ? true : void 0;
},
{
timeout: options?.timeout ?? 5e3,
interval: options?.interval ?? 100,
message: options?.message ?? `Text "${text}" not found`
}
);
}
/**
* Wait for condition to be true
*/
async waitFor(predicate, options) {
await waitFor(
async () => {
const screenText = await this.getScreenText();
const result = await predicate(screenText);
return result ? true : void 0;
},
{
timeout: options?.timeout ?? 5e3,
interval: options?.interval ?? 100,
message: options?.message ?? `Condition not met`
}
);
}
/**
* Wait for pattern to match screen content
*/
async waitForPattern(pattern, options) {
await waitFor(
async () => {
const screenText = await this.getScreenText();
return pattern.test(screenText) ? true : void 0;
},
{
timeout: options?.timeout ?? 5e3,
interval: options?.interval ?? 100,
message: options?.message ?? `Pattern ${pattern} not matched`
}
);
}
/**
* Wait for specific line to contain text
*/
async waitForLine(lineNumber, text, options) {
await waitFor(
async () => {
const lines = await this.getScreenLines();
const line = lines[lineNumber];
return line && line.includes(text) ? true : void 0;
},
{
timeout: options?.timeout ?? 5e3,
interval: options?.interval ?? 100,
message: options?.message ?? `Line ${lineNumber} does not contain "${text}"`
}
);
}
/**
* Assert specific line contains text
*/
async assertLine(lineNumber, predicate) {
const lines = await this.getScreenLines();
const line = lines[lineNumber];
if (!line) {
throw new Error(`Line ${lineNumber} does not exist. Screen has ${lines.length} lines.`);
}
if (typeof predicate === "string") {
if (!line.includes(predicate)) {
throw new Error(`Line ${lineNumber} does not contain "${predicate}". Line content: "${line}"`);
}
} else {
if (!predicate(line)) {
throw new Error(`Line ${lineNumber} assertion failed. Line content: "${line}"`);
}
}
}
/**
* Assert screen matches expected content or predicate
*/
async assertScreen(expected, options) {
if (typeof expected === "function") {
const screenText = await this.getScreenText();
if (!expected(screenText)) {
throw new Error("Screen assertion failed: predicate returned false");
}
return;
}
const capture = await this.captureScreen();
const actual = options?.ignoreAnsi ? capture.text : capture.raw;
const expectedText = Array.isArray(expected) ? expected.join("\n") : expected;
if (!compareScreens(actual, expectedText, options)) {
const diff = screenDiff(actual, expectedText);
throw new Error(`Screen assertion failed:
${diff}`);
}
}
/**
* Assert screen contains text
*/
async assertScreenContains(text, options) {
const capture = await this.captureScreen();
const screenText = normalizeText(capture.text, options);
const searchText = normalizeText(text, options);
if (!screenText.includes(searchText)) {
throw new Error(`Screen does not contain "${text}"`);
}
}
/**
* Assert screen matches pattern
*/
async assertScreenMatches(pattern, options) {
const capture = await this.captureScreen();
const screenText = normalizeText(capture.text, options);
if (!pattern.test(screenText)) {
throw new Error(`Screen does not match pattern: ${pattern}`);
}
}
/**
* Assert cursor is at specific position
*/
async assertCursorAt(position) {
const cursor = await this.getCursor();
if (cursor.x !== position.x || cursor.y !== position.y) {
throw new Error(
`Cursor position mismatch. Expected (${position.x}, ${position.y}), got (${cursor.x}, ${cursor.y})`
);
}
}
/**
* Get current cursor position
*/
async getCursor() {
this.ensureRunning();
const result = await this.adapter.exec(`tmux display -t ${this.sessionName} -p '#{cursor_x},#{cursor_y}'`);
if (result.code !== 0) {
throw new Error(`Failed to get cursor position: ${result.stderr}`);
}
const [x, y] = result.stdout.trim().split(",").map(Number);
return { x, y };
}
/**
* Take a snapshot (alias for takeSnapshot for backward compatibility)
*/
async snapshot(name, options) {
const { getSnapshotManager: getSnapshotManager2 } = await Promise.resolve().then(() => (init_snapshot_manager(), snapshot_manager_exports));
const manager = getSnapshotManager2();
const capture = await this.captureScreen();
let content = options?.customContent || (options?.stripAnsi ? capture.text : capture.raw);
if (options?.trim) {
content = content.trim();
}
const existing = await manager.load(name);
if (existing && !options?.updateSnapshot) {
let matches = false;
if (options?.compare) {
matches = options.compare(existing, content);
} else {
matches = existing === content;
}
if (!matches) {
const diff = screenDiff(existing, content);
throw new Error(`Snapshot mismatch for "${name}":
${diff}`);
}
} else {
await manager.save(name, content);
}
}
/**
* Take a snapshot
*/
async takeSnapshot(name) {
const capture = await this.captureScreen();
const snapshotName = name || `snapshot-${Date.now()}`;
const snapshot = {
id: `${this.sessionName}-${snapshotName}`,
name: snapshotName,
capture,
metadata: {
sessionName: this.sessionName,
timestamp: Date.now(),
size: this.config.size
}
};
this.snapshots.set(snapshot.id, snapshot);
return snapshot;
}
/**
* Compare current screen with snapshot
*/
async compareSnapshot(snapshot) {
const targetSnapshot = typeof snapshot === "string" ? this.snapshots.get(snapshot) || await this.loadSnapshot(snapshot) : snapshot;
if (!targetSnapshot) {
throw new Error(`Snapshot not found: ${snapshot}`);
}
const currentCapture = await this.captureScreen();
return currentCapture.raw === targetSnapshot.capture.raw;
}
/**
* Save snapshot to file
*/
async saveSnapshot(snapshot, path2) {
const snapshotPath = path2 || `${this.config.snapshotDir}/${snapshot.id}.json`;
const dir = snapshotPath.substring(0, snapshotPath.lastIndexOf("/"));
await this.adapter.mkdir(dir, { recursive: true });
await this.adapter.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2));
this.debug(`Snapshot saved to: ${snapshotPath}`);
}
/**
* Load snapshot from file
*/
async loadSnapshot(path2) {
const content = await this.adapter.readFile(path2);
const snapshot = JSON.parse(content);
this.snapshots.set(snapshot.id, snapshot);
return snapshot;
}
/**
* Resize the terminal
*/
async resize(size) {
this.ensureRunning();
const cmd = `tmux resize-window -t ${this.sessionName} -x ${size.cols} -y ${size.rows}`;
await this.adapter.exec(cmd);
this.config.size = size;
this.recordEvent("resize", size);
this.debug(`Resized to ${size.cols}x${size.rows}`);
await this.sleep(100);
}
/**
* Clear the screen
*/
async clear() {
this.ensureRunning();
await this.sendKey("l", { ctrl: true });
}
/**
* Reset the terminal
*/
async reset() {
this.ensureRunning();
await this.adapter.exec(`tmux send-keys -t ${this.sessionName} 'reset' Enter`);
await this.sleep(500);
}
/**
* Get current terminal size
*/
getSize() {
return { ...this.config.size };
}
/**
* Get session name
*/
getSessionName() {
return this.sessionName;
}
/**
* Get the last captured output (for debugging)
*/
getLastOutput() {
return this._outputBuffer;
}
/**
* Clear the output buffer
*/
clearOutput() {
this._outputBuffer = "";
}
/**
* Execute a tmux command directly
* Useful for advanced tmux operations not covered by higher-level methods
*/
async exec(command) {
const fullCommand = command.startsWith("tmux") ? command : `tmux ${command}`;
return this.adapter.exec(fullCommand);
}
/**
* Capture screen with cursor position
* Returns both screen content and cursor position
*/
async capture() {
const screen = await this.captureScreen();
const cursor = await this.getCursor();
return {
...screen,
cursor
};
}
/**
* Start recording session
*/
startRecording() {
if (this.recording) {
this.debug("Recording already in progress");
return;
}
this.recording = {
startTime: Date.now(),
events: [],
captures: []
};
this.debug("Recording started");
}
/**
* Stop recording and return the recording
*/
stopRecording() {
if (!this.recording) {
throw new Error("No recording in progress");
}
const recording = this.recording;
this.recording = null;
this.debug(`Recording stopped. ${recording.events.length} events, ${recording.captures.length} captures`);
return recording;
}
/**
* Play back a recording
*/
async playRecording(recording, speed = 1) {
this.debug(`Playing recording with ${recording.events.length} events at ${speed}x speed`);
const startTime = Date.now();
for (const event of recording.events) {
const eventTime = (event.timestamp - recording.startTime) / speed;
const currentTime = Date.now() - startTime;
if (eventTime > currentTime) {
await this.sleep(eventTime - currentTime);
}
switch (event.type) {
case "input":
if (event.data.type === "text") {
await this.sendText(event.data.text);
} else if (event.data.type === "paste") {
await this.paste(event.data.text);
}
break;
case "key":
await this.sendKey(event.data.key, event.data.modifiers);
break;
case "mouse":
await this.sendMouse(event.data);
break;
case "resize":
await this.resize(event.data);
break;
}
}
this.debug("Recording playback complete");
}
/**
* Sleep for specified milliseconds
*/
async sleep(ms) {
return sleep(ms);
}
/**
* Debug log
*/
debug(message) {
if (this.debugMode) {
console.log(`[TmuxTester:${this.sessionName}] ${message}`);
}
}
// Private helper methods
ensureRunning() {
if (!this.running) {
throw new Error("Tester is not running. Call start() first.");
}
}
/**
* Send a command to the terminal (public method)
*/
async sendCommand(command) {
this.ensureRunning();
const escaped = escapeShellArg(command);
const cmd = `tmux send-keys -t ${this.sessionName} ${escaped} Enter`;
await this.adapter.exec(cmd);
this.recordEvent("input", { type: "command", text: command });
await this.sleep(100);
}
recordEvent(type, data) {
if (this.recording) {
this.recording.events.push({
timestamp: Date.now(),
type,
data
});
}
}
};
export { TmuxTester };
//# sourceMappingURL=tmux-tester.js.map
//# sourceMappingURL=tmux-tester.js.map