Files
deepseek-harness/examples/tui-agent/tests/pty-harness.ts
T
Tianyi Cui c4647a8609 test: adopt execa for hand-rolled subprocess plumbing, parseArgs for llm-mock-server CLI, vi.waitFor for poll loops
Implements the execa Agent Note's four sub-changes:
- execa (root devDep + loader-smoke dep) replaces the hand-rolled
  spawn-collect-timeout choreography in loader-smoke, apps/cli and
  cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker
  built-lib e2e, the tui pty-harness outer collector, the jsonrpc
  keyless smoke, and crash-recovery's child spawn. Genuinely custom
  parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's
  line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint.
  The two loader-smoke /* v8 ignore */ OS-error branches are gone.
- llm-mock-server CLI tokenizes via node:util parseArgs; numeric
  coercion/bounds/cross-option constraints stay manual; pinned
  error-message tests updated to the parseArgs texts.
- both loadRootEnv copies in apps/web/tests are deleted: the owning
  vitest configs (web unconditionally, snapshot in record mode)
  already load the repo-root .env before these files run.
- the four poll loops (acp-snapshot harness waits + crash-recovery
  waitForFile) ride vi.waitFor with explicit {interval, timeout}.
2026-07-26 22:29:41 +08:00

215 lines
7.7 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execa } from 'execa'
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const POSIX_PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
env.update({"COLUMNS": "100", "LINES": "30"})
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
# banner to the per-letter gradient (one SGR per letter), breaking literal
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
env.pop("COLORTERM", None)
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + float(timeout_seconds)
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n")
sys.exit(124)
actual_exit = os.waitstatus_to_exitcode(status)
if actual_exit != int(expected_exit):
sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n")
sys.exit(125)
`
/** One terminal action sent after its marker has rendered. */
interface TuiPtyAction {
readonly waitFor: string
readonly send: string
}
/** Inputs for a keyless real-Loader TUI process smoke. */
export interface TuiPtySmokeOptions {
readonly label: string
readonly tempDirPrefix: string
readonly binScript: string
/** Config argument; ignored when {@link configArgs} is set. */
readonly configPath?: string
/** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */
readonly configArgs?: readonly string[]
readonly tsconfigPath: string
readonly actions?: readonly TuiPtyAction[]
readonly env?: Readonly<NodeJS.ProcessEnv>
readonly expectedExitCode?: number
readonly timeoutMs?: number
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
readonly prepare?: (cwd: string) => Promise<void>
/** Inspect the workspace after a passing run, before the temp dir is removed. */
readonly inspect?: (cwd: string) => Promise<void>
}
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
}
async function runPosixPtySmoke(
launch: ExampleLaunch,
cwd: string,
options: TuiPtySmokeOptions,
timeoutMs: number,
): Promise<string> {
// The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
// only backstops a wedged python3 process itself.
const result = await execa('python3', [
'-c',
POSIX_PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
JSON.stringify(options.actions ?? []),
String(options.expectedExitCode ?? 0),
String(timeoutMs / 1_000),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return result.stdout
}
async function runWindowsPtySmoke(
launch: ExampleLaunch,
cwd: string,
options: TuiPtySmokeOptions,
timeoutMs: number,
): Promise<string> {
const pty = await import('node-pty')
return await new Promise((resolve, reject) => {
const actions = options.actions ?? []
const expectedExitCode = options.expectedExitCode ?? 0
let output = ''
let actionIndex = 0
let timedOut = false
const terminal = pty.spawn(launch.command, launch.args, {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd,
env: definedEnv({
...process.env,
...launch.env,
// Match the POSIX driver: no COLORTERM, so the banner never takes the
// truecolor gradient path under a developer's shell.
COLORTERM: undefined,
COLUMNS: '100',
LINES: '30',
}),
})
const timer = setTimeout(() => {
timedOut = true
terminal.kill()
}, timeoutMs)
terminal.onData((chunk) => {
output += chunk
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
terminal.write(actions[actionIndex]!.send)
actionIndex += 1
}
})
terminal.onExit(({ exitCode, signal }) => {
clearTimeout(timer)
if (timedOut) {
reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
} else if (actionIndex !== actions.length) {
reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
} else if (exitCode !== expectedExitCode) {
reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
} else {
resolve(output)
}
})
})
}
/**
* Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
* marker-gated input, and return captured bytes after the expected process exit.
* @param options - launch paths, environment, actions, and expected exit code.
* @returns complete pseudo-terminal output.
*/
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const timeoutMs = options.timeoutMs ?? 25_000
try {
await options.prepare?.(cwd)
const launch = resolveExampleLaunch({
srcBin: options.binScript,
// `configPath` is the dsh `--config <path>` tree override; `configArgs`
// is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
configArgs: options.configArgs !== undefined
? [...options.configArgs]
/* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
: options.configPath !== undefined ? ['--config', options.configPath] : [],
tsconfigPath: options.tsconfigPath,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
...options.env,
},
})
const output = process.platform === 'win32'
? await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
: await runPosixPtySmoke(launch, cwd, options, timeoutMs)
// Inspect the workspace before `finally` removes it (e.g. the session log).
await options.inspect?.(cwd)
return output
} finally {
await rm(cwd, { recursive: true, force: true })
}
}