plugin-manager.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * The `plugins` Remote as a relay: every method reaches the shared manager
  3. * with its arguments, a manager failure crosses as the Remote error of the
  4. * same code, and a composition without a profile runtime still mounts the
  5. * service. What the manager does is pinned in `dsh-plugin-manager`'s own
  6. * tests over a real profile.
  7. */
  8. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { afterEach, describe, expect, it } from 'vitest'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import Loader from '@deepseek-ai/cordis-plugin-loader'
  14. import type { probePackage } from '@deepseek-ai/dsh-app-boot'
  15. import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  16. import {
  17. PluginOperationError, type PluginManager, type PluginOperationFailure, type SpawnLike,
  18. } from '@deepseek-ai/dsh-plugin-manager'
  19. import PluginManagerRemote, { remoteErrorOf, type Config } from '@deepseek-ai/dsh-host-plugin-manager'
  20. import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
  21. /** A complete config: the schema fills defaults at load, the type does not. */
  22. const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
  23. const contexts: Context[] = []
  24. afterEach(async () => {
  25. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  26. })
  27. /** Mount the Remote on a bare Loader context, over the given manager or the default one. */
  28. async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
  29. const ctx = new Context()
  30. contexts.push(ctx)
  31. await ctx.plugin(Loader)
  32. class TestRemote extends PluginManagerRemote {
  33. constructor(context: Context, config: Config) {
  34. super(context, config, manager === undefined ? {} : { manager })
  35. }
  36. }
  37. await ctx.plugin(TestRemote, CONFIG)
  38. const service = ctx.get('pluginManager')
  39. if (service === undefined) throw new Error('the Remote did not mount')
  40. return service
  41. }
  42. describe('PluginManagerRemote', () => {
  43. it('publishes the plugins namespace with one direct method per operation', async () => {
  44. const remote = await mount()
  45. expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
  46. expect(remoteMethods(remote).map(marker => marker.method)).toEqual([
  47. 'list', 'add', 'uninstall', 'enable', 'disable', 'retry', 'addRow', 'removeRow', 'setRowDisabled', 'dependents',
  48. ])
  49. })
  50. it('mounts without a profile runtime and reports plugins/unavailable as a Remote error', async () => {
  51. const remote = await mount()
  52. await expect(remote.list()).rejects.toMatchObject({ isDSHRemoteError: true, code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
  53. })
  54. it('relays every operation to the manager with its arguments and answer', async () => {
  55. const calls: unknown[][] = []
  56. const stub = new Proxy({}, {
  57. get: (_target, method: string) => (...args: unknown[]) => {
  58. calls.push([method, ...args])
  59. return Promise.resolve({ method })
  60. },
  61. }) as PluginManager
  62. const remote = await mount(stub)
  63. await expect(remote.list()).resolves.toEqual({ method: 'list' })
  64. await remote.add('spec', { enable: true })
  65. await remote.uninstall('pkg')
  66. await remote.enable('pkg')
  67. await remote.disable('pkg')
  68. await remote.retry('pkg')
  69. await remote.addRow('pkg', { kind: 'global' }, { module: './x.js', id: 'x', config: { a: 1 } })
  70. await remote.removeRow({ kind: 'preset', preset: 'standard' }, 'x')
  71. await remote.setRowDisabled({ kind: 'global' }, 'x', true)
  72. await remote.dependents('pkg')
  73. expect(calls).toEqual([
  74. ['list'],
  75. ['add', 'spec', { enable: true }],
  76. ['uninstall', 'pkg'],
  77. ['enable', 'pkg'],
  78. ['disable', 'pkg'],
  79. ['retry', 'pkg'],
  80. ['addRow', 'pkg', { kind: 'global' }, { module: './x.js', id: 'x', config: { a: 1 } }],
  81. ['removeRow', { kind: 'preset', preset: 'standard' }, 'x'],
  82. ['setRowDisabled', { kind: 'global' }, 'x', true],
  83. ['dependents', 'pkg'],
  84. ])
  85. })
  86. it('hands the manager readers into the context: the runtime, the agent count, the roster, and the seams', async () => {
  87. const profileDir = mkdtempSync(join(tmpdir(), 'dsh-host-plugin-manager-'))
  88. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  89. name: 'dsh-profile-web', private: true, dependencies: { pkg: '1.0.0' }, dsh: { profile: { bundles: [], patchReload: 'startup' } },
  90. }))
  91. const ctx = new Context()
  92. contexts.push(ctx)
  93. await ctx.plugin(Loader)
  94. ctx.provide('profileRuntime', {
  95. dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
  96. } as never)
  97. const probe: typeof probePackage = options => Promise.resolve({
  98. packageName: options.packageName, kind: 'plugin', ok: true, cordisSameCopy: null, rows: [], overrides: [], addable: [], checkedAt: '',
  99. })
  100. const spawn: SpawnLike = () => { throw new Error('this test spawns nothing') }
  101. class SeamedRemote extends PluginManagerRemote {
  102. constructor(context: Context, config: Config) {
  103. super(context, config, { spawn, probe })
  104. }
  105. }
  106. await ctx.plugin(SeamedRemote, CONFIG)
  107. const remote = ctx.get('pluginManager')
  108. if (remote === undefined) throw new Error('the Remote did not mount')
  109. try {
  110. // The runtime reader and the spawn seam: without an agent registry nothing runs, so the install reaches pnpm.
  111. await expect(remote.add('anything')).rejects.toThrow('this test spawns nothing')
  112. // The agent-count reader: a running session refuses an install before pnpm runs.
  113. ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
  114. await expect(remote.add('anything')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
  115. // The probe seam answers for the package, then the roster reader finds no roster for a preset target.
  116. await expect(remote.addRow('pkg', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no roster' } })
  117. } finally {
  118. rmSync(profileDir, { recursive: true, force: true })
  119. }
  120. })
  121. it('turns a manager failure into the Remote error of the same code and lets any other error through', async () => {
  122. const busy = new PluginOperationError('plugins/busy', 'busy', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } })
  123. const failing = {
  124. list: () => Promise.reject(busy),
  125. add: () => Promise.reject(new Error('the manager broke')),
  126. } as unknown as PluginManager
  127. const remote = await mount(failing)
  128. const error = await remote.list().catch((caught: unknown) => caught)
  129. expect(error).toBeInstanceOf(RemoteError)
  130. expect(error).toMatchObject({ code: 'plugins/busy', message: 'busy', details: busy.details, cause: busy })
  131. await expect(remote.add('x')).rejects.toThrow('the manager broke')
  132. })
  133. })
  134. describe('remoteErrorOf', () => {
  135. it('keeps every plugins/* code with its details and maps the generic refusal to gateway/bad-request', () => {
  136. const failures: PluginOperationFailure[] = [
  137. new PluginOperationError('plugins/unavailable', 'm', { reason: 'no profile runtime' }),
  138. new PluginOperationError('plugins/not-installed', 'm', { packageName: 'p' }),
  139. new PluginOperationError('plugins/not-enableable', 'm', { packageName: 'p', reason: 'r' }),
  140. new PluginOperationError('plugins/enable-failed', 'm', { packageName: 'p', reason: 'r' }),
  141. new PluginOperationError('plugins/install-failed', 'm', { spec: 's', exitCode: 1, log: 'l' }),
  142. new PluginOperationError('plugins/row-conflict', 'm', { rowId: 'x', target: { kind: 'global' } }),
  143. new PluginOperationError('plugins/busy', 'm', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } }),
  144. new PluginOperationError('plugins/agents-running', 'm', { operation: 'add', running: 1 }),
  145. ]
  146. for (const failure of failures) {
  147. const error = remoteErrorOf(failure)
  148. expect(error).toBeInstanceOf(RemoteError)
  149. expect(error).toMatchObject({ code: failure.code, message: 'm', details: failure.details, cause: failure })
  150. }
  151. const refused = new PluginOperationError('plugins/bad-request', 'nothing to do', {})
  152. expect(remoteErrorOf(refused)).toMatchObject({ code: 'gateway/bad-request', message: 'nothing to do', details: {}, cause: refused })
  153. })
  154. })
  155. describe('RemoteError codes', () => {
  156. it('declare their details', () => {
  157. const error = new RemoteError('plugins/row-conflict', 'taken', { rowId: 'x', target: { kind: 'global' } })
  158. expect(error.details).toEqual({ rowId: 'x', target: { kind: 'global' } })
  159. })
  160. })