pty-harness.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import { spawn } from 'node:child_process'
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  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. return await new Promise((resolve, reject) => {
  91. const child = spawn('python3', [
  92. '-c',
  93. POSIX_PTY_DRIVER,
  94. launch.command,
  95. JSON.stringify(launch.args),
  96. JSON.stringify(launch.env),
  97. cwd,
  98. JSON.stringify(options.actions ?? []),
  99. String(options.expectedExitCode ?? 0),
  100. String(timeoutMs / 1_000),
  101. ], { stdio: ['ignore', 'pipe', 'pipe'] })
  102. let stdout = ''
  103. let stderr = ''
  104. child.stdout.setEncoding('utf8')
  105. child.stdout.on('data', (chunk: string) => { stdout += chunk })
  106. child.stderr.setEncoding('utf8')
  107. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  108. const timer = setTimeout(() => {
  109. child.kill('SIGKILL')
  110. reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
  111. }, timeoutMs + 5_000)
  112. child.once('error', (error) => { clearTimeout(timer); reject(error) })
  113. child.once('exit', (code) => {
  114. clearTimeout(timer)
  115. if (code === 0) resolve(stdout)
  116. else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
  117. })
  118. })
  119. }
  120. async function runWindowsPtySmoke(
  121. launch: ExampleLaunch,
  122. cwd: string,
  123. options: TuiPtySmokeOptions,
  124. timeoutMs: number,
  125. ): Promise<string> {
  126. const pty = await import('node-pty')
  127. return await new Promise((resolve, reject) => {
  128. const actions = options.actions ?? []
  129. const expectedExitCode = options.expectedExitCode ?? 0
  130. let output = ''
  131. let actionIndex = 0
  132. let timedOut = false
  133. const terminal = pty.spawn(launch.command, launch.args, {
  134. name: 'xterm-256color',
  135. cols: 100,
  136. rows: 30,
  137. cwd,
  138. env: definedEnv({
  139. ...process.env,
  140. ...launch.env,
  141. // Match the POSIX driver: no COLORTERM, so the banner never takes the
  142. // truecolor gradient path under a developer's shell.
  143. COLORTERM: undefined,
  144. COLUMNS: '100',
  145. LINES: '30',
  146. }),
  147. })
  148. const timer = setTimeout(() => {
  149. timedOut = true
  150. terminal.kill()
  151. }, timeoutMs)
  152. terminal.onData((chunk) => {
  153. output += chunk
  154. while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
  155. terminal.write(actions[actionIndex]!.send)
  156. actionIndex += 1
  157. }
  158. })
  159. terminal.onExit(({ exitCode, signal }) => {
  160. clearTimeout(timer)
  161. if (timedOut) {
  162. reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
  163. } else if (actionIndex !== actions.length) {
  164. reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
  165. } else if (exitCode !== expectedExitCode) {
  166. reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
  167. } else {
  168. resolve(output)
  169. }
  170. })
  171. })
  172. }
  173. /**
  174. * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
  175. * marker-gated input, and return captured bytes after the expected process exit.
  176. * @param options - launch paths, environment, actions, and expected exit code.
  177. * @returns complete pseudo-terminal output.
  178. */
  179. export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
  180. const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
  181. const timeoutMs = options.timeoutMs ?? 25_000
  182. try {
  183. await options.prepare?.(cwd)
  184. const launch = resolveExampleLaunch({
  185. srcBin: options.binScript,
  186. configArgs: options.configArgs !== undefined
  187. ? [...options.configArgs]
  188. /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
  189. : [options.configPath ?? './cordis.yml'],
  190. tsconfigPath: options.tsconfigPath,
  191. env: {
  192. DSH_HOME: join(cwd, '.dsh'),
  193. DSH_AGENTS_HOME: join(cwd, '.agents'),
  194. ...options.env,
  195. },
  196. })
  197. const output = process.platform === 'win32'
  198. ? await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
  199. : await runPosixPtySmoke(launch, cwd, options, timeoutMs)
  200. // Inspect the workspace before `finally` removes it (e.g. the session log).
  201. await options.inspect?.(cwd)
  202. return output
  203. } finally {
  204. await rm(cwd, { recursive: true, force: true })
  205. }
  206. }