Add `dsh-host-plugin-manager`: the `pluginManager` service and the `plugins` Remote that install, uninstall, enable, disable, and retry bundles over the booted profile, add and remove rows in the profile's or an agent preset's user layer, report what a package's services keep alive, and fold the manifest, the probe record, and the live tree into one view per package. pnpm runs the way the CLI runs it, streaming its output as `plugins/install-log`; every change emits `plugins/changed`, and api-remotes forwards both. Enabling is the Loader's own transaction: a rejected recomposition restores the layer list, and a runtime recomposition records contained rows through `recordContainedStates` because the boot audit does not run again; `ContainedGroup.create` now records a row that resolved pending rather than clearing it. Give every agent preset a user patch layer: `cordis.patch.yml` beside a locally authored composition, or alone in `.agent-presets/<id>/` for a shipped one. Discovery attaches and judges it, the mount applies it as the include's runtime patches, standing generations follow the layer's content and return to a retired generation when it comes back, the composition inventory reports each row's `source` and `disabledBy`, a copy carries the layer, and `removeOverlay` deletes it. `dsh-global-tool-mask` is `tools.restrict()` as a scope-only row for such a layer. Add `dsh-patch-file` as the one home of the patch-list format: the parser moved out of app-boot, plus a comment-preserving key-level writer with lock, atomic replace, and readback. app-boot exports `layerTrust`, `bundleLayerPatches`, and `recordContainedStates`, and `ProfileRuntime` carries the install anchor.
755 lines
42 KiB
TypeScript
755 lines
42 KiB
TypeScript
/**
|
|
* The plugin manager over a real profile: a temporary harness home with one
|
|
* profile, packages staged the way pnpm leaves them, the host tree booted
|
|
* through `boot()` with the profile runtime the launcher provides, and a
|
|
* fake pnpm that edits the profile the way the real one does.
|
|
*/
|
|
|
|
import { EventEmitter } from 'node:events'
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { mkdtemp } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { PassThrough } from 'node:stream'
|
|
import type { ChildProcess } from 'node:child_process'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context, type Plugin } from '@deepseek-ai/cordis'
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
|
import {
|
|
boot, bundleLayerPatches, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
|
|
type Profile,
|
|
} from '@deepseek-ai/dsh-app-boot'
|
|
import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import PluginManager, {
|
|
type PluginInstallLogChunk, type PluginManagerInternals, type SpawnLike,
|
|
} from '@deepseek-ai/dsh-host-plugin-manager'
|
|
import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
|
|
|
|
const NAME = 'dsh-test'
|
|
|
|
/** A complete manager config: the schema fills defaults at load, the type does not. */
|
|
function managerConfig(overrides: Partial<PluginManager['config']> = {}): PluginManager['config'] {
|
|
return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides }
|
|
}
|
|
|
|
const contexts: Context[] = []
|
|
afterEach(async () => {
|
|
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
})
|
|
|
|
/** Builtins the staged bundles name; registered on every boot. */
|
|
const good: Plugin.Function = () => {}
|
|
const throws: Plugin.Function = () => { throw new Error('boom at apply') }
|
|
let flakyCalls = 0
|
|
const flaky: Plugin.Function = () => {
|
|
flakyCalls += 1
|
|
if (flakyCalls === 1) throw new Error('flaky first start')
|
|
}
|
|
const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) }
|
|
const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
|
|
const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
|
|
const prepare = (ctx: Context): void => {
|
|
ctx.loader.builtins.good = good
|
|
ctx.loader.builtins.throws = throws
|
|
ctx.loader.builtins.flaky = flaky
|
|
ctx.loader.builtins.provider = provider
|
|
ctx.loader.builtins['lonely-provider'] = lonelyProvider
|
|
ctx.loader.builtins.consumer = consumer
|
|
}
|
|
|
|
interface StagedHome {
|
|
home: string
|
|
profileDir: string
|
|
anchor: string
|
|
}
|
|
|
|
/** A harness home with one empty live profile and an install anchor that carries nothing. */
|
|
async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise<StagedHome> {
|
|
const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-'))
|
|
const profileDir = join(home, 'profiles', 'web')
|
|
mkdirSync(profileDir, { recursive: true })
|
|
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
|
name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } },
|
|
}, null, 2))
|
|
writeFileSync(join(profileDir, 'cordis.yml'), '[]\n')
|
|
const anchorDir = join(home, 'anchor')
|
|
mkdirSync(anchorDir, { recursive: true })
|
|
const anchor = join(anchorDir, 'package.json')
|
|
writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} }))
|
|
return { home, profileDir, anchor }
|
|
}
|
|
|
|
interface StagedPackage {
|
|
/** The bundle patch text; omitted stages a bundle-less package. */
|
|
patch?: string
|
|
/** `index.js` text, exported as the package main. */
|
|
main?: string
|
|
version?: string
|
|
stage?: 'boot' | 'runtime'
|
|
plugins?: { name: string; title?: string; config?: unknown }[]
|
|
files?: Record<string, string>
|
|
}
|
|
|
|
/** Stage one package under the profile's node_modules, the way pnpm leaves it. */
|
|
function stagePackage(profileDir: string, name: string, staged: StagedPackage): void {
|
|
const dir = join(profileDir, 'node_modules', name)
|
|
mkdirSync(dir, { recursive: true })
|
|
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
|
name,
|
|
version: staged.version ?? '1.0.0',
|
|
description: `staged ${name}`,
|
|
type: 'module',
|
|
...staged.main === undefined ? {} : { main: 'index.js' },
|
|
dsh: {
|
|
title: `Title of ${name}`,
|
|
...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml', ...staged.stage === undefined ? {} : { stage: staged.stage } } },
|
|
...staged.plugins === undefined ? {} : { plugins: staged.plugins },
|
|
},
|
|
}, null, 2))
|
|
if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch)
|
|
if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main)
|
|
for (const [file, text] of Object.entries(staged.files ?? {})) {
|
|
mkdirSync(join(dir, file, '..'), { recursive: true })
|
|
writeFileSync(join(dir, file), text)
|
|
}
|
|
}
|
|
|
|
/** Add a dependency to the profile manifest, as `pnpm add` does. */
|
|
function addDependency(profileDir: string, name: string, spec = '1.0.0'): void {
|
|
const path = join(profileDir, 'package.json')
|
|
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
|
|
manifest.dependencies[name] = spec
|
|
writeFileSync(path, JSON.stringify(manifest, null, 2))
|
|
}
|
|
|
|
function manifestOf(profileDir: string): { dependencies: Record<string, string>; dsh: { profile: { bundles: string[] } } } {
|
|
return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType<typeof manifestOf>
|
|
}
|
|
|
|
/** What the fake pnpm does for one invocation. */
|
|
type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
|
|
|
|
/** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */
|
|
function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = []): SpawnLike {
|
|
return (command, args, options) => {
|
|
calls.push([command, ...args])
|
|
expect(options.cwd).toBe(profileDir)
|
|
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean }
|
|
child.stdout = new PassThrough()
|
|
child.stderr = new PassThrough()
|
|
let killed = false
|
|
child.kill = () => { killed = true; return true }
|
|
setTimeout(() => {
|
|
const outcome = behavior(args)
|
|
if (outcome.error !== undefined) {
|
|
child.emit('error', outcome.error)
|
|
return
|
|
}
|
|
if (outcome.stdout !== undefined) child.stdout.write(outcome.stdout)
|
|
if (outcome.stderr !== undefined) child.stderr.write(outcome.stderr)
|
|
if (outcome.hang === true) {
|
|
// Report the kill the timeout sends, as a real child would.
|
|
const poll = setInterval(() => {
|
|
if (!killed) return
|
|
clearInterval(poll)
|
|
child.emit('close', null)
|
|
}, 10)
|
|
return
|
|
}
|
|
setTimeout(() => { child.emit('close', outcome.code) }, 5)
|
|
}, 5)
|
|
return child as unknown as ChildProcess
|
|
}
|
|
}
|
|
|
|
/** The pnpm every install test shares: `add <name>` stages nothing (the test did) and records the dependency. */
|
|
function recordingPnpm(profileDir: string, calls: string[][] = []): SpawnLike {
|
|
return fakePnpm(profileDir, (args) => {
|
|
const [verb, target] = args
|
|
if (verb === 'add' && target !== undefined) {
|
|
addDependency(profileDir, target)
|
|
return { code: 0, stdout: `+ ${target} 1.0.0\n` }
|
|
}
|
|
if (verb === 'remove' && target !== undefined) {
|
|
const path = join(profileDir, 'package.json')
|
|
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
|
|
const { [target]: _removed, ...remaining } = manifest.dependencies
|
|
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
|
|
rmSync(join(profileDir, 'node_modules', target), { recursive: true, force: true })
|
|
return { code: 0, stdout: `- ${target}\n` }
|
|
}
|
|
return { code: 1, stderr: 'unexpected pnpm invocation\n' }
|
|
}, calls)
|
|
}
|
|
|
|
interface Booted {
|
|
ctx: Context
|
|
manager: PluginManager
|
|
runtime: ProfileRuntime
|
|
changes: { reason: string; packageName?: string }[]
|
|
log: PluginInstallLogChunk[]
|
|
}
|
|
|
|
/** Boot the profile the way the launcher does, then mount the manager over it. */
|
|
async function bootProfile(staged: StagedHome, internals: PluginManagerInternals = {}, config: Partial<PluginManager['config']> = {}): Promise<Booted> {
|
|
const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
|
|
const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
|
|
...profile.layers.flatMap(bundleLayerPatches),
|
|
...loadOptionalPatches(NAME, profile.patchPath) ?? [],
|
|
])
|
|
const profile = load()
|
|
const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile), prepare)
|
|
contexts.push(ctx)
|
|
await ctx.plugin(ProfileRuntime, {
|
|
profile,
|
|
installAnchor: staged.anchor,
|
|
loadProfile: load,
|
|
compose: composeFor,
|
|
rootEntry: () => rootIncludeEntry(ctx),
|
|
readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
|
|
})
|
|
class TestManager extends PluginManager {
|
|
constructor(context: Context, managerConfig: PluginManager['config']) {
|
|
super(context, managerConfig, internals)
|
|
}
|
|
}
|
|
const changes: Booted['changes'] = []
|
|
const log: PluginInstallLogChunk[] = []
|
|
ctx.on('plugins/changed', (change) => { changes.push(change) })
|
|
ctx.on('plugins/install-log', (chunk) => { log.push(chunk) })
|
|
await ctx.plugin(TestManager, managerConfig(config))
|
|
const manager = ctx.get('pluginManager')
|
|
if (manager === undefined) throw new Error('manager did not mount')
|
|
return { ctx, manager, runtime: ctx.profileRuntime, changes, log }
|
|
}
|
|
|
|
const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry => entry.id)
|
|
|
|
const BUNDLE_ONE_ROW = '- insert:\n - id: hello\n name: cordis:good\n'
|
|
|
|
describe('PluginManager', () => {
|
|
it('publishes the plugins namespace with one direct method per operation', async () => {
|
|
const staged = await stageHome()
|
|
const { manager } = await bootProfile(staged)
|
|
expect(manager.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
|
|
expect(remoteMethods(manager).map(marker => marker.method)).toEqual([
|
|
'list', 'install', 'uninstall', 'enable', 'disable', 'retry', 'addRow', 'removeRow', 'setRowDisabled', 'dependents',
|
|
])
|
|
})
|
|
|
|
it('reports plugins/unavailable without a profile runtime', async () => {
|
|
const ctx = new Context()
|
|
contexts.push(ctx)
|
|
await ctx.plugin(Loader)
|
|
await ctx.plugin(PluginManager, managerConfig())
|
|
await expect(ctx.get('pluginManager')?.list()).rejects.toMatchObject({ code: 'plugins/unavailable' })
|
|
})
|
|
|
|
describe('list', () => {
|
|
it('folds installed, enabled, and probed facts into one view per package', async () => {
|
|
const staged = await stageHome()
|
|
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' } })
|
|
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
|
|
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
|
addDependency(staged.profileDir, 'ext-bundle')
|
|
addDependency(staged.profileDir, 'ext-lib')
|
|
addDependency(staged.profileDir, 'ext-plugin')
|
|
const { manager } = await bootProfile(staged)
|
|
|
|
const views = await manager.list()
|
|
|
|
expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([
|
|
['ext-bundle', 'bundle', 'disabled', true, false, 'external'],
|
|
['ext-lib', 'library', 'plain', true, false, 'external'],
|
|
['ext-plugin', 'plugin', 'plain', true, false, 'external'],
|
|
])
|
|
const bundle = views[0]
|
|
expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
|
|
// Rows come from the probe while the bundle is not composed, prefixed the way the launcher will prefix them.
|
|
expect(bundle?.rows).toEqual([{ entryId: 'ext-bundle/hello', originalId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
|
|
expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
|
|
expect(bundle?.probedAt).toEqual(expect.any(String) as string)
|
|
expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
|
|
})
|
|
|
|
it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n - id: ok\n name: cordis:good\n - id: bad\n name: cordis:throws\n' })
|
|
addDependency(staged.profileDir, 'ext-mixed')
|
|
const manifest = manifestOf(staged.profileDir)
|
|
manifest.dsh.profile.bundles.push('ext-mixed')
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
|
|
const { manager } = await bootProfile(staged)
|
|
|
|
const [view] = await manager.list()
|
|
|
|
expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
|
|
expect(view?.reason).toContain('boom at apply')
|
|
expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
|
|
['include:ext-mixed/ok', 'active', undefined],
|
|
['include:ext-mixed/bad', 'failed', 'apply'],
|
|
])
|
|
})
|
|
|
|
it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => {
|
|
const staged = await stageHome('startup')
|
|
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
|
|
addDependency(staged.profileDir, 'ext-broken')
|
|
stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-later')
|
|
const { manager } = await bootProfile(staged)
|
|
// Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no.
|
|
const manifest = manifestOf(staged.profileDir)
|
|
manifest.dsh.profile.bundles.push('ext-later')
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
|
|
|
|
const views = await manager.list()
|
|
|
|
expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable', reason: expect.stringContaining('no import for you') as string })
|
|
expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
|
|
})
|
|
|
|
it('reports a package whose probe cannot run with the probe\'s failure', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-odd')
|
|
stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-refused')
|
|
const { manager } = await bootProfile(staged, {
|
|
probe: ({ packageName }) => packageName === 'ext-odd'
|
|
? Promise.reject(new Error('probe exploded'))
|
|
: Promise.resolve({ packageName, kind: 'bundle', ok: false, reason: 'foreign cordis', cordisSameCopy: false, rows: [], overrides: [], addable: [], checkedAt: 'now' }),
|
|
})
|
|
|
|
const views = await manager.list()
|
|
|
|
expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'probe exploded', cordisSameCopy: null, rows: [] })
|
|
expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'not-enableable', reason: 'foreign cordis', cordisSameCopy: false })
|
|
await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot be probed: probe exploded' } })
|
|
await expect(manager.enable('ext-refused')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'foreign cordis' } })
|
|
})
|
|
|
|
it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
|
|
const staged = await stageHome('startup')
|
|
stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
|
|
const { manager } = await bootProfile(staged)
|
|
expect((await manager.list()).map(view => [view.name, view.trust, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'builtin', 'running', false, 1]])
|
|
// A manifest with no dsh section at all knows no bundles.
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
|
|
expect(await manager.list()).toEqual([])
|
|
|
|
// Bundles the manifest names after boot: one staged, one that resolves to nothing.
|
|
stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
|
|
const views = await manager.list()
|
|
expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([
|
|
['tpl', 'builtin', 'bundle', 'running'],
|
|
['tpl-later', 'builtin', 'bundle', 'restart-required'],
|
|
['ghost', 'builtin', 'library', 'plain'],
|
|
])
|
|
await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
|
// Retrying takes the bundle out first, which a template bundle refuses.
|
|
await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
})
|
|
|
|
it('folds disabled, user-disabled, and waiting rows', async () => {
|
|
const staged = await stageHome()
|
|
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' })
|
|
addDependency(staged.profileDir, 'ext-off')
|
|
stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n - id: w\n name: cordis:consumer\n' })
|
|
addDependency(staged.profileDir, 'ext-waiting')
|
|
const { manager } = await bootProfile(staged)
|
|
await manager.enable('ext-off')
|
|
await manager.enable('ext-waiting')
|
|
await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
|
|
|
|
const views = await manager.list()
|
|
|
|
const off = views.find(view => view.name === 'ext-off')
|
|
expect(off).toMatchObject({ status: 'running' })
|
|
expect(off?.rows.map(row => [row.originalId, row.enabled, row.disabledBy, row.phase])).toEqual([
|
|
['a', false, 'composition', null],
|
|
['b', false, 'user', null],
|
|
])
|
|
await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
|
|
const waiting = views.find(view => view.name === 'ext-waiting')
|
|
expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
|
|
expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:ext-waiting/w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
|
|
// Another bundle's recorded failure is not this one's row.
|
|
expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
|
|
})
|
|
|
|
it('reads anonymous and gated probe rows, unprefixed for a first-party package', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' })
|
|
addDependency(staged.profileDir, 'ext-anon')
|
|
stagePackage(staged.profileDir, 'fp-bundle', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'fp-bundle')
|
|
const manifest = JSON.parse(readFileSync(join(staged.profileDir, 'package.json'), 'utf8')) as { dsh: { profile: Record<string, unknown> } }
|
|
manifest.dsh.profile.firstParty = ['fp-bundle']
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
|
|
const { manager } = await bootProfile(staged)
|
|
|
|
const views = await manager.list()
|
|
|
|
expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
|
|
{ entryId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
|
|
{ entryId: 'ext-anon/gated', originalId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
|
|
])
|
|
await manager.enable('fp-bundle')
|
|
const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle')
|
|
expect(firstParty).toMatchObject({ trust: 'builtin', status: 'running' })
|
|
expect(firstParty?.rows.map(row => row.entryId)).toEqual(['include:hello'])
|
|
await manager.disable('fp-bundle')
|
|
expect((await manager.list()).find(view => view.name === 'fp-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
|
|
})
|
|
})
|
|
|
|
describe('install', () => {
|
|
it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
|
|
const calls: string[][] = []
|
|
const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
|
|
|
|
const result = await manager.install('github:acme/ext-new')
|
|
|
|
expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
|
|
// The fake pnpm records the spec itself as the dependency name, which
|
|
// resolves to nothing: a plain dependency whose probe cannot run.
|
|
expect(result).toEqual({ installed: ['github:acme/ext-new'], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
|
|
expect(log.map(chunk => [chunk.stream, chunk.text, chunk.exitCode])).toEqual([
|
|
['stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
|
|
['stdout', '', 0],
|
|
])
|
|
expect(changes).toEqual([{ reason: 'install' }])
|
|
})
|
|
|
|
it('reconciles by the installed name, and enables the new bundle when asked', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
|
|
const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
|
|
|
|
const result = await manager.install('ext-new', { enable: true })
|
|
|
|
expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
|
|
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
|
|
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:ext-new/hello']))
|
|
expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
|
|
expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
|
|
expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
|
|
})
|
|
|
|
it('reports a plain dependency and a non-zero exit with the log tail', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
|
|
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
|
|
|
|
expect(await manager.install('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [] })
|
|
expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain')
|
|
await manager.uninstall('ext-lib')
|
|
expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
|
|
})
|
|
|
|
it('installs into a manifest that declares no dependencies yet', async () => {
|
|
const staged = await stageHome()
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } }))
|
|
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
|
|
const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
|
|
const path = join(staged.profileDir, 'package.json')
|
|
const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } }))
|
|
return { code: 0 }
|
|
}) })
|
|
|
|
expect(await manager.install('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] })
|
|
})
|
|
|
|
it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => {
|
|
const staged = await stageHome()
|
|
const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) })
|
|
await expect(exits.manager.install('nope')).rejects.toMatchObject({
|
|
code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' },
|
|
})
|
|
expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 })
|
|
await expect(exits.manager.install(' ')).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
|
|
const erroringHome = await stageHome()
|
|
const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) })
|
|
await expect(erroring.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } })
|
|
expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true)
|
|
|
|
const hangingHome = await stageHome()
|
|
const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
|
|
await expect(hanging.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed' })
|
|
expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true)
|
|
})
|
|
|
|
it('keeps only the tail of a long log in the failure', async () => {
|
|
const staged = await stageHome()
|
|
const { manager } = await bootProfile(staged, {
|
|
spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })),
|
|
}, { installLogTailBytes: 256 })
|
|
await expect(manager.install('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
|
|
})
|
|
})
|
|
|
|
describe('enable, disable, and retry', () => {
|
|
it('composes an installed bundle live, and takes it out again', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-bundle')
|
|
const { ctx, manager, changes } = await bootProfile(staged)
|
|
|
|
expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
|
|
expect(entryIds(ctx)).toContain('include:ext-bundle/hello')
|
|
expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
|
|
expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
|
|
expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
|
|
expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
|
|
expect(changes.map(change => [change.reason, change.packageName])).toEqual([
|
|
['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
|
|
])
|
|
})
|
|
|
|
it('only writes the manifest on a startup-reload profile', async () => {
|
|
const staged = await stageHome('startup')
|
|
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-bundle')
|
|
const { ctx, manager } = await bootProfile(staged)
|
|
|
|
expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
|
|
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
|
|
expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
|
|
expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
|
|
})
|
|
|
|
it('refuses what cannot be enabled or disabled', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
|
|
addDependency(staged.profileDir, 'ext-lib')
|
|
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
|
|
addDependency(staged.profileDir, 'ext-broken')
|
|
const { manager } = await bootProfile(staged)
|
|
|
|
await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
|
await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
|
|
await expect(manager.enable('ext-broken')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('no import for you') as string } })
|
|
// A template bundle is not a dependency and cannot be disabled.
|
|
const manifest = manifestOf(staged.profileDir)
|
|
manifest.dsh.profile.bundles.push('template')
|
|
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
|
|
await expect(manager.disable('template')).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
})
|
|
|
|
it('restores the layer list when the tree rejects a boot-stage bundle', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n', stage: 'boot' })
|
|
addDependency(staged.profileDir, 'ext-fatal')
|
|
stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
|
|
addDependency(staged.profileDir, 'ext-fine')
|
|
const { ctx, manager } = await bootProfile(staged)
|
|
await manager.enable('ext-fine')
|
|
|
|
await expect(manager.enable('ext-fatal')).rejects.toMatchObject({
|
|
code: 'plugins/enable-failed', details: { packageName: 'ext-fatal', reason: expect.stringContaining('boom at apply') as string },
|
|
})
|
|
|
|
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
|
|
expect(entryIds(ctx)).toContain('include:ext-fine/hello')
|
|
expect(entryIds(ctx)).not.toContain('include:bad')
|
|
})
|
|
|
|
it('retries an isolated bundle by composing it again', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' })
|
|
addDependency(staged.profileDir, 'ext-flaky')
|
|
flakyCalls = 0
|
|
const { manager, changes } = await bootProfile(staged)
|
|
await manager.enable('ext-flaky')
|
|
expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string })
|
|
|
|
expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' })
|
|
|
|
expect((await manager.list())[0]).toMatchObject({ status: 'running' })
|
|
expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry'])
|
|
})
|
|
})
|
|
|
|
describe('rows in user layers', () => {
|
|
it('adds, disables, re-enables, and removes a row in the live global layer', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, '@acme/ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
|
addDependency(staged.profileDir, '@acme/ext-plugin')
|
|
const { ctx, manager, changes } = await bootProfile(staged)
|
|
|
|
const added = await manager.addRow('@acme/ext-plugin', { kind: 'global' })
|
|
|
|
expect(added).toEqual({ target: { kind: 'global' }, rowId: 'acme/ext-plugin', file: join(staged.profileDir, 'cordis.patch.yml') })
|
|
expect(readFileSync(added.file, 'utf8')).toBe('- insert:\n - id: acme/ext-plugin\n name: "@acme/ext-plugin"\n config: {}\n')
|
|
// Composed live: the row is in the tree, though its module cannot import from the temp home.
|
|
expect(entryIds(ctx)).toContain('include:acme/ext-plugin')
|
|
await expect(manager.addRow('@acme/ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
|
|
|
|
await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', true)
|
|
expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(true)
|
|
expect(readFileSync(added.file, 'utf8')).toContain('- id: acme/ext-plugin\n disabled: true\n')
|
|
await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', false)
|
|
expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(false)
|
|
expect(readFileSync(added.file, 'utf8')).not.toContain('disabled')
|
|
|
|
await manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')
|
|
expect(entryIds(ctx)).not.toContain('include:acme/ext-plugin')
|
|
await expect(manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
expect(changes.map(change => change.reason)).toEqual(['row', 'row', 'row', 'row'])
|
|
})
|
|
|
|
it('detects a conflict through the layer file when the tree was not recomposed', async () => {
|
|
const staged = await stageHome('startup')
|
|
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
|
addDependency(staged.profileDir, 'ext-plugin')
|
|
const { ctx, manager } = await bootProfile(staged)
|
|
|
|
await manager.addRow('ext-plugin', { kind: 'global' })
|
|
|
|
expect(entryIds(ctx)).not.toContain('include:ext-plugin')
|
|
await expect(manager.addRow('ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
|
|
})
|
|
|
|
it('adds a declared addable module with its default config and an explicit id, and refuses the rest', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-bundle', {
|
|
patch: BUNDLE_ONE_ROW,
|
|
plugins: [
|
|
{ name: './tools/sql.js', title: 'SQL', config: { dsn: 'sqlite://' } },
|
|
{ name: './missing.js' },
|
|
],
|
|
files: { 'tools/sql.js': 'export const name = "sql"\nexport function apply() {}\n' },
|
|
})
|
|
addDependency(staged.profileDir, 'ext-bundle')
|
|
const { manager } = await bootProfile(staged)
|
|
|
|
const added = await manager.addRow('ext-bundle', { kind: 'global' }, { module: './tools/sql.js', id: 'sql' })
|
|
expect(added.rowId).toBe('sql')
|
|
expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n name: ext-bundle/tools/sql.js\n config:\n dsn: sqlite://\n')
|
|
await expect(manager.addRow('ext-bundle', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
|
|
await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
|
|
await expect(manager.addRow('absent', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
|
})
|
|
|
|
it('writes a preset\'s layer through the roster, and refuses a preset target without one', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
|
addDependency(staged.profileDir, 'ext-plugin')
|
|
const { ctx, manager } = await bootProfile(staged)
|
|
await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable' })
|
|
|
|
const overlay = join(staged.home, '.agent-presets', 'standard', 'cordis.patch.yml')
|
|
const roster = {
|
|
overlayPathFor: (id: string) => Promise.resolve(join(staged.home, '.agent-presets', id, 'cordis.patch.yml')),
|
|
compositionInventory: () => Promise.resolve([{ id: 'standard', rows: [{ entryId: 'tool-web' }] }]),
|
|
list: () => Promise.resolve([{ id: 'standard', overlayPath: existsSync(overlay) ? overlay : undefined }]),
|
|
}
|
|
ctx.provide('agentPresets', roster)
|
|
// No layer exists yet: nothing references the package.
|
|
expect((await manager.dependents('ext-plugin')).references).toEqual([])
|
|
// A preset the inventory does not know has no rows to conflict with.
|
|
const elsewhere = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'other' })
|
|
expect(elsewhere.file).toBe(join(staged.home, '.agent-presets', 'other', 'cordis.patch.yml'))
|
|
|
|
const added = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })
|
|
expect(added).toEqual({ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', file: overlay })
|
|
await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' }, { id: 'tool-web' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
|
|
await manager.setRowDisabled({ kind: 'preset', preset: 'standard' }, 'tool-web', true)
|
|
expect(readFileSync(overlay, 'utf8')).toBe('- insert:\n - id: ext-plugin\n name: ext-plugin\n config: {}\n- id: tool-web\n disabled: true\n')
|
|
// The preset's layer is not part of the host tree.
|
|
expect(entryIds(ctx)).not.toContain('include:ext-plugin')
|
|
|
|
const dependents = await manager.dependents('ext-plugin')
|
|
expect(dependents.references).toEqual([{ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', moduleName: 'ext-plugin' }])
|
|
await manager.removeRow({ kind: 'preset', preset: 'standard' }, 'ext-plugin')
|
|
expect(readFileSync(overlay, 'utf8')).toBe('- id: tool-web\n disabled: true\n')
|
|
})
|
|
})
|
|
|
|
describe('dependents and uninstall', () => {
|
|
it('names the services other rows inject and the user-layer rows that reference the package', async () => {
|
|
const staged = await stageHome()
|
|
// Beside the provider: a row nobody injects, and a row switched off (no fiber to read).
|
|
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' })
|
|
addDependency(staged.profileDir, 'ext-provider')
|
|
writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [
|
|
'- insert:',
|
|
' - name: ext-provider/anonymous.js',
|
|
' disabled: true',
|
|
' - id: ref',
|
|
' name: ext-provider/tools/x.js',
|
|
' disabled: true',
|
|
' - id: grp',
|
|
' name: cordis:group',
|
|
' group: true',
|
|
' config:',
|
|
' - id: nested-ref',
|
|
' name: ext-provider',
|
|
' disabled: true',
|
|
'',
|
|
].join('\n'))
|
|
const { manager, runtime } = await bootProfile(staged)
|
|
await manager.enable('ext-provider')
|
|
// A built-in row injecting the bundle's service, composed once the
|
|
// provider is up (a boot would refuse a row left waiting).
|
|
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`)
|
|
await runtime.recompose()
|
|
|
|
const dependents = await manager.dependents('ext-provider')
|
|
|
|
expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:ext-provider/svc', injectedBy: ['include:needs-svc'] }])
|
|
expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
|
|
expect(dependents.references[0]).toEqual({ target: { kind: 'global' }, rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
|
|
})
|
|
|
|
it('ignores an unreadable user layer while collecting references', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
|
|
addDependency(staged.profileDir, 'ext-lib')
|
|
const { manager } = await bootProfile(staged)
|
|
writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n')
|
|
|
|
expect((await manager.dependents('ext-lib')).references).toEqual([])
|
|
})
|
|
|
|
it('disables, drops references, removes the package, and forgets its probe', async () => {
|
|
const staged = await stageHome()
|
|
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js' }], files: { 'extra.js': 'export function apply() {}\n' } })
|
|
addDependency(staged.profileDir, 'ext-bundle')
|
|
const calls: string[][] = []
|
|
const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
|
|
await manager.enable('ext-bundle')
|
|
await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
|
|
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:ext-bundle/hello', 'include:ext-bundle/extra.js']))
|
|
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
|
|
|
|
await manager.uninstall('ext-bundle')
|
|
|
|
expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
|
|
expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
|
|
expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
|
|
expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
|
|
expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
|
|
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
|
|
expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
|
|
await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('RemoteError codes', () => {
|
|
it('declare their details', () => {
|
|
const error = new RemoteError('plugins/row-conflict', 'taken', { rowId: 'x', target: { kind: 'global' } })
|
|
expect(error.details).toEqual({ rowId: 'x', target: { kind: 'global' } })
|
|
})
|
|
})
|