{"version":3,"file":"extensionContract.mjs","names":[],"sources":["../../../src/adapters/testing/extensionContract.ts"],"sourcesContent":["import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';\nimport { createPiTestHost, type PiTestHost, type PiTestHostOptions } from './piHost.ts';\n\n/**\n * The lifecycle every standard Pi entry owes its host, as runnable scenarios.\n *\n * Eight packages carry their own partial copy of this, and the copies disagree\n * about which parts matter. What they share is what a host actually depends on:\n * that loading the entry registers something, that shutdown can be delivered\n * twice, and that a `/reload` can install the entry again on the same runner.\n *\n * Scenarios are plain data, not `describe`/`it` calls, so this adds no test\n * framework to a package every extension already installs. A caller loops:\n *\n * ```ts\n * for (const scenario of standardExtensionScenarios({ factory, tools: ['x'] })) {\n *   it(scenario.name, () => scenario.run());\n * }\n * ```\n *\n * Deeper fencing is deliberately not here. A package that makes a shut-down\n * tool answer instead of run owns that message and that decision, so it keeps\n * the test too; this covers only what is true of every extension.\n */\n\nexport interface StandardExtensionContractOptions {\n  /** The package's standard Pi entry: the default export Pi invokes. */\n  factory(pi: ExtensionAPI): Promise<void> | void;\n  /** Tool names loading the entry must register. */\n  tools?: readonly string[];\n  /** Slash commands loading the entry must claim. */\n  commands?: readonly string[];\n  /** A fresh host per scenario, for a factory that needs one stubbed a certain way. */\n  createHost?: (options: PiTestHostOptions) => PiTestHost;\n}\n\nexport interface ExtensionContractScenario {\n  name: string;\n  run(): Promise<void>;\n}\n\nconst SHUTDOWN_EVENT = 'session_shutdown';\nconst START_EVENT = 'session_start';\n\nfunction fail(message: string): never {\n  throw new Error(message);\n}\n\nfunction assertContains(actual: readonly string[], expected: readonly string[], subject: string): void {\n  const missing = expected.filter((name) => !actual.includes(name));\n  if (missing.length > 0) {\n    fail(`Expected ${subject} ${missing.join(', ')}; the entry registered ${actual.join(', ') || 'nothing'}.`);\n  }\n}\n\nexport function standardExtensionScenarios(\n  options: StandardExtensionContractOptions,\n): readonly ExtensionContractScenario[] {\n  const expectedTools = options.tools ?? [];\n  const expectedCommands = options.commands ?? [];\n  const build = options.createHost ?? createPiTestHost;\n\n  /** Runs one scenario against its own host, and disposes it either way. */\n  const scenario = (\n    name: string,\n    hostOptions: PiTestHostOptions,\n    body: (host: PiTestHost) => Promise<void>,\n  ): ExtensionContractScenario => ({\n    name,\n    async run() {\n      const host = build(hostOptions);\n      try {\n        await body(host);\n      } finally {\n        await host.dispose();\n      }\n    },\n  });\n\n  return [\n    scenario('registers its declared tools and commands when the host loads it', {}, async (host) => {\n      await options.factory(host.pi);\n\n      assertContains(\n        host.tools.map(({ name }) => name),\n        expectedTools,\n        'the entry to register tools',\n      );\n      assertContains(\n        host.commands.map(({ name }) => name),\n        expectedCommands,\n        'the entry to claim commands',\n      );\n    }),\n\n    scenario('handles session_shutdown, so the host can reclaim what it registered', {}, async (host) => {\n      await options.factory(host.pi);\n\n      if (host.handlers(SHUTDOWN_EVENT).length === 0) {\n        fail('The entry registered no session_shutdown handler, so nothing it holds is ever released.');\n      }\n    }),\n\n    scenario('takes a repeated shutdown without failing the second one', {}, async (host) => {\n      // Pi delivers shutdown per registered extension, and a package that\n      // installed twice on one runner receives it twice.\n      await options.factory(host.pi);\n\n      await host.emit(SHUTDOWN_EVENT, { reason: 'quit' });\n      await host.emit(SHUTDOWN_EVENT, { reason: 'quit' });\n    }),\n\n    scenario('loads again on the same host after a shutdown, the way /reload does', {}, async (host) => {\n      await options.factory(host.pi);\n      await host.emit(SHUTDOWN_EVENT, { reason: 'reload' });\n      const before = host.tools.length;\n\n      await options.factory(host.pi);\n\n      if (expectedTools.length > 0 && host.tools.length === before) {\n        fail('Loading the entry again registered nothing: a module-level latch is surviving the reload.');\n      }\n      assertContains(\n        host.tools.slice(before).map(({ name }) => name),\n        expectedTools,\n        'the reloaded entry to register tools',\n      );\n    }),\n\n    // The cockpit and the RPC runtime both load extensions with no TUI. An\n    // entry that reaches for one there takes the whole session down.\n    scenario('installs and shuts down on a headless host', { hasUI: false, mode: 'rpc' }, async (host) => {\n      await options.factory(host.pi);\n      await host.emit(START_EVENT, { reason: 'startup' });\n      await host.emit(SHUTDOWN_EVENT, { reason: 'quit' });\n    }),\n  ];\n}\n"],"mappings":"gDAyCA,MAAM,EAAiB,mBAGvB,SAAS,EAAK,EAAwB,CACpC,MAAU,MAAM,CAAO,CACzB,CAEA,SAAS,EAAe,EAA2B,EAA6B,EAAuB,CACrG,IAAM,EAAU,EAAS,OAAQ,GAAS,CAAC,EAAO,SAAS,CAAI,CAAC,EAC5D,EAAQ,OAAS,GACnB,EAAK,YAAY,EAAQ,GAAG,EAAQ,KAAK,IAAI,EAAE,yBAAyB,EAAO,KAAK,IAAI,GAAK,UAAU,EAAE,CAE7G,CAEA,SAAgB,EACd,EACsC,CACtC,IAAM,EAAgB,EAAQ,OAAS,CAAC,EAClC,EAAmB,EAAQ,UAAY,CAAC,EACxC,EAAQ,EAAQ,YAAc,EAG9B,GACJ,EACA,EACA,KAC+B,CAC/B,OACA,MAAM,KAAM,CACV,IAAM,EAAO,EAAM,CAAW,EAC9B,GAAI,CACF,MAAM,EAAK,CAAI,CACjB,QAAU,CACR,MAAM,EAAK,QAAQ,CACrB,CACF,CACF,GAEA,MAAO,CACL,EAAS,mEAAoE,CAAC,EAAG,KAAO,IAAS,CAC/F,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAE7B,EACE,EAAK,MAAM,KAAK,CAAE,UAAW,CAAI,EACjC,EACA,6BACF,EACA,EACE,EAAK,SAAS,KAAK,CAAE,UAAW,CAAI,EACpC,EACA,6BACF,CACF,CAAC,EAED,EAAS,uEAAwE,CAAC,EAAG,KAAO,IAAS,CACnG,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAEzB,EAAK,SAAS,CAAc,CAAC,CAAC,SAAW,GAC3C,EAAK,yFAAyF,CAElG,CAAC,EAED,EAAS,2DAA4D,CAAC,EAAG,KAAO,IAAS,CAGvF,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAE7B,MAAM,EAAK,KAAK,EAAgB,CAAE,OAAQ,MAAO,CAAC,EAClD,MAAM,EAAK,KAAK,EAAgB,CAAE,OAAQ,MAAO,CAAC,CACpD,CAAC,EAED,EAAS,sEAAuE,CAAC,EAAG,KAAO,IAAS,CAClG,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAC7B,MAAM,EAAK,KAAK,EAAgB,CAAE,OAAQ,QAAS,CAAC,EACpD,IAAM,EAAS,EAAK,MAAM,OAE1B,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAEzB,EAAc,OAAS,GAAK,EAAK,MAAM,SAAW,GACpD,EAAK,2FAA2F,EAElG,EACE,EAAK,MAAM,MAAM,CAAM,CAAC,CAAC,KAAK,CAAE,UAAW,CAAI,EAC/C,EACA,sCACF,CACF,CAAC,EAID,EAAS,6CAA8C,CAAE,MAAO,GAAO,KAAM,KAAM,EAAG,KAAO,IAAS,CACpG,MAAM,EAAQ,QAAQ,EAAK,EAAE,EAC7B,MAAM,EAAK,KAAK,gBAAa,CAAE,OAAQ,SAAU,CAAC,EAClD,MAAM,EAAK,KAAK,EAAgB,CAAE,OAAQ,MAAO,CAAC,CACpD,CAAC,CACH,CACF"}