cmdline.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. /**
  2. * The launcher-to-app command line over a REAL Loader tree, mounted the way a
  3. * profile boot mounts it: Loader holds each row until its injections are
  4. * active, then resolves that row's config against its injection-ready context.
  5. */
  6. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  7. import { EventEmitter } from 'node:events'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { PassThrough } from 'node:stream'
  11. import { pathToFileURL } from 'node:url'
  12. import { Command } from 'commander'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import Loader from '@deepseek-ai/cordis-plugin-loader'
  15. import Include from '@deepseek-ai/cordis-plugin-include'
  16. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  17. import { afterEach, describe, expect, it, vi } from 'vitest'
  18. import { exitOnStdinEnd, internals, parseCmdline, provideCmdline, type AppReady } from '../src/index.ts'
  19. /** Every value one boot of the fixture tree observed. */
  20. interface Observed {
  21. /** Config the reading row started with; absent means it never started. */
  22. started?: Record<string, unknown>
  23. exits: number[]
  24. out: string
  25. }
  26. /** A booted fixture tree: what it observed, and its root for direct parser calls. */
  27. interface Fixture {
  28. observed: Observed
  29. ctx: Context
  30. }
  31. const disposers: (() => Promise<void>)[] = []
  32. /** Fixture tree roots, removed after their booted tree has been disposed. */
  33. const tempDirs: string[] = []
  34. const readyApp: AppReady = {
  35. onReady(listener) {
  36. listener()
  37. return () => {}
  38. },
  39. }
  40. function controlledAppReady(): { service: AppReady; commit(): void } {
  41. const listeners = new Set<() => void>()
  42. return {
  43. service: {
  44. onReady(listener) {
  45. listeners.add(listener)
  46. return () => { listeners.delete(listener) }
  47. },
  48. },
  49. commit() {
  50. for (const listener of [...listeners]) listener()
  51. listeners.clear()
  52. },
  53. }
  54. }
  55. afterEach(async () => {
  56. for (const dispose of disposers.splice(0)) await dispose()
  57. for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
  58. internals.stdin = process.stdin
  59. internals.stdout = process.stdout
  60. internals.stderr = process.stderr
  61. })
  62. /** In-memory stdin whose end edge and ended-before-bind state are controllable. */
  63. class TestStdin extends EventEmitter {
  64. readableEnded = false
  65. end(): void {
  66. this.readableEnded = true
  67. this.emit('end')
  68. }
  69. }
  70. /** The fixture app's flag family: one `--port` its rows read from the service. */
  71. function demoCommand(): Command {
  72. return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
  73. }
  74. /** The fixture app's action body: the resolved values its rows read. */
  75. const resolveDemo = (program: Command): { port?: number } => {
  76. const port = program.opts<{ port?: string }>().port
  77. if (port === undefined) return {}
  78. if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
  79. return { port: Number(port) }
  80. }
  81. /** A YAML `!!js` expression node, as the include parses one out of a patch file. */
  82. const expression = (source: string): unknown => ({ __jsExpr: source })
  83. /**
  84. * Mount a two-row composition the way a profile boot does: both rows at once,
  85. * with Loader ordering config resolution from their injections.
  86. * @param args - the invocation's inner arguments.
  87. * @param resolve - the app's action body; defaults to the fixture's own.
  88. * @returns the booted fixture.
  89. */
  90. async function bootFixture(
  91. args: string[],
  92. resolve: (program: Command) => unknown = resolveDemo,
  93. options: { objectInject?: boolean; withoutProvider?: boolean } = {},
  94. ): Promise<Fixture> {
  95. const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
  96. tempDirs.push(dir)
  97. const observed: Observed = { exits: [], out: '' }
  98. writeFileSync(join(dir, 'reader.mjs'), `
  99. export const name = 'reader'
  100. export const inject = ['demoStartup']
  101. export function apply(ctx, config) { globalThis.__observed.started = config }
  102. `)
  103. // The Loader imports a row through Node's own resolver, which cannot resolve
  104. // this workspace's sources; the row delegates to the real function the test
  105. // imported through the source-plane path mapping.
  106. writeFileSync(join(dir, 'startup.mjs'), `
  107. export const name = 'demo-startup'
  108. export const inject = ['cmdlineArgs']
  109. export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
  110. `)
  111. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  112. const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
  113. internals.stdout = observing
  114. internals.stderr = observing
  115. const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
  116. globals.__observed = observed
  117. globals.__provideDemoArgs = (ctx: Context) => {
  118. const program = demoCommand()
  119. program.action(() => { ctx.provide('demoStartup', resolve(program)) })
  120. parseCmdline(ctx, program)
  121. }
  122. // The composition, exactly as a profile delivers one: include patches whose
  123. // config carries `!!js` expressions.
  124. const composition: PatchOptions[] = [{
  125. insert: [
  126. ...options.withoutProvider === true
  127. ? []
  128. : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }],
  129. {
  130. id: 'reader',
  131. name: pathToFileURL(join(dir, 'reader.mjs')).href,
  132. inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'],
  133. config: { port: expression('ctx.demoStartup.port ?? 3080') },
  134. },
  135. ],
  136. }]
  137. const ctx = new Context()
  138. await ctx.plugin(Loader)
  139. ctx.loader.builtins.include = Include
  140. provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
  141. await ctx.loader.create({
  142. name: 'cordis:include',
  143. config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) },
  144. })
  145. await ctx.loader.await()
  146. disposers.push(async () => { await ctx.fiber.dispose() })
  147. return { observed, ctx }
  148. }
  149. describe('parseCmdline', () => {
  150. it('lets a row read the flag value the app resolved', async () => {
  151. const { observed } = await bootFixture(['--port', '8080'])
  152. expect(observed.started).toEqual({ port: 8080 })
  153. expect(observed.exits).toEqual([])
  154. })
  155. it('leaves a row on the value written beside the expression when no flag names one', async () => {
  156. const { observed } = await bootFixture([])
  157. expect(observed.started).toEqual({ port: 3080 })
  158. })
  159. it('recognizes the Loader object form of a provider-service injection', async () => {
  160. const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
  161. expect(observed.started).toEqual({ port: 8080 })
  162. })
  163. it('prints the app help, starts no reading row, and requests exit 0', async () => {
  164. const { observed } = await bootFixture(['--help'])
  165. expect(observed.out).toContain('Usage: demo')
  166. expect(observed.started).toBeUndefined()
  167. expect(observed.exits).toEqual([0])
  168. })
  169. it('rejects the invocation from the action without starting the app', async () => {
  170. const { observed } = await bootFixture(['--port', 'abc'])
  171. expect(observed.out).toContain('--port must be a number')
  172. expect(observed.started).toBeUndefined()
  173. expect(observed.exits).toEqual([1])
  174. })
  175. it('rethrows an action failure that is not commander asking to exit', async () => {
  176. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  177. const program = demoCommand().action(() => { throw new Error('action exploded') })
  178. expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
  179. })
  180. it('rethrows a thrown value that is not an object at all', async () => {
  181. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  182. const program = demoCommand().action(() => {
  183. const thrown: unknown = 'action threw a string'
  184. throw thrown
  185. })
  186. expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
  187. })
  188. it('runs the action without inspecting Loader rows or owning a service', async () => {
  189. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  190. let values: unknown
  191. const program = demoCommand()
  192. program.action(() => { values = resolveDemo(program) })
  193. parseCmdline(ctx, program)
  194. expect(values).toEqual({})
  195. expect(ctx.get('demoStartup')).toBeUndefined()
  196. })
  197. })
  198. describe('provideCmdline', () => {
  199. it('hands the app a snapshot the caller cannot mutate afterwards', () => {
  200. const ctx = new Context()
  201. const args = ['--resume', 'abc']
  202. provideCmdline(ctx, { args, exit: () => {} })
  203. args.push('--tampered')
  204. expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
  205. })
  206. it('refuses at load a program in which no command declares an action', async () => {
  207. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  208. expect(() => { parseCmdline(ctx, demoCommand()) })
  209. .toThrow('no command in the program declares an action')
  210. })
  211. it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
  212. const ctx = new Context()
  213. const exits: number[] = []
  214. let err = ''
  215. internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
  216. provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
  217. // The root declares no action of its own: the tree-wide guard accepts the
  218. // subcommand's, and the subcommand inherits the exit and output routing.
  219. const program = new Command().name('demo')
  220. const child = program.command('serve')
  221. child.action(() => { child.error('error: serve rejected') })
  222. parseCmdline(ctx, program)
  223. expect(err).toContain('serve rejected')
  224. expect(exits).toEqual([1])
  225. })
  226. it('fails loud when a parser runs without the launcher values', () => {
  227. const ctx = new Context()
  228. expect(() => { parseCmdline(ctx, demoCommand()) })
  229. .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
  230. })
  231. it('lets multiple parsers read the same immutable snapshot', () => {
  232. const ctx = new Context()
  233. provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
  234. const parseOnce = (): unknown => {
  235. let values: unknown
  236. const program = demoCommand()
  237. program.action(() => { values = resolveDemo(program) })
  238. parseCmdline(ctx, program)
  239. return values
  240. }
  241. expect(parseOnce()).toEqual({ port: 8080 })
  242. expect(parseOnce()).toEqual({ port: 8080 })
  243. expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
  244. })
  245. })
  246. describe('exitOnStdinEnd', () => {
  247. it('requests bounded exit on EOF and removes the listener on disposal', async () => {
  248. const ctx = new Context()
  249. const stdin = new TestStdin()
  250. const exits: number[] = []
  251. internals.stdin = stdin
  252. provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
  253. exitOnStdinEnd(ctx, 'test.stdin')
  254. stdin.end()
  255. expect(exits).toEqual([0])
  256. await ctx.fiber.dispose()
  257. stdin.emit('end')
  258. expect(exits).toEqual([0])
  259. })
  260. it('requests exit after binding to stdin that has already ended', async () => {
  261. const ctx = new Context()
  262. const stdin = new TestStdin()
  263. const exits: number[] = []
  264. stdin.readableEnded = true
  265. internals.stdin = stdin
  266. provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
  267. exitOnStdinEnd(ctx, 'test.stdin')
  268. stdin.end()
  269. await Promise.resolve()
  270. expect(exits).toEqual([0])
  271. })
  272. it('cancels an already-ended stream before its queued EOF handler runs', async () => {
  273. const ctx = new Context()
  274. const stdin = new TestStdin()
  275. const exits: number[] = []
  276. let queued: (() => void) | undefined
  277. const queue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((listener) => { queued = listener })
  278. stdin.readableEnded = true
  279. internals.stdin = stdin
  280. try {
  281. provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
  282. exitOnStdinEnd(ctx, 'test.stdin')
  283. await ctx.fiber.dispose()
  284. queued?.()
  285. expect(exits).toEqual([])
  286. } finally {
  287. queue.mockRestore()
  288. }
  289. })
  290. it('leaves protocol bytes buffered until the transport claims stdin', async () => {
  291. const ctx = new Context()
  292. const stdin = new PassThrough()
  293. const exits: number[] = []
  294. internals.stdin = stdin
  295. provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
  296. exitOnStdinEnd(ctx, 'test.stdin')
  297. const frame = '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'
  298. stdin.write(frame)
  299. expect(stdin.readableFlowing).not.toBe(true)
  300. let received = ''
  301. stdin.on('data', (chunk: Buffer) => { received += chunk.toString('utf8') })
  302. const ended = new Promise<void>((resolve) => { stdin.once('end', resolve) })
  303. stdin.end()
  304. await ended
  305. expect(received).toBe(frame)
  306. expect(exits).toEqual([0])
  307. await ctx.fiber.dispose()
  308. })
  309. it('waits for the launcher to commit successful startup after EOF', async () => {
  310. const ctx = new Context()
  311. const stdin = new TestStdin()
  312. const exits: number[] = []
  313. const ready = controlledAppReady()
  314. internals.stdin = stdin
  315. provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: ready.service })
  316. exitOnStdinEnd(ctx, 'test.stdin')
  317. stdin.end()
  318. expect(exits).toEqual([])
  319. ready.commit()
  320. expect(exits).toEqual([0])
  321. await ctx.fiber.dispose()
  322. })
  323. it('fails loud without a launcher exit request', () => {
  324. internals.stdin = new TestStdin()
  325. expect(() => { exitOnStdinEnd(new Context(), 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady')
  326. })
  327. it('fails loud without launcher startup readiness', () => {
  328. const ctx = new Context()
  329. internals.stdin = new TestStdin()
  330. provideCmdline(ctx, { args: [], exit: () => {} })
  331. expect(() => { exitOnStdinEnd(ctx, 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady')
  332. })
  333. })