index.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * Shared subprocess harness for keyless example smokes that boot a real
  3. * `cordis.yml` through the stdio-agent bin and Cordis Loader.
  4. *
  5. * @module @deepseek-ai/dsh-loader-smoke
  6. */
  7. import { spawn } from 'node:child_process'
  8. import { mkdtemp, rm } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { fileURLToPath } from 'node:url'
  12. const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
  13. const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx'))
  14. /** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
  15. export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
  16. /** Inputs that vary between real-Loader example smokes. */
  17. export interface LoaderSmokeOptions {
  18. /** Human-readable example name used in failure diagnostics. */
  19. readonly label: string
  20. /** Prefix for the isolated temporary process cwd. */
  21. readonly tempDirPrefix: string
  22. /** Absolute stdio-agent bin path. */
  23. readonly binScript: string
  24. /** Absolute real Loader config path. */
  25. readonly configPath: string
  26. /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */
  27. readonly tsconfigPath: string
  28. /** Environment overrides layered over the parent and isolated DSH homes. */
  29. readonly env?: Readonly<NodeJS.ProcessEnv>
  30. /** Lines written to stdin before EOF; omitted means immediate EOF. */
  31. readonly stdinLines?: readonly string[]
  32. /** Process deadline override for harness tests. */
  33. readonly processTimeoutMs?: number
  34. }
  35. /** Captured output from a Loader smoke that exited successfully. */
  36. export interface LoaderSmokeResult {
  37. /** Complete stdout after clean exit. */
  38. readonly stdout: string
  39. /** Complete stderr after clean exit. */
  40. readonly stderr: string
  41. }
  42. /**
  43. * Boot one real Loader tree from an isolated cwd, write the requested stdin
  44. * script, close stdin, and await a clean exit. The helper owns process kill and
  45. * temp-directory cleanup on every outcome.
  46. * @param options - example paths, environment, stdin, and diagnostic identity.
  47. * @returns captured stdout and stderr after a zero exit.
  48. */
  49. export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
  50. const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
  51. const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
  52. try {
  53. return await new Promise((resolve, reject) => {
  54. const child = spawn(
  55. process.execPath,
  56. ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
  57. {
  58. cwd,
  59. env: {
  60. ...process.env,
  61. DSH_HOME: join(cwd, '.dsh'),
  62. DSH_AGENTS_HOME: join(cwd, '.agents'),
  63. ...options.env,
  64. TSX_TSCONFIG_PATH: options.tsconfigPath,
  65. },
  66. stdio: ['pipe', 'pipe', 'pipe'],
  67. },
  68. )
  69. let stdout = ''
  70. let stderr = ''
  71. let deferredFailure: Error | undefined
  72. child.stdout.setEncoding('utf8')
  73. child.stdout.on('data', (chunk: string) => { stdout += chunk })
  74. child.stderr.setEncoding('utf8')
  75. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  76. const timer = setTimeout(() => {
  77. deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
  78. child.kill('SIGKILL')
  79. }, processTimeoutMs)
  80. child.once('exit', (code) => {
  81. clearTimeout(timer)
  82. if (deferredFailure !== undefined) {
  83. reject(deferredFailure)
  84. } else if (code === 0) {
  85. resolve({ stdout, stderr })
  86. } else {
  87. reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
  88. }
  89. })
  90. // process.execPath and a just-created pipe make these OS-error paths
  91. // impractical to induce without replacing the boundary under test.
  92. /* v8 ignore start */
  93. child.once('error', (error) => {
  94. clearTimeout(timer)
  95. reject(new Error(`${options.label} failed to start: ${error.message}`))
  96. })
  97. child.stdin.once('error', (error) => {
  98. deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
  99. child.kill('SIGKILL')
  100. })
  101. /* v8 ignore stop */
  102. child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
  103. })
  104. } finally {
  105. await rm(cwd, { recursive: true, force: true })
  106. }
  107. }