cc-haha/src/server/__tests__/plugins.test.ts
程序员阿江(Relakkes) 375d587ed1 Expose desktop plugin management through first-class APIs
The desktop app could read plugin-produced skills and agents, but it had no
plugin control plane of its own. This adds a dedicated Settings tab backed by
server-side plugin APIs so installed plugins can be inspected, enabled,
disabled, updated, reloaded, and uninstalled from the WebUI.

The implementation also teaches browser-based desktop dev sessions to honor a
custom backend URL, which made it possible to run isolated worktree ports for
real UI automation. During verification, the long-lived desktop server kept a
stale installed-plugin snapshot after external CLI mutations, so cache clearing
now resets that session-level plugin installation state as well.

Constraint: Desktop WebUI needed an isolated backend URL instead of the hard-coded 127.0.0.1:3456 fallback
Constraint: Reuse existing plugin operations and loaders instead of rebuilding plugin lifecycle logic in the desktop layer
Rejected: Fold plugin management into Skills or Adapters | mixed unrelated lifecycles and hid plugin-specific health/actions
Rejected: Expose only read-only plugin status in desktop | did not satisfy enable-disable-reload-uninstall verification needs
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop plugin actions routed through the shared plugin operation layer and clear installed-plugin session caches when plugin state changes externally
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test -- src/__tests__/pluginsSettings.test.tsx
Tested: bun test src/server/__tests__/plugins.test.ts src/server/__tests__/skills.test.ts
Tested: Browser automation against isolated ports 15120/38456 covering discord plugin list/detail/disable/apply/enable/update/uninstall flows
Not-tested: Full desktop session runtime parity with CLI /reload-plugins AppState refresh beyond the new desktop API path
2026-04-21 23:21:16 +08:00

79 lines
2.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { handlePluginsApi } from '../api/plugins.js'
let tmpDir: string
let originalConfigDir: string | undefined
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown>,
): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const init: RequestInit = { method }
if (body) {
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
}
const req = new Request(url.toString(), init)
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
describe('Plugins API', () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-plugins-api-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
await fs.rm(tmpDir, { recursive: true, force: true })
})
it('GET /api/plugins returns an empty plugin list for a clean config', async () => {
const { req, url, segments } = makeRequest('GET', '/api/plugins')
const res = await handlePluginsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
plugins: unknown[]
marketplaces: unknown[]
summary: { total: number; enabled: number; errorCount: number }
}
expect(body.plugins).toEqual([])
expect(Array.isArray(body.marketplaces)).toBe(true)
expect(body.summary.total).toBe(0)
expect(body.summary.enabled).toBe(0)
expect(body.summary.errorCount).toBe(0)
})
it('POST /api/plugins/reload returns numeric counters', async () => {
const { req, url, segments } = makeRequest('POST', '/api/plugins/reload', {})
const res = await handlePluginsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
ok: boolean
summary: Record<string, number>
}
expect(body.ok).toBe(true)
expect(typeof body.summary.enabled).toBe('number')
expect(typeof body.summary.skills).toBe('number')
expect(typeof body.summary.errors).toBe('number')
})
})