1
0

cmdline.spec.ts 14 KB

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