Files
deepseek-harness/apps/cli/tests/plugin.spec.ts
T
Yichen Jiang 7b713ca83b refactor(plugin-manager): move plugin management out of the Web host
What plugin management does now lives in `dsh-plugin-manager`, a boot-group
package beside `app-boot`: `PluginInstaller` runs pnpm, probes, and judges new
packages over the profile on disk, and `PluginManager` adds every operation
over the booted tree, each refusal a `PluginOperationError` with a `plugins/*`
code and no Remote protocol attached. The manager reads the profile runtime,
the preset roster, and the running-agent count through per-call readers, and
the roster only as `PresetLayers`, so the package depends on neither the
roster nor the agent registry.

`dsh-host-plugin-manager` keeps `pluginManager` and the `plugins` Remote as a
relay: one Remote method per manager method, and one exhaustive switch
turning a failure into the Remote error of the same code (`plugins/bad-request`
into `gateway/bad-request`). Its `./types` re-exports the manager's types, so
`api-remotes` and the client import one vocabulary unchanged.

`dsh plugin add` and `remove` now run the installer instead of a bare pnpm
forwarder: every new package is probed, a package that declares neither a
bundle nor a plugin module is removed again with its reason, and newly
installed bundles are enabled as before. Other pnpm verbs still forward.
2026-09-07 17:47:08 +08:00

152 lines
7.1 KiB
TypeScript

/**
* `dsh plugin add` and `remove` through the shared installer: a temporary
* harness home, a fake pnpm that stages packages and edits the manifest the
* way the real one does, and a fake probe. Nothing boots.
*/
import { EventEmitter } from 'node:events'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readProfileManifest, resolveProfileDir, type probePackage } from '@deepseek-ai/dsh-app-boot'
import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
import { runPlugin } from '../src/plugin.ts'
let home: string
let previousHome: string | undefined
let stderr: string
let stdout: string
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
previousHome = process.env.DSH_HOME
process.env.DSH_HOME = home
stderr = ''
stdout = ''
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
})
afterEach(() => {
vi.restoreAllMocks()
if (previousHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousHome
rmSync(home, { recursive: true, force: true })
})
/** Stage one package under the profile's node_modules and record it as a dependency, as `pnpm add` does. */
function install(profileDir: string, name: string, bundle: boolean): void {
const dir = join(profileDir, 'node_modules', name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name, version: '1.0.0', type: 'module',
...bundle ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {},
}))
if (bundle) writeFileSync(join(dir, 'cordis.patch.yml'), '- insert:\n - id: hello\n name: cordis:good\n')
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, [name]: '1.0.0' } }, null, 2))
}
/** Forget one package, as `pnpm remove` does. */
function uninstall(profileDir: string, name: string): void {
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
const { [name]: _gone, ...remaining } = manifest.dependencies ?? {}
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
rmSync(join(profileDir, 'node_modules', name), { recursive: true, force: true })
}
/** A pnpm that installs `ext-bundle` as a bundle and everything else as a plain library, or fails as told. */
function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
return (_command, args, options) => {
calls.push([...args])
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
child.stdout = new PassThrough()
child.stderr = new PassThrough()
setTimeout(() => {
if (failWith !== undefined && 'error' in failWith) {
child.emit('error', failWith.error)
return
}
if (failWith !== undefined) {
child.stderr.write('ERR_PNPM_FETCH\n')
child.emit('close', failWith.code)
return
}
const [verb, target] = args
const profileDir = options.cwd as string
if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
if (verb === 'remove' && target !== undefined) uninstall(profileDir, target)
child.stdout.write(`${verb === 'add' ? '+' : '-'} ${String(target)}\n`)
child.emit('close', 0)
}, 5)
return child as unknown as ChildProcess
}
}
/** A probe that reads the staged manifest: a package with `dsh.bundle` is a bundle, anything else a library. */
const fakeProbe: typeof probePackage = (options) => {
const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
return Promise.resolve({
packageName: options.packageName,
kind: manifest.dsh?.bundle === undefined ? 'library' : 'bundle',
ok: true,
cordisSameCopy: null,
rows: [],
overrides: [],
addable: [],
checkedAt: new Date().toISOString(),
})
}
describe('dsh plugin', () => {
it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
const calls: string[][] = []
const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), probe: fakeProbe })
expect(code).toBe(0)
const profileDir = resolveProfileDir('web', home)
expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib'], ['remove', 'ext-lib']])
const manifest = readProfileManifest('dsh', profileDir)
expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
expect(stdout).toContain('+ ext-bundle')
expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
})
it('remove goes through the installer and forgets the probe record', async () => {
const calls: string[][] = []
await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
expect(code).toBe(0)
expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
const profileDir = resolveProfileDir('web', home)
const manifest = readProfileManifest('dsh', profileDir)
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-bundle')
expect(manifest.dsh?.profile?.bundles).not.toContain('ext-bundle')
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
})
it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), probe: fakeProbe })
expect(failed).toBe(1)
expect(stderr).toContain('dsh: pnpm failed in profile directory')
expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), probe: fakeProbe })
expect(missing).toBe(127)
expect(stderr).toContain('dsh: pnpm not found on PATH')
})
})