test: dedupe tui pty helpers

This commit is contained in:
Vincent Koc
2026-05-29 00:02:52 +02:00
parent bd77ebc761
commit 7ebd600297
3 changed files with 177 additions and 318 deletions

View File

@@ -1,27 +1,9 @@
import { appendFileSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as nodePty from "@lydell/node-pty";
import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
type NodePtyRuntimeModule = typeof nodePty & {
default?: Partial<typeof nodePty>;
};
type KillablePtyHandle = PtyHandle & {
kill?: (signal?: string) => void;
};
type PtyRun = {
output: () => string;
write: (data: string, opts?: { delay?: boolean }) => Promise<void>;
waitForOutput: (needle: string, timeoutMs?: number) => Promise<string>;
waitForExit: (timeoutMs?: number) => Promise<PtyExitEvent>;
dispose: () => void;
};
import { sleep, startPty, type PtyRun } from "./tui-pty-test-support.js";
type FixtureLogEntry = {
method: string;
@@ -35,146 +17,6 @@ const EXIT_TIMEOUT_MS = 4_000;
const TEST_TIMEOUT_MS = 5_000;
const STARTUP_TEST_TIMEOUT_MS = 10_000;
function resolveSpawnPty() {
const runtime = nodePty as NodePtyRuntimeModule;
if (typeof runtime.spawn === "function") {
return runtime.spawn;
}
if (typeof runtime.default?.spawn === "function") {
return runtime.default.spawn;
}
throw new TypeError("@lydell/node-pty spawn export is unavailable");
}
const spawnPty = resolveSpawnPty();
function waitFor<T>(params: {
timeoutMs: number;
read: () => T | null;
onTimeout: () => Error;
}): Promise<T> {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
let result: T | null;
try {
result = params.read();
} catch (error) {
reject(error);
return;
}
if (result !== null) {
resolve(result);
return;
}
if (Date.now() - start >= params.timeoutMs) {
reject(params.onTimeout());
return;
}
setTimeout(tick, 25);
};
tick();
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readPositiveIntegerEnv(name: string): number | null {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
function readPtyDimensionEnv(name: string, fallback: number): number {
return readPositiveIntegerEnv(name) ?? fallback;
}
async function writePtyInput(
pty: PtyHandle,
data: string,
opts: { delay?: boolean } = {},
): Promise<void> {
const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS");
if (!delayMs || opts.delay === false) {
pty.write(data);
return;
}
const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1;
for (let idx = 0; idx < data.length; idx += chunkSize) {
pty.write(data.slice(idx, idx + chunkSize));
if (idx + chunkSize < data.length) {
await sleep(delayMs);
}
}
}
function mirrorPtyOutput(data: string) {
const mirrorPath = process.env.OPENCLAW_TUI_PTY_MIRROR_PATH;
if (!mirrorPath) {
return;
}
appendFileSync(mirrorPath, data, "utf8");
}
function startPty(command: string, args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv }) {
let output = "";
let exitEvent: PtyExitEvent | null = null;
const pty = spawnPty(command, args, {
name: "xterm-256color",
cols: readPtyDimensionEnv("OPENCLAW_TUI_PTY_COLS", 100),
rows: readPtyDimensionEnv("OPENCLAW_TUI_PTY_ROWS", 30),
cwd: opts.cwd,
env: {
...process.env,
...opts.env,
TERM: "xterm-256color",
} as Record<string, string>,
}) as KillablePtyHandle;
pty.onData((data) => {
output += data;
mirrorPtyOutput(data);
});
pty.onExit((event) => {
exitEvent = event;
});
const run: PtyRun = {
output: () => output,
write: async (data, writeOpts) => await writePtyInput(pty, data, writeOpts),
waitForOutput: async (needle, timeoutMs = OUTPUT_TIMEOUT_MS) =>
await waitFor({
timeoutMs,
read: () => {
if (output.includes(needle)) {
return output;
}
if (exitEvent) {
throw new Error(
`PTY exited before ${JSON.stringify(needle)}\nexit=${JSON.stringify(exitEvent)}\n${output}`,
);
}
return null;
},
onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`),
}),
waitForExit: async (timeoutMs = EXIT_TIMEOUT_MS) =>
await waitFor({
timeoutMs,
read: () => exitEvent,
onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`),
}),
dispose: () => {
if (!exitEvent) {
pty.kill?.("SIGTERM");
}
},
};
activeRuns.push(run);
return run;
}
async function readFixtureLog(logPath: string): Promise<FixtureLogEntry[]> {
try {
const text = await readFile(logPath, "utf8");
@@ -479,6 +321,7 @@ async function startTuiFixture(opts: { env?: NodeJS.ProcessEnv } = {}) {
const scriptPath = await writeTuiPtyFixtureScript(tempDir);
const logPath = path.join(tempDir, "fixture-log.jsonl");
const run = startPty(process.execPath, ["--import", "tsx", scriptPath], {
activeRuns,
cwd: process.cwd(),
env: {
OPENCLAW_THEME: "dark",
@@ -486,6 +329,8 @@ async function startTuiFixture(opts: { env?: NodeJS.ProcessEnv } = {}) {
NO_COLOR: undefined,
...opts.env,
},
exitTimeoutMs: EXIT_TIMEOUT_MS,
outputTimeoutMs: OUTPUT_TIMEOUT_MS,
});
return {

View File

@@ -1,28 +1,10 @@
import { appendFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import * as nodePty from "@lydell/node-pty";
import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
type NodePtyRuntimeModule = typeof nodePty & {
default?: Partial<typeof nodePty>;
};
type KillablePtyHandle = PtyHandle & {
kill?: (signal?: string) => void;
};
type PtyRun = {
output: () => string;
write: (data: string, opts?: { delay?: boolean }) => Promise<void>;
waitForOutput: (needle: string, timeoutMs?: number) => Promise<string>;
waitForExit: (timeoutMs?: number) => Promise<PtyExitEvent>;
dispose: () => void;
};
import { startPty, waitFor, type PtyRun } from "./tui-pty-test-support.js";
type MockModelServer = {
baseUrl: string;
@@ -36,146 +18,6 @@ const LOCAL_OUTPUT_TIMEOUT_MS = 60_000;
const LOCAL_EXIT_TIMEOUT_MS = 4_000;
const LOCAL_TEST_TIMEOUT_MS = 90_000;
function resolveSpawnPty() {
const runtime = nodePty as NodePtyRuntimeModule;
if (typeof runtime.spawn === "function") {
return runtime.spawn;
}
if (typeof runtime.default?.spawn === "function") {
return runtime.default.spawn;
}
throw new TypeError("@lydell/node-pty spawn export is unavailable");
}
const spawnPty = resolveSpawnPty();
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readPositiveIntegerEnv(name: string): number | null {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
function readPtyDimensionEnv(name: string, fallback: number): number {
return readPositiveIntegerEnv(name) ?? fallback;
}
async function writePtyInput(
pty: PtyHandle,
data: string,
opts: { delay?: boolean } = {},
): Promise<void> {
const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS");
if (!delayMs || opts.delay === false) {
pty.write(data);
return;
}
const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1;
for (let idx = 0; idx < data.length; idx += chunkSize) {
pty.write(data.slice(idx, idx + chunkSize));
if (idx + chunkSize < data.length) {
await sleep(delayMs);
}
}
}
function waitFor<T>(params: {
timeoutMs: number;
read: () => T | null;
onTimeout: () => Error;
}): Promise<T> {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
let result: T | null;
try {
result = params.read();
} catch (error) {
reject(error);
return;
}
if (result !== null) {
resolve(result);
return;
}
if (Date.now() - start >= params.timeoutMs) {
reject(params.onTimeout());
return;
}
setTimeout(tick, 25);
};
tick();
});
}
function mirrorPtyOutput(data: string) {
const mirrorPath = process.env.OPENCLAW_TUI_PTY_MIRROR_PATH;
if (!mirrorPath) {
return;
}
appendFileSync(mirrorPath, data, "utf8");
}
function startPty(command: string, args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv }) {
let output = "";
let exitEvent: PtyExitEvent | null = null;
const pty = spawnPty(command, args, {
name: "xterm-256color",
cols: readPtyDimensionEnv("OPENCLAW_TUI_PTY_COLS", 100),
rows: readPtyDimensionEnv("OPENCLAW_TUI_PTY_ROWS", 30),
cwd: opts.cwd,
env: {
...process.env,
...opts.env,
TERM: "xterm-256color",
} as Record<string, string>,
}) as KillablePtyHandle;
pty.onData((data) => {
output += data;
mirrorPtyOutput(data);
});
pty.onExit((event) => {
exitEvent = event;
});
const run: PtyRun = {
output: () => output,
write: async (data, writeOpts) => await writePtyInput(pty, data, writeOpts),
waitForOutput: async (needle, timeoutMs = LOCAL_OUTPUT_TIMEOUT_MS) =>
await waitFor({
timeoutMs,
read: () => {
if (output.includes(needle)) {
return output;
}
if (exitEvent) {
throw new Error(
`PTY exited before ${JSON.stringify(needle)}\nexit=${JSON.stringify(exitEvent)}\n${output}`,
);
}
return null;
},
onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`),
}),
waitForExit: async (timeoutMs = LOCAL_EXIT_TIMEOUT_MS) =>
await waitFor({
timeoutMs,
read: () => exitEvent,
onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`),
}),
dispose: () => {
if (!exitEvent) {
pty.kill?.("SIGTERM");
}
},
};
activeRuns.push(run);
return run;
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
@@ -368,6 +210,7 @@ async function startLocalModeTui() {
]);
const run = startPty(process.execPath, ["scripts/run-node.mjs", "tui", "--local"], {
activeRuns,
cwd: process.cwd(),
env: {
HOME: homeDir,
@@ -381,6 +224,8 @@ async function startLocalModeTui() {
OPENCLAW_CODEX_DISCOVERY_LIVE: "0",
NO_COLOR: undefined,
},
exitTimeoutMs: LOCAL_EXIT_TIMEOUT_MS,
outputTimeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
});
return {

View File

@@ -0,0 +1,169 @@
import { appendFileSync } from "node:fs";
import * as nodePty from "@lydell/node-pty";
import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty";
type NodePtyRuntimeModule = typeof nodePty & {
default?: Partial<typeof nodePty>;
};
type KillablePtyHandle = PtyHandle & {
kill?: (signal?: string) => void;
};
export type PtyRun = {
output: () => string;
write: (data: string, opts?: { delay?: boolean }) => Promise<void>;
waitForOutput: (needle: string, timeoutMs?: number) => Promise<string>;
waitForExit: (timeoutMs?: number) => Promise<PtyExitEvent>;
dispose: () => void;
};
export function waitFor<T>(params: {
timeoutMs: number;
read: () => T | null;
onTimeout: () => Error;
}): Promise<T> {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
let result: T | null;
try {
result = params.read();
} catch (error) {
reject(error);
return;
}
if (result !== null) {
resolve(result);
return;
}
if (Date.now() - start >= params.timeoutMs) {
reject(params.onTimeout());
return;
}
setTimeout(tick, 25);
};
tick();
});
}
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveSpawnPty() {
const runtime = nodePty as NodePtyRuntimeModule;
if (typeof runtime.spawn === "function") {
return runtime.spawn;
}
if (typeof runtime.default?.spawn === "function") {
return runtime.default.spawn;
}
throw new TypeError("@lydell/node-pty spawn export is unavailable");
}
const spawnPty = resolveSpawnPty();
function readPositiveIntegerEnv(name: string): number | null {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
function readPtyDimensionEnv(name: string, fallback: number): number {
return readPositiveIntegerEnv(name) ?? fallback;
}
async function writePtyInput(
pty: PtyHandle,
data: string,
opts: { delay?: boolean } = {},
): Promise<void> {
const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS");
if (!delayMs || opts.delay === false) {
pty.write(data);
return;
}
const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1;
for (let idx = 0; idx < data.length; idx += chunkSize) {
pty.write(data.slice(idx, idx + chunkSize));
if (idx + chunkSize < data.length) {
await sleep(delayMs);
}
}
}
function mirrorPtyOutput(data: string) {
const mirrorPath = process.env.OPENCLAW_TUI_PTY_MIRROR_PATH;
if (!mirrorPath) {
return;
}
appendFileSync(mirrorPath, data, "utf8");
}
export function startPty(
command: string,
args: string[],
opts: {
activeRuns?: PtyRun[];
cwd: string;
env: NodeJS.ProcessEnv;
exitTimeoutMs: number;
outputTimeoutMs: number;
},
) {
let output = "";
let exitEvent: PtyExitEvent | null = null;
const pty = spawnPty(command, args, {
name: "xterm-256color",
cols: readPtyDimensionEnv("OPENCLAW_TUI_PTY_COLS", 100),
rows: readPtyDimensionEnv("OPENCLAW_TUI_PTY_ROWS", 30),
cwd: opts.cwd,
env: {
...process.env,
...opts.env,
TERM: "xterm-256color",
} as Record<string, string>,
}) as KillablePtyHandle;
pty.onData((data) => {
output += data;
mirrorPtyOutput(data);
});
pty.onExit((event) => {
exitEvent = event;
});
const run: PtyRun = {
output: () => output,
write: async (data, writeOpts) => await writePtyInput(pty, data, writeOpts),
waitForOutput: async (needle, timeoutMs = opts.outputTimeoutMs) =>
await waitFor({
timeoutMs,
read: () => {
if (output.includes(needle)) {
return output;
}
if (exitEvent) {
throw new Error(
`PTY exited before ${JSON.stringify(needle)}\nexit=${JSON.stringify(exitEvent)}\n${output}`,
);
}
return null;
},
onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`),
}),
waitForExit: async (timeoutMs = opts.exitTimeoutMs) =>
await waitFor({
timeoutMs,
read: () => exitEvent,
onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`),
}),
dispose: () => {
if (!exitEvent) {
pty.kill?.("SIGTERM");
}
},
};
opts.activeRuns?.push(run);
return run;
}