pty-harness.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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. actions = json.loads(actions_json)
  13. pid, fd = pty.fork()
  14. if pid == 0:
  15. os.chdir(cwd)
  16. os.execvpe(node, [node, *json.loads(launch_args_json)], env)
  17. output = bytearray()
  18. action_index = 0
  19. deadline = time.monotonic() + float(timeout_seconds)
  20. status = None
  21. while time.monotonic() < deadline:
  22. ready, _, _ = select.select([fd], [], [], 0.05)
  23. if ready:
  24. try:
  25. chunk = os.read(fd, 65536)
  26. except OSError as error:
  27. if error.errno != errno.EIO:
  28. raise
  29. chunk = b""
  30. if chunk:
  31. output.extend(chunk)
  32. while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
  33. os.write(fd, actions[action_index]["send"].encode())
  34. action_index += 1
  35. waited, candidate = os.waitpid(pid, os.WNOHANG)
  36. if waited == pid:
  37. status = candidate
  38. break
  39. if status is None:
  40. os.kill(pid, signal.SIGKILL)
  41. _, status = os.waitpid(pid, 0)
  42. sys.stdout.buffer.write(output)
  43. if action_index != len(actions):
  44. sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n")
  45. sys.exit(124)
  46. actual_exit = os.waitstatus_to_exitcode(status)
  47. if actual_exit != int(expected_exit):
  48. sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n")
  49. sys.exit(125)
  50. `
  51. /** One terminal action sent after its marker has rendered. */
  52. interface TuiPtyAction {
  53. readonly waitFor: string
  54. readonly send: string
  55. }
  56. /** Inputs for a keyless real-Loader TUI process smoke. */
  57. export interface TuiPtySmokeOptions {
  58. readonly label: string
  59. readonly tempDirPrefix: string
  60. readonly binScript: string
  61. readonly configPath: string
  62. readonly tsconfigPath: string
  63. readonly actions?: readonly TuiPtyAction[]
  64. readonly env?: Readonly<NodeJS.ProcessEnv>
  65. readonly expectedExitCode?: number
  66. readonly timeoutMs?: number
  67. }
  68. function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
  69. return Object.fromEntries(
  70. Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
  71. )
  72. }
  73. async function runPosixPtySmoke(
  74. launch: ExampleLaunch,
  75. cwd: string,
  76. options: TuiPtySmokeOptions,
  77. timeoutMs: number,
  78. ): Promise<string> {
  79. return await new Promise((resolve, reject) => {
  80. const child = spawn('python3', [
  81. '-c',
  82. POSIX_PTY_DRIVER,
  83. launch.command,
  84. JSON.stringify(launch.args),
  85. JSON.stringify(launch.env),
  86. cwd,
  87. JSON.stringify(options.actions ?? []),
  88. String(options.expectedExitCode ?? 0),
  89. String(timeoutMs / 1_000),
  90. ], { stdio: ['ignore', 'pipe', 'pipe'] })
  91. let stdout = ''
  92. let stderr = ''
  93. child.stdout.setEncoding('utf8')
  94. child.stdout.on('data', (chunk: string) => { stdout += chunk })
  95. child.stderr.setEncoding('utf8')
  96. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  97. const timer = setTimeout(() => {
  98. child.kill('SIGKILL')
  99. reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
  100. }, timeoutMs + 5_000)
  101. child.once('error', (error) => { clearTimeout(timer); reject(error) })
  102. child.once('exit', (code) => {
  103. clearTimeout(timer)
  104. if (code === 0) resolve(stdout)
  105. else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
  106. })
  107. })
  108. }
  109. async function runWindowsPtySmoke(
  110. launch: ExampleLaunch,
  111. cwd: string,
  112. options: TuiPtySmokeOptions,
  113. timeoutMs: number,
  114. ): Promise<string> {
  115. const pty = await import('node-pty')
  116. return await new Promise((resolve, reject) => {
  117. const actions = options.actions ?? []
  118. const expectedExitCode = options.expectedExitCode ?? 0
  119. let output = ''
  120. let actionIndex = 0
  121. let timedOut = false
  122. const terminal = pty.spawn(launch.command, launch.args, {
  123. name: 'xterm-256color',
  124. cols: 100,
  125. rows: 30,
  126. cwd,
  127. env: definedEnv({
  128. ...process.env,
  129. ...launch.env,
  130. COLUMNS: '100',
  131. LINES: '30',
  132. }),
  133. })
  134. const timer = setTimeout(() => {
  135. timedOut = true
  136. terminal.kill()
  137. }, timeoutMs)
  138. terminal.onData((chunk) => {
  139. output += chunk
  140. while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
  141. terminal.write(actions[actionIndex]!.send)
  142. actionIndex += 1
  143. }
  144. })
  145. terminal.onExit(({ exitCode, signal }) => {
  146. clearTimeout(timer)
  147. if (timedOut) {
  148. reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
  149. } else if (actionIndex !== actions.length) {
  150. reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
  151. } else if (exitCode !== expectedExitCode) {
  152. reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
  153. } else {
  154. resolve(output)
  155. }
  156. })
  157. })
  158. }
  159. /**
  160. * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
  161. * marker-gated input, and return captured bytes after the expected process exit.
  162. * @param options - launch paths, environment, actions, and expected exit code.
  163. * @returns complete pseudo-terminal output.
  164. */
  165. export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
  166. const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
  167. const timeoutMs = options.timeoutMs ?? 25_000
  168. try {
  169. const launch = resolveExampleLaunch({
  170. srcBin: options.binScript,
  171. configArgs: [options.configPath],
  172. tsconfigPath: options.tsconfigPath,
  173. exposeInternals: true,
  174. env: {
  175. DSH_HOME: join(cwd, '.dsh'),
  176. DSH_AGENTS_HOME: join(cwd, '.agents'),
  177. ...options.env,
  178. },
  179. })
  180. if (process.platform === 'win32') {
  181. return await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
  182. }
  183. return await runPosixPtySmoke(launch, cwd, options, timeoutMs)
  184. } finally {
  185. await rm(cwd, { recursive: true, force: true })
  186. }
  187. }