manager.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. /** Persistent manager behavior through a real profile Include and Loader. */
  2. import { existsSync, 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, reconcileProfilePatches,
  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 type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  18. import { Group } from '@deepseek-ai/cordis-plugin-loader'
  19. import * as operations from '../src/operations.ts'
  20. import { parse } from 'yaml'
  21. import { execa } from 'execa'
  22. async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
  23. const home = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
  24. const dir = join(home, 'profiles', 'test')
  25. const anchor = join(home, 'package.json')
  26. writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
  27. initProfile(dir, ['core', 'extra'])
  28. const bundle = (name: string, rows: unknown[]) => {
  29. const path = join(dir, 'node_modules', name)
  30. mkdirSync(path, { recursive: true })
  31. writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  32. writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }]))
  33. 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')
  34. }
  35. bundle('core', [{ id: 'manager', name: 'cordis:manager', config }])
  36. bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
  37. const manifest = readProfileManifest('test', dir)
  38. manifest.dependencies = { extra: '1.0.0' }
  39. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  40. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  41. const overlays: PatchOptions[] = overlay ? [{ id: 'managed', disabled: true }] : []
  42. const profile: ProfileContext = {
  43. name: 'test',
  44. startedBundles: ['core', 'extra'],
  45. dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
  46. overlays, telemetryDisabledEnv: undefined,
  47. }
  48. const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => {
  49. ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } })
  50. prepare?.(ctx)
  51. ctx.provide('profileContext', profile)
  52. ctx.loader.builtins.manager = PluginManager
  53. })
  54. onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
  55. let stopHmr = async () => {}
  56. if (reload === 'live') {
  57. await ctx.plugin(Timer)
  58. const owner = await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
  59. stopHmr = () => owner.dispose()
  60. await ctx.hmr.runExclusive(async () => {})
  61. }
  62. return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays }
  63. }
  64. it('lists bundle versions and current-profile plugin targets', async () => {
  65. const { manager } = await fixture()
  66. const plugins = await manager.listPlugins()
  67. expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true })
  68. expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined()
  69. expect(await manager.listBundles()).toEqual([
  70. { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'management-required' },
  71. { name: 'extra', version: '1.0.0', enabled: true, removable: true },
  72. ])
  73. })
  74. it('turns a plugin off and on without duplicating patch overrides', async () => {
  75. const { manager, dir } = await fixture()
  76. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  77. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  78. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  79. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' })
  80. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' })
  81. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1)
  82. })
  83. it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => {
  84. const { manager, dir, bundle } = await fixture()
  85. bundle('third', [])
  86. await manager.setBundleEnabled('third', true)
  87. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
  88. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  89. expect((await manager.listPlugins()).some(row => row.patchId === 'managed')).toBe(false)
  90. await manager.setBundleEnabled('extra', true)
  91. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'third', 'extra'])
  92. })
  93. it('reports an overlay overriding a saved plugin toggle', async () => {
  94. const { manager } = await fixture('live', true)
  95. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  96. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' })
  97. })
  98. it('saves startup-only toggles and refuses removal of currently used packages', async () => {
  99. const { manager } = await fixture('startup')
  100. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  101. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
  102. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
  103. await manager.setBundleEnabled('extra', false)
  104. expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' })
  105. })
  106. it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
  107. const { manager } = await fixture()
  108. const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
  109. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' })
  110. expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' })
  111. expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' })
  112. expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' })
  113. })
  114. it('installs only valid bundle declarations and honors installation without activation', async () => {
  115. const { manager, dir, bundle } = await fixture()
  116. const initial = readProfileManifest('test', dir)
  117. delete initial.dependencies
  118. writeFileSync(join(dir, 'package.json'), JSON.stringify(initial))
  119. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  120. const name = String(args[1])
  121. bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
  122. const manifest = readProfileManifest('test', dir)
  123. manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
  124. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  125. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  126. })
  127. onTestFinished(() => { install.mockRestore() })
  128. expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } })
  129. expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
  130. expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
  131. expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
  132. expect(await manager.installBundle('another-bundle')).toMatchObject({ application: 'applied' })
  133. expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
  134. })
  135. it('cleans a blocked installation and retries only after explicit profile build approval', async () => {
  136. const { manager, dir, bundle } = await fixture()
  137. const policy = join(dir, 'pnpm-workspace.yaml')
  138. const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  139. const manifest = readProfileManifest('test', dir)
  140. const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
  141. if (args[0] === 'remove') {
  142. delete manifest.dependencies?.addon
  143. } else {
  144. manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
  145. if (run.mock.calls.length === 1) {
  146. writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n')
  147. completion.exitCode = 1
  148. completion.output = 'ERR_PNPM_IGNORED_BUILDS'
  149. } else {
  150. expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
  151. bundle('addon', [])
  152. }
  153. }
  154. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  155. return completion
  156. })
  157. onTestFinished(() => { run.mockRestore() })
  158. expect(await manager.installBundle('addon')).toMatchObject({
  159. application: 'failed', pendingBuilds: ['native'], cleanup: { name: 'addon' },
  160. })
  161. expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
  162. expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } })
  163. expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false })
  164. expect(run).toHaveBeenCalledTimes(2)
  165. expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ application: 'applied', changed: true })
  166. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
  167. })
  168. it('retains approved policy and reports it as changed when the registry fails before adding a dependency', async () => {
  169. const { manager, dir } = await fixture()
  170. writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'allowBuilds:\n native: set this to true or false\n')
  171. const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'registry unavailable', truncated: false, logPath: '/log' })
  172. onTestFinished(() => { run.mockRestore() })
  173. expect(await manager.installBundle('addon', { approvedBuilds: ['native'] })).toMatchObject({
  174. changed: true, application: 'failed', pendingBuilds: [], approvedBuilds: ['native'], error: { diagnostic: 'registry unavailable' },
  175. })
  176. expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } })
  177. })
  178. it('runs a real pnpm dependency script only after approval and cleanup retry', async () => {
  179. const { manager, dir, profile } = await fixture('startup')
  180. const addon = join(profile.cwd, 'addon')
  181. mkdirSync(addon)
  182. writeFileSync(join(addon, 'package.json'), JSON.stringify({ name: 'approval-fixture-addon', version: '1.0.0',
  183. scripts: { install: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  184. writeFileSync(join(addon, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n')
  185. writeFileSync(join(addon, 'cordis.patch.yml'), '[]\n')
  186. await execa('pnpm', ['pack', '--pack-destination', profile.cwd], { cwd: addon })
  187. const spec = 'file:./approval-fixture-addon-1.0.0.tgz'
  188. writeFileSync(join(dir, 'package.json'), '{"name":"approval-fixture","private":true}\n')
  189. writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n')
  190. const blocked = await manager.installBundle(spec, { enabled: false })
  191. expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', cleanup: { name: 'approval-fixture-addon' } })
  192. expect(blocked.pendingBuilds).toHaveLength(1)
  193. const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt')
  194. expect(existsSync(built)).toBe(false)
  195. expect(readProfileManifest('test', dir).dependencies?.['approval-fixture-addon']).toBeUndefined()
  196. const allowed = await manager.installBundle(spec, { enabled: false, approvedBuilds: blocked.pendingBuilds! })
  197. expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } })
  198. expect(readFileSync(built, 'utf8')).toBe('built')
  199. })
  200. it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
  201. const { manager, dir, ctx } = await fixture()
  202. const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  203. await ctx.hmr.runExclusive(async () => {
  204. expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
  205. })
  206. return { exitCode: 1, output: 'removal failed', truncated: false, logPath: join(dir, 'pnpm.log') }
  207. })
  208. onTestFinished(() => { remove.mockRestore() })
  209. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 1 } })
  210. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  211. expect((await manager.listBundles()).find(row => row.name === 'extra')?.enabled).toBe(false)
  212. rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
  213. remove.mockImplementationOnce(async () => {
  214. const manifest = readProfileManifest('test', dir)
  215. delete manifest.dependencies?.extra
  216. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  217. return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
  218. })
  219. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'applied' })
  220. expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
  221. })
  222. it('reports package failure separately from partial disk changes', async () => {
  223. const { manager, dir } = await fixture()
  224. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  225. const manifest = readProfileManifest('test', dir)
  226. manifest.dependencies = { ...manifest.dependencies, partial: '1' }
  227. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  228. return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') }
  229. })
  230. onTestFinished(() => { install.mockRestore() })
  231. expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } })
  232. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
  233. expect((await manager.listBundles()).find(row => row.name === 'partial')?.error?.diagnostic).toContain('cannot resolve profile bundle')
  234. })
  235. it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
  236. const { manager, dir } = await fixture()
  237. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: true }\n')
  238. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  239. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'failed' })
  240. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: false')
  241. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: false }\n')
  242. const result = await manager.setPluginEnabled(id, true)
  243. expect(result, JSON.stringify(result)).toMatchObject({ application: 'applied' })
  244. })
  245. it('combines concurrent changes into durable notices without waking Agents', async () => {
  246. const session = Session.create(SessionId('manager-notices'))
  247. const wake = vi.fn()
  248. const notices: UserMessage[] = []
  249. const liveAgents = [{
  250. inject(message: UserMessage) {
  251. notices.push(message)
  252. session.append('user/message', message, { surfaceOp: 'append' })
  253. }, followup: wake, steer: wake,
  254. }, { inject() { throw new Error('already disposed') } }]
  255. const agents = { list: () => liveAgents }
  256. const { manager } = await fixture('live', false, (ctx) => { ctx.provide('agents', agents as unknown as AgentRegistry) })
  257. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  258. await Promise.all([manager.setPluginEnabled(id, false), manager.setPluginEnabled(id, true)])
  259. expect(notices).toHaveLength(1)
  260. const noticeText = notices[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
  261. expect(noticeText).toContain('"enabled":false')
  262. expect(noticeText).toContain('"enabled":true')
  263. expect(wake).not.toHaveBeenCalled()
  264. expect(session.snapshotEvents().filter(row => row.type === 'user/message')).toHaveLength(1)
  265. })
  266. it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
  267. const { manager, dir, profile } = await fixture()
  268. writeFileSync(profile.installAnchor, '{}')
  269. writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
  270. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } })
  271. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
  272. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
  273. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
  274. writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
  275. expect((await manager.listBundles())[0]?.version).toBeUndefined()
  276. writeFileSync(join(dir, 'package.json'), '{}')
  277. expect(await manager.listBundles()).toEqual([])
  278. expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' })
  279. writeFileSync(profile.installAnchor, '{"dependencies":{"missing-builtin":"1"}}')
  280. expect(await manager.listBundles()).toEqual([])
  281. })
  282. it('refuses management bundle disablement and permits repeated bundle selections', async () => {
  283. const { manager } = await fixture()
  284. expect(await manager.setBundleEnabled('core', false)).toMatchObject({ application: 'failed', changed: false })
  285. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ application: 'applied', changed: false })
  286. })
  287. it.each([
  288. '@deepseek-ai/dsh-host-plugin-inventory',
  289. '@deepseek-ai/dsh-typert-registry',
  290. '@deepseek-ai/dsh-api-remotes',
  291. ])('protects the management dependency %s and its containing bundle', async (name) => {
  292. const { ctx, manager, bundle, profile, dir } = await fixture('startup')
  293. bundle('extra', [{ id: 'dependency', name, disabled: true }])
  294. await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
  295. const entry = (await manager.listPlugins()).find(row => row.moduleName === name)!
  296. expect(entry).toMatchObject({ readOnlyReason: 'management-required' })
  297. const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
  298. const patch = readFileSync(profile.patchPath, 'utf8')
  299. expect(await manager.setPluginEnabled(entry.entryId, false)).toMatchObject({
  300. changed: false, application: 'failed', error: { code: 'management-required' },
  301. })
  302. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
  303. removable: false, readOnlyReason: 'management-required',
  304. })
  305. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({
  306. changed: false, application: 'failed', error: { code: 'management-required' },
  307. })
  308. expect(await manager.removeBundle('extra')).toMatchObject({
  309. changed: false, application: 'failed', error: { code: 'not-removable' },
  310. })
  311. expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
  312. expect(readFileSync(profile.patchPath, 'utf8')).toBe(patch)
  313. })
  314. it('addresses children inside profile groups and marks ambiguous ids read-only', async () => {
  315. const { manager, bundle, profile } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group })
  316. bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true,
  317. config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }])
  318. expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' })
  319. expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined()
  320. const entries = composeEntries([readProfilePatches('test', profile)])
  321. const duplicate = entries.find(row => row.id === 'managed')!
  322. writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }]))
  323. expect((await manager.listPlugins()).find(row => row.entryId === 'include:managed')?.readOnlyReason).toBe('unaddressable')
  324. })
  325. it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
  326. const { manager } = await fixture()
  327. expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
  328. })
  329. it.each([0, 1])('cleans a newly added non-bundle once and exposes leftovers when cleanup exits %i', async (cleanupExit) => {
  330. const { manager, dir, bundle } = await fixture()
  331. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  332. const manifest = readProfileManifest('test', dir)
  333. if (args[0] === 'add') {
  334. bundle('plain', [])
  335. writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
  336. manifest.dependencies = { ...manifest.dependencies, plain: '1' }
  337. } else if (cleanupExit === 0) {
  338. delete manifest.dependencies?.plain
  339. rmSync(join(dir, 'node_modules', 'plain'), { recursive: true })
  340. }
  341. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  342. return { exitCode: args[0] === 'add' ? 0 : cleanupExit, output: 'package diagnostic', truncated: false, logPath: join(dir, `${args[0]}.log`) }
  343. })
  344. onTestFinished(() => { install.mockRestore() })
  345. expect(await manager.installBundle('plain')).toMatchObject({ changed: cleanupExit !== 0, application: 'failed', stage: 'install',
  346. error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
  347. cleanup: { name: 'plain', packageResult: { exitCode: cleanupExit } },
  348. remainingDependencies: cleanupExit === 0 ? [] : ['plain'] })
  349. expect(install.mock.calls.map(call => call[1])).toEqual([['add', 'plain'], ['remove', 'plain']])
  350. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
  351. const leftover = (await manager.listBundles()).find(row => row.name === 'plain')
  352. if (cleanupExit === 0) expect(leftover).toBeUndefined()
  353. else {
  354. expect(leftover).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
  355. expect(await manager.removeBundle('plain')).toMatchObject({ stage: 'remove', application: 'failed' })
  356. }
  357. })
  358. it('never removes an existing dependency after installation validation fails', async () => {
  359. const { manager, dir } = await fixture()
  360. writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
  361. const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/operation.log' })
  362. onTestFinished(() => { install.mockRestore() })
  363. const result = await manager.installBundle('extra')
  364. expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } })
  365. expect(result.cleanup).toBeUndefined()
  366. expect(install).toHaveBeenCalledOnce()
  367. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  368. })
  369. it('keeps a valid installed bundle when its subsequent activation fails', async () => {
  370. const { manager, dir, bundle } = await fixture()
  371. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  372. bundle('broken', [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }])
  373. const manifest = readProfileManifest('test', dir)
  374. manifest.dependencies = { ...manifest.dependencies, broken: '1' }
  375. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  376. return { exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }
  377. })
  378. onTestFinished(() => { install.mockRestore() })
  379. const result = await manager.installBundle('broken')
  380. expect(result).toMatchObject({ application: 'failed', stage: 'enable', packageResult: { exitCode: 0 } })
  381. expect(result.cleanup).toBeUndefined()
  382. expect(install).toHaveBeenCalledOnce()
  383. expect((await manager.listBundles()).find(row => row.name === 'broken')).toMatchObject({ enabled: true, removable: true })
  384. })
  385. it('returns unchanged failures as warnings while toggling and removing another bundle', async () => {
  386. const { manager, dir, bundle, ctx } = await fixture()
  387. bundle('broken', [
  388. { id: 'broken', name: './plugin.mjs', config: { fail: true } },
  389. { id: 'missing', name: './missing.mjs' },
  390. { id: 'pending', name: './pending.mjs' },
  391. ])
  392. writeFileSync(join(dir, 'node_modules/broken/pending.mjs'), 'export const inject = ["unavailable"]; export function apply() {}')
  393. expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
  394. const brokenId = (await manager.listPlugins()).find(row => row.patchId === 'broken')!.entryId
  395. expect(await manager.setPluginEnabled(brokenId, true)).toMatchObject({ application: 'failed' })
  396. expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
  397. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  398. const changed = await manager.setPluginEnabled(id, false)
  399. expect(changed).toMatchObject({ application: 'applied' })
  400. expect(changed.warnings).toHaveLength(3)
  401. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ application: 'applied' })
  402. const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  403. expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
  404. const manifest = readProfileManifest('test', dir)
  405. delete manifest.dependencies?.extra
  406. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  407. return { exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }
  408. })
  409. onTestFinished(() => { remove.mockRestore() })
  410. const removed = await manager.removeBundle('extra')
  411. expect(removed.application).toBe('applied')
  412. expect(removed.warnings).toHaveLength(3)
  413. expect(remove).toHaveBeenCalledOnce()
  414. })
  415. it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => {
  416. const { manager, dir } = await fixture()
  417. const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') })
  418. onTestFinished(() => { install.mockRestore() })
  419. expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
  420. expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' })
  421. expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed' })
  422. install.mockImplementationOnce(async () => {
  423. writeFileSync(join(dir, 'package.json'), '{}')
  424. return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
  425. })
  426. expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' })
  427. })
  428. it('handles missing patch files and retains non-Error package diagnostics', async () => {
  429. const { manager, dir } = await fixture()
  430. rmSync(join(dir, 'cordis.patch.yml'))
  431. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  432. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  433. const install = vi.spyOn(operations, 'runProfilePnpm').mockRejectedValueOnce('pnpm rejected operation')
  434. onTestFinished(() => { install.mockRestore() })
  435. expect(await manager.installBundle('new')).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error', diagnostic: 'pnpm rejected operation' } })
  436. rmSync(join(dir, 'cordis.patch.yml'))
  437. mkdirSync(join(dir, 'cordis.patch.yml'))
  438. await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
  439. })
  440. it('bounds batched notices and discloses omitted operation results', async () => {
  441. const messages: UserMessage[] = []
  442. const { manager } = await fixture('live', false, (ctx) => {
  443. ctx.provide('agents', { list: () => [{ inject: (message: UserMessage) => { messages.push(message) } }] } as unknown as AgentRegistry)
  444. }, { outputBytes: 1, notificationDelayMs: 0 })
  445. await manager.setBundleEnabled('extra', false)
  446. expect(messages[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')).toContain('"omitted":1')
  447. })
  448. it('applies a manager change through the active HMR service', async () => {
  449. const { manager } = await fixture()
  450. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  451. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  452. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  453. })
  454. it('refuses removal of a hot-installed bundle after HMR is disabled', async () => {
  455. const { ctx, manager, dir, bundle, stopHmr } = await fixture()
  456. bundle('later', [{ id: 'later', name: './plugin.mjs', config: { service: 'laterProbe' } }])
  457. const manifest = readProfileManifest('test', dir)
  458. manifest.dependencies = { ...manifest.dependencies, later: '1' }
  459. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  460. expect(await manager.setBundleEnabled('later', true)).toMatchObject({ application: 'applied' })
  461. await stopHmr()
  462. expect(ctx.get('hmr')).toBeUndefined()
  463. expect(await manager.setBundleEnabled('later', false)).toMatchObject({ application: 'restart-required' })
  464. expect(ctx.get('laterProbe')).toBe(true)
  465. expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
  466. })
  467. it('omits installation-owned plain packages from the bundle inventory', async () => {
  468. const { manager, dir, profile, bundle } = await fixture()
  469. bundle('installation-plain', [])
  470. writeFileSync(join(dir, 'node_modules/installation-plain/package.json'), '{"name":"installation-plain"}')
  471. writeFileSync(profile.installAnchor, '{"dependencies":{"installation-plain":"1"}}')
  472. expect((await manager.listBundles()).some(row => row.name === 'installation-plain')).toBe(false)
  473. })
  474. it.each(['throw', 'remove-manifest-dependencies', 'invalid-manifest'] as const)('preserves the original installation failure when cleanup encounters %s', async (failure) => {
  475. const { manager, dir, profile } = await fixture()
  476. writeFileSync(profile.installAnchor, '{}')
  477. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  478. if (args[0] === 'add') {
  479. const manifest = readProfileManifest('test', dir)
  480. manifest.dependencies = { ...manifest.dependencies, partial: '1' }
  481. writeFileSync(join(dir, 'package.json'), failure === 'invalid-manifest' ? '{' : JSON.stringify(manifest))
  482. } else if (failure === 'throw') throw new Error('cleanup unavailable')
  483. else writeFileSync(join(dir, 'package.json'), '{}')
  484. return { exitCode: args[0] === 'add' ? 42 : 0, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
  485. })
  486. onTestFinished(() => { install.mockRestore() })
  487. const result = await manager.installBundle('partial')
  488. expect(result).toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'fetch failed' } })
  489. if (failure === 'throw') {
  490. expect(result.cleanup).toMatchObject({ name: 'partial', error: { diagnostic: 'cleanup unavailable' } })
  491. expect(result.remainingDependencies).toEqual(['partial'])
  492. } else if (failure === 'invalid-manifest') {
  493. expect(result.warnings?.[0]).toContain('SyntaxError')
  494. expect(install).toHaveBeenCalledOnce()
  495. } else expect(result.remainingDependencies).toEqual([])
  496. })
  497. it.each(['ambiguous', 'selected', 'installation-owned'] as const)('does not clean newly declared dependencies that are %s', async (reason) => {
  498. const { manager, dir, profile } = await fixture()
  499. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  500. const manifest = readProfileManifest('test', dir)
  501. manifest.dependencies = { ...manifest.dependencies, partial: '1', ...reason === 'ambiguous' ? { another: '1' } : {} }
  502. if (reason === 'selected') manifest.dsh!.profile!.bundles!.push('partial')
  503. if (reason === 'installation-owned') writeFileSync(profile.installAnchor, '{"dependencies":{"partial":"1"}}')
  504. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  505. return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
  506. })
  507. onTestFinished(() => { install.mockRestore() })
  508. const result = await manager.installBundle('partial')
  509. expect(result.application).toBe('failed')
  510. expect(result.cleanup).toBeUndefined()
  511. expect(result.remainingDependencies).toContain('partial')
  512. expect(install).toHaveBeenCalledOnce()
  513. })
  514. it('does not delete a bundle retained by a higher-priority overlay', async () => {
  515. const { ctx, manager, overlays, dir } = await fixture()
  516. const entry = [...ctx.loader.entries()].find(row => row.id === 'include:managed')!
  517. overlays.push({ insert: [{ ...entry.options }] })
  518. const remove = vi.spyOn(operations, 'runProfilePnpm')
  519. onTestFinished(() => { remove.mockRestore() })
  520. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', error: { code: 'bundle-in-use' } })
  521. expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'bundle-in-use' } })
  522. expect(remove).not.toHaveBeenCalled()
  523. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  524. expect(ctx.get('managedProbe')).toBe(true)
  525. })
  526. it('applies watched configuration while pnpm installation is still running', async () => {
  527. const { ctx, manager, dir, profile, bundle } = await fixture()
  528. const entered = Promise.withResolvers<undefined>()
  529. const release = Promise.withResolvers<undefined>()
  530. const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  531. entered.resolve(undefined)
  532. await release.promise
  533. bundle('new-bundle', [])
  534. const manifest = readProfileManifest('test', dir)
  535. manifest.dependencies = { ...manifest.dependencies, 'new-bundle': '1.0.0' }
  536. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  537. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  538. })
  539. const installing = manager.installBundle('new-bundle')
  540. onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() })
  541. await entered.promise
  542. writeFileSync(profile.patchPath, '- id: managed\n disabled: true\n')
  543. await vi.waitFor(() => { expect(ctx.get('managedProbe')).toBeUndefined() }, { timeout: 10000 })
  544. expect(pnpm).toHaveBeenCalledOnce()
  545. release.resolve(undefined)
  546. expect(await installing).toMatchObject({ application: 'applied', changed: true })
  547. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
  548. expect(ctx.get('managedProbe')).toBeUndefined()
  549. })