1
0

plugin-manager.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  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, vi } from 'vitest'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import Loader from '@deepseek-ai/cordis-plugin-loader'
  14. import type { readPackageMetadata } 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, type PluginInstallRequestId,
  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, installKillGraceMs: 50, installLogTailBytes: 16_384, inspectTimeoutMs: 1_000 }
  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('waits for queued profile recomposition before publishing changed issues', async () => {
  44. const ctx = new Context()
  45. contexts.push(ctx)
  46. await ctx.plugin(Loader)
  47. ctx.loader.builtins.good = { inject: ['missingService'], apply() {} }
  48. const id = await ctx.loader.create({ name: 'cordis:good' })
  49. await ctx.plugin(PluginManagerRemote, CONFIG)
  50. await new Promise<void>(resolve => setImmediate(resolve))
  51. let release!: () => void
  52. const gate = new Promise<void>((resolve) => { release = resolve })
  53. const whenIdle = vi.fn(() => gate)
  54. ctx.provide('profileRuntime', { whenIdle } as never)
  55. const changes: string[] = []
  56. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  57. ctx.provide('missingService', {})
  58. ctx.emit('loader/entry-init', ctx.loader.resolve(id))
  59. try {
  60. await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
  61. expect(changes).toEqual([])
  62. } finally { release() }
  63. await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
  64. })
  65. it('reports a failed refresh and cancels publication when disposed during settlement', async () => {
  66. const ctx = new Context()
  67. contexts.push(ctx)
  68. await ctx.plugin(Loader)
  69. ctx.loader.builtins.good = () => {}
  70. const id = await ctx.loader.create({ name: 'cordis:good' })
  71. const entry = ctx.loader.resolve(id)
  72. const remote = ctx.plugin(PluginManagerRemote, CONFIG)
  73. await remote
  74. const changes: string[] = []
  75. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  76. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  77. const settle = vi.spyOn(ctx.loader, 'await').mockRejectedValueOnce(new Error('settlement unavailable'))
  78. try {
  79. ctx.emit('loader/entry-init', entry)
  80. await vi.waitFor(() => { expect(warn).toHaveBeenCalled() })
  81. let enter!: () => void
  82. const entered = new Promise<void>((resolve) => { enter = resolve })
  83. let release!: () => void
  84. const gate = new Promise<void>((resolve) => { release = resolve })
  85. settle.mockImplementationOnce(async () => { enter(); await gate })
  86. ctx.emit('loader/entry-init', entry)
  87. await entered
  88. try {
  89. await remote.dispose()
  90. } finally { release() }
  91. await Promise.resolve()
  92. expect(changes).toEqual([])
  93. } finally {
  94. settle.mockRestore()
  95. warn.mockRestore()
  96. }
  97. })
  98. it('announces Loader dependency recovery and stops publishing after the Remote is disposed', async () => {
  99. const ctx = new Context()
  100. contexts.push(ctx)
  101. await ctx.plugin(Loader)
  102. const changes: string[] = []
  103. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  104. const remote = ctx.plugin(PluginManagerRemote, CONFIG)
  105. await remote
  106. ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
  107. const id = await ctx.loader.create({ name: 'cordis:waiting' })
  108. await vi.waitFor(() => { expect(changes).toContain('runtime') })
  109. expect(ctx.loader.resolve(id).fiber?.state).toBe(0)
  110. changes.length = 0
  111. ctx.provide('lateService', {})
  112. await vi.waitFor(() => {
  113. expect(ctx.loader.resolve(id).fiber?.state).toBe(2)
  114. expect(changes).toContain('runtime')
  115. })
  116. await remote.dispose()
  117. const count = changes.length
  118. await ctx.loader.resolve(id).update({ disabled: true })
  119. await ctx.loader.await()
  120. expect(changes).toHaveLength(count)
  121. })
  122. it('keeps unrelated healthy changes and unchanged errors silent, but reports error changes and recovery', async () => {
  123. const ctx = new Context()
  124. contexts.push(ctx)
  125. await ctx.plugin(Loader)
  126. ctx.loader.builtins.good = () => {}
  127. const id = await ctx.loader.create({ name: 'cordis:good' })
  128. const entry = ctx.loader.resolve(id)
  129. const changes: string[] = []
  130. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  131. await ctx.plugin(PluginManagerRemote, CONFIG)
  132. await new Promise<void>(resolve => setImmediate(resolve))
  133. await entry.update({ config: { healthy: true } })
  134. await ctx.loader.await()
  135. await new Promise<void>(resolve => setImmediate(resolve))
  136. expect(changes).toEqual([])
  137. entry.lastFailure = { stage: 'update', error: 'first update failed' }
  138. ctx.emit('loader/entry-init', entry)
  139. await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
  140. ctx.emit('loader/entry-init', entry)
  141. await new Promise<void>(resolve => setImmediate(resolve))
  142. expect(changes).toEqual(['runtime'])
  143. entry.lastFailure = { stage: 'update', error: 'different update failed' }
  144. ctx.emit('loader/entry-init', entry)
  145. await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
  146. delete entry.lastFailure
  147. ctx.emit('loader/entry-init', entry)
  148. await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime', 'runtime']) })
  149. })
  150. it('shares one pending refresh across a burst of Loader events', async () => {
  151. const ctx = new Context()
  152. contexts.push(ctx)
  153. await ctx.plugin(Loader)
  154. ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
  155. const id = await ctx.loader.create({ name: 'cordis:waiting' })
  156. const entry = ctx.loader.resolve(id)
  157. const changes: string[] = []
  158. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  159. await ctx.plugin(PluginManagerRemote, CONFIG)
  160. await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
  161. const gate = Promise.withResolvers<undefined>()
  162. const whenIdle = vi.fn(() => gate.promise)
  163. ctx.provide('profileRuntime', { whenIdle } as never)
  164. const settle = vi.spyOn(ctx.loader, 'await')
  165. try {
  166. ctx.emit('loader/entry-init', entry)
  167. await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
  168. for (let i = 0; i < 100; i++) ctx.emit('loader/entry-init', entry)
  169. expect(settle).toHaveBeenCalledOnce()
  170. expect(whenIdle).toHaveBeenCalledOnce()
  171. ctx.provide('lateService', {})
  172. gate.resolve(undefined)
  173. await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
  174. expect(entry.fiber?.state).toBe(2)
  175. } finally {
  176. gate.resolve(undefined)
  177. settle.mockRestore()
  178. }
  179. })
  180. it('discards a diagnostic snapshot changed while it was being read', async () => {
  181. const ctx = new Context()
  182. contexts.push(ctx)
  183. await ctx.plugin(Loader)
  184. ctx.loader.builtins.good = () => {}
  185. const id = await ctx.loader.create({ name: 'cordis:good' })
  186. const entry = ctx.loader.resolve(id)
  187. await ctx.plugin(PluginManagerRemote, CONFIG)
  188. await new Promise<void>(resolve => setImmediate(resolve))
  189. const changes: string[] = []
  190. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  191. entry.lastFailure = { stage: 'update', error: new Error('transient failure') }
  192. const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
  193. queueMicrotask(() => {
  194. delete entry.lastFailure
  195. ctx.emit('loader/entry-init', entry)
  196. })
  197. return false
  198. })
  199. try {
  200. ctx.emit('loader/entry-init', entry)
  201. await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(2) })
  202. await new Promise<void>(resolve => setImmediate(resolve))
  203. expect(changes).toEqual([])
  204. } finally {
  205. read.mockRestore()
  206. }
  207. })
  208. it('cancels publication when disposed during the diagnostic read', async () => {
  209. const ctx = new Context()
  210. contexts.push(ctx)
  211. await ctx.plugin(Loader)
  212. ctx.loader.builtins.good = () => {}
  213. const id = await ctx.loader.create({ name: 'cordis:good' })
  214. const entry = ctx.loader.resolve(id)
  215. const remote = ctx.plugin(PluginManagerRemote, CONFIG)
  216. await remote
  217. await new Promise<void>(resolve => setImmediate(resolve))
  218. const changes: string[] = []
  219. ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
  220. entry.lastFailure = { stage: 'update', error: new Error('update failed') }
  221. const disposed = Promise.withResolvers<undefined>()
  222. const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
  223. queueMicrotask(() => { void remote.dispose().then(() => { disposed.resolve(undefined) }, disposed.reject) })
  224. return false
  225. })
  226. try {
  227. ctx.emit('loader/entry-init', entry)
  228. await disposed.promise
  229. await new Promise<void>(resolve => setImmediate(resolve))
  230. expect(changes).toEqual([])
  231. } finally {
  232. read.mockRestore()
  233. }
  234. })
  235. it('publishes the plugins namespace with one direct method per operation', async () => {
  236. const remote = await mount()
  237. expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
  238. expect(remoteMethods(remote).map(marker => marker.method)).toEqual([
  239. 'list', 'inspect', 'add', 'cancelInstall', 'uninstall', 'enable', 'disable', 'retry', 'setRowDisabled', 'dependents',
  240. ])
  241. })
  242. it('rejects malformed cancellation ids without running a manager operation', async () => {
  243. const remote = await mount()
  244. await expect(remote.cancelInstall('not-an-id' as PluginInstallRequestId)).rejects.toMatchObject({ code: 'gateway/bad-request' })
  245. await expect(remote.add('pkg', { requestId: 'not-an-id' as PluginInstallRequestId })).rejects.toMatchObject({ code: 'gateway/bad-request' })
  246. await expect(remote.cancelInstall('f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId)).resolves.toEqual({ status: 'not-running' })
  247. })
  248. it('mounts without a profile runtime and reports plugins/unavailable as a Remote error', async () => {
  249. const remote = await mount()
  250. await expect(remote.list()).rejects.toMatchObject({ isDSHRemoteError: true, code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
  251. })
  252. it('relays every operation to the manager with its arguments and answer', async () => {
  253. const calls: unknown[][] = []
  254. const stub = new Proxy({}, {
  255. get: (_target, method: string) => (...args: unknown[]) => {
  256. calls.push([method, ...args])
  257. return Promise.resolve({ method })
  258. },
  259. }) as PluginManager
  260. const remote = await mount(stub)
  261. const signal = AbortSignal.abort()
  262. await expect(remote.list()).resolves.toEqual({ method: 'list' })
  263. await remote.inspect('spec', signal)
  264. await remote.add('spec', { enable: true })
  265. await remote.cancelInstall('f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId)
  266. await remote.uninstall('pkg')
  267. await remote.enable('pkg')
  268. await remote.disable('pkg')
  269. await remote.retry('pkg')
  270. await remote.setRowDisabled('x', true)
  271. await remote.dependents('pkg')
  272. expect(calls).toEqual([
  273. ['list'],
  274. ['inspect', 'spec', signal],
  275. ['add', 'spec', { enable: true }],
  276. ['cancelInstall', 'f2340b6d-40bb-46b7-8b94-217bdf5010bd'],
  277. ['uninstall', 'pkg'],
  278. ['enable', 'pkg'],
  279. ['disable', 'pkg'],
  280. ['retry', 'pkg'],
  281. ['setRowDisabled', 'x', true],
  282. ['dependents', 'pkg'],
  283. ])
  284. })
  285. it('hands the manager readers into the context: the runtime, the agent count, and the seams', async () => {
  286. const profileDir = mkdtempSync(join(tmpdir(), 'dsh-host-plugin-manager-'))
  287. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  288. name: 'dsh-profile-web', private: true, dependencies: { pkg: '1.0.0' }, dsh: { profile: { bundles: [], patchReload: 'startup' } },
  289. }))
  290. const ctx = new Context()
  291. contexts.push(ctx)
  292. await ctx.plugin(Loader)
  293. ctx.provide('profileRuntime', {
  294. dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
  295. whenIdle: async () => {},
  296. } as never)
  297. const metadata: typeof readPackageMetadata = options => ({
  298. packageName: options.packageName, kind: 'unknown', cordisSameCopy: null, rows: [], overrides: [],
  299. })
  300. const spawn: SpawnLike = () => { throw new Error('this test spawns nothing') }
  301. class SeamedRemote extends PluginManagerRemote {
  302. constructor(context: Context, config: Config) {
  303. super(context, config, { spawn, metadata })
  304. }
  305. }
  306. await ctx.plugin(SeamedRemote, CONFIG)
  307. const remote = ctx.get('pluginManager')
  308. if (remote === undefined) throw new Error('the Remote did not mount')
  309. try {
  310. // The runtime reader and the spawn seam: without an agent registry nothing runs, so the install reaches pnpm.
  311. await expect(remote.add('anything')).rejects.toThrow('this test spawns nothing')
  312. // The agent-count reader: a running session refuses an install before pnpm runs.
  313. ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
  314. await expect(remote.add('anything')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
  315. await expect(remote.enable('pkg')).rejects.toMatchObject({ code: 'plugins/not-enableable' })
  316. } finally {
  317. rmSync(profileDir, { recursive: true, force: true })
  318. }
  319. })
  320. it('turns a manager failure into the Remote error of the same code and lets any other error through', async () => {
  321. const busy = new PluginOperationError('plugins/busy', 'busy', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } })
  322. const failing = {
  323. list: () => Promise.reject(busy),
  324. add: () => Promise.reject(new Error('the manager broke')),
  325. } as unknown as PluginManager
  326. const remote = await mount(failing)
  327. const error = await remote.list().catch((caught: unknown) => caught)
  328. expect(error).toBeInstanceOf(RemoteError)
  329. expect(error).toMatchObject({ code: 'plugins/busy', message: 'busy', details: busy.details, cause: busy })
  330. await expect(remote.add('x')).rejects.toThrow('the manager broke')
  331. })
  332. })
  333. describe('remoteErrorOf', () => {
  334. it('keeps every plugins/* code with its details and maps the generic refusal to gateway/bad-request', () => {
  335. const failures: PluginOperationFailure[] = [
  336. new PluginOperationError('plugins/unavailable', 'm', { reason: 'no profile runtime' }),
  337. new PluginOperationError('plugins/not-installed', 'm', { packageName: 'p' }),
  338. new PluginOperationError('plugins/not-enableable', 'm', { packageName: 'p', reason: 'r' }),
  339. new PluginOperationError('plugins/enable-failed', 'm', { packageName: 'p', reason: 'r' }),
  340. new PluginOperationError('plugins/install-cancelled', 'm', { requestId: 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId }),
  341. new PluginOperationError('plugins/install-failed', 'm', { spec: 's', exitCode: 1, log: 'l', kind: 'unknown' }),
  342. new PluginOperationError('plugins/inspect-rejected', 'm', { spec: 's', problem: 'not-found', reason: 'r' }),
  343. new PluginOperationError('plugins/busy', 'm', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } }),
  344. new PluginOperationError('plugins/agents-running', 'm', { operation: 'add', running: 1 }),
  345. ]
  346. for (const failure of failures) {
  347. const error = remoteErrorOf(failure)
  348. expect(error).toBeInstanceOf(RemoteError)
  349. expect(error).toMatchObject({ code: failure.code, message: 'm', details: failure.details, cause: failure })
  350. }
  351. const refused = new PluginOperationError('plugins/bad-request', 'nothing to do', {})
  352. expect(remoteErrorOf(refused)).toMatchObject({ code: 'gateway/bad-request', message: 'nothing to do', details: {}, cause: refused })
  353. })
  354. })
  355. describe('RemoteError codes', () => {
  356. it('declare their details', () => {
  357. const error = new RemoteError('plugins/not-installed', 'absent', { packageName: 'x' })
  358. expect(error.details).toEqual({ packageName: 'x' })
  359. })
  360. })