manager.spec.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. /** Persistent manager behavior through a real profile Include and Loader. */
  2. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { realpath } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import { expect, it, onTestFinished, vi } from 'vitest'
  9. import {
  10. boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES,
  11. type ProfileContext,
  12. } from '@deepseek-ai/dsh-app-boot'
  13. import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
  14. import Hmr from '@deepseek-ai/dsh-hmr'
  15. import Timer from '@deepseek-ai/cordis-plugin-timer'
  16. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  17. import { Group } from '@deepseek-ai/cordis-plugin-loader'
  18. import * as operations from '../src/operations.ts'
  19. import { parse, parseDocument } from 'yaml'
  20. async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager']) {
  21. // pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
  22. const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
  23. const dir = join(home, 'profiles', 'test')
  24. const anchor = join(home, 'package.json')
  25. writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
  26. initProfile(dir, ['core', 'extra'])
  27. const bundle = (name: string, rows: unknown[]) => {
  28. const path = join(dir, 'node_modules', name)
  29. mkdirSync(path, { recursive: true })
  30. writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  31. writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }]))
  32. 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')
  33. }
  34. bundle('core', [{ id: 'manager', name: 'cordis:manager', config }])
  35. bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
  36. const manifest = readProfileManifest('test', dir)
  37. manifest.dependencies = { extra: '1.0.0' }
  38. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  39. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  40. const overlays: PatchOptions[] = overlay ? [{ id: 'managed', disabled: true }] : []
  41. const profile: ProfileContext = {
  42. name: 'test',
  43. ...(packageManager === undefined ? {} : { packageManager }),
  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, dir } = 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).toBe('management-required')
  69. expect(await manager.listBundles()).toEqual([
  70. {
  71. name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required',
  72. rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
  73. },
  74. {
  75. name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true,
  76. rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
  77. },
  78. ])
  79. })
  80. it('describes a bundle by its manifest and patch: one-liner, rows without a live entry, and the built-in rows it changes', async () => {
  81. const { manager, dir, bundle } = await fixture()
  82. bundle('described', [{ id: 'described-row', name: './plugin.mjs' }])
  83. writeFileSync(join(dir, 'node_modules', 'described', 'package.json'), JSON.stringify({
  84. name: 'described', version: '2.0.0', description: 'Describes itself.', dsh: { bundle: { patch: './cordis.patch.yml' } },
  85. }))
  86. // An anonymous row is not addressable and is left out of the rows.
  87. writeFileSync(join(dir, 'node_modules', 'described', 'cordis.patch.yml'), JSON.stringify([
  88. { insert: [{ id: 'described-row', name: './plugin.mjs' }, { name: './plugin.mjs' }] }, { id: 'managed', disabled: true }, { id: 'described-row', config: {} },
  89. ]))
  90. const manifest = readProfileManifest('test', dir)
  91. manifest.dependencies = { ...manifest.dependencies, described: '2.0.0' }
  92. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  93. const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href
  94. expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({
  95. name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true,
  96. rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
  97. })
  98. await manager.setBundleEnabled('described', true)
  99. expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([
  100. { rowId: 'described-row', moduleName, entryId: 'include:described-row' },
  101. ])
  102. // Off again, the rows lose their entries.
  103. await manager.setBundleEnabled('described', false)
  104. expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }])
  105. })
  106. it('turns a plugin off and on without duplicating patch overrides', async () => {
  107. const { manager, dir } = await fixture()
  108. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  109. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  110. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  111. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' })
  112. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' })
  113. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1)
  114. })
  115. it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => {
  116. const { manager, dir, bundle } = await fixture()
  117. bundle('third', [])
  118. await manager.setBundleEnabled('third', true)
  119. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
  120. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  121. expect((await manager.listPlugins()).some(row => row.patchId === 'managed')).toBe(false)
  122. await manager.setBundleEnabled('extra', true)
  123. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'third', 'extra'])
  124. })
  125. it('reports an overlay overriding a saved plugin toggle', async () => {
  126. const { manager } = await fixture('live', true)
  127. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  128. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' })
  129. })
  130. it('saves startup-only toggles and refuses removal of currently used packages', async () => {
  131. const { manager } = await fixture('startup')
  132. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  133. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
  134. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
  135. await manager.setBundleEnabled('extra', false)
  136. expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'stop-profile' } })
  137. })
  138. it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
  139. const { manager } = await fixture()
  140. const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
  141. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed', error: { code: 'management-required' } })
  142. expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unknown-plugin' } })
  143. expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed', error: { code: 'not-removable' } })
  144. // A name no bundle directory answers to fails with the resolver's own diagnostic.
  145. expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error' } })
  146. })
  147. it('installs only valid bundle declarations and honors installation without activation', async () => {
  148. const { manager, dir, bundle } = await fixture()
  149. const initial = readProfileManifest('test', dir)
  150. delete initial.dependencies
  151. writeFileSync(join(dir, 'package.json'), JSON.stringify(initial))
  152. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
  153. const name = String(args[1])
  154. bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
  155. const manifest = readProfileManifest('test', dir)
  156. manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
  157. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  158. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  159. })
  160. onTestFinished(() => { install.mockRestore() })
  161. expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({
  162. changed: true, application: 'applied', stage: 'enable', target: 'new-bundle', bundle: 'new-bundle', packageResult: { exitCode: 0 },
  163. })
  164. expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
  165. expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
  166. expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
  167. expect(await manager.installBundle('another-bundle')).toMatchObject({ application: 'applied' })
  168. expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
  169. })
  170. it('reports blocked scripts after a failed installation and retries only after explicit profile build approval', async () => {
  171. const { manager, dir, bundle } = await fixture()
  172. const policy = join(dir, 'pnpm-workspace.yaml')
  173. const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  174. const manifest = readProfileManifest('test', dir)
  175. const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
  176. manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
  177. if (run.mock.calls.length === 1) {
  178. writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n')
  179. completion.exitCode = 1
  180. completion.output = 'ERR_PNPM_IGNORED_BUILDS'
  181. } else {
  182. expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
  183. bundle('addon', [])
  184. }
  185. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  186. return completion
  187. })
  188. onTestFinished(() => { run.mockRestore() })
  189. // The failed run's manifest change is put back; the policy pnpm wrote stays, so its undecided names can be offered.
  190. expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', pendingBuilds: ['native'] })
  191. expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
  192. expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } })
  193. expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false, error: { code: 'stale-approval' } })
  194. expect(run).toHaveBeenCalledTimes(1)
  195. expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({
  196. application: 'applied', changed: true, approvedBuilds: ['native'],
  197. })
  198. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
  199. })
  200. it('retains approved policy and reports it as changed when the registry fails before adding a dependency', async () => {
  201. const { manager, dir } = await fixture()
  202. writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'allowBuilds:\n native: set this to true or false\n')
  203. const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'registry unavailable', truncated: false, logPath: '/log' })
  204. onTestFinished(() => { run.mockRestore() })
  205. expect(await manager.installBundle('addon', { approvedBuilds: ['native'] })).toMatchObject({
  206. changed: true, application: 'failed', pendingBuilds: [], approvedBuilds: ['native'], error: { diagnostic: 'registry unavailable' },
  207. })
  208. expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } })
  209. })
  210. it('runs a real pnpm dependency script only after approval and retry', async () => {
  211. const { manager, dir, profile } = await fixture('startup')
  212. const addon = join(profile.cwd, 'addon')
  213. mkdirSync(addon)
  214. writeFileSync(join(addon, 'package.json'), JSON.stringify({ name: 'approval-fixture-addon', version: '1.0.0',
  215. scripts: { install: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  216. writeFileSync(join(addon, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n')
  217. writeFileSync(join(addon, 'cordis.patch.yml'), '[]\n')
  218. writeFileSync(join(dir, 'package.json'), '{"name":"approval-fixture","private":true}\n')
  219. const policy = parseDocument(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))
  220. policy.set('offline', true)
  221. policy.set('storeDir', join(profile.cwd, 'store'))
  222. writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
  223. const blocked = await manager.installBundle('file:./addon', { enabled: false })
  224. expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } })
  225. expect(blocked.pendingBuilds).toHaveLength(1)
  226. const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt')
  227. expect(existsSync(built)).toBe(false)
  228. expect(readProfileManifest('test', dir).dependencies?.['approval-fixture-addon']).toBeUndefined()
  229. const allowed = await manager.installBundle('file:./addon', { enabled: false, approvedBuilds: blocked.pendingBuilds! })
  230. expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } })
  231. expect(readFileSync(built, 'utf8')).toBe('built')
  232. })
  233. it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
  234. const { manager, dir } = await fixture()
  235. writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
  236. const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
  237. onTestFinished(() => { run.mockRestore() })
  238. expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
  239. })
  240. it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
  241. const { manager, dir, ctx } = await fixture()
  242. const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  243. await ctx.hmr.runExclusive(async () => {
  244. expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
  245. })
  246. return { exitCode: 1, output: 'removal failed', truncated: false, logPath: join(dir, 'pnpm.log') }
  247. })
  248. onTestFinished(() => { remove.mockRestore() })
  249. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 1 } })
  250. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  251. expect((await manager.listBundles()).find(row => row.name === 'extra')?.enabled).toBe(false)
  252. rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
  253. remove.mockImplementationOnce(async () => {
  254. const manifest = readProfileManifest('test', dir)
  255. delete manifest.dependencies?.extra
  256. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  257. return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
  258. })
  259. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'applied' })
  260. expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
  261. })
  262. it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => {
  263. const { manager, dir } = await fixture()
  264. const lockPath = join(dir, 'pnpm-lock.yaml')
  265. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  266. const manifest = readProfileManifest('test', dir)
  267. manifest.dependencies = { ...manifest.dependencies, partial: '1' }
  268. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  269. writeFileSync(lockPath, 'partial lockfile\n')
  270. return { exitCode: 42, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry/partial: ENOTFOUND', truncated: false, logPath: join(dir, 'pnpm.log') }
  271. })
  272. onTestFinished(() => { install.mockRestore() })
  273. const before = readFileSync(join(dir, 'package.json'), 'utf8')
  274. expect(await manager.installBundle('partial')).toMatchObject({
  275. changed: false, application: 'failed', stage: 'install', target: 'partial',
  276. error: { code: 'operation-error', diagnostic: expect.stringContaining('ENOTFOUND') as string },
  277. packageResult: { exitCode: 42, kind: 'network' },
  278. })
  279. expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
  280. // A lockfile the run created is removed; one that existed is put back.
  281. expect(existsSync(lockPath)).toBe(false)
  282. writeFileSync(lockPath, 'original lockfile\n')
  283. expect(await manager.installBundle('partial')).toMatchObject({ changed: false, application: 'failed' })
  284. expect(readFileSync(lockPath, 'utf8')).toBe('original lockfile\n')
  285. expect((await manager.listBundles()).some(row => row.name === 'partial')).toBe(false)
  286. expect(install).toHaveBeenCalledTimes(2)
  287. })
  288. it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
  289. const { manager, dir } = await fixture()
  290. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: true }\n')
  291. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  292. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'failed' })
  293. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: false')
  294. writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n config: { fail: false }\n')
  295. const result = await manager.setPluginEnabled(id, true)
  296. expect(result, JSON.stringify(result)).toMatchObject({ application: 'applied' })
  297. })
  298. it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => {
  299. const { manager, dir, profile } = await fixture()
  300. writeFileSync(profile.installAnchor, '{}')
  301. writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
  302. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } })
  303. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
  304. // Switched off, a dependency without a bundle patch is a library the page has no business with.
  305. expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
  306. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
  307. writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
  308. expect((await manager.listBundles())[0]?.version).toBeUndefined()
  309. writeFileSync(join(dir, 'package.json'), '{}')
  310. expect(await manager.listBundles()).toEqual([])
  311. expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' })
  312. writeFileSync(profile.installAnchor, '{"dependencies":{"missing-builtin":"1"}}')
  313. expect(await manager.listBundles()).toEqual([])
  314. })
  315. it('refuses management bundle disablement and permits repeated bundle selections', async () => {
  316. const { manager } = await fixture()
  317. expect(await manager.setBundleEnabled('core', false)).toMatchObject({ application: 'failed', changed: false })
  318. expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ application: 'applied', changed: false })
  319. })
  320. it.each([
  321. '@deepseek-ai/dsh-host-plugin-inventory',
  322. '@deepseek-ai/dsh-typert-registry',
  323. '@deepseek-ai/dsh-api-remotes',
  324. ])('protects the management dependency %s and its containing bundle', async (name) => {
  325. const { ctx, manager, bundle, profile, dir } = await fixture('startup')
  326. bundle('extra', [{ id: 'dependency', name, disabled: true }])
  327. await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
  328. const entry = (await manager.listPlugins()).find(row => row.moduleName === name)!
  329. expect(entry).toMatchObject({ readOnlyReason: 'management-required' })
  330. const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
  331. const patch = readFileSync(profile.patchPath, 'utf8')
  332. expect(await manager.setPluginEnabled(entry.entryId, false)).toMatchObject({
  333. changed: false, application: 'failed', error: { code: 'management-required' },
  334. })
  335. expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
  336. removable: false, readOnlyReason: 'management-required',
  337. })
  338. expect(await manager.setBundleEnabled('extra', false)).toMatchObject({
  339. changed: false, application: 'failed', error: { code: 'management-required' },
  340. })
  341. expect(await manager.removeBundle('extra')).toMatchObject({
  342. changed: false, application: 'failed', error: { code: 'not-removable' },
  343. })
  344. expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
  345. expect(readFileSync(profile.patchPath, 'utf8')).toBe(patch)
  346. })
  347. it('addresses children inside profile groups and marks ambiguous ids read-only', async () => {
  348. const { manager, bundle, profile } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group })
  349. bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true,
  350. config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }])
  351. expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' })
  352. expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined()
  353. const entries = composeEntries([readProfilePatches('test', profile)])
  354. const duplicate = entries.find(row => row.id === 'managed')!
  355. writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }]))
  356. expect((await manager.listPlugins()).find(row => row.entryId === 'include:managed')?.readOnlyReason).toBe('unaddressable')
  357. })
  358. it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
  359. const { manager } = await fixture()
  360. expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed', error: { code: 'invalid-spec' } })
  361. })
  362. it('restores the manifest when the package pnpm added declares no bundle', async () => {
  363. const { manager, dir, bundle } = await fixture()
  364. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  365. bundle('plain', [])
  366. writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
  367. const manifest = readProfileManifest('test', dir)
  368. manifest.dependencies = { ...manifest.dependencies, plain: '1' }
  369. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  370. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  371. })
  372. onTestFinished(() => { install.mockRestore() })
  373. expect(await manager.installBundle('plain')).toMatchObject({
  374. changed: false, application: 'failed', stage: 'install', error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
  375. })
  376. // The manifest is put back rather than cleaned through another pnpm run.
  377. expect(install).toHaveBeenCalledOnce()
  378. expect(readProfileManifest('test', dir)).toMatchObject({ dependencies: { extra: '1.0.0' }, dsh: { profile: { bundles: ['core', 'extra'] } } })
  379. expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false)
  380. })
  381. it('never removes an existing dependency after installation validation fails', async () => {
  382. const { manager, dir } = await fixture()
  383. writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
  384. const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/operation.log' })
  385. onTestFinished(() => { install.mockRestore() })
  386. const result = await manager.installBundle('extra')
  387. expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } })
  388. expect(install).toHaveBeenCalledOnce()
  389. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  390. })
  391. it('keeps a valid installed bundle when its subsequent activation fails', async () => {
  392. const { manager, dir, bundle } = await fixture()
  393. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  394. bundle('broken', [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }])
  395. const manifest = readProfileManifest('test', dir)
  396. manifest.dependencies = { ...manifest.dependencies, broken: '1' }
  397. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  398. return { exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }
  399. })
  400. onTestFinished(() => { install.mockRestore() })
  401. const result = await manager.installBundle('broken')
  402. expect(result).toMatchObject({ application: 'failed', stage: 'enable', target: 'broken', bundle: 'broken', packageResult: { exitCode: 0 } })
  403. expect(install).toHaveBeenCalledOnce()
  404. expect((await manager.listBundles()).find(row => row.name === 'broken')).toMatchObject({ enabled: true, removable: true })
  405. })
  406. it('returns unchanged failures as warnings while toggling and removing another bundle', async () => {
  407. const { manager, dir, bundle, ctx } = await fixture()
  408. bundle('broken', [
  409. { id: 'broken', name: './plugin.mjs', config: { fail: true } },
  410. { id: 'missing', name: './missing.mjs' },
  411. { id: 'pending', name: './pending.mjs' },
  412. ])
  413. writeFileSync(join(dir, 'node_modules/broken/pending.mjs'), 'export const inject = ["unavailable"]; export function apply() {}')
  414. expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
  415. const brokenId = (await manager.listPlugins()).find(row => row.patchId === 'broken')!.entryId
  416. expect(await manager.setPluginEnabled(brokenId, true)).toMatchObject({ application: 'failed' })
  417. expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
  418. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  419. const changed = await manager.setPluginEnabled(id, false)
  420. expect(changed).toMatchObject({ application: 'applied' })
  421. expect(changed.warnings).toHaveLength(3)
  422. expect(await manager.setPluginEnabled(id, true)).toMatchObject({ application: 'applied' })
  423. const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  424. expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
  425. const manifest = readProfileManifest('test', dir)
  426. delete manifest.dependencies?.extra
  427. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  428. return { exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }
  429. })
  430. onTestFinished(() => { remove.mockRestore() })
  431. const removed = await manager.removeBundle('extra')
  432. expect(removed.application).toBe('applied')
  433. expect(removed.warnings).toHaveLength(3)
  434. expect(remove).toHaveBeenCalledOnce()
  435. })
  436. it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => {
  437. const { manager, dir } = await fixture()
  438. const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') })
  439. onTestFinished(() => { install.mockRestore() })
  440. expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
  441. expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' })
  442. expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } })
  443. install.mockImplementationOnce(async () => {
  444. writeFileSync(join(dir, 'package.json'), '{}')
  445. return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
  446. })
  447. expect(await manager.installBundle('unknown')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } })
  448. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  449. })
  450. it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => {
  451. const { ctx, manager, dir, bundle } = await fixture()
  452. const chunks: PluginInstallLogChunk[] = []
  453. const phases: PluginInstallProgress[] = []
  454. const changes: PluginChange[] = []
  455. ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) })
  456. ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
  457. ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
  458. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args, options) => {
  459. options.onOutput?.('Progress: resolved 1\n', 'stdout')
  460. options.onOutput?.('warning\n', 'stderr')
  461. const name = String(args[1])
  462. bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
  463. const manifest = readProfileManifest('test', dir)
  464. manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
  465. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  466. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  467. })
  468. onTestFinished(() => { install.mockRestore() })
  469. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  470. expect(await manager.installBundle('streamed', { enabled: false, requestId })).toMatchObject({ application: 'applied', changed: true, bundle: 'streamed' })
  471. expect(install).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), ['add', 'streamed'],
  472. expect.objectContaining({ command: 'pnpm', execution: 'service' }))
  473. const jobId = chunks[0]?.jobId
  474. expect(chunks).toEqual([
  475. { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: 'Progress: resolved 1\n' },
  476. { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stderr', text: 'warning\n' },
  477. { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: '', exitCode: 0 },
  478. ])
  479. expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'applying' }])
  480. expect(changes).toEqual([{ reason: 'install' }])
  481. // A run without a request id streams too, unidentified.
  482. await manager.removeBundle('streamed')
  483. expect(chunks.at(-1)).toMatchObject({ argv: ['pnpm', 'remove', 'streamed'], stream: 'stdout', exitCode: 0 })
  484. expect(chunks.at(-1)).not.toHaveProperty('requestId')
  485. expect(changes).toEqual([{ reason: 'install' }, { reason: 'remove' }])
  486. })
  487. it('stops a run on request, restores the files, and answers not-running or too-late otherwise', async () => {
  488. const { ctx, manager, dir, bundle } = await fixture()
  489. const phases: PluginInstallProgress[] = []
  490. ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
  491. const started = Promise.withResolvers<undefined>()
  492. const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, _args, options) => {
  493. const manifest = readProfileManifest('test', dir)
  494. manifest.dependencies = { ...manifest.dependencies, slow: '1' }
  495. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  496. started.resolve(undefined)
  497. await new Promise<undefined>((resolve) => { options.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true }) })
  498. return { exitCode: 1, output: 'killed', truncated: false, logPath: join(dir, 'pnpm.log') }
  499. })
  500. onTestFinished(() => { install.mockRestore() })
  501. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  502. const before = readFileSync(join(dir, 'package.json'), 'utf8')
  503. const run = manager.installBundle('slow', { requestId })
  504. await started.promise
  505. expect(await manager.cancelInstall('00000000-0000-4000-8000-000000000000' as PluginInstallRequestId)).toEqual({ status: 'not-running' })
  506. expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
  507. expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
  508. const cancelled = await run
  509. expect(cancelled).toMatchObject({ application: 'cancelled', changed: false, stage: 'install', packageResult: { exitCode: 1 } })
  510. expect(cancelled.error).toBeUndefined()
  511. expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'cancelling' }])
  512. expect(await manager.cancelInstall(requestId)).toEqual({ status: 'not-running' })
  513. // Once pnpm has exited and the bundle is being applied, the run cannot be stopped.
  514. install.mockImplementation(async (_context, args) => {
  515. const name = String(args[1])
  516. bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
  517. const manifest = readProfileManifest('test', dir)
  518. manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
  519. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  520. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  521. })
  522. let tooLate: Promise<unknown> | undefined
  523. ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) })
  524. expect(await manager.installBundle('late', { requestId })).toMatchObject({ application: 'applied', bundle: 'late' })
  525. expect(await tooLate).toEqual({ status: 'too-late' })
  526. // A request aborted before its turn under the lock never starts pnpm.
  527. const early = manager.installBundle('never', { requestId })
  528. const queued = manager.installBundle('after', { requestId: '11111111-1111-4111-8111-111111111111' as PluginInstallRequestId })
  529. expect(await manager.cancelInstall('11111111-1111-4111-8111-111111111111' as PluginInstallRequestId)).toEqual({ status: 'cancelled' })
  530. await early
  531. expect(await queued).toMatchObject({ application: 'cancelled', changed: false })
  532. })
  533. it('reads what a spec names before installing it', async () => {
  534. const { manager, dir, profile } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000, pnpmCommand: 'pnpm-test' })
  535. const view = vi.spyOn(operations, 'viewProfilePackage')
  536. onTestFinished(() => { view.mockRestore() })
  537. const answers = (stdout: string) => view.mockResolvedValueOnce({ exitCode: 0, stdout, stderr: '', timedOut: false })
  538. answers(JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  539. expect(await manager.inspect('dsh-x')).toEqual({
  540. status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true,
  541. })
  542. expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000 })
  543. const signal = AbortSignal.abort()
  544. answers(JSON.stringify([{ name: 'dsh-lib', version: '1.0.0', dsh: { bundle: {} } }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]))
  545. expect(await manager.inspect('dsh-lib@^1', signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle' })
  546. expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal })
  547. // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
  548. answers('\x1b[36m' + JSON.stringify({ version: '0.0.1', description: '', dsh: { bundle: { patch: './p.yml' } } }) + '\x1b[39m\n')
  549. expect(await manager.inspect('dsh-bare')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: true })
  550. const failure = (stderr: string, exitCode: number | null = 1, more: Partial<operations.PackageViewResult> = {}) =>
  551. view.mockResolvedValueOnce({ exitCode, stdout: '', stderr, timedOut: false, ...more })
  552. failure('npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n')
  553. expect(await manager.inspect('nope')).toMatchObject({ status: 'refused', problem: 'not-found', reason: expect.stringContaining('E404') as string })
  554. failure('ERR_PNPM_NO_MATCHING_VERSION No matching version found for old@9\n')
  555. expect(await manager.inspect('old@9')).toMatchObject({ status: 'refused', problem: 'not-found' })
  556. failure('ERR_PNPM_META_FETCH_FAIL request failed, reason: getaddrinfo ENOTFOUND registry\n')
  557. expect(await manager.inspect('far')).toMatchObject({ status: 'refused', problem: 'network' })
  558. view.mockResolvedValueOnce({ exitCode: 3, stdout: 'plain text\n', stderr: '', timedOut: false })
  559. expect(await manager.inspect('odd')).toEqual({ status: 'refused', problem: 'unknown', reason: 'plain text' })
  560. failure('', 4)
  561. expect(await manager.inspect('quiet')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view exited with 4' })
  562. failure('', null, { timedOut: true })
  563. expect(await manager.inspect('slow')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view timed out after 1000ms' })
  564. failure('', null, { cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })
  565. expect(await manager.inspect('gone')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('ENOENT') as string })
  566. answers('not json')
  567. expect(await manager.inspect('garbled')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string })
  568. answers('"just a string"')
  569. expect(await manager.inspect('scalar')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
  570. answers('')
  571. expect(await manager.inspect('silent')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
  572. // What is installed, or supplied by the installation, is refused before the registry is asked.
  573. expect(await manager.inspect('extra')).toEqual({ status: 'refused', problem: 'already-installed', reason: 'extra is already installed' })
  574. expect(await manager.inspect('./relative')).toEqual({ status: 'refused', problem: 'invalid-spec', reason: 'a local path must be absolute' })
  575. expect(await manager.inspect('github:acme/dsh-remote')).toEqual({ status: 'accepted', kind: 'git', bundle: null })
  576. const tarball = join(profile.home, 'pack.tgz')
  577. expect(await manager.inspect(tarball)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the tarball does not exist' })
  578. writeFileSync(tarball, '')
  579. expect(await manager.inspect(tarball)).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
  580. expect(await manager.inspect('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
  581. // A directory answers from its own manifest.
  582. const local = join(profile.home, 'dev', 'dsh-local')
  583. mkdirSync(local, { recursive: true })
  584. expect(await manager.inspect(join(profile.home, 'dev', 'missing'))).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the path does not exist' })
  585. expect(await manager.inspect(local)).toMatchObject({ status: 'refused', problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string })
  586. writeFileSync(join(local, 'package.json'), '{"version":"1.0.0"}')
  587. expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the package.json names no package' })
  588. writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.' }))
  589. expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-local declares no dsh.bundle' })
  590. writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } }))
  591. expect(await manager.inspect(`file:${local}`)).toEqual({ status: 'accepted', kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true })
  592. writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'core', dsh: { bundle: { patch: './p.yml' } } }))
  593. expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'already-installed', reason: 'core is already installed' })
  594. // A profile and an installation that list nothing know nothing.
  595. writeFileSync(join(dir, 'package.json'), '{}')
  596. writeFileSync(profile.installAnchor, '{}')
  597. expect(await manager.inspect(local)).toEqual({ status: 'accepted', kind: 'path', name: 'core', bundle: true })
  598. expect(view).toHaveBeenCalledTimes(13)
  599. })
  600. it('announces each manager operation as a change, and a patch generation applied outside it not at all', async () => {
  601. const { ctx, manager, profile } = await fixture('startup')
  602. const changes: PluginChange[] = []
  603. ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
  604. await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
  605. expect(changes).toEqual([])
  606. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  607. await manager.setPluginEnabled(id, false)
  608. expect(changes).toEqual([{ reason: 'plugin' }])
  609. await manager.setBundleEnabled('extra', false)
  610. expect(changes).toEqual([{ reason: 'plugin' }, { reason: 'bundle' }])
  611. })
  612. it('handles missing patch files and retains non-Error package diagnostics', async () => {
  613. const { manager, dir } = await fixture()
  614. rmSync(join(dir, 'cordis.patch.yml'))
  615. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  616. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  617. const install = vi.spyOn(operations, 'runProfilePnpm').mockRejectedValueOnce('pnpm rejected operation')
  618. onTestFinished(() => { install.mockRestore() })
  619. expect(await manager.installBundle('new')).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error', diagnostic: 'pnpm rejected operation' } })
  620. rmSync(join(dir, 'cordis.patch.yml'))
  621. mkdirSync(join(dir, 'cordis.patch.yml'))
  622. await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
  623. })
  624. it('applies a manager change through the active HMR service', async () => {
  625. const { manager } = await fixture()
  626. const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
  627. expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
  628. expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
  629. })
  630. it('refuses removal of a hot-installed bundle after HMR is disabled', async () => {
  631. const { ctx, manager, dir, bundle, stopHmr } = await fixture()
  632. bundle('later', [{ id: 'later', name: './plugin.mjs', config: { service: 'laterProbe' } }])
  633. const manifest = readProfileManifest('test', dir)
  634. manifest.dependencies = { ...manifest.dependencies, later: '1' }
  635. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  636. expect(await manager.setBundleEnabled('later', true)).toMatchObject({ application: 'applied' })
  637. await stopHmr()
  638. expect(ctx.get('hmr')).toBeUndefined()
  639. expect(await manager.setBundleEnabled('later', false)).toMatchObject({ application: 'restart-required' })
  640. expect(ctx.get('laterProbe')).toBe(true)
  641. expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
  642. })
  643. it('offers the launcher\'s optional bundles switched off and never removable', async () => {
  644. const { manager, profile } = await fixture()
  645. // The launcher names the bundles the installation ships; the fixture supplies one of them from the
  646. // installation's own node_modules, which the resolver consults before the profile's and before the repository's.
  647. const offered = OPTIONAL_BUNDLES[0]!
  648. const supplied = join(profile.home, 'node_modules', offered)
  649. mkdirSync(supplied, { recursive: true })
  650. writeFileSync(join(supplied, 'package.json'), JSON.stringify({
  651. name: offered, version: '3.0.0', description: 'Package one-liner.', dsh: { bundle: { patch: './cordis.patch.yml' } },
  652. }))
  653. writeFileSync(join(supplied, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'offered-row', name: './plugin.mjs', config: { service: 'offeredProbe' } }] }]))
  654. writeFileSync(join(supplied, 'plugin.mjs'), 'export function apply(ctx, config) { ctx.provide(config?.service ?? "offeredProbe", true) }\n')
  655. writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } }))
  656. expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({
  657. name: offered, version: '3.0.0', description: 'Package one-liner.',
  658. enabled: false, installed: false, optional: true, removable: false,
  659. rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [],
  660. })
  661. expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' })
  662. expect((await manager.listBundles()).find(row => row.name === offered)).toMatchObject({ enabled: true, optional: true, removable: false })
  663. expect(await manager.removeBundle(offered)).toMatchObject({ changed: false, application: 'failed' })
  664. })
  665. it('omits installation-owned plain packages from the bundle inventory', async () => {
  666. const { manager, dir, profile, bundle } = await fixture()
  667. bundle('installation-plain', [])
  668. writeFileSync(join(dir, 'node_modules/installation-plain/package.json'), '{"name":"installation-plain"}')
  669. writeFileSync(profile.installAnchor, '{"dependencies":{"installation-plain":"1"}}')
  670. expect((await manager.listBundles()).some(row => row.name === 'installation-plain')).toBe(false)
  671. })
  672. it('does not delete a bundle retained by a higher-priority overlay', async () => {
  673. const { ctx, manager, overlays, dir } = await fixture()
  674. const entry = [...ctx.loader.entries()].find(row => row.id === 'include:managed')!
  675. overlays.push({ insert: [{ ...entry.options }] })
  676. const remove = vi.spyOn(operations, 'runProfilePnpm')
  677. onTestFinished(() => { remove.mockRestore() })
  678. expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', error: { code: 'bundle-in-use' } })
  679. expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'bundle-in-use' } })
  680. expect(remove).not.toHaveBeenCalled()
  681. expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
  682. expect(ctx.get('managedProbe')).toBe(true)
  683. })
  684. it('applies watched configuration while pnpm installation is still running', async () => {
  685. const { ctx, manager, dir, profile, bundle } = await fixture()
  686. const entered = Promise.withResolvers<undefined>()
  687. const release = Promise.withResolvers<undefined>()
  688. const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
  689. entered.resolve(undefined)
  690. await release.promise
  691. bundle('new-bundle', [])
  692. const manifest = readProfileManifest('test', dir)
  693. manifest.dependencies = { ...manifest.dependencies, 'new-bundle': '1.0.0' }
  694. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  695. return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
  696. })
  697. const installing = manager.installBundle('new-bundle')
  698. onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() })
  699. await entered.promise
  700. writeFileSync(profile.patchPath, '- id: managed\n disabled: true\n')
  701. await vi.waitFor(() => { expect(ctx.get('managedProbe')).toBeUndefined() }, { timeout: 10000 })
  702. expect(pnpm).toHaveBeenCalledOnce()
  703. release.resolve(undefined)
  704. expect(await installing).toMatchObject({ application: 'applied', changed: true })
  705. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
  706. expect(ctx.get('managedProbe')).toBeUndefined()
  707. })
  708. it('installs and removes with the bundled pnpm when PATH contains no pnpm', async () => {
  709. const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url))
  710. const { manager, dir } = await fixture('startup', false, undefined, { pnpmCommand: 'must-not-be-used' }, {
  711. command: process.execPath, args: ['--expose-internals', pnpm], env: { PATH: '', ELECTRON_RUN_AS_NODE: '1' },
  712. })
  713. const target = join(dir, 'local-bundle')
  714. mkdirSync(target)
  715. writeFileSync(join(target, 'package.json'), JSON.stringify({ name: '@test/desktop-manager', version: '1.0.0',
  716. dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  717. writeFileSync(join(target, 'cordis.patch.yml'), '[]\n')
  718. // Fixture-only packages need no registry resolution during this local install.
  719. const manifest = readProfileManifest('test', dir)
  720. delete manifest.dependencies
  721. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  722. const installed = await manager.installBundle(target)
  723. expect(installed.error).toBeUndefined()
  724. expect(installed.packageResult?.exitCode).toBe(0)
  725. expect(readProfileManifest('test', dir).dependencies).toHaveProperty('@test/desktop-manager')
  726. const removed = await manager.removeBundle('@test/desktop-manager')
  727. expect(removed.error).toBeUndefined()
  728. expect(removed.packageResult?.exitCode).toBe(0)
  729. expect(readProfileManifest('test', dir).dependencies ?? {}).not.toHaveProperty('@test/desktop-manager')
  730. })