pty-harness.ts 8.7 KB

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