UNPKG

tui-tester

Version:

End-to-end testing framework for terminal user interfaces

1,634 lines (1,625 loc) 77.1 kB
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