pty-harness.ts 9.8 KB

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