index.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /**
  2. * Shared subprocess harness for keyless example smokes that boot a real
  3. * `cordis.yml` through an app bin and Cordis Loader.
  4. *
  5. * It also owns the mode-aware launch resolver every example subprocess harness shares
  6. * ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the
  7. * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths`
  8. * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an
  9. * installed consumer does, while Node type-strips relative example-local TypeScript plugins).
  10. *
  11. * @module @deepseek-ai/dsh-loader-smoke
  12. */
  13. import { mkdtemp, rm } from 'node:fs/promises'
  14. import { tmpdir } from 'node:os'
  15. import { join } from 'node:path'
  16. import { execa } from 'execa'
  17. const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
  18. /** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
  19. export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
  20. /** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
  21. export type ExampleMode = 'src' | 'lib'
  22. /** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */
  23. export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
  24. /**
  25. * Parse an {@link ExampleMode} from a raw string, defaulting to `src` when absent so an unset
  26. * environment reproduces the dev/tsx behavior. Throws on any other value rather than silently
  27. * falling back, so a typo in a gate's env fails loud.
  28. * @param raw - the raw value; defaults to `process.env.DSH_EXAMPLE_MODE`.
  29. * @returns the validated mode.
  30. */
  31. export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE_MODE_ENV]): ExampleMode {
  32. switch (raw) {
  33. case undefined:
  34. case '':
  35. case 'src':
  36. return 'src'
  37. case 'lib':
  38. return 'lib'
  39. default:
  40. throw new Error(`${EXAMPLE_MODE_ENV} must be 'src' or 'lib', got ${JSON.stringify(raw)}.`)
  41. }
  42. }
  43. /** Inputs to {@link resolveExampleLaunch}. */
  44. export interface ExampleLaunchOptions {
  45. /** Absolute path to the example bin's TypeScript source entry (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
  46. readonly srcBin: string
  47. /** Explicit plain-Node entry for `lib` mode; test fixtures may point this at Node-type-strippable TypeScript. */
  48. readonly libBin?: string | undefined
  49. /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */
  50. readonly configArgs?: readonly string[]
  51. /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */
  52. readonly mode?: ExampleMode
  53. /** Absolute repo tsconfig whose `paths` map resolves unbuilt workspace imports. Required in `src` mode, ignored in `lib`. */
  54. readonly tsconfigPath?: string
  55. /** Extra environment entries the mode-specific ones layer over; the caller then merges the result over `process.env`. */
  56. readonly env?: NodeJS.ProcessEnv
  57. }
  58. /** The resolved spawn: `spawn(command, args, { env: { ...process.env, ...env } })`. */
  59. export interface ExampleLaunch {
  60. /** The executable to spawn — always the current Node binary. */
  61. readonly command: string
  62. /** Node flags, the resolved bin, then the caller's `configArgs`. */
  63. readonly args: string[]
  64. /** Mode-specific environment (`TSX_TSCONFIG_PATH` in `src`, nothing added in `lib`) layered over the caller's `env`. */
  65. readonly env: NodeJS.ProcessEnv
  66. }
  67. /** Derive the built-lib bin (`<pkg>/lib/<name>.js`) from a source bin (`<pkg>/src/<name>.ts`). */
  68. function toLibBin(srcBin: string): string {
  69. const markerLength = '/src/'.length
  70. const cut = Math.max(srcBin.lastIndexOf('/src/'), srcBin.lastIndexOf('\\src\\'))
  71. if (cut === -1) {
  72. throw new Error(`resolveExampleLaunch: expected a "/src/" segment or Windows equivalent in bin path ${JSON.stringify(srcBin)}.`)
  73. }
  74. const separator = srcBin.slice(cut, cut + 1)
  75. const tail = srcBin.slice(cut + markerLength).replace(/\.ts$/, '.js')
  76. return `${srcBin.slice(0, cut)}${separator}lib${separator}${tail}`
  77. }
  78. /**
  79. * Resolve how to spawn an example bin in the selected mode.
  80. *
  81. * `src` yields `node --import <tsx> <srcBin> <configArgs>` with `TSX_TSCONFIG_PATH` set so the
  82. * tsconfig `paths` map resolves workspace imports to source. `lib` yields
  83. * `node <libBin> <configArgs>` under plain Node with no tsx and no paths map, so
  84. * bare package plugins resolve through real package `exports` into built `lib/`; relative example-local
  85. * TypeScript plugins remain source files loaded through Node's built-in type stripping. Bare resolution
  86. * requires the config to live below a workspace that declares its `cordis.yml` package dependencies.
  87. *
  88. * @param options - the source bin, config arguments, mode, and environment.
  89. * @returns the command, argument vector, and mode-specific environment to spawn with.
  90. */
  91. export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch {
  92. const mode = options.mode ?? resolveExampleMode()
  93. const configArgs = options.configArgs ?? []
  94. const env: NodeJS.ProcessEnv = { ...options.env }
  95. if (mode === 'src') {
  96. if (options.tsconfigPath === undefined) {
  97. throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.")
  98. }
  99. const tsxLoader = import.meta.resolve('tsx')
  100. env.TSX_TSCONFIG_PATH = options.tsconfigPath
  101. return { command: process.execPath, args: ['--import', tsxLoader, options.srcBin, ...configArgs], env }
  102. }
  103. return { command: process.execPath, args: [options.libBin ?? toLibBin(options.srcBin), ...configArgs], env }
  104. }
  105. /** Inputs that vary between real-Loader example smokes. */
  106. export interface LoaderSmokeOptions {
  107. /** Human-readable example name used in failure diagnostics. */
  108. readonly label: string
  109. /** Prefix for the isolated temporary process cwd. */
  110. readonly tempDirPrefix: string
  111. /** Absolute app-bin source path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
  112. readonly binScript: string
  113. /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
  114. readonly libBinScript?: string | undefined
  115. /** Absolute real Loader config path, passed as the sole bin argument by default. */
  116. readonly configPath: string
  117. /** Complete argv after the bin path; overrides the default `[configPath]`. */
  118. readonly binArgs?: readonly string[]
  119. /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */
  120. readonly tsconfigPath: string
  121. /** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */
  122. readonly mode?: ExampleMode
  123. /** Environment overrides layered over the parent and isolated DSH homes. */
  124. readonly env?: Readonly<NodeJS.ProcessEnv>
  125. /** Process deadline override for harness tests. */
  126. readonly processTimeoutMs?: number
  127. /** Optional world-state setup run in the isolated cwd before process start. */
  128. readonly prepare?: (cwd: string) => Promise<void> | void
  129. /** Optional world-state assertion run in the isolated cwd before cleanup. */
  130. readonly inspect?: (cwd: string) => Promise<void> | void
  131. }
  132. /** Captured output from a Loader smoke that exited successfully. */
  133. export interface LoaderSmokeResult {
  134. /** Complete stdout after clean exit. */
  135. readonly stdout: string
  136. /** Complete stderr after clean exit. */
  137. readonly stderr: string
  138. }
  139. /**
  140. * Boot one real Loader tree from an isolated cwd, close stdin immediately, and
  141. * await a clean exit. The helper owns process kill and temp-directory cleanup on
  142. * every outcome, and picks src/lib via {@link resolveExampleLaunch}.
  143. * @param options - example paths, mode, environment, and diagnostic identity.
  144. * @returns captured stdout and stderr after a zero exit.
  145. */
  146. export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
  147. const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
  148. const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
  149. try {
  150. await options.prepare?.(cwd)
  151. const launch = resolveExampleLaunch({
  152. srcBin: options.binScript,
  153. libBin: options.libBinScript,
  154. configArgs: options.binArgs ?? [options.configPath],
  155. ...options.mode !== undefined ? { mode: options.mode } : {},
  156. tsconfigPath: options.tsconfigPath,
  157. env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
  158. })
  159. // `input: ''` writes nothing and closes stdin — the fixture-visible
  160. // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
  161. // deadline, and nonzero exits into independent result fields, so the
  162. // diagnostics below embed both streams on every failure.
  163. const result = await execa(launch.command, launch.args, {
  164. cwd,
  165. env: launch.env,
  166. input: '',
  167. timeout: processTimeoutMs,
  168. killSignal: 'SIGKILL',
  169. reject: false,
  170. stripFinalNewline: false,
  171. })
  172. if (result.timedOut) {
  173. throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  174. }
  175. if (result.failed) {
  176. throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  177. }
  178. await options.inspect?.(cwd)
  179. return { stdout: result.stdout, stderr: result.stderr }
  180. } finally {
  181. await rm(cwd, { recursive: true, force: true })
  182. }
  183. }