mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 17:01:06 +00:00
test: dedupe tui pty helpers
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
169
src/tui/tui-pty-test-support.ts
Normal file
169
src/tui/tui-pty-test-support.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user