plugin-manager.spec.ts 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. /**
  2. * The plugin manager over a real profile: a temporary harness home with one
  3. * profile, packages staged the way pnpm leaves them, the host tree booted
  4. * through `boot()` with the profile runtime the launcher provides, and a
  5. * fake pnpm that edits the profile the way the real one does. The manager
  6. * is built the way the Web host's adapter builds it, reading the runtime,
  7. * the agent registry off the context per call.
  8. */
  9. import { EventEmitter } from 'node:events'
  10. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  11. import { mkdtemp } from 'node:fs/promises'
  12. import { tmpdir } from 'node:os'
  13. import { join } from 'node:path'
  14. import { PassThrough } from 'node:stream'
  15. import type { ChildProcess } from 'node:child_process'
  16. import { afterEach, describe, expect, it } from 'vitest'
  17. import { Context, type Plugin } from '@deepseek-ai/cordis'
  18. import Loader from '@deepseek-ai/cordis-plugin-loader'
  19. import {
  20. boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, rootIncludeEntry,
  21. type ComposedStack, type Profile, readPackageMetadata,
  22. } from '@deepseek-ai/dsh-app-boot'
  23. import {
  24. PluginManager, type PluginInstallLogChunk, type PluginToolingConfig, type SpawnLike,
  25. } from '@deepseek-ai/dsh-plugin-manager'
  26. import type {} from '@deepseek-ai/dsh-agent'
  27. const NAME = 'dsh-test'
  28. /** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
  29. function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
  30. return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installLogTailBytes: 16_384, ...overrides }
  31. }
  32. /** Test seams: the child spawner and the static metadata reader. */
  33. interface Internals {
  34. spawn?: SpawnLike
  35. metadata?: typeof readPackageMetadata
  36. }
  37. const contexts: Context[] = []
  38. afterEach(async () => {
  39. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  40. })
  41. /** Builtins the staged bundles name; registered on every boot. */
  42. const good: Plugin.Function = () => {}
  43. const throws: Plugin.Function = () => { throw new Error('boom at apply') }
  44. let flakyCalls = 0
  45. const flaky: Plugin.Function = () => {
  46. flakyCalls += 1
  47. if (flakyCalls === 1) throw new Error('flaky first start')
  48. }
  49. const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) }
  50. const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
  51. const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
  52. const prepare = (ctx: Context): void => {
  53. ctx.loader.builtins.configured = {
  54. Config: { '~standard': { version: 1, vendor: 'manager-test', validate(input: unknown) {
  55. const config = input as { value: number }
  56. return config.value < 0 ? { issues: [{ message: 'negative config' }] } : { value: config }
  57. } } },
  58. apply() {},
  59. }
  60. ctx.loader.builtins.good = good
  61. ctx.loader.builtins.throws = throws
  62. ctx.loader.builtins.flaky = flaky
  63. ctx.loader.builtins.provider = provider
  64. ctx.loader.builtins['lonely-provider'] = lonelyProvider
  65. ctx.loader.builtins.consumer = consumer
  66. }
  67. interface StagedHome {
  68. home: string
  69. profileDir: string
  70. anchor: string
  71. }
  72. /** A harness home with one empty live profile and an install anchor that carries nothing. */
  73. async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise<StagedHome> {
  74. const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-'))
  75. const profileDir = join(home, 'profiles', 'web')
  76. mkdirSync(profileDir, { recursive: true })
  77. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  78. name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } },
  79. }, null, 2))
  80. writeFileSync(join(profileDir, 'cordis.yml'), '[]\n')
  81. const anchorDir = join(home, 'anchor')
  82. mkdirSync(anchorDir, { recursive: true })
  83. const anchor = join(anchorDir, 'package.json')
  84. writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} }))
  85. return { home, profileDir, anchor }
  86. }
  87. interface StagedPackage {
  88. /** The bundle patch text; omitted stages a bundle-less package. */
  89. patch?: string
  90. /** `index.js` text, exported as the package main. */
  91. main?: string
  92. version?: string
  93. plugins?: { name: string; title?: string; config?: unknown }[]
  94. files?: Record<string, string>
  95. }
  96. /** Stage one package under the profile's node_modules, the way pnpm leaves it. */
  97. function stagePackage(profileDir: string, name: string, staged: StagedPackage): void {
  98. const dir = join(profileDir, 'node_modules', name)
  99. mkdirSync(dir, { recursive: true })
  100. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  101. name,
  102. version: staged.version ?? '1.0.0',
  103. description: `staged ${name}`,
  104. type: 'module',
  105. ...staged.main === undefined ? {} : { main: 'index.js' },
  106. dsh: {
  107. title: `Title of ${name}`,
  108. ...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml' } },
  109. ...staged.plugins === undefined ? {} : { plugins: staged.plugins },
  110. },
  111. }, null, 2))
  112. if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch)
  113. if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main)
  114. for (const [file, text] of Object.entries(staged.files ?? {})) {
  115. mkdirSync(join(dir, file, '..'), { recursive: true })
  116. writeFileSync(join(dir, file), text)
  117. }
  118. }
  119. /** Add a dependency to the profile manifest, as `pnpm add` does. */
  120. function addDependency(profileDir: string, name: string, spec = '1.0.0'): void {
  121. const path = join(profileDir, 'package.json')
  122. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
  123. manifest.dependencies[name] = spec
  124. writeFileSync(path, JSON.stringify(manifest, null, 2))
  125. }
  126. function manifestOf(profileDir: string): { dependencies: Record<string, string>; dsh: { profile: { bundles: string[] } } } {
  127. return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType<typeof manifestOf>
  128. }
  129. /** What the fake pnpm does for one invocation. */
  130. type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
  131. /** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */
  132. function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = []): SpawnLike {
  133. return (command, args, options) => {
  134. calls.push([command, ...args])
  135. expect(options.cwd).toBe(profileDir)
  136. const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean }
  137. child.stdout = new PassThrough()
  138. child.stderr = new PassThrough()
  139. let killed = false
  140. child.kill = () => { killed = true; return true }
  141. setTimeout(() => {
  142. const outcome = behavior(args)
  143. if (outcome.error !== undefined) {
  144. child.emit('error', outcome.error)
  145. return
  146. }
  147. if (outcome.stdout !== undefined) child.stdout.write(outcome.stdout)
  148. if (outcome.stderr !== undefined) child.stderr.write(outcome.stderr)
  149. if (outcome.hang === true) {
  150. // Report the kill the timeout sends, as a real child would.
  151. const poll = setInterval(() => {
  152. if (!killed) return
  153. clearInterval(poll)
  154. child.emit('close', null)
  155. }, 10)
  156. return
  157. }
  158. setTimeout(() => { child.emit('close', outcome.code) }, 5)
  159. }, 5)
  160. return child as unknown as ChildProcess
  161. }
  162. }
  163. /** The pnpm every install test shares: `add <name>` stages nothing (the test did) and records the dependency. */
  164. function recordingPnpm(profileDir: string, calls: string[][] = []): SpawnLike {
  165. return fakePnpm(profileDir, (args) => {
  166. const [verb, target] = args
  167. if (verb === 'add' && target !== undefined) {
  168. addDependency(profileDir, target)
  169. return { code: 0, stdout: `+ ${target} 1.0.0\n` }
  170. }
  171. if (verb === 'remove' && target !== undefined) {
  172. const path = join(profileDir, 'package.json')
  173. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
  174. const { [target]: _removed, ...remaining } = manifest.dependencies
  175. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
  176. rmSync(join(profileDir, 'node_modules', target), { recursive: true, force: true })
  177. return { code: 0, stdout: `- ${target}\n` }
  178. }
  179. return { code: 1, stderr: 'unexpected pnpm invocation\n' }
  180. }, calls)
  181. }
  182. interface Booted {
  183. ctx: Context
  184. manager: PluginManager
  185. runtime: ProfileRuntime
  186. changes: { reason: string; packageName?: string }[]
  187. log: PluginInstallLogChunk[]
  188. }
  189. /** Boot the profile the way the launcher does, then build the manager over it. */
  190. async function bootProfile(staged: StagedHome, internals: Internals = {}, config: Partial<PluginToolingConfig> = {}): Promise<Booted> {
  191. const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
  192. const composeFor = (profile: Profile): ComposedStack => {
  193. const stack = composeProfileStack(profile.layers, [
  194. { label: profile.patchPath, patches: loadOptionalPatches(NAME, profile.patchPath) ?? [] },
  195. ])
  196. return { ...stack, patches: structuredClone(stack.patches) }
  197. }
  198. const profile = load()
  199. const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, async (ctx) => {
  200. prepare(ctx)
  201. await ctx.plugin(ProfileRuntime, {
  202. profile, stack: composeFor(profile), installAnchor: staged.anchor,
  203. loadProfile: load, compose: composeFor, rootEntry: () => rootIncludeEntry(ctx),
  204. })
  205. })
  206. contexts.push(ctx)
  207. const changes: Booted['changes'] = []
  208. const log: PluginInstallLogChunk[] = []
  209. ctx.on('plugins/changed', (change) => { changes.push(change) })
  210. ctx.on('plugins/install-log', (chunk) => { log.push(chunk) })
  211. const host = ctx.plugin({ inject: ['loader'], apply() {} })
  212. await host.await()
  213. const manager = managerOver(host.ctx, internals, config)
  214. return { ctx, manager, runtime: ctx.profileRuntime, changes, log }
  215. }
  216. /** The manager as the Web host's adapter builds it: runtime and agent count read off the context per call. */
  217. function managerOver(ctx: Context, internals: Internals = {}, config: Partial<PluginToolingConfig> = {}): PluginManager {
  218. return new PluginManager(ctx, {
  219. config: managerConfig(config),
  220. runtime: () => ctx.get('profileRuntime'),
  221. runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
  222. ...internals,
  223. })
  224. }
  225. const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry => entry.id)
  226. const BUNDLE_ONE_ROW = '- insert:\n - id: hello\n name: cordis:good\n'
  227. describe('PluginManager', () => {
  228. it('keeps an installed package with malformed metadata manageable', async () => {
  229. const staged = await stageHome()
  230. stagePackage(staged.profileDir, 'broken', {})
  231. addDependency(staged.profileDir, 'broken')
  232. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  233. writeFileSync(join(staged.profileDir, 'node_modules/broken/package.json'), '{ invalid json')
  234. const view = (await manager.list())[0]!
  235. expect(view.name).toBe('broken')
  236. expect(view.installed).toBe(true)
  237. expect(view.reason).toBeTruthy()
  238. await manager.uninstall('broken')
  239. expect(await manager.list()).toEqual([])
  240. })
  241. it('shows failed group imports while omitting healthy structural groups', async () => {
  242. const staged = await stageHome()
  243. stagePackage(staged.profileDir, 'groups', { patch: `- insert:
  244. - id: healthy
  245. name: cordis:group
  246. group: true
  247. config: []
  248. - id: broken-group
  249. name: ./missing.mjs
  250. group: true
  251. config: []
  252. ` })
  253. addDependency(staged.profileDir, 'groups')
  254. const { manager } = await bootProfile(staged)
  255. await manager.enable('groups')
  256. const view = (await manager.list())[0]!
  257. expect(view.status).toBe('failed')
  258. expect(view.rows.map(row => row.rowId)).toEqual(['broken-group'])
  259. expect(view.rows[0]?.phase).toBe('failed')
  260. expect(view.rows[0]?.failure?.stage).toBe('import')
  261. })
  262. it('shows a failed config update beside the still-active old instance and attributes overrides to their owner', async () => {
  263. const staged = await stageHome()
  264. stagePackage(staged.profileDir, 'base', {
  265. patch: '- insert:\n - id: core-config\n name: cordis:configured\n config: { value: 1 }\n',
  266. })
  267. const manifest = manifestOf(staged.profileDir)
  268. manifest.dsh.profile.bundles.push('base')
  269. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
  270. stagePackage(staged.profileDir, 'ext', { patch: '- insert:\n - id: ext-ok\n name: cordis:good\n- id: core-config\n config: { value: -1 }\n' })
  271. addDependency(staged.profileDir, 'ext')
  272. const { ctx, manager } = await bootProfile(staged)
  273. const result = await manager.enable('ext')
  274. expect(result.issues?.[0]?.entryId).toBe('include:core-config')
  275. const core = ctx.loader.resolve('include:core-config')
  276. expect(core.fiber?.state).toBe(2)
  277. expect(core.fiber?.config).toEqual({ value: 1 })
  278. expect(core.options.config).toEqual({ value: -1 })
  279. const views = await manager.list()
  280. const ext = views.find(view => view.name === 'ext')!
  281. expect(ext.status).toBe('partial')
  282. expect(ext.issues?.[0]?.stage).toBe('update')
  283. expect(ext.rows.map(row => row.rowId)).toEqual(['ext-ok'])
  284. expect(ctx.profileRuntime.originOfEntry(core)?.packageName).toBe('base')
  285. expect(views.find(view => view.name === 'base')?.rows[0]).toMatchObject({ phase: 'active', failure: { stage: 'update' } })
  286. writeFileSync(join(staged.profileDir, 'node_modules/ext/cordis.patch.yml'), '- id: core-config\n config: { value: -1 }\n')
  287. await ctx.profileRuntime.recompose({ reloadBundles: true })
  288. expect((await manager.list()).find(view => view.name === 'ext')?.status).toBe('failed')
  289. await manager.disable('ext')
  290. expect(core.fiber?.config).toEqual({ value: 1 })
  291. expect((await manager.list()).find(view => view.name === 'base')?.status).toBe('running')
  292. })
  293. it('keeps a rejected preparation out of the layer list without promising rollback of live plugin effects', async () => {
  294. const staged = await stageHome()
  295. stagePackage(staged.profileDir, 'base', { patch: BUNDLE_ONE_ROW })
  296. const manifest = manifestOf(staged.profileDir)
  297. manifest.dsh.profile.bundles.push('base')
  298. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
  299. stagePackage(staged.profileDir, 'clash', { patch: BUNDLE_ONE_ROW })
  300. addDependency(staged.profileDir, 'clash')
  301. const { ctx, manager } = await bootProfile(staged)
  302. const changedManifest = manifestOf(staged.profileDir)
  303. changedManifest.dsh.profile.bundles.push('missing-base')
  304. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(changedManifest))
  305. await expect(manager.enable('clash')).rejects.toMatchObject({ code: 'plugins/enable-failed' })
  306. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['base', 'missing-base'])
  307. expect(ctx.loader.resolve('include:hello').fiber?.state).toBe(2)
  308. })
  309. it('does not execute a declared main module during install or list and refuses an undeclared entry', async () => {
  310. const staged = await stageHome()
  311. const marker = join(staged.profileDir, 'executed')
  312. stagePackage(staged.profileDir, 'declared', {
  313. plugins: [{ name: '.', config: { custom: 1 } }],
  314. main: `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad'); export function apply() {}`,
  315. })
  316. stagePackage(staged.profileDir, 'unknown', { main: 'export function apply() {}' })
  317. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  318. await manager.add('declared')
  319. await manager.add('unknown')
  320. const views = await manager.list()
  321. expect(views.find(view => view.name === 'declared')?.addable[0]).toMatchObject({ moduleName: 'declared', config: { custom: 1 } })
  322. expect(existsSync(marker)).toBe(false)
  323. for (const id of ['', 'nested:child']) {
  324. await expect(manager.addRow('declared', { id })).rejects.toMatchObject({ code: 'plugins/bad-request' })
  325. }
  326. const added = await manager.addRow('declared', { config: null })
  327. expect(readFileSync(added.file, 'utf8')).toContain('config: null')
  328. expect(views.find(view => view.name === 'unknown')?.kind).toBe('unknown')
  329. await expect(manager.addRow('unknown')).rejects.toMatchObject({ code: 'plugins/not-enableable' })
  330. })
  331. it('reports plugins/unavailable without a profile runtime', async () => {
  332. const ctx = new Context()
  333. contexts.push(ctx)
  334. await ctx.plugin(Loader)
  335. await expect(managerOver(ctx).list()).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
  336. })
  337. describe('list', () => {
  338. it('folds installed, enabled, declared and running facts into one view per package', async () => {
  339. const staged = await stageHome()
  340. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js', title: 'Extra' }], files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } })
  341. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  342. stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
  343. addDependency(staged.profileDir, 'ext-bundle')
  344. addDependency(staged.profileDir, 'ext-lib')
  345. addDependency(staged.profileDir, 'ext-plugin')
  346. const { manager } = await bootProfile(staged)
  347. const views = await manager.list()
  348. expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled])).toEqual([
  349. ['ext-bundle', 'bundle', 'disabled', true, false],
  350. ['ext-lib', 'unknown', 'plain', true, false],
  351. ['ext-plugin', 'plugin', 'plain', true, false],
  352. ])
  353. const bundle = views[0]
  354. expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', liveReload: true })
  355. // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
  356. expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
  357. expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
  358. })
  359. it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
  360. const staged = await stageHome()
  361. stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n - id: ok\n name: cordis:good\n - id: bad\n name: cordis:throws\n' })
  362. addDependency(staged.profileDir, 'ext-mixed')
  363. const manifest = manifestOf(staged.profileDir)
  364. manifest.dsh.profile.bundles.push('ext-mixed')
  365. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  366. const { manager } = await bootProfile(staged)
  367. const [view] = await manager.list()
  368. expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
  369. expect(view?.reason).toContain('boom at apply')
  370. expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
  371. ['include:ok', 'active', undefined],
  372. ['include:bad', 'failed', 'activation'],
  373. ])
  374. })
  375. it('lists a bundle left out by an id conflict as failed, with the conflict as its row', async () => {
  376. const staged = await stageHome()
  377. stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
  378. addDependency(staged.profileDir, 'ext-one')
  379. stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
  380. addDependency(staged.profileDir, 'ext-two')
  381. const manifest = manifestOf(staged.profileDir)
  382. manifest.dsh.profile.bundles.push('ext-one', 'ext-two')
  383. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  384. const { ctx, manager } = await bootProfile(staged)
  385. const views = await manager.list()
  386. expect(entryIds(ctx)).toContain('include:hello')
  387. expect(views.find(view => view.name === 'ext-one')).toMatchObject({ status: 'running' })
  388. const two = views.find(view => view.name === 'ext-two')
  389. expect(two).toMatchObject({ status: 'failed', enabled: true })
  390. expect(two?.reason).toContain('already declared by ext-one')
  391. expect(two?.rows).toEqual([{
  392. entryId: 'conflict:ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
  393. failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
  394. }])
  395. })
  396. it('does not import a disabled bundle and reports a stale layer as restart-required', async () => {
  397. const staged = await stageHome('startup')
  398. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  399. addDependency(staged.profileDir, 'ext-broken')
  400. stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW })
  401. addDependency(staged.profileDir, 'ext-later')
  402. const { manager } = await bootProfile(staged)
  403. // Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no.
  404. const manifest = manifestOf(staged.profileDir)
  405. manifest.dsh.profile.bundles.push('ext-later')
  406. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  407. const views = await manager.list()
  408. expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
  409. expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
  410. })
  411. it('reports unapplied live selections as failures instead of requiring a restart', async () => {
  412. const staged = await stageHome()
  413. stagePackage(staged.profileDir, 'ext-live', { patch: BUNDLE_ONE_ROW })
  414. addDependency(staged.profileDir, 'ext-live')
  415. const initial = manifestOf(staged.profileDir)
  416. initial.dsh.profile.bundles = ['ext-live']
  417. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
  418. const { ctx, manager } = await bootProfile(staged)
  419. try {
  420. initial.dsh.profile.bundles = []
  421. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
  422. const [view] = await manager.list()
  423. expect(view).toMatchObject({ enabled: false, liveReload: true, status: 'failed', reason: expect.stringContaining('not been applied') as string })
  424. expect(view?.rows[0]?.phase).toBe('active')
  425. } finally {
  426. await ctx.fiber.dispose()
  427. rmSync(staged.home, { recursive: true, force: true })
  428. }
  429. })
  430. it('reports unreadable declarations and treats physical peer identity as advisory', async () => {
  431. const staged = await stageHome()
  432. stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
  433. addDependency(staged.profileDir, 'ext-odd')
  434. stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
  435. addDependency(staged.profileDir, 'ext-refused')
  436. const { manager } = await bootProfile(staged, {
  437. metadata: ({ packageName }) => {
  438. if (packageName === 'ext-odd') throw new Error('invalid declarations')
  439. return { packageName, kind: 'bundle', cordisSameCopy: false, rows: [], overrides: [], addable: [] }
  440. },
  441. })
  442. const views = await manager.list()
  443. expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'invalid declarations', cordisSameCopy: null, rows: [] })
  444. expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'disabled', cordisSameCopy: false })
  445. await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot read package declarations: invalid declarations' } })
  446. await expect(manager.enable('ext-refused')).resolves.toMatchObject({ effect: 'live' })
  447. })
  448. it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
  449. const staged = await stageHome('startup')
  450. stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
  451. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
  452. const { manager } = await bootProfile(staged)
  453. expect((await manager.list()).map(view => [view.name, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'running', false, 1]])
  454. // A manifest with no dsh section at all knows no bundles.
  455. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
  456. expect(await manager.list()).toEqual([])
  457. // Bundles the manifest names after boot: one staged, one that resolves to nothing.
  458. stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
  459. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
  460. const views = await manager.list()
  461. expect(views.map(view => [view.name, view.kind, view.status])).toEqual([
  462. ['tpl', 'bundle', 'running'],
  463. ['tpl-later', 'bundle', 'restart-required'],
  464. ['ghost', 'unknown', 'plain'],
  465. ])
  466. await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  467. // Retrying takes the bundle out first, which a template bundle refuses.
  468. await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  469. })
  470. it('folds disabled, user-disabled, and waiting rows', async () => {
  471. const staged = await stageHome()
  472. stagePackage(staged.profileDir, 'ext-off', { patch: '- insert:\n - id: a\n name: cordis:good\n disabled: true\n - id: b\n name: cordis:good\n' })
  473. addDependency(staged.profileDir, 'ext-off')
  474. stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n - id: w\n name: cordis:consumer\n' })
  475. addDependency(staged.profileDir, 'ext-waiting')
  476. const { manager } = await bootProfile(staged)
  477. await manager.enable('ext-off')
  478. await manager.enable('ext-waiting')
  479. await manager.setRowDisabled('b', true)
  480. const views = await manager.list()
  481. const off = views.find(view => view.name === 'ext-off')
  482. expect(off).toMatchObject({ status: 'running' })
  483. expect(off?.rows.map(row => [row.rowId, row.enabled, row.disabledBy, row.phase])).toEqual([
  484. ['a', false, 'composition', null],
  485. ['b', false, 'user', null],
  486. ])
  487. await manager.setRowDisabled('b', true)
  488. const waiting = views.find(view => view.name === 'ext-waiting')
  489. expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
  490. expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
  491. // Another bundle's recorded failure is not this one's row.
  492. expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
  493. })
  494. it('reads anonymous and gated static patch rows', async () => {
  495. const staged = await stageHome()
  496. stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' })
  497. addDependency(staged.profileDir, 'ext-anon')
  498. stagePackage(staged.profileDir, 'installed-bundle', { patch: BUNDLE_ONE_ROW })
  499. addDependency(staged.profileDir, 'installed-bundle')
  500. const { manager } = await bootProfile(staged)
  501. const views = await manager.list()
  502. expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
  503. { entryId: 'cordis:good', rowId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
  504. { entryId: 'gated', rowId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
  505. ])
  506. await manager.enable('installed-bundle')
  507. const installed = (await manager.list()).find(view => view.name === 'installed-bundle')
  508. expect(installed).toMatchObject({ status: 'running' })
  509. expect(installed?.rows.map(row => row.entryId)).toEqual(['include:hello'])
  510. await manager.disable('installed-bundle')
  511. expect((await manager.list()).find(view => view.name === 'installed-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
  512. })
  513. })
  514. describe('install', () => {
  515. it('removes a package that is neither a bundle nor a plugin module and says why', async () => {
  516. const staged = await stageHome()
  517. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const answer = 42\n' })
  518. const calls: string[][] = []
  519. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  520. const result = await manager.add('ext-lib')
  521. expect(result).toMatchObject({
  522. installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [],
  523. })
  524. expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
  525. expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
  526. expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
  527. })
  528. it('removes an installed bundle whose row id another layer already owns', async () => {
  529. const staged = await stageHome()
  530. stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
  531. addDependency(staged.profileDir, 'ext-one')
  532. const manifest = manifestOf(staged.profileDir)
  533. manifest.dsh.profile.bundles.push('ext-one')
  534. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  535. stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
  536. const calls: string[][] = []
  537. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  538. const result = await manager.add('ext-two', { enable: true })
  539. expect(result).toMatchObject({
  540. installed: [], enabled: [], installedOnly: [],
  541. removed: [{ name: 'ext-two', reason: 'row "hello" is already declared by ext-one' }],
  542. })
  543. expect(calls).toEqual([['pnpm', 'add', 'ext-two'], ['pnpm', 'remove', 'ext-two']])
  544. expect(manifestOf(staged.profileDir)).toMatchObject({ dsh: { profile: { bundles: expect.not.arrayContaining(['ext-two']) as string[] } } })
  545. expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two')
  546. })
  547. it('reports a bundle whose patch disappears after metadata discovery', async () => {
  548. const staged = await stageHome()
  549. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  550. const calls: string[][] = []
  551. const { manager } = await bootProfile(staged, {
  552. spawn: recordingPnpm(staged.profileDir, calls),
  553. metadata: (options) => {
  554. const metadata = readPackageMetadata(options)
  555. rmSync(join(staged.profileDir, 'node_modules/ext-new/cordis.patch.yml'))
  556. return metadata
  557. },
  558. })
  559. const result = await manager.add('ext-new')
  560. expect(result.removed).toEqual([{ name: 'ext-new', reason: expect.stringContaining('cordis.patch.yml') as string }])
  561. expect(calls).toEqual([['pnpm', 'add', 'ext-new'], ['pnpm', 'remove', 'ext-new']])
  562. })
  563. it('keeps an unexecuted package installed with its bundle disabled', async () => {
  564. const staged = await stageHome()
  565. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  566. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  567. const result = await manager.add('ext-broken')
  568. expect(result).toMatchObject({ installed: ['ext-broken'], removed: [] })
  569. expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
  570. })
  571. it('restores the manifest when pnpm fails after writing it', async () => {
  572. const staged = await stageHome()
  573. const manifestPath = join(staged.profileDir, 'package.json')
  574. const before = readFileSync(manifestPath, 'utf8')
  575. const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, (args) => {
  576. addDependency(staged.profileDir, args[1] ?? 'ext-ghost')
  577. return { code: 1, stderr: 'ERR_PNPM_FETCH_404\n' }
  578. }) })
  579. await expect(manager.add('ext-ghost')).rejects.toMatchObject({ code: 'plugins/install-failed' })
  580. expect(readFileSync(manifestPath, 'utf8')).toBe(before)
  581. })
  582. it('refuses a second mutation while one is still running', async () => {
  583. const staged = await stageHome()
  584. stagePackage(staged.profileDir, 'ext-slow', { patch: BUNDLE_ONE_ROW })
  585. let release = (): void => {}
  586. const gate = new Promise<void>((resolve) => { release = resolve })
  587. const spawn: SpawnLike = (_command, args) => {
  588. const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: () => boolean }
  589. child.stdout = new PassThrough()
  590. child.stderr = new PassThrough()
  591. child.kill = () => true
  592. void gate.then(() => {
  593. addDependency(staged.profileDir, args[1] ?? 'ext-slow')
  594. child.emit('close', 0)
  595. })
  596. return child as unknown as ChildProcess
  597. }
  598. const { manager } = await bootProfile(staged, { spawn })
  599. const first = manager.add('ext-slow')
  600. await expect(manager.enable('ext-slow')).rejects.toMatchObject({
  601. code: 'plugins/busy', details: { operation: 'enable', active: { operation: 'add', subject: 'ext-slow' } },
  602. })
  603. release()
  604. await expect(first).resolves.toMatchObject({ installed: ['ext-slow'] })
  605. // The lock is released with the run: the refused call now goes through.
  606. await expect(manager.enable('ext-slow')).resolves.toMatchObject({ changed: true })
  607. })
  608. it('refuses to change node_modules while a session is running', async () => {
  609. const staged = await stageHome()
  610. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  611. addDependency(staged.profileDir, 'ext-bundle')
  612. const calls: string[][] = []
  613. const { ctx, manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  614. ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
  615. await expect(manager.add('ext-new')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
  616. await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'uninstall' } })
  617. expect(calls).toEqual([])
  618. // Enabling recomposes the tree without touching node_modules.
  619. await expect(manager.enable('ext-bundle')).resolves.toMatchObject({ changed: true })
  620. })
  621. it('runs pnpm add, records the dependency and leaves its bundle disabled', async () => {
  622. const staged = await stageHome()
  623. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  624. const calls: string[][] = []
  625. const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  626. const result = await manager.add('github:acme/ext-new')
  627. expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
  628. // The fake pnpm records the spec itself as the dependency name, which
  629. // resolves to nothing: an installed dependency whose declarations cannot be read.
  630. expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
  631. expect(log.map(chunk => [chunk.argv, chunk.cwd, chunk.stream, chunk.text, chunk.exitCode])).toEqual([
  632. [['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
  633. [['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '', 0],
  634. ])
  635. expect(changes).toEqual([{ reason: 'install' }])
  636. })
  637. it('has pnpm colour its output and streams the escapes as they come', async () => {
  638. const staged = await stageHome()
  639. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  640. const colours: (string | undefined)[] = []
  641. const pnpm = fakePnpm(staged.profileDir, (args) => {
  642. addDependency(staged.profileDir, args[1] as string)
  643. return { code: 0, stdout: '\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n' }
  644. })
  645. const { manager, log } = await bootProfile(staged, {
  646. spawn: (command, args, options) => {
  647. colours.push(options.env?.FORCE_COLOR)
  648. return pnpm(command, args, options)
  649. },
  650. })
  651. await manager.add('ext-new')
  652. expect(colours).toEqual(['1'])
  653. expect(log[0]?.text).toBe('\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n')
  654. })
  655. it('reconciles by the installed name, and enables the new bundle when asked', async () => {
  656. const staged = await stageHome()
  657. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  658. const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  659. const result = await manager.add('ext-new', { enable: true })
  660. expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
  661. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
  662. expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
  663. expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
  664. expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
  665. })
  666. it('reports a plugin module as plain and uninstalls it', async () => {
  667. const staged = await stageHome()
  668. stagePackage(staged.profileDir, 'ext-lib', { main: 'export function apply() {}\n' })
  669. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  670. expect(await manager.add('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [] })
  671. expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain')
  672. await manager.uninstall('ext-lib')
  673. expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
  674. })
  675. it('installs into a manifest that declares no dependencies yet', async () => {
  676. const staged = await stageHome()
  677. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } }))
  678. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  679. const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
  680. const path = join(staged.profileDir, 'package.json')
  681. const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
  682. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } }))
  683. return { code: 0 }
  684. }) })
  685. expect(await manager.add('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] })
  686. })
  687. it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => {
  688. const staged = await stageHome()
  689. const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) })
  690. await expect(exits.manager.add('nope')).rejects.toMatchObject({
  691. code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' },
  692. })
  693. expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 })
  694. await expect(exits.manager.add(' ')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  695. const erroringHome = await stageHome()
  696. const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) })
  697. await expect(erroring.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } })
  698. expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true)
  699. const hangingHome = await stageHome()
  700. const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
  701. await expect(hanging.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed' })
  702. expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true)
  703. })
  704. it('keeps only the tail of a long log in the failure', async () => {
  705. const staged = await stageHome()
  706. const { manager } = await bootProfile(staged, {
  707. spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })),
  708. }, { installLogTailBytes: 256 })
  709. await expect(manager.add('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
  710. })
  711. })
  712. describe('enable, disable, and retry', () => {
  713. it('composes an installed bundle live, and takes it out again', async () => {
  714. const staged = await stageHome()
  715. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  716. addDependency(staged.profileDir, 'ext-bundle')
  717. const { ctx, manager, changes } = await bootProfile(staged)
  718. expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
  719. expect(entryIds(ctx)).toContain('include:hello')
  720. expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
  721. expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
  722. expect(entryIds(ctx)).not.toContain('include:hello')
  723. expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
  724. expect(changes.map(change => [change.reason, change.packageName])).toEqual([
  725. ['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
  726. ])
  727. })
  728. it('only writes the manifest on a startup-reload profile', async () => {
  729. const staged = await stageHome('startup')
  730. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  731. addDependency(staged.profileDir, 'ext-bundle')
  732. const { ctx, manager } = await bootProfile(staged)
  733. expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
  734. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
  735. expect(entryIds(ctx)).not.toContain('include:hello')
  736. expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
  737. })
  738. it('refuses what cannot be enabled or disabled', async () => {
  739. const staged = await stageHome()
  740. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  741. addDependency(staged.profileDir, 'ext-lib')
  742. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  743. addDependency(staged.profileDir, 'ext-broken')
  744. const { manager } = await bootProfile(staged)
  745. await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  746. await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
  747. await expect(manager.enable('ext-broken')).resolves.toMatchObject({ effect: 'live' })
  748. // A template bundle is not a dependency and cannot be disabled.
  749. const manifest = manifestOf(staged.profileDir)
  750. manifest.dsh.profile.bundles.push('template')
  751. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  752. await expect(manager.disable('template')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  753. await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  754. })
  755. it('retains live enablement and reports failed rows after startup', async () => {
  756. const staged = await stageHome()
  757. stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n' })
  758. addDependency(staged.profileDir, 'ext-fatal')
  759. stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
  760. addDependency(staged.profileDir, 'ext-fine')
  761. const { ctx, manager } = await bootProfile(staged)
  762. await manager.enable('ext-fine')
  763. const result = await manager.enable('ext-fatal')
  764. expect(result.effect).toBe('live')
  765. expect(result.issues?.[0]?.message).toContain('boom at apply')
  766. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine', 'ext-fatal'])
  767. expect(entryIds(ctx)).toContain('include:hello')
  768. expect(entryIds(ctx)).toContain('include:bad')
  769. })
  770. it('retries a bundle by removing and re-adding its layer', async () => {
  771. const staged = await stageHome()
  772. stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' })
  773. addDependency(staged.profileDir, 'ext-flaky')
  774. flakyCalls = 0
  775. const { manager, changes } = await bootProfile(staged)
  776. await manager.enable('ext-flaky')
  777. expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string })
  778. expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' })
  779. expect((await manager.list())[0]).toMatchObject({ status: 'running' })
  780. expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry'])
  781. })
  782. })
  783. describe('rows in user layers', () => {
  784. it('adds, disables, re-enables, and removes a row in the live global layer', async () => {
  785. const staged = await stageHome()
  786. stagePackage(staged.profileDir, '@acme/ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
  787. addDependency(staged.profileDir, '@acme/ext-plugin')
  788. const { ctx, manager, changes } = await bootProfile(staged)
  789. const added = await manager.addRow('@acme/ext-plugin')
  790. expect(added).toEqual({ rowId: 'acme/ext-plugin', file: join(staged.profileDir, 'cordis.patch.yml') })
  791. expect(readFileSync(added.file, 'utf8')).toBe('- insert:\n - id: acme/ext-plugin\n name: "@acme/ext-plugin"\n config: {}\n')
  792. // Composed live: the row is in the tree, though its module cannot import from the temp home.
  793. expect(entryIds(ctx)).toContain('include:acme/ext-plugin')
  794. await expect(manager.addRow('@acme/ext-plugin')).rejects.toMatchObject({ code: 'plugins/row-conflict' })
  795. await manager.setRowDisabled('acme/ext-plugin', true)
  796. expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(true)
  797. expect(readFileSync(added.file, 'utf8')).toContain('- id: acme/ext-plugin\n disabled: true\n')
  798. await manager.setRowDisabled('acme/ext-plugin', false)
  799. expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(false)
  800. expect(readFileSync(added.file, 'utf8')).not.toContain('disabled')
  801. await manager.removeRow('acme/ext-plugin')
  802. expect(entryIds(ctx)).not.toContain('include:acme/ext-plugin')
  803. await expect(manager.removeRow('acme/ext-plugin')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  804. expect(changes.map(change => change.reason)).toEqual(['row', 'row', 'row', 'row'])
  805. })
  806. it('detects a conflict through the layer file when the tree was not recomposed', async () => {
  807. const staged = await stageHome('startup')
  808. stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
  809. addDependency(staged.profileDir, 'ext-plugin')
  810. const { ctx, manager } = await bootProfile(staged)
  811. await manager.addRow('ext-plugin')
  812. expect(entryIds(ctx)).not.toContain('include:ext-plugin')
  813. await expect(manager.addRow('ext-plugin')).rejects.toMatchObject({ code: 'plugins/row-conflict' })
  814. })
  815. it('adds a declared addable module with its default config and an explicit id, and refuses the rest', async () => {
  816. const staged = await stageHome()
  817. stagePackage(staged.profileDir, 'ext-bundle', {
  818. patch: BUNDLE_ONE_ROW,
  819. plugins: [
  820. { name: './tools/sql.js', title: 'SQL', config: { dsn: 'sqlite://' } },
  821. { name: './missing.js' },
  822. ],
  823. files: { 'tools/sql.js': 'export const name = "sql"\nexport function apply() {}\n' },
  824. })
  825. addDependency(staged.profileDir, 'ext-bundle')
  826. const { manager } = await bootProfile(staged)
  827. const added = await manager.addRow('ext-bundle', { module: './tools/sql.js', id: 'sql' })
  828. expect(added.rowId).toBe('sql')
  829. expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n name: ext-bundle/tools/sql.js\n config:\n dsn: sqlite://\n')
  830. await expect(manager.addRow('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-enableable' })
  831. await expect(manager.addRow('ext-bundle', { module: './missing.js' })).resolves.toMatchObject({ rowId: 'ext-bundle/missing.js' })
  832. await expect(manager.addRow('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  833. })
  834. })
  835. describe('dependents and uninstall', () => {
  836. it('names the services other rows inject and the user-layer rows that reference the package', async () => {
  837. const staged = await stageHome()
  838. // Beside the provider: a row nobody injects, and a row switched off (no fiber to read).
  839. stagePackage(staged.profileDir, 'ext-provider', { patch: '- insert:\n - id: svc\n name: cordis:provider\n - id: lonely\n name: cordis:lonely-provider\n - id: off\n name: cordis:good\n disabled: true\n' })
  840. addDependency(staged.profileDir, 'ext-provider')
  841. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [
  842. '- insert:',
  843. ' - name: ext-provider/anonymous.js',
  844. ' disabled: true',
  845. ' - id: ref',
  846. ' name: ext-provider/tools/x.js',
  847. ' disabled: true',
  848. ' - id: grp',
  849. ' name: cordis:group',
  850. ' group: true',
  851. ' config:',
  852. ' - id: nested-ref',
  853. ' name: ext-provider',
  854. ' disabled: true',
  855. '',
  856. ].join('\n'))
  857. const { manager, runtime } = await bootProfile(staged)
  858. await manager.enable('ext-provider')
  859. // A built-in row injecting the bundle's service, composed once the
  860. // provider is up (a boot would refuse a row left waiting).
  861. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), `${readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')}- id: svc\n disabled: false\n- insert:\n - id: needs-svc\n name: cordis:consumer\n`)
  862. await runtime.recompose()
  863. const dependents = await manager.dependents('ext-provider')
  864. expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:svc', injectedBy: ['include:needs-svc'] }])
  865. expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
  866. expect(dependents.references[0]).toEqual({ rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
  867. })
  868. it('ignores an unreadable user layer while collecting references', async () => {
  869. const staged = await stageHome()
  870. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  871. addDependency(staged.profileDir, 'ext-lib')
  872. const { manager } = await bootProfile(staged)
  873. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n')
  874. expect((await manager.dependents('ext-lib')).references).toEqual([])
  875. })
  876. it('disables, drops references, removes the package, and forgets its probe', async () => {
  877. const staged = await stageHome()
  878. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js' }], files: { 'extra.js': 'export function apply() {}\n' } })
  879. addDependency(staged.profileDir, 'ext-bundle')
  880. const calls: string[][] = []
  881. const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  882. await manager.enable('ext-bundle')
  883. await manager.addRow('ext-bundle', { module: './extra.js' })
  884. expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
  885. await manager.uninstall('ext-bundle')
  886. expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
  887. expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
  888. expect(entryIds(ctx)).not.toContain('include:hello')
  889. expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
  890. expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
  891. expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
  892. await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  893. })
  894. })
  895. })