Files
ycode/scripts/claude-capabilities-regression.ts
T

200 lines
12 KiB
TypeScript

import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const testRoot = await fs.mkdtemp(path.resolve('.tmp/claude-capabilities-'))
const userData = path.join(testRoot, 'data')
const workspace = path.join(testRoot, 'workspace')
await Promise.all([userData, workspace].map(folder => fs.mkdir(folder, { recursive: true })))
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
function document(name: string, extra = ''): string {
return `---\nname: ${name}\ndescription: Controlled ${name}\n${extra}---\nInstructions for ${name}. Read references/guide.txt when needed.\n`
}
async function skill(scope: '.agents/skills' | '.claude/skills', folder: string, name: string, extra = ''): Promise<string> {
const root = path.join(workspace, scope, folder)
await fs.mkdir(path.join(root, 'references'), { recursive: true })
await fs.writeFile(path.join(root, 'SKILL.md'), document(name, extra))
await fs.writeFile(path.join(root, 'references', 'guide.txt'), `RESOURCE_${name}`)
return root
}
await skill('.agents/skills', 'automatic', 'automatic')
await skill('.agents/skills', 'manual', 'manual', 'disable-model-invocation: true\n')
await skill('.agents/skills', 'selected-only', 'selected-only', 'user-invocable: false\n')
await skill('.agents/skills', 'disabled', 'disabled')
await skill('.claude/skills', 'legacy', 'legacy')
const electronStub = {
name: 'isolated-electron-path',
setup(context: import('esbuild').PluginBuild) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
loader: 'js',
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
}))
}
}
async function compile(entry: string, file: string): Promise<string> {
const outfile = path.join(testRoot, file)
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
return outfile
}
const adapterPath = await compile('src/main/capabilities/claude-capabilities.ts', 'adapter.mjs')
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
const mcpPath = await compile('src/main/capabilities/mcp.ts', 'mcp.mjs')
const adapter = await import(pathToFileURL(adapterPath).href)
const skills = await import(pathToFileURL(skillsPath).href)
const listed = await skills.listSkills(workspace)
const byName = (name: string) => listed.find((item: { name: string }) => item.name === name)
const automatic = byName('automatic')
const manual = byName('manual')
const selectedOnly = byName('selected-only')
const disabled = byName('disabled')
assert.ok(automatic && manual && selectedOnly && disabled)
check('only .agents/skills is discovered', listed.some((item: { name: string }) => item.name === 'legacy'), false)
await skills.setSkillEnabled(disabled.id, false, workspace)
let executeSignal: AbortSignal | undefined
let discoverOptions: { serverId?: string; query?: string } | undefined
const mcp = {
async discover(options?: { serverId?: string; query?: string }) {
discoverOptions = options
return [
{ id: 'ready', name: 'Ready server', status: 'connected', available: true,
tools: [
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
{ name: 'search-only', description: 'Find needles', inputSchema: { type: 'object', properties: {} } }
] },
{ id: 'untrusted', name: 'Unapproved server', status: 'disconnected', available: false,
reason: '请先批准当前 MCP 配置', tools: [] }
]
},
async execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
executeSignal = signal
if (serverId === 'untrusted') return { ok: false, content: '请先批准当前 MCP 配置' }
if (serverId === 'disabled') return { ok: false, content: 'MCP 服务已禁用' }
if (toolName === 'wait') return new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })), { once: true })
})
return { ok: true, content: `${serverId}:${String(args.text ?? '')}` }
}
}
const runtime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [manual.id], selectedMcpServerIds: ['ready'] }, { mcp })
const rootController = new AbortController()
const rootBoundRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, requestSignal: () => rootController.signal }, { mcp })
const rootBoundCall = rootBoundRuntime.callMcp('ready', 'wait', {}, new AbortController().signal)
await new Promise(resolve => setTimeout(resolve, 0))
rootController.abort()
await assert.rejects(rootBoundCall, /取消/)
check('root cancellation reaches MCP independently of SDK callback signal', executeSignal?.aborted, true)
const catalog = await runtime.list()
check('automatic skill appears without selection', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === automatic.id && !item.selected), true)
check('explicit manual-only skill appears as selected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === manual.id && item.selected), true)
check('user-invocable=false remains model-discoverable but unselected',
catalog.skills.some((item: { id: string; selected: boolean }) => item.id === selectedOnly.id && !item.selected), true)
check('disabled skill is omitted', catalog.skills.some((item: { id: string }) => item.id === disabled.id), false)
check('MCP selection is message-scoped metadata', [catalog.mcp.find((item: { id: string }) => item.id === 'ready').selected,
catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').selected], [true, false])
check('approval failure is reported truthfully', catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').reason, '请先批准当前 MCP 配置')
check('default discovery omits tool schemas',
'inputSchema' in catalog.mcp.find((item: { id: string }) => item.id === 'ready').tools[0], false)
check('default discovery sends summary-only options', discoverOptions, {})
const toolSearch = await runtime.list('Echo')
check('tool-only search keeps its parent server', toolSearch.mcp.map((item: { id: string }) => item.id), ['ready'])
check('tool search returns only the matched tool with schema',
toolSearch.mcp[0].tools, [{ name: 'echo', description: 'Echo text',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
check('tool search forwards query to MCP discovery', discoverOptions, { query: 'Echo' })
const drilled = await runtime.list(undefined, 'ready')
check('server drill-down returns all tool schemas',
drilled.mcp[0].tools.every((item: { inputSchema?: unknown }) => item.inputSchema !== undefined), true)
check('server drill-down forwards server identity', discoverOptions, { serverId: 'ready' })
check('automatic skill body loads on demand', (await runtime.loadSkill(automatic.id)).includes('Instructions for automatic.'), true)
check('explicit selected skill body loads', (await runtime.explicitSkillContext()).includes('Instructions for manual.'), true)
check('relative skill resource resolves from package root',
await runtime.skillResource(automatic.id, 'references/guide.txt'), 'RESOURCE_automatic')
await assert.rejects(runtime.skillResource(automatic.id, '../outside.txt'), /路径/)
checks.push('resource traversal is rejected')
check('automatic-only skill remains available to the model',
(await runtime.loadSkill(selectedOnly.id)).includes('Instructions for selected-only.'), true)
check('automatic-only resource remains model-readable',
await runtime.skillResource(selectedOnly.id, 'references/guide.txt'), 'RESOURCE_selected-only')
const automaticRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace }, { mcp })
await assert.rejects(automaticRuntime.loadSkill(manual.id), /自动调用/)
checks.push('manual-only skill remains unavailable without an explicit reference')
await assert.rejects(automaticRuntime.loadSkill(disabled.id), /禁用/)
checks.push('disabled skill body cannot load')
const explicitOnly = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [selectedOnly.id] }, { mcp })
await assert.rejects(explicitOnly.loadSkill(selectedOnly.id), /手动调用/)
checks.push('explicit selection cannot bypass user-invocable=false')
runtime.setRequestSelection({ explicitSkillIds: [], selectedMcpServerIds: [] })
const nextCatalog = await runtime.list()
check('next request clears prior explicit skill and MCP selection',
[nextCatalog.skills.some((item: { id: string }) => item.id === manual.id),
nextCatalog.mcp.some((item: { id: string; selected: boolean }) => item.id === 'ready' && item.selected)], [false, false])
await assert.rejects(runtime.loadSkill(manual.id), /自动调用/)
checks.push('explicit skill permission does not leak into the next request')
check('real MCP call is passed through', await runtime.callMcp('ready', 'echo', { text: 'actual' }, new AbortController().signal),
{ ok: true, content: 'ready:actual' })
check('unapproved MCP call fails instead of pretending success',
await runtime.callMcp('untrusted', 'echo', {}, new AbortController().signal),
{ ok: false, content: '请先批准当前 MCP 配置' })
check('disabled MCP call fails',
await runtime.callMcp('disabled', 'echo', {}, new AbortController().signal),
{ ok: false, content: 'MCP 服务已禁用' })
const controller = new AbortController()
const pending = runtime.callMcp('ready', 'wait', {}, controller.signal)
await new Promise<void>(resolve => setImmediate(resolve))
controller.abort()
await assert.rejects(pending, (error: unknown) => (error as Error).name === 'AbortError')
check('cancellation signal reaches MCP execution', executeSignal?.aborted, true)
await skills.setSkillEnabled(disabled.id, false, workspace)
await assert.rejects(runtime.loadSkill(disabled.id), /禁用/)
checks.push('global disable takes effect on the next bridge call')
await assert.rejects(runtime.skillResource(disabled.id, 'references/guide.txt'), /禁用/)
checks.push('disabled skill resource cannot load')
const realMcp = await import(pathToFileURL(mcpPath).href)
const mcpEvents = path.join(testRoot, 'mcp-events.jsonl')
try {
let fixture = await realMcp.saveMcpServer({
name: 'Schema fixture',
transport: 'stdio',
command: process.execPath,
args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', mcpEvents],
enabled: true
})
await realMcp.trustMcpServer(fixture.id, fixture.configHash)
fixture = await realMcp.connectMcpServer(fixture.id)
const summary = await realMcp.discoverMcpCapabilities()
check('real MCP default discovery omits schemas',
summary.find((item: { id: string }) => item.id === fixture.id).tools.every((item: object) => !('inputSchema' in item)), true)
const drilled = await realMcp.discoverMcpCapabilities({ serverId: fixture.id })
const echo = drilled[0].tools.find((item: { name: string }) => item.name === 'echo')
check('real MCP server drill-down includes schemas',
echo.inputSchema, { type: 'object', properties: { text: { type: 'string' } } })
const searched = await realMcp.discoverMcpCapabilities({ query: 'echo' })
check('real MCP tool search includes schema',
searched.find((item: { id: string }) => item.id === fixture.id).tools,
[{ name: 'echo', description: 'Controlled stdio echo',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
} finally {
await realMcp.disposeMcpServers()
}
assert.ok(runtime.server, 'SDK MCP server should be constructed')
checks.push('Claude SDK capability server constructed')
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot }))