| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- 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 })
- }
- }
|