tui-tester
Version:
End-to-end testing framework for terminal user interfaces
881 lines (876 loc) • 22.7 kB
JavaScript
import { promisify } from 'util';
import * as fs from 'fs/promises';
import { exec, spawn } from 'child_process';
// src/adapters/base.ts
var 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 = 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 };
}
};
var 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(path) {
const file = Bun.file(path);
return await file.text();
}
async writeFile(path, content) {
await Bun.write(path, content);
}
async exists(path) {
const file = Bun.file(path);
return await file.exists();
}
async mkdir(path, options) {
const recursive = options?.recursive ? "-p" : "";
await this.exec(`mkdir ${recursive} "${path}"`);
}
async rmdir(path, options) {
const recursive = options?.recursive ? "-rf" : "";
await this.exec(`rm ${recursive} "${path}"`);
}
};
var execAsync = promisify(exec);
var 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);
});
}
};
var 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 = 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 };
}
};
var 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(path) {
return await Deno.readTextFile(path);
}
async writeFile(path, content) {
await Deno.writeTextFile(path, content);
}
async exists(path) {
try {
await Deno.stat(path);
return true;
} catch {
return false;
}
}
async mkdir(path, options) {
await Deno.mkdir(path, options);
}
async rmdir(path, options) {
await Deno.remove(path, { recursive: options?.recursive });
}
};
// src/adapters/index.ts
var adapterRegistry = /* @__PURE__ */ new Map();
function registerAdapter(name, adapterClass, options) {
adapterRegistry.set(name.toLowerCase(), {
adapterClass,
detect: options?.detect,
priority: options?.priority ?? 0
});
}
var defaultAdapterName = null;
function setDefaultAdapter(name) {
defaultAdapterName = name.toLowerCase();
}
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();
}
}
var currentAdapter = null;
function getAdapter() {
if (!currentAdapter) {
currentAdapter = createAdapter();
}
return currentAdapter;
}
function setAdapter(adapter) {
currentAdapter = adapter;
}
function resetAdapter() {
currentAdapter = null;
}
export { BaseRuntimeAdapter, createAdapter, detectRuntime, getAdapter, registerAdapter, resetAdapter, setAdapter, setDefaultAdapter };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map