plugin-manager.spec.ts 70 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270
  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 { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  10. import { mkdtemp } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { PassThrough } from 'node:stream'
  14. import { execFileSync } from 'node:child_process'
  15. import { afterEach, describe, expect, it, vi } from 'vitest'
  16. import { Context, type Plugin } from '@deepseek-ai/cordis'
  17. import Loader from '@deepseek-ai/cordis-plugin-loader'
  18. import {
  19. boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, rootIncludeEntry,
  20. type ComposedStack, type Profile, readPackageMetadata,
  21. } from '@deepseek-ai/dsh-app-boot'
  22. import {
  23. PluginManager, PluginInstaller, PluginOperationError, pluginOperationFailureOf,
  24. type PluginInstallLogChunk, type PluginInstallRequestId, type PluginInstallProgress,
  25. type PluginToolingConfig, type SpawnLike,
  26. } from '@deepseek-ai/dsh-plugin-manager'
  27. import type {} from '@deepseek-ai/dsh-agent'
  28. const NAME = 'dsh-test'
  29. /** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
  30. function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
  31. return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installKillGraceMs: 50, installLogTailBytes: 16_384, inspectTimeoutMs: 1_000, ...overrides }
  32. }
  33. /** Test seams: the child spawner and the static metadata reader. */
  34. interface Internals {
  35. spawn?: SpawnLike
  36. metadata?: typeof readPackageMetadata
  37. }
  38. const contexts: Context[] = []
  39. afterEach(async () => {
  40. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  41. })
  42. /** Builtins the staged bundles name; registered on every boot. */
  43. const good: Plugin.Function = () => {}
  44. const throws: Plugin.Function = () => { throw new Error('boom at apply') }
  45. let flakyCalls = 0
  46. const flaky: Plugin.Function = () => {
  47. flakyCalls += 1
  48. if (flakyCalls === 1) throw new Error('flaky first start')
  49. }
  50. const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) }
  51. const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
  52. const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
  53. const prepare = (ctx: Context): void => {
  54. ctx.loader.builtins.configured = {
  55. Config: { '~standard': { version: 1, vendor: 'manager-test', validate(input: unknown) {
  56. const config = input as { value: number }
  57. return config.value < 0 ? { issues: [{ message: 'negative config' }] } : { value: config }
  58. } } },
  59. apply() {},
  60. }
  61. ctx.loader.builtins.good = good
  62. ctx.loader.builtins.throws = throws
  63. ctx.loader.builtins.flaky = flaky
  64. ctx.loader.builtins.provider = provider
  65. ctx.loader.builtins['lonely-provider'] = lonelyProvider
  66. ctx.loader.builtins.consumer = consumer
  67. }
  68. interface StagedHome {
  69. home: string
  70. profileDir: string
  71. anchor: string
  72. }
  73. /** A harness home with one empty live profile and an install anchor that carries nothing. */
  74. async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise<StagedHome> {
  75. const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-'))
  76. const profileDir = join(home, 'profiles', 'web')
  77. mkdirSync(profileDir, { recursive: true })
  78. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  79. name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } },
  80. }, null, 2))
  81. writeFileSync(join(profileDir, 'cordis.yml'), '[]\n')
  82. const anchorDir = join(home, 'anchor')
  83. mkdirSync(anchorDir, { recursive: true })
  84. const anchor = join(anchorDir, 'package.json')
  85. writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} }))
  86. return { home, profileDir, anchor }
  87. }
  88. interface StagedPackage {
  89. /** The bundle patch text; omitted stages a bundle-less package. */
  90. patch?: string
  91. /** `index.js` text, exported as the package main. */
  92. main?: string
  93. version?: string
  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. },
  110. }, null, 2))
  111. if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch)
  112. if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main)
  113. for (const [file, text] of Object.entries(staged.files ?? {})) {
  114. mkdirSync(join(dir, file, '..'), { recursive: true })
  115. writeFileSync(join(dir, file), text)
  116. }
  117. }
  118. /** Add a dependency to the profile manifest, as `pnpm add` does. */
  119. function addDependency(profileDir: string, name: string, spec = '1.0.0'): void {
  120. const path = join(profileDir, 'package.json')
  121. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
  122. manifest.dependencies[name] = spec
  123. writeFileSync(path, JSON.stringify(manifest, null, 2))
  124. }
  125. function manifestOf(profileDir: string): { dependencies: Record<string, string>; dsh: { profile: { bundles: string[] } } } {
  126. return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType<typeof manifestOf>
  127. }
  128. /** What the fake pnpm does for one invocation. */
  129. type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
  130. /** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */
  131. function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = [], gate = Promise.resolve()): SpawnLike {
  132. return (spec) => {
  133. const [, ...args] = spec.argv
  134. calls.push([...spec.argv])
  135. expect(spec.cwd).toBe(profileDir)
  136. const stdout = new PassThrough()
  137. const stderr = new PassThrough()
  138. const exit = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  139. const finish = (exitCode: number | null, signal: NodeJS.Signals | null = null): void => {
  140. stdout.end(); stderr.end(); exit.resolve({ exitCode, signal })
  141. }
  142. const terminate = (): void => { finish(null, 'SIGTERM') }
  143. spec.signal?.addEventListener('abort', terminate, { once: true })
  144. const clean = (): void => { spec.signal?.removeEventListener('abort', terminate) }
  145. void exit.promise.then(clean, clean)
  146. void gate.then(() => {
  147. if (spec.signal?.aborted) { terminate(); return }
  148. const outcome = behavior(args)
  149. if (outcome.error !== undefined) {
  150. stdout.end(); stderr.end(); exit.reject(outcome.error)
  151. return
  152. }
  153. if (outcome.stdout !== undefined) stdout.write(outcome.stdout)
  154. if (outcome.stderr !== undefined) stderr.write(outcome.stderr)
  155. if (!outcome.hang) finish(outcome.code)
  156. })
  157. return {
  158. stdin: undefined, stdout, stderr, control: undefined, collected: {}, done: exit.promise, terminate,
  159. waitForExit: () => exit.promise.then(() => true, () => true),
  160. }
  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('installs and lists a non-bundle package without executing its main module', async () => {
  310. const staged = await stageHome()
  311. const marker = join(staged.profileDir, 'executed')
  312. stagePackage(staged.profileDir, 'plain', {
  313. main: `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad'); export function apply() {}`,
  314. })
  315. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  316. await manager.add('plain')
  317. expect((await manager.list()).find(view => view.name === 'plain')?.kind).toBe('unknown')
  318. expect(existsSync(marker)).toBe(false)
  319. })
  320. it('reports plugins/unavailable without a profile runtime', async () => {
  321. const ctx = new Context()
  322. contexts.push(ctx)
  323. await ctx.plugin(Loader)
  324. await expect(managerOver(ctx).list()).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
  325. })
  326. describe('list', () => {
  327. it('folds installed, enabled, declared and running facts into one view per package', async () => {
  328. const staged = await stageHome()
  329. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } })
  330. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  331. stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
  332. addDependency(staged.profileDir, 'ext-bundle')
  333. addDependency(staged.profileDir, 'ext-lib')
  334. addDependency(staged.profileDir, 'ext-plugin')
  335. const { manager } = await bootProfile(staged)
  336. const views = await manager.list()
  337. expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled])).toEqual([
  338. ['ext-bundle', 'bundle', 'disabled', true, false],
  339. ['ext-lib', 'unknown', 'plain', true, false],
  340. ['ext-plugin', 'unknown', 'plain', true, false],
  341. ])
  342. const bundle = views[0]
  343. expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', liveReload: true })
  344. // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
  345. expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
  346. })
  347. it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
  348. const staged = await stageHome()
  349. stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n - id: ok\n name: cordis:good\n - id: bad\n name: cordis:throws\n' })
  350. addDependency(staged.profileDir, 'ext-mixed')
  351. const manifest = manifestOf(staged.profileDir)
  352. manifest.dsh.profile.bundles.push('ext-mixed')
  353. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  354. const { manager } = await bootProfile(staged)
  355. const [view] = await manager.list()
  356. expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
  357. expect(view?.reason).toContain('boom at apply')
  358. expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
  359. ['include:ok', 'active', undefined],
  360. ['include:bad', 'failed', 'activation'],
  361. ])
  362. })
  363. it('lists a bundle left out by an id conflict as failed, with the conflict as its row', async () => {
  364. const staged = await stageHome()
  365. stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
  366. addDependency(staged.profileDir, 'ext-one')
  367. stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
  368. addDependency(staged.profileDir, 'ext-two')
  369. const manifest = manifestOf(staged.profileDir)
  370. manifest.dsh.profile.bundles.push('ext-one', 'ext-two')
  371. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  372. const { ctx, manager } = await bootProfile(staged)
  373. const views = await manager.list()
  374. expect(entryIds(ctx)).toContain('include:hello')
  375. expect(views.find(view => view.name === 'ext-one')).toMatchObject({ status: 'running' })
  376. const two = views.find(view => view.name === 'ext-two')
  377. expect(two).toMatchObject({ status: 'failed', enabled: true })
  378. expect(two?.reason).toContain('already declared by ext-one')
  379. expect(two?.rows).toEqual([{
  380. entryId: 'conflict:ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
  381. failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
  382. }])
  383. })
  384. it('does not import a disabled bundle and reports a stale layer as restart-required', async () => {
  385. const staged = await stageHome('startup')
  386. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  387. addDependency(staged.profileDir, 'ext-broken')
  388. stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW })
  389. addDependency(staged.profileDir, 'ext-later')
  390. const { manager } = await bootProfile(staged)
  391. // Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no.
  392. const manifest = manifestOf(staged.profileDir)
  393. manifest.dsh.profile.bundles.push('ext-later')
  394. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  395. const views = await manager.list()
  396. expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
  397. expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
  398. })
  399. it('reports unapplied live selections as failures instead of requiring a restart', async () => {
  400. const staged = await stageHome()
  401. stagePackage(staged.profileDir, 'ext-live', { patch: BUNDLE_ONE_ROW })
  402. addDependency(staged.profileDir, 'ext-live')
  403. const initial = manifestOf(staged.profileDir)
  404. initial.dsh.profile.bundles = ['ext-live']
  405. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
  406. const { ctx, manager } = await bootProfile(staged)
  407. try {
  408. initial.dsh.profile.bundles = []
  409. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
  410. const [view] = await manager.list()
  411. expect(view).toMatchObject({ enabled: false, liveReload: true, status: 'failed', reason: expect.stringContaining('not been applied') as string })
  412. expect(view?.rows[0]?.phase).toBe('active')
  413. } finally {
  414. await ctx.fiber.dispose()
  415. rmSync(staged.home, { recursive: true, force: true })
  416. }
  417. })
  418. it('reports unreadable declarations and treats physical peer identity as advisory', async () => {
  419. const staged = await stageHome()
  420. stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
  421. addDependency(staged.profileDir, 'ext-odd')
  422. stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
  423. addDependency(staged.profileDir, 'ext-refused')
  424. const { manager } = await bootProfile(staged, {
  425. metadata: ({ packageName }) => {
  426. if (packageName === 'ext-odd') throw new Error('invalid declarations')
  427. return { packageName, kind: 'bundle', cordisSameCopy: false, rows: [], overrides: [] }
  428. },
  429. })
  430. const views = await manager.list()
  431. expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'invalid declarations', cordisSameCopy: null, rows: [] })
  432. expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'disabled', cordisSameCopy: false })
  433. await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot read package declarations: invalid declarations' } })
  434. await expect(manager.enable('ext-refused')).resolves.toMatchObject({ effect: 'live' })
  435. })
  436. it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
  437. const staged = await stageHome('startup')
  438. stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
  439. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
  440. const { manager } = await bootProfile(staged)
  441. expect((await manager.list()).map(view => [view.name, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'running', false, 1]])
  442. // A manifest with no dsh section at all knows no bundles.
  443. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
  444. expect(await manager.list()).toEqual([])
  445. // Bundles the manifest names after boot: one staged, one that resolves to nothing.
  446. stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
  447. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
  448. const views = await manager.list()
  449. expect(views.map(view => [view.name, view.kind, view.status])).toEqual([
  450. ['tpl', 'bundle', 'running'],
  451. ['tpl-later', 'bundle', 'restart-required'],
  452. ['ghost', 'unknown', 'plain'],
  453. ])
  454. await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  455. // Retrying takes the bundle out first, which a template bundle refuses.
  456. await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  457. })
  458. it('folds disabled, user-disabled, and waiting rows', async () => {
  459. const staged = await stageHome()
  460. 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' })
  461. addDependency(staged.profileDir, 'ext-off')
  462. stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n - id: w\n name: cordis:consumer\n' })
  463. addDependency(staged.profileDir, 'ext-waiting')
  464. const { manager } = await bootProfile(staged)
  465. await manager.enable('ext-off')
  466. await manager.enable('ext-waiting')
  467. await manager.setRowDisabled('b', true)
  468. const views = await manager.list()
  469. const off = views.find(view => view.name === 'ext-off')
  470. expect(off).toMatchObject({ status: 'running' })
  471. expect(off?.rows.map(row => [row.rowId, row.enabled, row.disabledBy, row.phase])).toEqual([
  472. ['a', false, 'composition', null],
  473. ['b', false, 'user', null],
  474. ])
  475. await manager.setRowDisabled('b', true)
  476. const waiting = views.find(view => view.name === 'ext-waiting')
  477. expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
  478. expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
  479. // Another bundle's recorded failure is not this one's row.
  480. expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
  481. })
  482. it('reads anonymous and gated static patch rows', async () => {
  483. const staged = await stageHome()
  484. stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' })
  485. addDependency(staged.profileDir, 'ext-anon')
  486. stagePackage(staged.profileDir, 'installed-bundle', { patch: BUNDLE_ONE_ROW })
  487. addDependency(staged.profileDir, 'installed-bundle')
  488. const { manager } = await bootProfile(staged)
  489. const views = await manager.list()
  490. expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
  491. { entryId: 'cordis:good', rowId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
  492. { entryId: 'gated', rowId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
  493. ])
  494. await manager.enable('installed-bundle')
  495. const installed = (await manager.list()).find(view => view.name === 'installed-bundle')
  496. expect(installed).toMatchObject({ status: 'running' })
  497. expect(installed?.rows.map(row => row.entryId)).toEqual(['include:hello'])
  498. await manager.disable('installed-bundle')
  499. expect((await manager.list()).find(view => view.name === 'installed-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
  500. })
  501. })
  502. describe('add', () => {
  503. it('removes a package that is neither a bundle nor a plugin module and says why', async () => {
  504. const staged = await stageHome()
  505. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const answer = 42\n' })
  506. const calls: string[][] = []
  507. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  508. const result = await manager.add('ext-lib')
  509. expect(result).toMatchObject({
  510. installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [],
  511. })
  512. expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
  513. expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
  514. expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
  515. })
  516. it('removes an installed bundle whose row id another layer already owns', async () => {
  517. const staged = await stageHome()
  518. stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
  519. addDependency(staged.profileDir, 'ext-one')
  520. const manifest = manifestOf(staged.profileDir)
  521. manifest.dsh.profile.bundles.push('ext-one')
  522. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  523. stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
  524. const calls: string[][] = []
  525. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  526. const result = await manager.add('ext-two', { enable: true })
  527. expect(result).toMatchObject({
  528. installed: [], enabled: [], installedOnly: [],
  529. removed: [{ name: 'ext-two', reason: 'row "hello" is already declared by ext-one' }],
  530. })
  531. expect(calls).toEqual([['pnpm', 'add', 'ext-two'], ['pnpm', 'remove', 'ext-two']])
  532. expect(manifestOf(staged.profileDir)).toMatchObject({ dsh: { profile: { bundles: expect.not.arrayContaining(['ext-two']) as string[] } } })
  533. expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two')
  534. })
  535. it('reports a bundle whose patch disappears after metadata discovery', async () => {
  536. const staged = await stageHome()
  537. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  538. const calls: string[][] = []
  539. const { manager } = await bootProfile(staged, {
  540. spawn: recordingPnpm(staged.profileDir, calls),
  541. metadata: (options) => {
  542. const metadata = readPackageMetadata(options)
  543. rmSync(join(staged.profileDir, 'node_modules/ext-new/cordis.patch.yml'))
  544. return metadata
  545. },
  546. })
  547. const result = await manager.add('ext-new')
  548. expect(result.removed).toEqual([{ name: 'ext-new', reason: expect.stringContaining('cordis.patch.yml') as string }])
  549. expect(calls).toEqual([['pnpm', 'add', 'ext-new'], ['pnpm', 'remove', 'ext-new']])
  550. })
  551. it('keeps an unexecuted package installed with its bundle disabled', async () => {
  552. const staged = await stageHome()
  553. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  554. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  555. const result = await manager.add('ext-broken')
  556. expect(result).toMatchObject({ installed: ['ext-broken'], removed: [] })
  557. expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
  558. })
  559. it('restores the manifest when pnpm fails after writing it', async () => {
  560. const staged = await stageHome()
  561. const manifestPath = join(staged.profileDir, 'package.json')
  562. const before = readFileSync(manifestPath, 'utf8')
  563. const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, (args) => {
  564. addDependency(staged.profileDir, args[1] ?? 'ext-ghost')
  565. return { code: 1, stderr: 'ERR_PNPM_FETCH_404\n' }
  566. }) })
  567. await expect(manager.add('ext-ghost')).rejects.toMatchObject({ code: 'plugins/install-failed' })
  568. expect(readFileSync(manifestPath, 'utf8')).toBe(before)
  569. })
  570. it.each([undefined, 'original lockfile\n'])('cancels a silent install, waits for its process range and restores lockfile %s', async (lockfile) => {
  571. const staged = await stageHome()
  572. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  573. const manifestPath = join(staged.profileDir, 'package.json')
  574. const lockPath = join(staged.profileDir, 'pnpm-lock.yaml')
  575. const manifest = readFileSync(manifestPath, 'utf8')
  576. if (lockfile !== undefined) writeFileSync(lockPath, lockfile)
  577. const started = Promise.withResolvers<undefined>()
  578. const gone = Promise.withResolvers<undefined>()
  579. const pnpm = fakePnpm(staged.profileDir, () => {
  580. addDependency(staged.profileDir, 'partial')
  581. writeFileSync(lockPath, 'partially written lockfile')
  582. started.resolve(undefined)
  583. return { code: null, hang: true }
  584. })
  585. const { ctx, manager, log } = await bootProfile(staged, {
  586. spawn: spec => ({ ...pnpm(spec), waitForExit: () => gone.promise.then(() => true) }),
  587. })
  588. const states: PluginInstallProgress[] = []
  589. ctx.on('plugins/install-state', (progress) => { states.push(progress) })
  590. const answer = manager.add('partial', { enable: true, requestId }).catch((error: unknown) => error)
  591. await started.promise
  592. expect(states).toEqual([{ requestId, phase: 'installing' }])
  593. expect(log).toEqual([])
  594. expect(await manager.cancelInstall('other' as PluginInstallRequestId)).toEqual({ status: 'not-running' })
  595. const cancel = manager.cancelInstall(requestId)
  596. const repeated = manager.cancelInstall(requestId)
  597. try {
  598. expect(states.at(-1)).toEqual({ requestId, phase: 'cancelling' })
  599. await expect(manager.add('another')).rejects.toMatchObject({ code: 'plugins/busy' })
  600. expect(readFileSync(lockPath, 'utf8')).toBe('partially written lockfile')
  601. } finally { gone.resolve(undefined) }
  602. expect(await cancel).toEqual({ status: 'cancelled' })
  603. expect(await repeated).toEqual({ status: 'cancelled' })
  604. expect(await answer).toMatchObject({ code: 'plugins/install-cancelled', details: { requestId } })
  605. expect(readFileSync(manifestPath, 'utf8')).toBe(manifest)
  606. expect(existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : undefined).toBe(lockfile)
  607. expect(states.some(state => state.phase === 'applying')).toBe(false)
  608. expect(log.every(chunk => chunk.requestId === requestId)).toBe(true)
  609. expect(await manager.cancelInstall(requestId)).toEqual({ status: 'not-running' })
  610. await expect(manager.add('')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  611. })
  612. it.skipIf(process.platform === 'win32')('terminates a real process group even when its parent exits zero and its child ignores TERM', async () => {
  613. // POSIX process groups and SIGTERM are the behavior under test; Windows owns taskkill semantics.
  614. const staged = await stageHome()
  615. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  616. const script = join(staged.profileDir, 'add')
  617. writeFileSync(script, `
  618. import { spawn } from 'node:child_process';
  619. import { writeFileSync } from 'node:fs';
  620. const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => {}); console.log(String(process.pid)); setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'inherit'] });
  621. child.stdout.once('data', bytes => {
  622. writeFileSync('pnpm-lock.yaml', 'partial');
  623. console.log(String(bytes).trim());
  624. });
  625. process.on('SIGTERM', () => process.exit(0));
  626. setInterval(() => {}, 1000);
  627. `)
  628. const ready = Promise.withResolvers<number>()
  629. const { ctx, manager, log } = await bootProfile(staged, {}, {
  630. pnpmCommand: process.execPath, installTimeoutMs: 30_000, installKillGraceMs: 50,
  631. })
  632. ctx.on('plugins/install-log', (chunk) => {
  633. if (chunk.stream === 'stdout' && chunk.text.trim()) ready.resolve(Number(chunk.text.trim()))
  634. })
  635. const result = manager.add('slow', { requestId, enable: true }).catch((error: unknown) => error)
  636. try {
  637. const pid = await ready.promise
  638. expect(pid).toBeGreaterThan(0)
  639. expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
  640. expect(await result).toMatchObject({ code: 'plugins/install-cancelled' })
  641. expect(log.at(-1)?.exitCode).toBe(0)
  642. // A reparented Linux child may remain a zombie until init reaps it; it cannot execute or write.
  643. let status = ''
  644. try { status = execFileSync('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8' }).trim() } catch (error) {
  645. if ((error as { status?: number }).status !== 1) throw error
  646. }
  647. expect(status === '' || status.startsWith('Z')).toBe(true)
  648. expect(existsSync(join(staged.profileDir, 'pnpm-lock.yaml'))).toBe(false)
  649. } finally { await manager.cancelInstall(requestId) }
  650. })
  651. it('reports a preparation failure instead of claiming cancellation completed', async () => {
  652. const staged = await stageHome()
  653. mkdirSync(join(staged.profileDir, 'pnpm-lock.yaml'))
  654. const { ctx, manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  655. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  656. let cancelled: Promise<unknown> | undefined
  657. ctx.on('plugins/install-state', (progress) => {
  658. if (progress.phase === 'installing') cancelled = manager.cancelInstall(requestId).catch((error: unknown) => error)
  659. })
  660. const failed = await manager.add('pkg', { requestId }).catch((error: unknown) => error)
  661. expect(failed).toBeInstanceOf(Error)
  662. expect(await cancelled).toBe(failed)
  663. expect(failed).not.toMatchObject({ code: 'plugins/install-cancelled' })
  664. })
  665. it('keeps application authoritative when cancellation arrives after preparation', async () => {
  666. const staged = await stageHome()
  667. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  668. const { ctx, manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  669. const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
  670. let cancel: ReturnType<PluginManager['cancelInstall']> | undefined
  671. ctx.on('plugins/install-state', (progress) => {
  672. if (progress.phase === 'applying') cancel = manager.cancelInstall(requestId)
  673. })
  674. const answer = await manager.add('ext-new', { requestId, enable: true })
  675. expect(await cancel).toEqual({ status: 'too-late' })
  676. expect(answer.enabled).toEqual(['ext-new'])
  677. })
  678. it('stops an in-flight installer when its owning plugin is disposed', async () => {
  679. const staged = await stageHome()
  680. const started = Promise.withResolvers<undefined>()
  681. const { ctx, manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
  682. started.resolve(undefined)
  683. return { code: null, hang: true }
  684. }) })
  685. const answer = manager.add('slow').catch((error: unknown) => error)
  686. await started.promise
  687. await ctx.fiber.dispose()
  688. expect(await answer).toMatchObject({ code: 'plugins/install-cancelled' })
  689. })
  690. it('uses the standalone installer without a runtime or cancellation controller', async () => {
  691. const staged = await stageHome()
  692. stagePackage(staged.profileDir, 'ext-plain', {})
  693. const log: PluginInstallLogChunk[] = []
  694. const installer = new PluginInstaller({
  695. profileDir: staged.profileDir, profileName: 'web', installAnchor: staged.anchor,
  696. loadProfile: () => loadProfile(NAME, 'web', staged.anchor, staged.home),
  697. config: managerConfig(), color: false, spawn: recordingPnpm(staged.profileDir), installLog: (chunk) => { log.push(chunk) },
  698. })
  699. expect(await installer.add('ext-plain')).toMatchObject({ installed: ['ext-plain'] })
  700. expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-plain')
  701. expect(log.every(chunk => chunk.requestId === undefined)).toBe(true)
  702. await installer.remove('ext-plain')
  703. expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-plain')
  704. // A caller's signal bounds an inspection beside the deadline: one already aborted never reads the registry.
  705. await expect(installer.inspect('ext-view', AbortSignal.abort())).rejects.toMatchObject({
  706. code: 'plugins/inspect-rejected', details: { spec: 'ext-view', problem: 'unknown' },
  707. })
  708. })
  709. it('does not start package removal after removing its user row disposes the manager', async () => {
  710. const staged = await stageHome()
  711. stagePackage(staged.profileDir, 'ext-old', { main: 'export function apply() {}' })
  712. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), '- insert:\n - id: old-row\n name: ext-old\n')
  713. addDependency(staged.profileDir, 'ext-old')
  714. const calls: string[][] = []
  715. const { ctx, manager, runtime } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  716. const recompose = runtime.recompose.bind(runtime)
  717. vi.spyOn(runtime, 'recompose').mockImplementationOnce(async (options) => {
  718. const result = await recompose(options)
  719. await ctx.fiber.dispose()
  720. return result
  721. })
  722. await expect(manager.uninstall('ext-old')).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'manager disposed' } })
  723. expect(calls).toEqual([])
  724. })
  725. it('joins a package removal process during host disposal without exposing installation cancellation for it', async () => {
  726. const staged = await stageHome()
  727. stagePackage(staged.profileDir, 'ext-old', {})
  728. addDependency(staged.profileDir, 'ext-old')
  729. const started = Promise.withResolvers<undefined>()
  730. const { ctx, manager, log } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
  731. started.resolve(undefined)
  732. return { code: null, hang: true, stdout: 'removing' }
  733. }) })
  734. const result = manager.uninstall('ext-old').catch((error: unknown) => error)
  735. await started.promise
  736. expect(await manager.cancelInstall(log[0]?.requestId as PluginInstallRequestId)).toEqual({ status: 'not-running' })
  737. await ctx.fiber.dispose()
  738. expect(await result).toMatchObject({ code: 'plugins/install-cancelled' })
  739. })
  740. it('refuses a second mutation while one is still running', async () => {
  741. const staged = await stageHome()
  742. stagePackage(staged.profileDir, 'ext-slow', { patch: BUNDLE_ONE_ROW })
  743. let release = (): void => {}
  744. const gate = new Promise<void>((resolve) => { release = resolve })
  745. const spawn = fakePnpm(staged.profileDir, (args) => {
  746. addDependency(staged.profileDir, args[1] ?? 'ext-slow')
  747. return { code: 0 }
  748. }, [], gate)
  749. const { manager } = await bootProfile(staged, { spawn })
  750. const first = manager.add('ext-slow')
  751. await expect(manager.enable('ext-slow')).rejects.toMatchObject({
  752. code: 'plugins/busy', details: { operation: 'enable', active: { operation: 'add', subject: 'ext-slow' } },
  753. })
  754. release()
  755. await expect(first).resolves.toMatchObject({ installed: ['ext-slow'] })
  756. // The lock is released with the run: the refused call now goes through.
  757. await expect(manager.enable('ext-slow')).resolves.toMatchObject({ changed: true })
  758. })
  759. it('refuses to change node_modules while a session is running', async () => {
  760. const staged = await stageHome()
  761. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  762. addDependency(staged.profileDir, 'ext-bundle')
  763. const calls: string[][] = []
  764. const { ctx, manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  765. ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
  766. await expect(manager.add('ext-new')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
  767. await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'uninstall' } })
  768. expect(calls).toEqual([])
  769. // Enabling recomposes the tree without touching node_modules.
  770. await expect(manager.enable('ext-bundle')).resolves.toMatchObject({ changed: true })
  771. })
  772. it('runs pnpm add, records the dependency and leaves its bundle disabled', async () => {
  773. const staged = await stageHome()
  774. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  775. const calls: string[][] = []
  776. const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  777. const result = await manager.add('github:acme/ext-new')
  778. expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
  779. // The fake pnpm records the spec itself as the dependency name, which
  780. // resolves to nothing: an installed dependency whose declarations cannot be read.
  781. expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
  782. expect(log.map(chunk => [chunk.argv, chunk.cwd, chunk.stream, chunk.text, chunk.exitCode])).toEqual([
  783. [['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
  784. [['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '', 0],
  785. ])
  786. expect(changes).toEqual([{ reason: 'install' }])
  787. })
  788. it('has pnpm colour its output and streams the escapes as they come', async () => {
  789. const staged = await stageHome()
  790. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  791. const colours: (string | undefined)[] = []
  792. const pnpm = fakePnpm(staged.profileDir, (args) => {
  793. addDependency(staged.profileDir, args[1] as string)
  794. return { code: 0, stdout: '\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n' }
  795. })
  796. const { manager, log } = await bootProfile(staged, {
  797. spawn: (spec) => {
  798. colours.push(spec.env?.FORCE_COLOR)
  799. return pnpm(spec)
  800. },
  801. })
  802. await manager.add('ext-new')
  803. expect(colours).toEqual(['1'])
  804. expect(log[0]?.text).toBe('\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n')
  805. })
  806. it('reconciles by the installed name, and enables the new bundle when asked', async () => {
  807. const staged = await stageHome()
  808. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  809. const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  810. const result = await manager.add('ext-new', { enable: true })
  811. expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
  812. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
  813. expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
  814. expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
  815. expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
  816. })
  817. it('reports a plugin module as plain and uninstalls it', async () => {
  818. const staged = await stageHome()
  819. stagePackage(staged.profileDir, 'ext-lib', { main: 'export function apply() {}\n' })
  820. const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
  821. expect(await manager.add('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [] })
  822. expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain')
  823. await manager.uninstall('ext-lib')
  824. expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
  825. })
  826. it('installs into a manifest that declares no dependencies yet', async () => {
  827. const staged = await stageHome()
  828. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } }))
  829. stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
  830. const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
  831. const path = join(staged.profileDir, 'package.json')
  832. const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
  833. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } }))
  834. return { code: 0 }
  835. }) })
  836. expect(await manager.add('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] })
  837. })
  838. it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => {
  839. const staged = await stageHome()
  840. const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) })
  841. await expect(exits.manager.add('nope')).rejects.toMatchObject({
  842. code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' },
  843. })
  844. expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 })
  845. await expect(exits.manager.add(' ')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  846. const erroringHome = await stageHome()
  847. const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) })
  848. await expect(erroring.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } })
  849. expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true)
  850. const hangingHome = await stageHome()
  851. const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
  852. await expect(hanging.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed' })
  853. expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true)
  854. })
  855. it('keeps only the tail of a long log in the failure', async () => {
  856. const staged = await stageHome()
  857. const { manager } = await bootProfile(staged, {
  858. spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })),
  859. }, { installLogTailBytes: 256 })
  860. await expect(manager.add('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
  861. })
  862. it('classifies a failure by how the run ended and what pnpm printed', async () => {
  863. const outputs: Record<string, string> = {
  864. 'disk': 'ERR_PNPM_ENOSPC ENOSPC: no space left on device\n',
  865. 'blocked': 'ERR_PNPM_IGNORED_BUILDS Ignored build scripts: node-pty\n',
  866. 'gone': 'ERR_PNPM_META_FETCH_FAIL GET https://registry/x: getaddrinfo ENOTFOUND registry\n',
  867. 'odd': 'something else\n',
  868. }
  869. const staged = await stageHome()
  870. const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, args => ({ code: 1, stderr: outputs[args[1] ?? ''] ?? '' })) })
  871. for (const [spec, kind] of [['disk', 'disk-full'], ['blocked', 'build-blocked'], ['gone', 'network'], ['odd', 'unknown']] as const) {
  872. await expect(manager.add(spec)).rejects.toMatchObject({ code: 'plugins/install-failed', details: { spec, kind } })
  873. }
  874. const missingHome = await stageHome()
  875. const missing = await bootProfile(missingHome, {
  876. spawn: fakePnpm(missingHome.profileDir, () => ({ code: null, error: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })),
  877. })
  878. await expect(missing.manager.add('x')).rejects.toMatchObject({ details: { kind: 'pnpm-missing' } })
  879. const hangingHome = await stageHome()
  880. const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
  881. await expect(hanging.manager.add('x')).rejects.toMatchObject({ details: { kind: 'timeout' } })
  882. })
  883. })
  884. describe('inspect', () => {
  885. /** The registry as a fake pnpm view answers it: one JSON object per package, several for a range. */
  886. type ViewAnswer = { code?: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
  887. const registry = (profileDir: string, answers: Record<string, ViewAnswer>): SpawnLike =>
  888. fakePnpm(profileDir, (args) => {
  889. expect(args.slice(0, 1)).toEqual(['view'])
  890. expect(args.slice(2)).toEqual(['name', 'version', 'description', 'dsh', '--json'])
  891. const answer = answers[args[1] ?? '']
  892. if (answer === undefined) return { code: 1, stderr: 'unexpected pnpm view\n' }
  893. return { code: answer.code ?? 0, ...answer }
  894. })
  895. it('reads a registry name through pnpm view, newest version of a range last', async () => {
  896. const staged = await stageHome()
  897. const { manager } = await bootProfile(staged, {
  898. spawn: registry(staged.profileDir, {
  899. 'dsh-x': { stdout: JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { title: 'Sidebar', bundle: { patch: './cordis.patch.yml' } } }) },
  900. 'dsh-lib@^1': { stdout: JSON.stringify([{ name: 'dsh-lib', version: '1.0.0' }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]) },
  901. 'dsh-bare': { stdout: '\u001b[36m' + JSON.stringify({ version: '0.0.1', description: '' }) + '\u001b[39m\n' },
  902. }),
  903. })
  904. await expect(manager.inspect('dsh-x')).resolves.toEqual({
  905. kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', title: 'Sidebar', bundle: true,
  906. })
  907. await expect(manager.inspect('dsh-lib@^1')).resolves.toEqual({ kind: 'registry', name: 'dsh-lib', version: '1.1.0', bundle: false })
  908. // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
  909. await expect(manager.inspect('dsh-bare')).resolves.toEqual({ kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: false })
  910. })
  911. it('refuses a registry name the registry cannot answer for, by what it said', async () => {
  912. const staged = await stageHome()
  913. const { manager } = await bootProfile(staged, {
  914. spawn: registry(staged.profileDir, {
  915. 'nope': { code: 1, stderr: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n' },
  916. 'old@9': { code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for old@9\n' },
  917. 'far': { code: 1, stderr: 'ERR_PNPM_META_FETCH_FAIL request failed, reason: getaddrinfo ENOTFOUND registry\n' },
  918. 'odd': { code: 3, stdout: 'plain text\n' },
  919. 'quiet': { code: 4 },
  920. 'garbled': { stdout: 'not json' },
  921. 'scalar': { stdout: '"just a string"' },
  922. 'slow': { hang: true },
  923. 'gone': { code: null, error: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) },
  924. }),
  925. }, { inspectTimeoutMs: 100 })
  926. const problem = async (spec: string): Promise<{ problem: string; reason: string }> => {
  927. const failure = await manager.inspect(spec).then(() => undefined, (error: unknown) => pluginOperationFailureOf(error))
  928. if (failure?.code !== 'plugins/inspect-rejected') throw new Error(`${spec}: ${String(failure?.code)}`)
  929. expect(failure.details.spec).toBe(spec)
  930. return { problem: failure.details.problem, reason: failure.details.reason }
  931. }
  932. await expect(problem('nope')).resolves.toMatchObject({ problem: 'not-found', reason: expect.stringContaining('E404') as string })
  933. await expect(problem('old@9')).resolves.toMatchObject({ problem: 'not-found', reason: expect.stringContaining('No matching version') as string })
  934. await expect(problem('far')).resolves.toMatchObject({ problem: 'network' })
  935. // Without stderr, stdout stands in as the reason; with neither, the exit code does.
  936. await expect(problem('odd')).resolves.toEqual({ problem: 'unknown', reason: 'plain text' })
  937. await expect(problem('quiet')).resolves.toEqual({ problem: 'unknown', reason: 'pnpm view exited with 4' })
  938. await expect(problem('garbled')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string })
  939. await expect(problem('scalar')).resolves.toEqual({ problem: 'unknown', reason: 'pnpm view answered no package' })
  940. await expect(problem('slow')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('timed out') as string })
  941. await expect(problem('gone')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('ENOENT') as string })
  942. })
  943. it('reads a directory\'s manifest, and refuses what is not a package or is already installed', async () => {
  944. const staged = await stageHome()
  945. const dir = join(staged.home, 'dev', 'dsh-local')
  946. mkdirSync(dir, { recursive: true })
  947. writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } }))
  948. const bare = join(staged.home, 'dev', 'bare')
  949. mkdirSync(bare, { recursive: true })
  950. writeFileSync(join(bare, 'package.json'), '{"version":"1.0.0"}')
  951. const broken = join(staged.home, 'dev', 'broken')
  952. mkdirSync(broken, { recursive: true })
  953. writeFileSync(join(broken, 'package.json'), '{')
  954. const tarball = join(staged.home, 'dev', 'pack.tgz')
  955. writeFileSync(tarball, '')
  956. addDependency(staged.profileDir, 'dsh-installed')
  957. const { manager } = await bootProfile(staged, { spawn: registry(staged.profileDir, {}) })
  958. await expect(manager.inspect(`file:${dir}`)).resolves.toEqual({ kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true })
  959. await expect(manager.inspect(tarball)).resolves.toEqual({ kind: 'tarball', bundle: null })
  960. await expect(manager.inspect('github:acme/dsh-remote')).resolves.toEqual({ kind: 'git', bundle: null })
  961. await expect(manager.inspect(join(staged.home, 'dev', 'missing'))).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the path does not exist' } })
  962. await expect(manager.inspect(join(staged.home, 'dev', 'missing.tgz'))).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the tarball does not exist' } })
  963. await expect(manager.inspect(bare)).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the package.json names no package' } })
  964. await expect(manager.inspect(broken)).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string } })
  965. await expect(manager.inspect('dsh-installed')).rejects.toMatchObject({ details: { problem: 'already-installed', reason: 'dsh-installed is already installed' } })
  966. writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-installed' }))
  967. await expect(manager.inspect(dir)).rejects.toMatchObject({ details: { problem: 'already-installed' } })
  968. await expect(manager.inspect('./relative')).rejects.toMatchObject({ details: { problem: 'invalid-spec' } })
  969. })
  970. it('reports plugins/unavailable without a profile runtime', async () => {
  971. const ctx = new Context()
  972. contexts.push(ctx)
  973. await ctx.plugin(Loader)
  974. await expect(managerOver(ctx).inspect('x')).rejects.toMatchObject({ code: 'plugins/unavailable' })
  975. })
  976. })
  977. describe('enable, disable, and retry', () => {
  978. it('composes an installed bundle live, and takes it out again', async () => {
  979. const staged = await stageHome()
  980. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  981. addDependency(staged.profileDir, 'ext-bundle')
  982. const { ctx, manager, changes } = await bootProfile(staged)
  983. expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
  984. expect(entryIds(ctx)).toContain('include:hello')
  985. expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
  986. expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
  987. expect(entryIds(ctx)).not.toContain('include:hello')
  988. expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
  989. expect(changes.map(change => [change.reason, change.packageName])).toEqual([
  990. ['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
  991. ])
  992. })
  993. it('only writes the manifest on a startup-reload profile', async () => {
  994. const staged = await stageHome('startup')
  995. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  996. addDependency(staged.profileDir, 'ext-bundle')
  997. const { ctx, manager } = await bootProfile(staged)
  998. expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
  999. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
  1000. expect(entryIds(ctx)).not.toContain('include:hello')
  1001. expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
  1002. })
  1003. it('refuses what cannot be enabled or disabled', async () => {
  1004. const staged = await stageHome()
  1005. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  1006. addDependency(staged.profileDir, 'ext-lib')
  1007. stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
  1008. addDependency(staged.profileDir, 'ext-broken')
  1009. const { manager } = await bootProfile(staged)
  1010. await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  1011. await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
  1012. await expect(manager.enable('ext-broken')).resolves.toMatchObject({ effect: 'live' })
  1013. // A template bundle is not a dependency and cannot be disabled.
  1014. const manifest = manifestOf(staged.profileDir)
  1015. manifest.dsh.profile.bundles.push('template')
  1016. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
  1017. await expect(manager.disable('template')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  1018. await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
  1019. })
  1020. it('retains live enablement and reports failed rows after startup', async () => {
  1021. const staged = await stageHome()
  1022. stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n' })
  1023. addDependency(staged.profileDir, 'ext-fatal')
  1024. stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
  1025. addDependency(staged.profileDir, 'ext-fine')
  1026. const { ctx, manager } = await bootProfile(staged)
  1027. await manager.enable('ext-fine')
  1028. const result = await manager.enable('ext-fatal')
  1029. expect(result.effect).toBe('live')
  1030. expect(result.issues?.[0]?.message).toContain('boom at apply')
  1031. expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine', 'ext-fatal'])
  1032. expect(entryIds(ctx)).toContain('include:hello')
  1033. expect(entryIds(ctx)).toContain('include:bad')
  1034. })
  1035. it('retries a bundle by removing and re-adding its layer', async () => {
  1036. const staged = await stageHome()
  1037. stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' })
  1038. addDependency(staged.profileDir, 'ext-flaky')
  1039. flakyCalls = 0
  1040. const { manager, changes } = await bootProfile(staged)
  1041. await manager.enable('ext-flaky')
  1042. expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string })
  1043. expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' })
  1044. expect((await manager.list())[0]).toMatchObject({ status: 'running' })
  1045. expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry'])
  1046. })
  1047. })
  1048. describe('rows in user layers', () => {
  1049. it('persists a startup profile row toggle without changing its mounted tree', async () => {
  1050. const staged = await stageHome('startup')
  1051. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  1052. addDependency(staged.profileDir, 'ext-bundle')
  1053. const manifest = manifestOf(staged.profileDir)
  1054. manifest.dsh.profile.bundles.push('ext-bundle')
  1055. writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
  1056. const { ctx, manager } = await bootProfile(staged)
  1057. await manager.setRowDisabled('hello', true)
  1058. expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: true')
  1059. expect(ctx.loader.resolve('include:hello').disabled).toBe(false)
  1060. })
  1061. it('disables and restores a bundle row through the live user layer', async () => {
  1062. const staged = await stageHome()
  1063. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
  1064. addDependency(staged.profileDir, 'ext-bundle')
  1065. const { ctx, manager, changes } = await bootProfile(staged)
  1066. await manager.enable('ext-bundle')
  1067. const file = join(staged.profileDir, 'cordis.patch.yml')
  1068. await manager.setRowDisabled('hello', true)
  1069. expect(ctx.loader.resolve('include:hello').disabled).toBe(true)
  1070. expect(readFileSync(file, 'utf8')).toContain('disabled: true')
  1071. await manager.setRowDisabled('hello', false)
  1072. expect(ctx.loader.resolve('include:hello').disabled).toBe(false)
  1073. expect(readFileSync(file, 'utf8')).not.toContain('disabled')
  1074. expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'row'])
  1075. })
  1076. })
  1077. describe('dependents and uninstall', () => {
  1078. it('names the services other rows inject and the user-layer rows that reference the package', async () => {
  1079. const staged = await stageHome()
  1080. // Beside the provider: a row nobody injects, and a row switched off (no fiber to read).
  1081. 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' })
  1082. addDependency(staged.profileDir, 'ext-provider')
  1083. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [
  1084. '- insert:',
  1085. ' - name: ext-provider/anonymous.js',
  1086. ' disabled: true',
  1087. ' - id: ref',
  1088. ' name: ext-provider/tools/x.js',
  1089. ' disabled: true',
  1090. ' - id: grp',
  1091. ' name: cordis:group',
  1092. ' group: true',
  1093. ' config:',
  1094. ' - id: nested-ref',
  1095. ' name: ext-provider',
  1096. ' disabled: true',
  1097. '',
  1098. ].join('\n'))
  1099. const { manager, runtime } = await bootProfile(staged)
  1100. await manager.enable('ext-provider')
  1101. // A built-in row injecting the bundle's service, composed once the
  1102. // provider is up (a boot would refuse a row left waiting).
  1103. 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`)
  1104. await runtime.recompose()
  1105. const dependents = await manager.dependents('ext-provider')
  1106. expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:svc', injectedBy: ['include:needs-svc'] }])
  1107. expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
  1108. expect(dependents.references[0]).toEqual({ rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
  1109. })
  1110. it('ignores an unreadable user layer while collecting references', async () => {
  1111. const staged = await stageHome()
  1112. stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
  1113. addDependency(staged.profileDir, 'ext-lib')
  1114. const { manager } = await bootProfile(staged)
  1115. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n')
  1116. expect((await manager.dependents('ext-lib')).references).toEqual([])
  1117. })
  1118. it('disables a bundle and removes references from a handwritten user patch before uninstalling', async () => {
  1119. const staged = await stageHome()
  1120. stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, files: { 'extra.js': 'export function apply() {}\n' } })
  1121. addDependency(staged.profileDir, 'ext-bundle')
  1122. const calls: string[][] = []
  1123. const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
  1124. await manager.enable('ext-bundle')
  1125. writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), '- insert:\n - id: ext-bundle/extra.js\n name: ext-bundle/extra.js\n')
  1126. await ctx.profileRuntime.recompose()
  1127. expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
  1128. await manager.uninstall('ext-bundle')
  1129. expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
  1130. expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
  1131. expect(entryIds(ctx)).not.toContain('include:hello')
  1132. expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
  1133. expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
  1134. expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'uninstall'])
  1135. await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
  1136. })
  1137. })
  1138. })
  1139. describe('PluginOperationError', () => {
  1140. it('carries its code and details, and is what pluginOperationFailureOf narrows to', () => {
  1141. const failure = new PluginOperationError('plugins/not-installed', 'absent', { packageName: 'x' })
  1142. expect(failure).toMatchObject({ name: 'PluginOperationError', code: 'plugins/not-installed', message: 'absent', details: { packageName: 'x' } })
  1143. expect(pluginOperationFailureOf(failure)).toBe(failure)
  1144. expect(pluginOperationFailureOf(new Error('plain'))).toBeUndefined()
  1145. })
  1146. })