manager.spec.ts 18 KB

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