startup.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * The one-shot app's ordinary command-line provider over a real Loader tree:
  3. * the task, exact Session identity, and output mode become injected runner
  4. * config, while help and interactive usage errors leave the consumer pending.
  5. */
  6. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { pathToFileURL } from 'node:url'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import Loader from '@deepseek-ai/cordis-plugin-loader'
  12. import Include from '@deepseek-ai/cordis-plugin-include'
  13. import { internals as cmdlineInternals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
  14. import { afterEach, describe, expect, it } from 'vitest'
  15. import {
  16. apply,
  17. HEADLESS_STARTUP_SERVICE,
  18. type HeadlessStartupValues,
  19. } from '../src/startup.ts'
  20. import { internals as startupInternals } from '../src/startup-internals.ts'
  21. /** What one boot of the fixture tree observed. */
  22. interface Observed {
  23. exits: number[]
  24. out: string
  25. err: string
  26. runnerConfig?: unknown
  27. }
  28. const disposers: (() => Promise<void>)[] = []
  29. /** The real process facts captured before any test substitutes them. */
  30. const originalInternals = { ...startupInternals }
  31. /** Fixture tree roots, removed after their booted tree has been disposed. */
  32. const tempDirs: string[] = []
  33. afterEach(async () => {
  34. for (const dispose of disposers.splice(0)) await dispose()
  35. for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
  36. cmdlineInternals.stdout = process.stdout
  37. cmdlineInternals.stderr = process.stderr
  38. startupInternals.stdinIsTty = () => process.stdin.isTTY
  39. startupInternals.stdout = process.stdout
  40. })
  41. /**
  42. * Mount the real provider over a runner stand-in.
  43. * @param args - the invocation's inner arguments.
  44. * @param options - process facts the provider reads.
  45. * @returns the resolved service value and observed runner/process effects.
  46. */
  47. async function bootStartup(
  48. args: string[],
  49. options: { stdinIsTty?: boolean } = {},
  50. ): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
  51. const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
  52. tempDirs.push(dir)
  53. const observed: Observed = { exits: [], out: '', err: '' }
  54. writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
  55. // Loader imports through Node's resolver, so this fixture delegates to the
  56. // source-plane plugin already imported by the test.
  57. writeFileSync(join(dir, 'startup.mjs'), `
  58. export const name = 'headless-startup'
  59. export const inject = ['cmdlineArgs']
  60. export const apply = ctx => globalThis.__headlessStartupApply(ctx)
  61. `)
  62. const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href
  63. writeFileSync(join(dir, 'cordis.yml'), [
  64. '- id: headless-runner',
  65. ` name: ${rowUrl}`,
  66. ` inject: [${HEADLESS_STARTUP_SERVICE}]`,
  67. ' config:',
  68. ' task: !!js ctx.headlessStartup.task',
  69. ' sessionId: !!js ctx.headlessStartup.sessionId',
  70. ' json: !!js ctx.headlessStartup.json',
  71. '- id: headless-startup',
  72. ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
  73. '',
  74. ].join('\n'))
  75. const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
  76. // Commander's own output keeps landing in `out` so existing assertions see
  77. // the full transcript, while `err` isolates what stderr actually carried.
  78. const observingErr = {
  79. write: (chunk: string) => {
  80. observed.out += chunk
  81. observed.err += chunk
  82. return true
  83. },
  84. }
  85. cmdlineInternals.stdout = observing
  86. cmdlineInternals.stderr = observingErr
  87. startupInternals.stdinIsTty = () => options.stdinIsTty === true
  88. startupInternals.stdout = observing
  89. const globals = globalThis as unknown as {
  90. __headlessStartupApply: typeof apply
  91. __headlessStartupObserved: Observed
  92. }
  93. globals.__headlessStartupApply = apply
  94. globals.__headlessStartupObserved = observed
  95. const ctx = new Context()
  96. await ctx.plugin(Loader)
  97. ctx.loader.builtins.include = Include
  98. provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
  99. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } })
  100. await ctx.loader.await()
  101. disposers.push(async () => { await ctx.fiber.dispose() })
  102. return {
  103. task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined,
  104. observed,
  105. }
  106. }
  107. describe('headless command-line provider', () => {
  108. it('joins the task positional into the runner config', async () => {
  109. const { task, observed } = await bootStartup(['run', 'the', 'tests'])
  110. expect(task).toEqual({ task: 'run the tests', sessionId: undefined, json: false })
  111. expect(observed.runnerConfig).toMatchObject({ task: 'run the tests', json: false })
  112. expect(observed.exits).toEqual([])
  113. })
  114. it('publishes the machine-readable output mode and the exact Session identity', async () => {
  115. const { task, observed } = await bootStartup(['--json', '--session-id', 'session-exact', 'do', 'it'])
  116. expect(task).toEqual({ task: 'do it', sessionId: 'session-exact', json: true })
  117. expect(observed.runnerConfig).toMatchObject({ task: 'do it', sessionId: 'session-exact', json: true })
  118. })
  119. it('keeps the stdin marker as the task so the runner reads the pipe', async () => {
  120. const { task } = await bootStartup(['-'], { stdinIsTty: false })
  121. expect(task).toEqual({ task: '-', sessionId: undefined, json: false })
  122. })
  123. it('defers an absent task to stdin when stdin is not a terminal', async () => {
  124. const { task, observed } = await bootStartup([], { stdinIsTty: false })
  125. expect(task).toEqual({ task: undefined, sessionId: undefined, json: false })
  126. expect(observed.runnerConfig).toMatchObject({ json: false })
  127. })
  128. it.each([{ args: [] as string[] }, { args: [' '] }])('rejects an interactive invocation with no task ($args)', async ({ args }) => {
  129. const { task, observed } = await bootStartup(args, { stdinIsTty: true })
  130. expect(observed.out).toContain('a task is required')
  131. expect(task).toBeUndefined()
  132. expect(observed.runnerConfig).toBeUndefined()
  133. expect(observed.exits).toEqual([1])
  134. })
  135. it('rejects an explicitly empty Session identity', async () => {
  136. const { task, observed } = await bootStartup(['--session-id', '', 'do', 'it'])
  137. expect(observed.out).toContain('--session-id requires a non-empty session id')
  138. expect(task).toBeUndefined()
  139. expect(observed.exits).toEqual([1])
  140. })
  141. it('keeps the caller-provided exact Session identity verbatim', async () => {
  142. const { task } = await bootStartup(['--session-id', ' session-x ', 'do', 'it'])
  143. expect(task).toEqual({ task: 'do it', sessionId: ' session-x ', json: false })
  144. })
  145. it('rejects a lone stdin marker mixed with other task words', async () => {
  146. const { task, observed } = await bootStartup(['-', 'do', 'it'])
  147. expect(observed.out).toContain('`-` must be the only task argument')
  148. expect(task).toBeUndefined()
  149. expect(observed.exits).toEqual([1])
  150. })
  151. it('writes the JSON error event for a --json usage error', async () => {
  152. const { task, observed } = await bootStartup(['--json'], { stdinIsTty: true })
  153. const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
  154. expect(first).toEqual({
  155. type: 'error',
  156. message: 'a task is required, for example: dsh --profile headless "run the tests"',
  157. })
  158. expect(task).toBeUndefined()
  159. expect(observed.err).toBe('')
  160. expect(observed.exits).toEqual([1])
  161. })
  162. it('writes the JSON error event for an empty Session identity in --json mode', async () => {
  163. const { observed } = await bootStartup(['--json', '--session-id', '', 'do', 'it'])
  164. const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
  165. expect(first.type).toBe('error')
  166. expect(first.message).toContain('--session-id requires a non-empty session id')
  167. expect(observed.err).toBe('')
  168. expect(observed.exits).toEqual([1])
  169. })
  170. it('writes the JSON error event for a commander grammar rejection in --json mode', async () => {
  171. const { task, observed } = await bootStartup(['--json', '--bogus', 'do', 'it'])
  172. const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
  173. expect(first).toEqual({ type: 'error', message: "unknown option '--bogus'" })
  174. expect(task).toBeUndefined()
  175. expect(observed.err).toBe('')
  176. expect(observed.exits).toEqual([1])
  177. })
  178. it('does not install the JSON error override for a --json option value', async () => {
  179. const { observed } = await bootStartup(['--session-id', '--json'], { stdinIsTty: true })
  180. expect(observed.out).toContain('a task is required')
  181. expect(observed.out).not.toContain('"type":"error"')
  182. expect(observed.exits).toEqual([1])
  183. })
  184. it('does not install the JSON error override for a --json positional after --', async () => {
  185. const { task, observed } = await bootStartup(['--', '--json'], { stdinIsTty: false })
  186. expect(task).toEqual({ task: '--json', sessionId: undefined, json: false })
  187. expect(observed.out).not.toContain('"type":"error"')
  188. })
  189. it('rejects a blank positional task instead of reading stdin', async () => {
  190. const { task, observed } = await bootStartup([' '], { stdinIsTty: false })
  191. expect(observed.out).toContain('a task is required')
  192. expect(task).toBeUndefined()
  193. expect(observed.runnerConfig).toBeUndefined()
  194. expect(observed.exits).toEqual([1])
  195. })
  196. it('reports the real process stdin terminal state by default', () => {
  197. const original = Object.getOwnPropertyDescriptor(process, 'stdin')
  198. Object.defineProperty(process, 'stdin', { value: { isTTY: true }, configurable: true })
  199. try {
  200. expect(originalInternals.stdinIsTty()).toBe(true)
  201. } finally {
  202. if (original !== undefined) Object.defineProperty(process, 'stdin', original)
  203. }
  204. })
  205. it('fails loud without the launcher command line and exit request', () => {
  206. expect(() => { apply(new Context()) }).toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
  207. })
  208. it('prints its own help and leaves the runner pending', async () => {
  209. const { task, observed } = await bootStartup(['--help'])
  210. expect(observed.out).toContain('dsh --profile headless')
  211. expect(observed.out).toContain('the answer goes to stdout and diagnostics to stderr')
  212. expect(observed.out).toContain('--session-id')
  213. expect(task).toBeUndefined()
  214. expect(observed.runnerConfig).toBeUndefined()
  215. expect(observed.exits).toEqual([0])
  216. })
  217. })