manager.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. /** Persistent manager behavior through a real profile Include and Loader. */
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import type { Context } from '@deepseek-ai/cordis'
  6. import type AgentRegistry from '@deepseek-ai/dsh-agent'
  7. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  9. import { expect, it, onTestFinished, vi } from 'vitest'
  10. import {
  11. boot, composeEntries, initProfile, readProfilePatches, readProfileManifest,
  12. type ProfileContext,
  13. } from '@deepseek-ai/dsh-app-boot'
  14. import PluginManager, { type Config } from '../src/index.ts'
  15. import Hmr from '@deepseek-ai/dsh-hmr'
  16. import Timer from '@deepseek-ai/cordis-plugin-timer'
  17. import { Group } from '@deepseek-ai/cordis-plugin-loader'
  18. import * as operations from '../src/operations.ts'
  19. async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
  20. const home = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
  21. const dir = join(home, 'profiles', 'test')
  22. const anchor = join(home, 'package.json')
  23. writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
  24. initProfile(dir, ['core', 'extra'])
  25. const bundle = (name: string, rows: unknown[]) => {
  26. const path = join(dir, 'node_modules', name)
  27. mkdirSync(path, { recursive: true })
  28. writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  29. writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }]))
  30. writeFileSync(join(path, 'plugin.mjs'), 'export function apply(ctx, config) { if (config?.fail) throw new Error("test activation failed"); ctx.provide(config?.service ?? "managedProbe", true) }\n')
  31. }
  32. bundle('core', [{ id: 'manager', name: 'cordis:manager', config }])
  33. bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
  34. const manifest = readProfileManifest('test', dir)
  35. manifest.dependencies = { extra: '1.0.0' }
  36. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  37. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  38. const profile: ProfileContext = {
  39. name: 'test',
  40. startedBundles: ['core', 'extra'],
  41. dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
  42. overlays: overlay ? [{ id: 'managed', disabled: true }] : [], telemetryDisabledEnv: undefined,
  43. }
  44. const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => {
  45. ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } })
  46. prepare?.(ctx)
  47. ctx.provide('profileContext', profile)
  48. ctx.loader.builtins.manager = PluginManager
  49. })
  50. onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
  51. let stopHmr = async () => {}
  52. if (reload === 'live') {
  53. await ctx.plugin(Timer)
  54. const owner = await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
  55. stopHmr = () => owner.dispose()
  56. await ctx.hmr.runExclusive(async () => {})
  57. }
  58. return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr }
  59. }
  60. it('lists bundle versions and current-profile plugin targets', async () => {
  61. const { manager } = await fixture()
  62. const plugins = await manager.listPlugins()
  63. expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true })
  64. expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined()
  65. expect(await manager.listBundles()).toEqual([
  66. { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'This bundle provides plugin management components' },
  67. { name: 'extra', version: '1.0.0', enabled: true, removable: true },
  68. ])
  69. })
  70. it('turns a plugin off and on without duplicating patch overrides', async () => {
  71. const { manager, dir } = await fixture()
  72. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  73. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  74. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  75. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' })
  76. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' })
  77. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1)
  78. })
  79. it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => {
  80. const { manager, dir, bundle } = await fixture()
  81. bundle('third', [])
  82. await manager.setBundleEnabled('third', true)
  83. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
  84. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  85. expect((await manager.listPlugins()).some(row => row.patchId === 'managed')).toBe(false)
  86. await manager.setBundleEnabled('extra', true)
  87. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'third', 'extra'])
  88. })
  89. it('reports an overlay overriding a saved plugin toggle', async () => {
  90. const { manager } = await fixture('live', true)
  91. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  92. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' })
  93. })
  94. it('saves startup-only toggles and refuses removal of currently used packages', async () => {
  95. const { manager } = await fixture('startup')
  96. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  97. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
  98. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
  99. await manager.setBundleEnabled('extra', false)
  100. expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' })
  101. })
  102. it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
  103. const { manager } = await fixture()
  104. const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
  105. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' })
  106. expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' })
  107. expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' })
  108. expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' })
  109. })
  110. it('installs only valid bundle declarations and honors installation without activation', async () => {
  111. const { manager, dir, bundle } = await fixture()
  112. const initial = readProfileManifest('test', dir)
  113. delete initial.dependencies
  114. writeFileSync(join(dir, 'package.json'), JSON.stringify(initial))
  115. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  116. const name = String(args[1])
  117. bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
  118. const manifest = readProfileManifest('test', dir)
  119. manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
  120. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  121. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  122. })
  123. onTestFinished(() => { install.mockRestore() })
  124. expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } })
  125. expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
  126. expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
  127. expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
  128. expect(await manager.installBundle('another-bundle')).toMatchObject({ application: 'applied' })
  129. expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
  130. })
  131. it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
  132. const { manager, dir, ctx } = await fixture()
  133. const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  134. expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
  135. return { exitCode: 1, output: 'removal failed', truncated: false, logPath: join(dir, 'pnpm.log') }
  136. })
  137. onTestFinished(() => { remove.mockRestore() })
  138. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 1 } })
  139. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  140. expect((await manager.listBundles()).find(row => row.name === 'extra')?.enabled).toBe(false)
  141. rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
  142. remove.mockImplementationOnce(async () => {
  143. const manifest = readProfileManifest('test', dir)
  144. delete manifest.dependencies?.extra
  145. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  146. return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
  147. })
  148. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'applied' })
  149. expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
  150. })
  151. it('reports package failure separately from partial disk changes', async () => {
  152. const { manager, dir } = await fixture()
  153. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  154. const manifest = readProfileManifest('test', dir)
  155. manifest.dependencies = { ...manifest.dependencies, partial: '1' }
  156. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  157. return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') }
  158. })
  159. onTestFinished(() => { install.mockRestore() })
  160. expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } })
  161. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
  162. expect((await manager.listBundles()).find(row => row.name === 'partial')?.error).toContain('cannot resolve profile bundle')
  163. })
  164. it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
  165. const { manager, dir } = await fixture()
  166. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: true }\n')
  167. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  168. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'failed' })
  169. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: false')
  170. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: false }\n')
  171. const result = await manager.setPluginEnabled(id, true)
  172. expect(result, JSON.stringify(result)).toMatchObject({ application: 'applied' })
  173. })
  174. it('combines concurrent changes into durable notices without waking Agents', async () => {
  175. const session = Session.create(SessionId('manager-notices'))
  176. const wake = vi.fn()
  177. const notices: UserMessage[] = []
  178. const liveAgents = [{
  179. inject(message: UserMessage) {
  180. notices.push(message)
  181. session.append('user/message', message, { surfaceOp: 'append' })
  182. }, followup: wake, steer: wake,
  183. }, { inject() { throw new Error('already disposed') } }]
  184. const agents = { list: () => liveAgents }
  185. const { manager } = await fixture('live', false, (ctx) => { ctx.provide('agents', agents as unknown as AgentRegistry) })
  186. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  187. await Promise.all([manager.setPluginEnabled(id, false), manager.setPluginEnabled(id, true)])
  188. expect(notices).toHaveLength(1)
  189. expect(JSON.stringify(notices)).toContain('disabled')
  190. expect(JSON.stringify(notices)).toContain('enabled')
  191. expect(wake).not.toHaveBeenCalled()
  192. expect(session.snapshotEvents().filter(row => row.type === 'user/message')).toHaveLength(1)
  193. })
  194. it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
  195. const { manager, dir, profile } = await fixture()
  196. writeFileSync(profile.installAnchor, '{}')
  197. writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
  198. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: 'Not a bundle: extra' })
  199. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
  200. expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
  201. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
  202. writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
  203. expect((await manager.listBundles())[0]?.version).toBeUndefined()
  204. writeFileSync(join(dir, 'package.json'), '{}')
  205. expect(await manager.listBundles()).toEqual([])
  206. expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' })
  207. writeFileSync(profile.installAnchor, '{"dependencies":{"missing-builtin":"1"}}')
  208. expect(await manager.listBundles()).toEqual([])
  209. })
  210. it('refuses management bundle disablement and permits repeated bundle selections', async () => {
  211. const { manager } = await fixture()
  212. expect(await manager.setBundleEnabled('core', false)).toMatchObject({ application: 'failed', changed: false })
  213. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ application: 'applied', changed: false })
  214. })
  215. it('addresses children inside profile groups and marks ambiguous ids read-only', async () => {
  216. const { manager, bundle, profile } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group })
  217. bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true,
  218. config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }])
  219. expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' })
  220. expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined()
  221. const entries = composeEntries([readProfilePatches('test', profile)])
  222. const duplicate = entries.find(row => row.id === 'managed')!
  223. writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }]))
  224. expect((await manager.listPlugins()).find(row => row.entryId === 'include:managed')?.readOnlyReason).toContain('not uniquely addressable')
  225. })
  226. it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
  227. const { manager } = await fixture()
  228. expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
  229. })
  230. it('retains a successful install that cannot be activated as a bundle', async () => {
  231. const { manager, dir, bundle } = await fixture()
  232. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  233. bundle('plain', [])
  234. writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
  235. const manifest = readProfileManifest('test', dir)
  236. manifest.dependencies = { ...manifest.dependencies, plain: '1' }
  237. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  238. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  239. })
  240. onTestFinished(() => { install.mockRestore() })
  241. expect(await manager.installBundle('plain')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 0 } })
  242. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
  243. })
  244. it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => {
  245. const { manager, dir } = await fixture()
  246. const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') })
  247. onTestFinished(() => { install.mockRestore() })
  248. expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
  249. expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' })
  250. expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed' })
  251. install.mockImplementationOnce(async () => {
  252. writeFileSync(join(dir, 'package.json'), '{}')
  253. return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
  254. })
  255. expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' })
  256. })
  257. it('handles missing patch files and retains non-Error package diagnostics', async () => {
  258. const { manager, dir } = await fixture()
  259. rmSync(join(dir, 'cordis.patch.yml'))
  260. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  261. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  262. const install = vi.spyOn(operations, 'runProfilePnpm').mockRejectedValueOnce('pnpm rejected operation')
  263. onTestFinished(() => { install.mockRestore() })
  264. expect(await manager.installBundle('new')).toMatchObject({ changed: false, application: 'failed', message: 'pnpm rejected operation' })
  265. rmSync(join(dir, 'cordis.patch.yml'))
  266. mkdirSync(join(dir, 'cordis.patch.yml'))
  267. await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
  268. })
  269. it('bounds batched notices and discloses omitted operation results', async () => {
  270. const messages: UserMessage[] = []
  271. const { manager } = await fixture('live', false, (ctx) => {
  272. ctx.provide('agents', { list: () => [{ inject: (message: UserMessage) => { messages.push(message) } }] } as unknown as AgentRegistry)
  273. }, { outputBytes: 1, notificationDelayMs: 0 })
  274. await manager.setBundleEnabled('extra', false)
  275. expect(JSON.stringify(messages)).toContain('1 additional operations omitted')
  276. })
  277. it('applies a manager change through the active HMR service', async () => {
  278. const { manager } = await fixture()
  279. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  280. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  281. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  282. })
  283. it('refuses removal of a hot-installed bundle after HMR is disabled', async () => {
  284. const { ctx, manager, dir, bundle, stopHmr } = await fixture()
  285. bundle('later', [{ id: 'later', name: './plugin.mjs', config: { service: 'laterProbe' } }])
  286. const manifest = readProfileManifest('test', dir)
  287. manifest.dependencies = { ...manifest.dependencies, later: '1' }
  288. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  289. expect(await manager.setBundleEnabled('later', true)).toMatchObject({ application: 'applied' })
  290. await stopHmr()
  291. expect(ctx.get('hmr')).toBeUndefined()
  292. expect(await manager.setBundleEnabled('later', false)).toMatchObject({ application: 'restart-required' })
  293. expect(ctx.get('laterProbe')).toBe(true)
  294. expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
  295. })