plugin-manager.spec.ts 50 KB

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