From 7ebd600297a31cfec59158348bd5e91070fadcc1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 00:02:52 +0200 Subject: [PATCH] test: dedupe tui pty helpers --- src/tui/tui-pty-harness.e2e.test.ts | 163 +-------------------------- src/tui/tui-pty-local.e2e.test.ts | 163 +-------------------------- src/tui/tui-pty-test-support.ts | 169 ++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 318 deletions(-) create mode 100644 src/tui/tui-pty-test-support.ts diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index f73989f717e..c5c7663c2ec 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -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; -}; - -type KillablePtyHandle = PtyHandle & { - kill?: (signal?: string) => void; -}; - -type PtyRun = { - output: () => string; - write: (data: string, opts?: { delay?: boolean }) => Promise; - waitForOutput: (needle: string, timeoutMs?: number) => Promise; - waitForExit: (timeoutMs?: number) => Promise; - 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(params: { - timeoutMs: number; - read: () => T | null; - onTimeout: () => Error; -}): Promise { - 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 { - 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, - }) 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 { 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 { diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index d3a96ab33e5..9c5a7f988d8 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -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; -}; - -type KillablePtyHandle = PtyHandle & { - kill?: (signal?: string) => void; -}; - -type PtyRun = { - output: () => string; - write: (data: string, opts?: { delay?: boolean }) => Promise; - waitForOutput: (needle: string, timeoutMs?: number) => Promise; - waitForExit: (timeoutMs?: number) => Promise; - 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 { - 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(params: { - timeoutMs: number; - read: () => T | null; - onTimeout: () => Error; -}): Promise { - 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, - }) 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 { 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 { diff --git a/src/tui/tui-pty-test-support.ts b/src/tui/tui-pty-test-support.ts new file mode 100644 index 00000000000..e3d207ff78d --- /dev/null +++ b/src/tui/tui-pty-test-support.ts @@ -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; +}; + +type KillablePtyHandle = PtyHandle & { + kill?: (signal?: string) => void; +}; + +export type PtyRun = { + output: () => string; + write: (data: string, opts?: { delay?: boolean }) => Promise; + waitForOutput: (needle: string, timeoutMs?: number) => Promise; + waitForExit: (timeoutMs?: number) => Promise; + dispose: () => void; +}; + +export function waitFor(params: { + timeoutMs: number; + read: () => T | null; + onTimeout: () => Error; +}): Promise { + 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 { + 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, + }) 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; +}