{"version":3,"file":"test-harness.cjs","names":["InternalCLI","contextStorage"],"sources":["../../src/lib/test-harness.ts"],"sourcesContent":["import { ParsedArgs } from '@cli-forge/parser';\nimport { contextStorage, ForgeContextData } from './async-context';\nimport { resetGlobalProviders } from './context';\nimport { AnyInternalCLI, InternalCLI } from './internal-cli';\nimport { CLI, GlobalProviderConfig, ProviderConfig } from './public-api';\n\nconst mockedDisposers: Array<() => void> = [];\n\nexport type TestHarnessParseResult<T extends ParsedArgs> = {\n  /**\n   * Parsed arguments. Note the the typing of this is based on the CLI typings,\n   * but no runtime validation outside of the configured validation checks on\n   * individual options will be performed. If you want to validate the arguments,\n   * you should do so in your test or configure a `validate` callback for the option.\n   */\n  args: T;\n\n  /**\n   * The command chain that was resolved during parsing. This is used for testing\n   * that the correct command is ran when resolving a subcommand. A test that checks\n   * this may look like:\n   *\n   * ```ts\n   * const harness = new TestHarness(cli);\n   * const { args, commandChain } = await harness.parse(['hello', '--name=sir']);\n   * expect(commandChain).toEqual(['hello']);\n   * ```\n   *\n   * The above test would check that the `hello` command was resolved when parsing\n   * the argstring, and since only one command's handler will ever be called, this\n   * can be used to ensure that the correct command is ran.\n   */\n  commandChain: string[];\n};\n\n/**\n * Options accepted by {@link TestHarness.mockContext} and\n * {@link TestHarness.runWithMockedContext}.\n *\n * `providers` mirrors the shape of `.provide()`: each entry is either an\n * eager value, an `executionScope` factory config, or a `global` factory\n * config. The runtime detection matches `InternalCLI.provide()` — any\n * object with a `factory` function is treated as a factory registration.\n */\nexport type MockContextProviders<TArgs, TProviders> = {\n  [K in keyof TProviders]?:\n    | TProviders[K]\n    | ProviderConfig<TProviders[K], TArgs>\n    | GlobalProviderConfig<TProviders[K]>;\n};\n\nexport interface MockContextOptions<TArgs extends ParsedArgs, TProviders> {\n  args?: Partial<TArgs>;\n  providers?: MockContextProviders<TArgs, TProviders>;\n  commandChain?: string[];\n}\n\nfunction buildMockContextData<TArgs extends ParsedArgs, TProviders>(\n  cli: CLI<TArgs, any, any, any, TProviders> | undefined,\n  options: MockContextOptions<TArgs, TProviders>\n): ForgeContextData {\n  // Walk from the mocked CLI up through its parents to build an id chain,\n  // so `getCommandContext(root)` / `getCommandContext(running)` / any\n  // ancestor on the chain all validate successfully inside the mock.\n  const commandIdChain: string[] = [];\n  if (cli && InternalCLI.isInternalCLI(cli)) {\n    let walk: AnyInternalCLI | undefined = cli;\n    while (walk) {\n      commandIdChain.unshift(walk.commandId);\n      walk = walk.getParent() as AnyInternalCLI | undefined;\n    }\n  }\n\n  const contextData: ForgeContextData = {\n    args: (options.args ?? {}) as Record<string, unknown>,\n    commandChain: options.commandChain ?? [],\n    commandIdChain,\n    providers: new Map(),\n    providerFactories: new Map(),\n    handlerPhase: true,\n    resolving: new Set(),\n  };\n\n  for (const [key, value] of Object.entries(options.providers ?? {})) {\n    if (\n      value !== null &&\n      typeof value === 'object' &&\n      'factory' in (value as object) &&\n      typeof (value as { factory: unknown }).factory === 'function'\n    ) {\n      const config = value as {\n        factory: Function;\n        lifetime?: 'global' | 'executionScope';\n      };\n      contextData.providerFactories.set(key, {\n        factory: config.factory,\n        lifetime: config.lifetime ?? 'executionScope',\n      });\n    } else {\n      contextData.providers.set(key, value);\n    }\n  }\n\n  return contextData;\n}\n\n/**\n * Utility for testing CLI instances. Can check argument parsing and validation, including\n * command chain resolution.\n */\nexport class TestHarness<T extends ParsedArgs> {\n  private cli: InternalCLI<T>;\n\n  constructor(cli: CLI<T, any, any, any, any>) {\n    if (InternalCLI.isInternalCLI(cli)) {\n      this.cli = cli;\n      mockHandler(cli);\n    } else {\n      throw new Error(\n        'TestHarness can only be used with CLI instances created by `cli`.'\n      );\n    }\n  }\n\n  /**\n   * Mocks the CLI context for testing DI providers and command context outside\n   * of a real `forge()` execution. Returns a cleanup function that removes the\n   * mocked context when called.\n   *\n   * `options.providers` accepts both eager values and the same\n   * `{ factory, lifetime }` configs that `.provide()` takes — factory mocks\n   * run lazily via `inject()` and receive the mocked `args`.\n   *\n   * This helper is best suited for simple synchronous tests. It does not\n   * maintain a stack of prior contexts: calling `mockContext()` while\n   * another mock is active overwrites the current store, and the returned\n   * cleanup function blanks the store rather than restoring whatever was\n   * there before.\n   *\n   * For tests that use `await` across the mocked block or need nested\n   * mocked contexts that unwind properly, prefer {@link runWithMockedContext},\n   * which uses `AsyncLocalStorage.run()` for proper scoping.\n   *\n   * @example\n   * ```ts\n   * afterEach(() => TestHarness.clearMockedContexts());\n   *\n   * it('resolves provider', () => {\n   *   const cleanup = TestHarness.mockContext(myApp, {\n   *     providers: {\n   *       db: mockDb,\n   *       logger: { factory: (args) => makeLogger(args.logLevel) },\n   *     },\n   *   });\n   *   const ctx = getCommandContext(myApp);\n   *   expect(ctx.inject('db')).toBe(mockDb);\n   *   cleanup();\n   * });\n   * ```\n   */\n  static mockContext<TArgs extends ParsedArgs, TProviders>(\n    _cli: CLI<TArgs, any, any, any, TProviders>,\n    options: MockContextOptions<TArgs, TProviders>\n  ): () => void {\n    const contextData = buildMockContextData(_cli, options);\n    contextStorage.enterWith(contextData);\n\n    const dispose = () => {\n      // Unconditionally blank the current store on dispose. Capturing the\n      // previous store and restoring it looks cleaner for nested mocks, but\n      // it misbehaves when `clearMockedContexts()` batch-disposes out of\n      // order. Callers that need proper nesting should use\n      // `runWithMockedContext`, which scopes via `AsyncLocalStorage.run()`.\n      contextStorage.enterWith(undefined);\n      const idx = mockedDisposers.indexOf(dispose);\n      if (idx !== -1) mockedDisposers.splice(idx, 1);\n    };\n\n    mockedDisposers.push(dispose);\n    return dispose;\n  }\n\n  /**\n   * Runs `fn` inside a mocked DI context using `AsyncLocalStorage.run()`.\n   *\n   * Unlike {@link mockContext}, this variant scopes the context properly\n   * across async boundaries — any `await` inside `fn` keeps seeing the\n   * mocked providers, and the context is automatically torn down when `fn`\n   * resolves (or throws). Prefer this for anything that isn't a trivial\n   * synchronous assertion.\n   *\n   * @example\n   * ```ts\n   * const result = await TestHarness.runWithMockedContext(\n   *   myApp,\n   *   { providers: { db: mockDb } },\n   *   async () => {\n   *     const ctx = getCommandContext(myApp);\n   *     return ctx.inject('db').query('select 1');\n   *   }\n   * );\n   * ```\n   */\n  static async runWithMockedContext<TArgs extends ParsedArgs, TProviders, R>(\n    _cli: CLI<TArgs, any, any, any, TProviders>,\n    options: MockContextOptions<TArgs, TProviders>,\n    fn: () => R | Promise<R>\n  ): Promise<R> {\n    const contextData = buildMockContextData(_cli, options);\n    // Wrap in an async function so synchronous throws from fn() are\n    // converted to rejected promises, and await inside fn sees the scoped\n    // context (AsyncLocalStorage.run propagates the store through awaits).\n    return await contextStorage.run(contextData, async () => fn());\n  }\n\n  /**\n   * Removes all mocked contexts registered via {@link mockContext} and\n   * clears the `lifetime: 'global'` provider cache so the next test starts\n   * with a fresh slate.\n   *\n   * Typically called from `afterEach`:\n   * ```ts\n   * afterEach(() => TestHarness.clearMockedContexts());\n   * ```\n   */\n  static clearMockedContexts(): void {\n    for (const dispose of [...mockedDisposers]) {\n      dispose();\n    }\n    // Belt-and-braces: even after all disposers ran, blank the store so any\n    // lingering reference from a test that forgot to capture its cleanup\n    // doesn't leak into the next test.\n    contextStorage.enterWith(undefined);\n    resetGlobalProviders();\n  }\n\n  /**\n   * Clears only the `lifetime: 'global'` provider cache without touching\n   * any active mocked contexts. Useful when a specific test needs to\n   * re-initialize global providers without discarding the surrounding mock.\n   */\n  static resetGlobalProviders(): void {\n    resetGlobalProviders();\n  }\n\n  async parse(args: string[]): Promise<TestHarnessParseResult<T>> {\n    const argv = await this.cli.forge(args);\n\n    return {\n      args: argv,\n      commandChain: this.cli.commandChain,\n    };\n  }\n}\n\nfunction mockHandler(cli: AnyInternalCLI) {\n  if (cli.configuration?.handler) {\n    cli.configuration.handler = () => {\n      // Mocked, should do nothing.\n    };\n  }\n  for (const command in cli.registeredCommands) {\n    mockHandler(cli.registeredCommands[command]);\n  }\n}\n"],"mappings":";;;;;AAMA,MAAM,kBAAqC,EAAE;AAmD7C,SAAS,qBACP,KACA,SACkB;CAIlB,MAAM,iBAA2B,EAAE;AACnC,KAAI,OAAOA,yBAAAA,YAAY,cAAc,IAAI,EAAE;EACzC,IAAI,OAAmC;AACvC,SAAO,MAAM;AACX,kBAAe,QAAQ,KAAK,UAAU;AACtC,UAAO,KAAK,WAAW;;;CAI3B,MAAM,cAAgC;EACpC,MAAO,QAAQ,QAAQ,EAAE;EACzB,cAAc,QAAQ,gBAAgB,EAAE;EACxC;EACA,2BAAW,IAAI,KAAK;EACpB,mCAAmB,IAAI,KAAK;EAC5B,cAAc;EACd,2BAAW,IAAI,KAAK;EACrB;AAED,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,QAAQ,aAAa,EAAE,CAAC,CAChE,KACE,UAAU,QACV,OAAO,UAAU,YACjB,aAAc,SACd,OAAQ,MAA+B,YAAY,YACnD;EACA,MAAM,SAAS;AAIf,cAAY,kBAAkB,IAAI,KAAK;GACrC,SAAS,OAAO;GAChB,UAAU,OAAO,YAAY;GAC9B,CAAC;OAEF,aAAY,UAAU,IAAI,KAAK,MAAM;AAIzC,QAAO;;;;;;AAOT,IAAa,cAAb,MAA+C;CAC7C;CAEA,YAAY,KAAiC;AAC3C,MAAIA,yBAAAA,YAAY,cAAc,IAAI,EAAE;AAClC,QAAK,MAAM;AACX,eAAY,IAAI;QAEhB,OAAM,IAAI,MACR,oEACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCL,OAAO,YACL,MACA,SACY;EACZ,MAAM,cAAc,qBAAqB,MAAM,QAAQ;AACvD,4BAAA,eAAe,UAAU,YAAY;EAErC,MAAM,gBAAgB;AAMpB,6BAAA,eAAe,UAAU,KAAA,EAAU;GACnC,MAAM,MAAM,gBAAgB,QAAQ,QAAQ;AAC5C,OAAI,QAAQ,GAAI,iBAAgB,OAAO,KAAK,EAAE;;AAGhD,kBAAgB,KAAK,QAAQ;AAC7B,SAAO;;;;;;;;;;;;;;;;;;;;;;;CAwBT,aAAa,qBACX,MACA,SACA,IACY;EACZ,MAAM,cAAc,qBAAqB,MAAM,QAAQ;AAIvD,SAAO,MAAMC,0BAAAA,eAAe,IAAI,aAAa,YAAY,IAAI,CAAC;;;;;;;;;;;;CAahE,OAAO,sBAA4B;AACjC,OAAK,MAAM,WAAW,CAAC,GAAG,gBAAgB,CACxC,UAAS;AAKX,4BAAA,eAAe,UAAU,KAAA,EAAU;AACnC,sBAAA,sBAAsB;;;;;;;CAQxB,OAAO,uBAA6B;AAClC,sBAAA,sBAAsB;;CAGxB,MAAM,MAAM,MAAoD;AAG9D,SAAO;GACL,MAHW,MAAM,KAAK,IAAI,MAAM,KAAK;GAIrC,cAAc,KAAK,IAAI;GACxB;;;AAIL,SAAS,YAAY,KAAqB;AACxC,KAAI,IAAI,eAAe,QACrB,KAAI,cAAc,gBAAgB;AAIpC,MAAK,MAAM,WAAW,IAAI,mBACxB,aAAY,IAAI,mBAAmB,SAAS"}