pty-harness.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { mkdtemp, rm } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { execa } from 'execa'
  5. import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  6. const POSIX_PTY_DRIVER = String.raw`
  7. import errno, json, os, pty, select, signal, sys, time
  8. node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
  9. env = os.environ.copy()
  10. env.update(json.loads(launch_env_json))
  11. env.update({"COLUMNS": "100", "LINES": "30"})
  12. # Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
  13. # banner to the per-letter gradient (one SGR per letter), breaking literal
  14. # DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
  15. env.pop("COLORTERM", None)
  16. actions = json.loads(actions_json)
  17. pid, fd = pty.fork()
  18. if pid == 0:
  19. os.chdir(cwd)
  20. os.execvpe(node, [node, *json.loads(launch_args_json)], env)
  21. output = bytearray()
  22. action_index = 0
  23. deadline = time.monotonic() + float(timeout_seconds)
  24. status = None
  25. while time.monotonic() < deadline:
  26. ready, _, _ = select.select([fd], [], [], 0.05)
  27. if ready:
  28. try:
  29. chunk = os.read(fd, 65536)
  30. except OSError as error:
  31. if error.errno != errno.EIO:
  32. raise
  33. chunk = b""
  34. if chunk:
  35. output.extend(chunk)
  36. while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
  37. os.write(fd, actions[action_index]["send"].encode())
  38. action_index += 1
  39. waited, candidate = os.waitpid(pid, os.WNOHANG)
  40. if waited == pid:
  41. status = candidate
  42. break
  43. if status is None:
  44. os.kill(pid, signal.SIGKILL)
  45. _, status = os.waitpid(pid, 0)
  46. sys.stdout.buffer.write(output)
  47. if action_index != len(actions):
  48. sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n")
  49. sys.exit(124)
  50. actual_exit = os.waitstatus_to_exitcode(status)
  51. if actual_exit != int(expected_exit):
  52. sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n")
  53. sys.exit(125)
  54. `
  55. /** One terminal action sent after its marker has rendered. */
  56. interface TuiPtyAction {
  57. readonly waitFor: string
  58. readonly send: string
  59. }
  60. /** Inputs for a keyless real-Loader TUI process smoke. */
  61. export interface TuiPtySmokeOptions {
  62. readonly label: string
  63. readonly tempDirPrefix: string
  64. readonly binScript: string
  65. /** Config argument; ignored when {@link configArgs} is set. */
  66. readonly configPath?: string
  67. /** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */
  68. readonly configArgs?: readonly string[]
  69. readonly tsconfigPath: string
  70. readonly actions?: readonly TuiPtyAction[]
  71. readonly env?: Readonly<NodeJS.ProcessEnv>
  72. readonly expectedExitCode?: number
  73. readonly timeoutMs?: number
  74. /** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
  75. readonly prepare?: (cwd: string) => Promise<void>
  76. /** Inspect the workspace after a passing run, before the temp dir is removed. */
  77. readonly inspect?: (cwd: string) => Promise<void>
  78. }
  79. function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
  80. return Object.fromEntries(
  81. Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
  82. )
  83. }
  84. async function runPosixPtySmoke(
  85. launch: ExampleLaunch,
  86. cwd: string,
  87. options: TuiPtySmokeOptions,
  88. timeoutMs: number,
  89. ): Promise<string> {
  90. // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
  91. // only backstops a wedged python3 process itself.
  92. const result = await execa('python3', [
  93. '-c',
  94. POSIX_PTY_DRIVER,
  95. launch.command,
  96. JSON.stringify(launch.args),
  97. JSON.stringify(launch.env),
  98. cwd,
  99. JSON.stringify(options.actions ?? []),
  100. String(options.expectedExitCode ?? 0),
  101. String(timeoutMs / 1_000),
  102. ], {
  103. stdin: 'ignore',
  104. timeout: timeoutMs + 5_000,
  105. killSignal: 'SIGKILL',
  106. reject: false,
  107. stripFinalNewline: false,
  108. })
  109. if (result.timedOut) {
  110. throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  111. }
  112. if (result.failed) {
  113. throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  114. }
  115. return result.stdout
  116. }
  117. async function runWindowsPtySmoke(
  118. launch: ExampleLaunch,
  119. cwd: string,
  120. options: TuiPtySmokeOptions,
  121. timeoutMs: number,
  122. ): Promise<string> {
  123. const pty = await import('node-pty')
  124. return await new Promise((resolve, reject) => {
  125. const actions = options.actions ?? []
  126. const expectedExitCode = options.expectedExitCode ?? 0
  127. let output = ''
  128. let actionIndex = 0
  129. let timedOut = false
  130. const terminal = pty.spawn(launch.command, launch.args, {
  131. name: 'xterm-256color',
  132. cols: 100,
  133. rows: 30,
  134. cwd,
  135. env: definedEnv({
  136. ...process.env,
  137. ...launch.env,
  138. // Match the POSIX driver: no COLORTERM, so the banner never takes the
  139. // truecolor gradient path under a developer's shell.
  140. COLORTERM: undefined,
  141. COLUMNS: '100',
  142. LINES: '30',
  143. }),
  144. })
  145. const timer = setTimeout(() => {
  146. timedOut = true
  147. terminal.kill()
  148. }, timeoutMs)
  149. terminal.onData((chunk) => {
  150. output += chunk
  151. while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
  152. terminal.write(actions[actionIndex]!.send)
  153. actionIndex += 1
  154. }
  155. })
  156. terminal.onExit(({ exitCode, signal }) => {
  157. clearTimeout(timer)
  158. if (timedOut) {
  159. reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
  160. } else if (actionIndex !== actions.length) {
  161. reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
  162. } else if (exitCode !== expectedExitCode) {
  163. reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
  164. } else {
  165. resolve(output)
  166. }
  167. })
  168. })
  169. }
  170. /**
  171. * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
  172. * marker-gated input, and return captured bytes after the expected process exit.
  173. * @param options - launch paths, environment, actions, and expected exit code.
  174. * @returns complete pseudo-terminal output.
  175. */
  176. export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
  177. const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
  178. const timeoutMs = options.timeoutMs ?? 25_000
  179. try {
  180. await options.prepare?.(cwd)
  181. const launch = resolveExampleLaunch({
  182. srcBin: options.binScript,
  183. // `configPath` is the dsh `--config <path>` tree override; `configArgs`
  184. // is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
  185. configArgs: options.configArgs !== undefined
  186. ? [...options.configArgs]
  187. /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
  188. : options.configPath !== undefined ? ['--config', options.configPath] : [],
  189. tsconfigPath: options.tsconfigPath,
  190. env: {
  191. DSH_HOME: join(cwd, '.dsh'),
  192. DSH_AGENTS_HOME: join(cwd, '.agents'),
  193. ...options.env,
  194. },
  195. })
  196. const output = process.platform === 'win32'
  197. ? await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
  198. : await runPosixPtySmoke(launch, cwd, options, timeoutMs)
  199. // Inspect the workspace before `finally` removes it (e.g. the session log).
  200. await options.inspect?.(cwd)
  201. return output
  202. } finally {
  203. await rm(cwd, { recursive: true, force: true })
  204. }
  205. }