cmdline.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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 { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { pathToFileURL } from 'node:url'
  10. import { Command } from 'commander'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import Loader from '@deepseek-ai/cordis-plugin-loader'
  13. import Include from '@deepseek-ai/cordis-plugin-include'
  14. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  15. import { afterEach, describe, expect, it } from 'vitest'
  16. import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
  17. /** Every value one boot of the fixture tree observed. */
  18. interface Observed {
  19. /** Config the reading row started with; absent means it never started. */
  20. started?: Record<string, unknown>
  21. exits: number[]
  22. out: string
  23. }
  24. /** A booted fixture tree: what it observed, and its root for direct parser calls. */
  25. interface Fixture {
  26. observed: Observed
  27. ctx: Context
  28. }
  29. const disposers: (() => Promise<void>)[] = []
  30. afterEach(async () => {
  31. for (const dispose of disposers.splice(0)) await dispose()
  32. internals.stdout = process.stdout
  33. internals.stderr = process.stderr
  34. })
  35. /** The fixture app's flag family: one `--port` its rows read from the service. */
  36. function demoCommand(): Command {
  37. return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
  38. }
  39. /** The fixture app's action body: the resolved values its rows read. */
  40. const resolveDemo = (program: Command): { port?: number } => {
  41. const port = program.opts<{ port?: string }>().port
  42. if (port === undefined) return {}
  43. if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
  44. return { port: Number(port) }
  45. }
  46. /** A YAML `!!js` expression node, as the include parses one out of a patch file. */
  47. const expression = (source: string): unknown => ({ __jsExpr: source })
  48. /**
  49. * Mount a two-row composition the way a profile boot does: both rows at once,
  50. * with Loader ordering config resolution from their injections.
  51. * @param args - the invocation's inner arguments.
  52. * @param resolve - the app's action body; defaults to the fixture's own.
  53. * @returns the booted fixture.
  54. */
  55. async function bootFixture(
  56. args: string[],
  57. resolve: (program: Command) => unknown = resolveDemo,
  58. options: { objectInject?: boolean; withoutProvider?: boolean } = {},
  59. ): Promise<Fixture> {
  60. const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
  61. const observed: Observed = { exits: [], out: '' }
  62. writeFileSync(join(dir, 'reader.mjs'), `
  63. export const name = 'reader'
  64. export const inject = ['demoStartup']
  65. export function apply(ctx, config) { globalThis.__observed.started = config }
  66. `)
  67. // The Loader imports a row through Node's own resolver, which cannot resolve
  68. // this workspace's sources; the row delegates to the real function the test
  69. // imported through the source-plane path mapping.
  70. writeFileSync(join(dir, 'startup.mjs'), `
  71. export const name = 'demo-startup'
  72. export const inject = ['cmdlineArgs']
  73. export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
  74. `)
  75. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  76. const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
  77. internals.stdout = observing
  78. internals.stderr = observing
  79. const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
  80. globals.__observed = observed
  81. globals.__provideDemoArgs = (ctx: Context) => {
  82. const program = demoCommand()
  83. program.action(() => { ctx.provide('demoStartup', resolve(program)) })
  84. parseCmdline(ctx, program)
  85. }
  86. // The composition, exactly as a profile delivers one: include patches whose
  87. // config carries `!!js` expressions.
  88. const composition: PatchOptions[] = [{
  89. insert: [
  90. ...options.withoutProvider === true
  91. ? []
  92. : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }],
  93. {
  94. id: 'reader',
  95. name: pathToFileURL(join(dir, 'reader.mjs')).href,
  96. inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'],
  97. config: { port: expression('ctx.demoStartup.port ?? 3080') },
  98. },
  99. ],
  100. }]
  101. const ctx = new Context()
  102. await ctx.plugin(Loader)
  103. ctx.loader.builtins.include = Include
  104. provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
  105. await ctx.loader.create({
  106. name: 'cordis:include',
  107. config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) },
  108. })
  109. await ctx.loader.await()
  110. disposers.push(async () => { await ctx.fiber.dispose() })
  111. return { observed, ctx }
  112. }
  113. describe('parseCmdline', () => {
  114. it('lets a row read the flag value the app resolved', async () => {
  115. const { observed } = await bootFixture(['--port', '8080'])
  116. expect(observed.started).toEqual({ port: 8080 })
  117. expect(observed.exits).toEqual([])
  118. })
  119. it('leaves a row on the value written beside the expression when no flag names one', async () => {
  120. const { observed } = await bootFixture([])
  121. expect(observed.started).toEqual({ port: 3080 })
  122. })
  123. it('recognizes the Loader object form of a provider-service injection', async () => {
  124. const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
  125. expect(observed.started).toEqual({ port: 8080 })
  126. })
  127. it('prints the app help, starts no reading row, and requests exit 0', async () => {
  128. const { observed } = await bootFixture(['--help'])
  129. expect(observed.out).toContain('Usage: demo')
  130. expect(observed.started).toBeUndefined()
  131. expect(observed.exits).toEqual([0])
  132. })
  133. it('rejects the invocation from the action without starting the app', async () => {
  134. const { observed } = await bootFixture(['--port', 'abc'])
  135. expect(observed.out).toContain('--port must be a number')
  136. expect(observed.started).toBeUndefined()
  137. expect(observed.exits).toEqual([1])
  138. })
  139. it('rethrows an action failure that is not commander asking to exit', async () => {
  140. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  141. const program = demoCommand().action(() => { throw new Error('action exploded') })
  142. expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
  143. })
  144. it('rethrows a thrown value that is not an object at all', async () => {
  145. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  146. const program = demoCommand().action(() => {
  147. const thrown: unknown = 'action threw a string'
  148. throw thrown
  149. })
  150. expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
  151. })
  152. it('runs the action without inspecting Loader rows or owning a service', async () => {
  153. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  154. let values: unknown
  155. const program = demoCommand()
  156. program.action(() => { values = resolveDemo(program) })
  157. parseCmdline(ctx, program)
  158. expect(values).toEqual({})
  159. expect(ctx.get('demoStartup')).toBeUndefined()
  160. })
  161. })
  162. describe('provideCmdline', () => {
  163. it('hands the app a snapshot the caller cannot mutate afterwards', () => {
  164. const ctx = new Context()
  165. const args = ['--resume', 'abc']
  166. provideCmdline(ctx, { args, exit: () => {} })
  167. args.push('--tampered')
  168. expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
  169. })
  170. it('refuses at load a program in which no command declares an action', async () => {
  171. const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
  172. expect(() => { parseCmdline(ctx, demoCommand()) })
  173. .toThrow('no command in the program declares an action')
  174. })
  175. it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
  176. const ctx = new Context()
  177. const exits: number[] = []
  178. let err = ''
  179. internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
  180. provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
  181. // The root declares no action of its own: the tree-wide guard accepts the
  182. // subcommand's, and the subcommand inherits the exit and output routing.
  183. const program = new Command().name('demo')
  184. const child = program.command('serve')
  185. child.action(() => { child.error('error: serve rejected') })
  186. parseCmdline(ctx, program)
  187. expect(err).toContain('serve rejected')
  188. expect(exits).toEqual([1])
  189. })
  190. it('fails loud when a parser runs without the launcher values', () => {
  191. const ctx = new Context()
  192. expect(() => { parseCmdline(ctx, demoCommand()) })
  193. .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
  194. })
  195. it('lets multiple parsers read the same immutable snapshot', () => {
  196. const ctx = new Context()
  197. provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
  198. const parseOnce = (): unknown => {
  199. let values: unknown
  200. const program = demoCommand()
  201. program.action(() => { values = resolveDemo(program) })
  202. parseCmdline(ctx, program)
  203. return values
  204. }
  205. expect(parseOnce()).toEqual({ port: 8080 })
  206. expect(parseOnce()).toEqual({ port: 8080 })
  207. expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
  208. })
  209. })