plugin-manager.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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,
  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, 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('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', 'add', 'uninstall', 'enable', 'disable', 'retry', 'setRowDisabled', 'dependents',
  240. ])
  241. })
  242. it('mounts without a profile runtime and reports plugins/unavailable as a Remote error', async () => {
  243. const remote = await mount()
  244. await expect(remote.list()).rejects.toMatchObject({ isDSHRemoteError: true, code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
  245. })
  246. it('relays every operation to the manager with its arguments and answer', async () => {
  247. const calls: unknown[][] = []
  248. const stub = new Proxy({}, {
  249. get: (_target, method: string) => (...args: unknown[]) => {
  250. calls.push([method, ...args])
  251. return Promise.resolve({ method })
  252. },
  253. }) as PluginManager
  254. const remote = await mount(stub)
  255. await expect(remote.list()).resolves.toEqual({ method: 'list' })
  256. await remote.add('spec', { enable: true })
  257. await remote.uninstall('pkg')
  258. await remote.enable('pkg')
  259. await remote.disable('pkg')
  260. await remote.retry('pkg')
  261. await remote.setRowDisabled('x', true)
  262. await remote.dependents('pkg')
  263. expect(calls).toEqual([
  264. ['list'],
  265. ['add', 'spec', { enable: true }],
  266. ['uninstall', 'pkg'],
  267. ['enable', 'pkg'],
  268. ['disable', 'pkg'],
  269. ['retry', 'pkg'],
  270. ['setRowDisabled', 'x', true],
  271. ['dependents', 'pkg'],
  272. ])
  273. })
  274. it('hands the manager readers into the context: the runtime, the agent count, and the seams', async () => {
  275. const profileDir = mkdtempSync(join(tmpdir(), 'dsh-host-plugin-manager-'))
  276. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  277. name: 'dsh-profile-web', private: true, dependencies: { pkg: '1.0.0' }, dsh: { profile: { bundles: [], patchReload: 'startup' } },
  278. }))
  279. const ctx = new Context()
  280. contexts.push(ctx)
  281. await ctx.plugin(Loader)
  282. ctx.provide('profileRuntime', {
  283. dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
  284. whenIdle: async () => {},
  285. } as never)
  286. const metadata: typeof readPackageMetadata = options => ({
  287. packageName: options.packageName, kind: 'unknown', cordisSameCopy: null, rows: [], overrides: [],
  288. })
  289. const spawn: SpawnLike = () => { throw new Error('this test spawns nothing') }
  290. class SeamedRemote extends PluginManagerRemote {
  291. constructor(context: Context, config: Config) {
  292. super(context, config, { spawn, metadata })
  293. }
  294. }
  295. await ctx.plugin(SeamedRemote, CONFIG)
  296. const remote = ctx.get('pluginManager')
  297. if (remote === undefined) throw new Error('the Remote did not mount')
  298. try {
  299. // The runtime reader and the spawn seam: without an agent registry nothing runs, so the install reaches pnpm.
  300. await expect(remote.add('anything')).rejects.toThrow('this test spawns nothing')
  301. // The agent-count reader: a running session refuses an install before pnpm runs.
  302. ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
  303. await expect(remote.add('anything')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
  304. await expect(remote.enable('pkg')).rejects.toMatchObject({ code: 'plugins/not-enableable' })
  305. } finally {
  306. rmSync(profileDir, { recursive: true, force: true })
  307. }
  308. })
  309. it('turns a manager failure into the Remote error of the same code and lets any other error through', async () => {
  310. const busy = new PluginOperationError('plugins/busy', 'busy', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } })
  311. const failing = {
  312. list: () => Promise.reject(busy),
  313. add: () => Promise.reject(new Error('the manager broke')),
  314. } as unknown as PluginManager
  315. const remote = await mount(failing)
  316. const error = await remote.list().catch((caught: unknown) => caught)
  317. expect(error).toBeInstanceOf(RemoteError)
  318. expect(error).toMatchObject({ code: 'plugins/busy', message: 'busy', details: busy.details, cause: busy })
  319. await expect(remote.add('x')).rejects.toThrow('the manager broke')
  320. })
  321. })
  322. describe('remoteErrorOf', () => {
  323. it('keeps every plugins/* code with its details and maps the generic refusal to gateway/bad-request', () => {
  324. const failures: PluginOperationFailure[] = [
  325. new PluginOperationError('plugins/unavailable', 'm', { reason: 'no profile runtime' }),
  326. new PluginOperationError('plugins/not-installed', 'm', { packageName: 'p' }),
  327. new PluginOperationError('plugins/not-enableable', 'm', { packageName: 'p', reason: 'r' }),
  328. new PluginOperationError('plugins/enable-failed', 'm', { packageName: 'p', reason: 'r' }),
  329. new PluginOperationError('plugins/install-failed', 'm', { spec: 's', exitCode: 1, log: 'l' }),
  330. new PluginOperationError('plugins/busy', 'm', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } }),
  331. new PluginOperationError('plugins/agents-running', 'm', { operation: 'add', running: 1 }),
  332. ]
  333. for (const failure of failures) {
  334. const error = remoteErrorOf(failure)
  335. expect(error).toBeInstanceOf(RemoteError)
  336. expect(error).toMatchObject({ code: failure.code, message: 'm', details: failure.details, cause: failure })
  337. }
  338. const refused = new PluginOperationError('plugins/bad-request', 'nothing to do', {})
  339. expect(remoteErrorOf(refused)).toMatchObject({ code: 'gateway/bad-request', message: 'nothing to do', details: {}, cause: refused })
  340. })
  341. })
  342. describe('RemoteError codes', () => {
  343. it('declare their details', () => {
  344. const error = new RemoteError('plugins/not-installed', 'absent', { packageName: 'x' })
  345. expect(error.details).toEqual({ packageName: 'x' })
  346. })
  347. })