994 lines
29 KiB
TypeScript
994 lines
29 KiB
TypeScript
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import {
|
|
afterEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi
|
|
} from 'vitest'
|
|
import {
|
|
CapabilityService,
|
|
type CapabilityCipher,
|
|
type CapabilityServiceOptions
|
|
} from './capability-service'
|
|
import {
|
|
BrowserProfileService,
|
|
MemoryBrowserProfileStore
|
|
} from './browser-profile-service'
|
|
import { CapabilityDiagnostics } from './capability-diagnostics'
|
|
|
|
const temporaryDirectories: string[] = []
|
|
|
|
const cipher: CapabilityCipher = {
|
|
isAvailable: () => true,
|
|
encrypt: (value) => Buffer.from(`encrypted:${value}`),
|
|
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
|
|
}
|
|
|
|
class FailingBrowserProfileService extends BrowserProfileService {
|
|
failNextAddAfterSave = false
|
|
failNextRemoveAfterSave = false
|
|
|
|
override async addReference(
|
|
...args: Parameters<BrowserProfileService['addReference']>
|
|
): ReturnType<BrowserProfileService['addReference']> {
|
|
const result = await super.addReference(...args)
|
|
if (this.failNextAddAfterSave) {
|
|
this.failNextAddAfterSave = false
|
|
throw new Error('Injected add reference failure')
|
|
}
|
|
return result
|
|
}
|
|
|
|
override async removeReference(
|
|
...args: Parameters<BrowserProfileService['removeReference']>
|
|
): ReturnType<BrowserProfileService['removeReference']> {
|
|
const result = await super.removeReference(...args)
|
|
if (this.failNextRemoveAfterSave) {
|
|
this.failNextRemoveAfterSave = false
|
|
throw new Error('Injected remove reference failure')
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
async function writeSkill(
|
|
root: string,
|
|
id: string,
|
|
name: string,
|
|
body = '仅用于离线测试。'
|
|
): Promise<void> {
|
|
const directory = join(root, id)
|
|
await mkdir(directory, { recursive: true })
|
|
await writeFile(
|
|
join(directory, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
`id: ${id}`,
|
|
`name: ${name}`,
|
|
`description: ${name}的测试说明`,
|
|
'version: 1.0.0',
|
|
'tags:',
|
|
' - 测试',
|
|
'---',
|
|
'',
|
|
`# ${name}`,
|
|
'',
|
|
body
|
|
].join('\n'),
|
|
'utf8'
|
|
)
|
|
}
|
|
|
|
async function createService(
|
|
options: CapabilityServiceOptions = {
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
}
|
|
): Promise<{
|
|
directory: string
|
|
filePath: string
|
|
builtinRoot: string
|
|
importedRoot: string
|
|
service: CapabilityService
|
|
}> {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-capabilities-'))
|
|
temporaryDirectories.push(directory)
|
|
const filePath = join(directory, 'capabilities.json')
|
|
const builtinRoot = join(directory, 'builtin')
|
|
const importedRoot = join(directory, 'imported')
|
|
await writeSkill(builtinRoot, 'document-writing', '文档写作')
|
|
return {
|
|
directory,
|
|
filePath,
|
|
builtinRoot,
|
|
importedRoot,
|
|
service: new CapabilityService(
|
|
filePath,
|
|
builtinRoot,
|
|
importedRoot,
|
|
cipher,
|
|
options
|
|
)
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
describe('CapabilityService', () => {
|
|
it('memoizes concurrent loads and retries after a failed load', async () => {
|
|
const initialStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => undefined)
|
|
}
|
|
const created = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(initialStore)
|
|
})
|
|
|
|
await Promise.all([
|
|
created.service.getComputerCapabilityStatus('host-browser-control'),
|
|
created.service.getComputerCapabilityStatus('host-browser-control'),
|
|
created.service.getComputerCapabilityStatus('host-browser-control')
|
|
])
|
|
expect(initialStore.load).toHaveBeenCalledOnce()
|
|
|
|
let profileLoads = 0
|
|
const browserProfiles = new BrowserProfileService({
|
|
load: vi.fn(async () => {
|
|
profileLoads += 1
|
|
if (profileLoads === 1) {
|
|
throw new Error('Injected profile load failure')
|
|
}
|
|
return undefined
|
|
}),
|
|
save: vi.fn(async () => undefined)
|
|
})
|
|
const retrying = new CapabilityService(
|
|
created.filePath,
|
|
created.builtinRoot,
|
|
created.importedRoot,
|
|
cipher,
|
|
{
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles
|
|
}
|
|
)
|
|
|
|
await expect(
|
|
Promise.all([
|
|
retrying.getComputerCapabilityStatus('host-browser-control'),
|
|
retrying.getComputerCapabilityStatus('host-browser-control')
|
|
])
|
|
).rejects.toThrow('Injected profile load failure')
|
|
await expect(
|
|
retrying.getComputerCapabilityStatus('host-browser-control')
|
|
).resolves.toEqual({ enabled: false, supported: true })
|
|
expect(profileLoads).toBe(2)
|
|
})
|
|
|
|
it('reports safe enabled and supported capability status', async () => {
|
|
const { service } = await createService()
|
|
|
|
await expect(
|
|
service.getComputerCapabilityStatus('host-browser-control')
|
|
).resolves.toEqual({ enabled: false, supported: true })
|
|
|
|
await service.setComputerCapabilityEnabled(
|
|
'host-browser-control',
|
|
true
|
|
)
|
|
await expect(
|
|
service.getComputerCapabilityStatus('host-browser-control')
|
|
).resolves.toEqual({ enabled: true, supported: true })
|
|
})
|
|
|
|
it('discovers built-in skills and persists enablement and assignments', async () => {
|
|
const { filePath, builtinRoot, importedRoot, service } =
|
|
await createService()
|
|
|
|
await expect(service.getSnapshot()).resolves.toMatchObject({
|
|
skills: [
|
|
{
|
|
id: 'document-writing',
|
|
source: 'builtin',
|
|
enabled: true,
|
|
assignments: ['model', 'opencode', 'continue']
|
|
}
|
|
]
|
|
})
|
|
|
|
await service.setSkillEnabled('document-writing', false)
|
|
await service.setSkillAssignments('document-writing', ['model'])
|
|
|
|
const reloaded = new CapabilityService(
|
|
filePath,
|
|
builtinRoot,
|
|
importedRoot,
|
|
cipher
|
|
)
|
|
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
|
|
skills: [
|
|
{
|
|
id: 'document-writing',
|
|
enabled: false,
|
|
assignments: ['model']
|
|
}
|
|
]
|
|
})
|
|
await expect(
|
|
reloaded.getSkillInstructions('continue', 10_000)
|
|
).resolves.toBe('')
|
|
await reloaded.setSkillEnabled('document-writing', true)
|
|
await expect(
|
|
reloaded.getSkillInstructions('model', 10_000)
|
|
).resolves.toContain('仅用于离线测试')
|
|
await expect(
|
|
reloaded.getRuntimeSkillContext('model', 10_000)
|
|
).resolves.toMatchObject({
|
|
instructions: expect.stringContaining('仅用于离线测试'),
|
|
packages: [
|
|
{
|
|
id: 'document-writing',
|
|
directory: join(builtinRoot, 'document-writing')
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
it('imports and removes a managed SKILL.md package', async () => {
|
|
const { directory, service } = await createService()
|
|
const packageRoot = join(directory, 'source-skill')
|
|
await writeSkill(packageRoot, 'meeting-helper', '会议助手')
|
|
const source = join(packageRoot, 'meeting-helper')
|
|
await writeFile(join(source, 'template.txt'), 'template', 'utf8')
|
|
|
|
const imported = await service.importSkill(source)
|
|
expect(imported.skills).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'meeting-helper',
|
|
source: 'imported'
|
|
})
|
|
)
|
|
|
|
const removed = await service.removeSkill('meeting-helper')
|
|
expect(removed.skills).not.toContainEqual(
|
|
expect.objectContaining({ id: 'meeting-helper' })
|
|
)
|
|
await expect(
|
|
service.removeSkill('document-writing')
|
|
).rejects.toThrow('只能删除已导入')
|
|
})
|
|
|
|
it('imports a standard SKILL.md that identifies itself by name', async () => {
|
|
const { directory, service } = await createService()
|
|
const source = join(directory, 'standard-source', 'summarize-diff')
|
|
await mkdir(source, { recursive: true })
|
|
await writeFile(
|
|
join(source, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
'name: summarize-diff',
|
|
'description: |',
|
|
' 概括暂存的改动。',
|
|
' 当用户需要待提交变更摘要时使用。',
|
|
'allowed-tools:',
|
|
' - Read',
|
|
' - Grep',
|
|
'compatibility: droid',
|
|
'---',
|
|
'',
|
|
'# Summarize Diff',
|
|
'',
|
|
'仅用于离线测试。'
|
|
].join('\n'),
|
|
'utf8'
|
|
)
|
|
|
|
const imported = await service.importSkill(source)
|
|
|
|
expect(imported.skills).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'summarize-diff',
|
|
source: 'imported',
|
|
description: '概括暂存的改动。 当用户需要待提交变更摘要时使用。'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('imports every Skill found under a suite directory', async () => {
|
|
const { directory, service } = await createService()
|
|
const suite = join(directory, 'suite', 'skills')
|
|
await writeSkill(suite, 'alpha-skill', 'Alpha')
|
|
await writeSkill(suite, 'beta-skill', 'Beta')
|
|
|
|
const imported = await service.importSkill(join(directory, 'suite'))
|
|
|
|
expect(imported.skills.map((skill) => skill.id)).toEqual(
|
|
expect.arrayContaining(['alpha-skill', 'beta-skill'])
|
|
)
|
|
})
|
|
|
|
it('reports a readable error when the selected directory has no SKILL.md', async () => {
|
|
const { directory, service } = await createService()
|
|
const empty = join(directory, 'empty-directory')
|
|
await mkdir(empty, { recursive: true })
|
|
|
|
await expect(service.importSkill(empty)).rejects.toThrow(
|
|
'没有找到 SKILL.md'
|
|
)
|
|
})
|
|
|
|
it('exposes the skill directory and names skills dropped by the budget', async () => {
|
|
const { builtinRoot, service } = await createService()
|
|
await writeSkill(
|
|
builtinRoot,
|
|
'oversized-skill',
|
|
'超长技能',
|
|
'超长技能说明。'.repeat(80)
|
|
)
|
|
|
|
const instructions = await service.getSkillInstructions('model')
|
|
expect(instructions).toContain(join(builtinRoot, 'document-writing'))
|
|
expect(instructions).toContain(join(builtinRoot, 'oversized-skill'))
|
|
|
|
const truncated = await service.getSkillInstructions('model', 200)
|
|
expect(truncated).toContain('因超出注入上限未加载')
|
|
expect(truncated).toContain('超长技能')
|
|
|
|
const fullyTruncated = await service.getSkillInstructions('model', 1)
|
|
expect(fullyTruncated).toContain('因超出注入上限未加载')
|
|
expect(fullyTruncated).toContain('文档写作')
|
|
expect(fullyTruncated).toContain('超长技能')
|
|
})
|
|
|
|
it('imports a managed Skill from a ZIP package', async () => {
|
|
const { directory, importedRoot, service } = await createService()
|
|
const packageRoot = join(directory, 'zip-source')
|
|
await writeSkill(packageRoot, 'meeting-helper', '会议助手')
|
|
const skillMarkdown = await readFile(
|
|
join(packageRoot, 'meeting-helper', 'SKILL.md')
|
|
)
|
|
const archivePath = join(directory, 'meeting-helper.zip')
|
|
await writeFile(
|
|
archivePath,
|
|
zipSync({
|
|
'meeting-helper/SKILL.md': skillMarkdown,
|
|
'meeting-helper/template.txt': strToU8('template')
|
|
})
|
|
)
|
|
|
|
const imported = await service.importSkill(archivePath)
|
|
|
|
expect(imported.skills).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'meeting-helper',
|
|
source: 'imported'
|
|
})
|
|
)
|
|
await expect(
|
|
readFile(
|
|
join(importedRoot, 'meeting-helper', 'template.txt'),
|
|
'utf8'
|
|
)
|
|
).resolves.toBe('template')
|
|
})
|
|
|
|
it('rejects unsafe paths in a Skill ZIP package', async () => {
|
|
const { directory, importedRoot, service } = await createService()
|
|
const packageRoot = join(directory, 'unsafe-source')
|
|
await writeSkill(packageRoot, 'unsafe-skill', '不安全 Skill')
|
|
const skillMarkdown = await readFile(
|
|
join(packageRoot, 'unsafe-skill', 'SKILL.md')
|
|
)
|
|
const archivePath = join(directory, 'unsafe-skill.zip')
|
|
await writeFile(
|
|
archivePath,
|
|
zipSync({
|
|
'../escape.txt': strToU8('escape'),
|
|
'unsafe-skill/SKILL.md': skillMarkdown
|
|
})
|
|
)
|
|
|
|
await expect(service.importSkill(archivePath)).rejects.toThrow(
|
|
'Skill ZIP 包含不安全路径'
|
|
)
|
|
await expect(
|
|
readFile(join(importedRoot, 'escape.txt'), 'utf8')
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
it('encrypts remote MCP secrets and never returns them publicly', async () => {
|
|
const { filePath, service } = await createService()
|
|
const snapshot = await service.saveMcpServer(undefined, {
|
|
name: 'Remote MCP',
|
|
description: 'Remote test server',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'replace', value: 'secret-token-value' },
|
|
transport: 'http',
|
|
url: 'https://mcp.example.com/mcp'
|
|
})
|
|
const server = snapshot.mcpServers[0]
|
|
expect(server).toMatchObject({
|
|
name: 'Remote MCP',
|
|
transport: 'http',
|
|
secretConfigured: true
|
|
})
|
|
expect(JSON.stringify(snapshot)).not.toContain('secret-token-value')
|
|
expect(await readFile(filePath, 'utf8')).not.toContain(
|
|
'secret-token-value'
|
|
)
|
|
if (!server) {
|
|
throw new Error('Expected saved MCP server')
|
|
}
|
|
await expect(
|
|
service.getResolvedMcpServer(server.id)
|
|
).resolves.toMatchObject({
|
|
secret: 'secret-token-value'
|
|
})
|
|
})
|
|
|
|
it('stores stdio command and arguments as separate values', async () => {
|
|
const { service } = await createService()
|
|
const snapshot = await service.saveMcpServer(undefined, {
|
|
name: 'Local MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'keep' },
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js', '--safe']
|
|
})
|
|
|
|
expect(snapshot.mcpServers[0]).toMatchObject({
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js', '--safe']
|
|
})
|
|
})
|
|
|
|
it('allows bearer tokens over the full IPv4 loopback range', async () => {
|
|
const { service } = await createService()
|
|
|
|
await expect(
|
|
service.saveMcpServer(undefined, {
|
|
name: 'Loopback MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'replace', value: 'secret-token-value' },
|
|
transport: 'http',
|
|
url: 'http://127.0.0.2/mcp'
|
|
})
|
|
).resolves.toMatchObject({
|
|
mcpServers: [
|
|
expect.objectContaining({
|
|
name: 'Loopback MCP',
|
|
url: 'http://127.0.0.2/mcp'
|
|
})
|
|
]
|
|
})
|
|
})
|
|
|
|
it('allows bearer tokens over HTTP on any configured host', async () => {
|
|
const { service } = await createService()
|
|
|
|
const snapshot = await service.saveMcpServer(undefined, {
|
|
name: 'Intranet MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'replace', value: 'secret-token-value' },
|
|
transport: 'http',
|
|
url: 'http://mcp.internal/mcp'
|
|
})
|
|
expect(snapshot).toMatchObject({
|
|
mcpServers: [
|
|
expect.objectContaining({
|
|
name: 'Intranet MCP',
|
|
secretConfigured: true,
|
|
url: 'http://mcp.internal/mcp'
|
|
})
|
|
]
|
|
})
|
|
const server = snapshot.mcpServers[0]
|
|
if (!server) {
|
|
throw new Error('Expected saved intranet MCP server')
|
|
}
|
|
await expect(
|
|
service.getResolvedMcpServer(server.id)
|
|
).resolves.toMatchObject({ secret: 'secret-token-value' })
|
|
})
|
|
|
|
it('allows public HTTP MCP servers with or without bearer tokens', async () => {
|
|
const { service } = await createService()
|
|
|
|
await expect(
|
|
service.saveMcpServer(undefined, {
|
|
name: 'Public plaintext MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'replace', value: 'secret-token-value' },
|
|
transport: 'http',
|
|
url: 'http://mcp.example.com/mcp'
|
|
})
|
|
).resolves.toMatchObject({
|
|
mcpServers: [
|
|
expect.objectContaining({
|
|
url: 'http://mcp.example.com/mcp',
|
|
secretConfigured: true
|
|
})
|
|
]
|
|
})
|
|
|
|
await expect(
|
|
service.saveMcpServer(undefined, {
|
|
name: 'Public MCP without token',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
secret: { action: 'clear' },
|
|
transport: 'http',
|
|
url: 'http://mcp.example.com/no-token'
|
|
})
|
|
).resolves.toMatchObject({
|
|
mcpServers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
url: 'http://mcp.example.com/no-token',
|
|
secretConfigured: false
|
|
})
|
|
])
|
|
})
|
|
})
|
|
|
|
it('rejects MCP assignments to Agent Runtimes', async () => {
|
|
const { service } = await createService()
|
|
|
|
await expect(
|
|
service.saveMcpServer(undefined, {
|
|
name: 'Agent MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['opencode'],
|
|
secret: { action: 'keep' },
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js']
|
|
})
|
|
).rejects.toThrow('只能分配给直连模型')
|
|
})
|
|
|
|
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
|
const { filePath, builtinRoot, importedRoot } = await createService()
|
|
await writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
skills: {},
|
|
mcpServers: [
|
|
{
|
|
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
|
|
name: 'Legacy MCP',
|
|
description: '',
|
|
enabled: true,
|
|
assignments: ['opencode'],
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js']
|
|
}
|
|
]
|
|
}),
|
|
'utf8'
|
|
)
|
|
const service = new CapabilityService(
|
|
filePath,
|
|
builtinRoot,
|
|
importedRoot,
|
|
cipher
|
|
)
|
|
|
|
await expect(service.getSnapshot()).resolves.toMatchObject({
|
|
mcpServers: [
|
|
expect.objectContaining({ assignments: ['model'] })
|
|
]
|
|
})
|
|
expect(await readFile(filePath, 'utf8')).toContain(
|
|
'"assignments": [\n "model"'
|
|
)
|
|
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
|
|
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
|
|
})
|
|
|
|
it('migrates v1 to v2 without losing skills, MCP configuration, or encrypted secrets', async () => {
|
|
const { filePath, builtinRoot, importedRoot } = await createService()
|
|
const credential = Buffer.from(
|
|
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
|
|
).toString('base64')
|
|
await writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
skills: {
|
|
'document-writing': {
|
|
enabled: false,
|
|
assignments: ['model']
|
|
}
|
|
},
|
|
mcpServers: [
|
|
{
|
|
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
|
|
name: 'Preserved MCP',
|
|
description: 'migration',
|
|
enabled: true,
|
|
assignments: ['model'],
|
|
credential: {
|
|
formatVersion: 1,
|
|
scheme: 'electron-safe-storage',
|
|
ciphertextBase64: credential
|
|
},
|
|
transport: 'http',
|
|
url: 'https://mcp.example.com/mcp'
|
|
}
|
|
]
|
|
}),
|
|
'utf8'
|
|
)
|
|
const service = new CapabilityService(
|
|
filePath,
|
|
builtinRoot,
|
|
importedRoot,
|
|
cipher,
|
|
{
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
}
|
|
)
|
|
|
|
await expect(service.getSnapshot()).resolves.toMatchObject({
|
|
skills: [
|
|
expect.objectContaining({
|
|
id: 'document-writing',
|
|
enabled: false,
|
|
assignments: ['model']
|
|
})
|
|
],
|
|
mcpServers: [
|
|
expect.objectContaining({
|
|
name: 'Preserved MCP',
|
|
secretConfigured: true
|
|
})
|
|
],
|
|
computerCapabilities: [
|
|
expect.objectContaining({
|
|
id: 'host-browser-control',
|
|
enabled: false
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'linux-desktop-control',
|
|
enabled: false
|
|
})
|
|
]
|
|
})
|
|
const persisted = await readFile(filePath, 'utf8')
|
|
expect(persisted).toContain('"version": 2')
|
|
expect(persisted).toContain(credential)
|
|
expect(persisted).not.toContain('preserved-secret')
|
|
})
|
|
|
|
it('gates enablement on the supported platform and architecture', async () => {
|
|
const { service } = await createService({
|
|
platform: 'darwin',
|
|
architecture: 'arm64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
})
|
|
|
|
await expect(
|
|
service.setComputerCapabilityEnabled(
|
|
'linux-desktop-control',
|
|
true
|
|
)
|
|
).rejects.toThrow('不支持')
|
|
const snapshot = await service.setComputerCapabilityEnabled(
|
|
'host-browser-control',
|
|
true
|
|
)
|
|
expect(snapshot.computerCapabilities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'host-browser-control',
|
|
enabled: true
|
|
})
|
|
])
|
|
)
|
|
})
|
|
|
|
it('maintains browser profile references and rejects unknown profiles', async () => {
|
|
const { service } = await createService()
|
|
await expect(
|
|
service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
'b7f29e4c-1c4a-4aa0-ac58-5165451dde07'
|
|
)
|
|
).rejects.toThrow('不存在')
|
|
|
|
const created = await service.createBrowserProfile('隔离工作配置')
|
|
const profileId = created.browserProfiles?.profiles[0]?.id
|
|
if (!profileId) {
|
|
throw new Error('Expected browser profile')
|
|
}
|
|
await service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
profileId
|
|
)
|
|
await expect(service.removeBrowserProfile(profileId)).rejects.toThrow(
|
|
'Referenced'
|
|
)
|
|
await service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
null
|
|
)
|
|
await expect(service.removeBrowserProfile(profileId)).resolves.toMatchObject(
|
|
{
|
|
browserProfiles: { profiles: [], defaultProfileId: null }
|
|
}
|
|
)
|
|
})
|
|
|
|
it('compensates browser profile references when add or capability persistence fails', async () => {
|
|
const addProfiles = new FailingBrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
const addCase = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: addProfiles
|
|
})
|
|
const addCreated = await addCase.service.createBrowserProfile(
|
|
'添加失败配置'
|
|
)
|
|
const addProfileId = addCreated.browserProfiles?.profiles[0]?.id
|
|
if (!addProfileId) {
|
|
throw new Error('Expected add-failure browser profile')
|
|
}
|
|
addProfiles.failNextAddAfterSave = true
|
|
|
|
await expect(
|
|
addCase.service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
addProfileId
|
|
)
|
|
).rejects.toThrow('Injected add reference failure')
|
|
await expect(
|
|
addCase.service.removeBrowserProfile(addProfileId)
|
|
).resolves.toMatchObject({
|
|
browserProfiles: { profiles: [], defaultProfileId: null }
|
|
})
|
|
|
|
const persistProfiles = new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
const persistCase = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: persistProfiles
|
|
})
|
|
const persistCreated =
|
|
await persistCase.service.createBrowserProfile('保存失败配置')
|
|
const persistProfileId =
|
|
persistCreated.browserProfiles?.profiles[0]?.id
|
|
if (!persistProfileId) {
|
|
throw new Error('Expected persistence-failure browser profile')
|
|
}
|
|
await mkdir(persistCase.filePath)
|
|
|
|
await expect(
|
|
persistCase.service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
persistProfileId
|
|
)
|
|
).rejects.toThrow()
|
|
await expect(
|
|
persistCase.service.removeBrowserProfile(persistProfileId)
|
|
).resolves.toMatchObject({
|
|
browserProfiles: { profiles: [], defaultProfileId: null }
|
|
})
|
|
})
|
|
|
|
it('rolls back capability and profile stores when old-reference removal fails', async () => {
|
|
const browserProfiles = new FailingBrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
const { service } = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles
|
|
})
|
|
const first = await service.createBrowserProfile('原配置')
|
|
const firstId = first.browserProfiles?.profiles[0]?.id
|
|
const second = await service.createBrowserProfile('新配置')
|
|
const secondId = second.browserProfiles?.profiles[1]?.id
|
|
if (!firstId || !secondId) {
|
|
throw new Error('Expected two browser profiles')
|
|
}
|
|
await service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
firstId
|
|
)
|
|
browserProfiles.failNextRemoveAfterSave = true
|
|
|
|
await expect(
|
|
service.setComputerCapabilityBrowserProfile(
|
|
'host-browser-control',
|
|
secondId
|
|
)
|
|
).rejects.toThrow('Injected remove reference failure')
|
|
await expect(service.getSnapshot()).resolves.toMatchObject({
|
|
computerCapabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'host-browser-control',
|
|
browserProfileId: firstId
|
|
})
|
|
])
|
|
})
|
|
await expect(service.removeBrowserProfile(secondId)).resolves.toBeDefined()
|
|
await expect(service.removeBrowserProfile(firstId)).rejects.toThrow(
|
|
'Referenced'
|
|
)
|
|
})
|
|
|
|
it('redacts injected diagnostics and never claims browser availability outside Electron', async () => {
|
|
process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET =
|
|
'service-diagnostic-secret-value'
|
|
const diagnostics = new CapabilityDiagnostics([
|
|
{
|
|
id: 'browser-executable',
|
|
run: async () => ({
|
|
status: 'available',
|
|
summary:
|
|
'token=visible-token service-diagnostic-secret-value C:\\Users\\Alice\\browser'
|
|
})
|
|
},
|
|
{
|
|
id: 'managed-profile-root',
|
|
run: async () => ({
|
|
status: 'available',
|
|
summary: 'managed storage ready'
|
|
})
|
|
}
|
|
])
|
|
const { service } = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
),
|
|
diagnostics
|
|
})
|
|
await service.setComputerCapabilityEnabled('host-browser-control', true)
|
|
const report = await service.diagnoseComputerCapability(
|
|
'host-browser-control'
|
|
)
|
|
expect(report.status).toBe('available')
|
|
expect(JSON.stringify(report)).not.toContain('visible-token')
|
|
expect(JSON.stringify(report)).not.toContain(
|
|
'service-diagnostic-secret-value'
|
|
)
|
|
|
|
const outsideElectron = await createService({
|
|
platform: 'win32',
|
|
architecture: 'x64',
|
|
electronTarget: false,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
),
|
|
diagnostics
|
|
})
|
|
await expect(
|
|
outsideElectron.service.setComputerCapabilityEnabled(
|
|
'host-browser-control',
|
|
true
|
|
)
|
|
).rejects.toThrow('诊断不可用')
|
|
await expect(
|
|
outsideElectron.service.getComputerCapabilityStatus(
|
|
'host-browser-control'
|
|
)
|
|
).resolves.toEqual({ enabled: false, supported: true })
|
|
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
|
|
})
|
|
|
|
it('keeps Linux desktop unavailable without a registered native adapter', async () => {
|
|
const { service } = await createService({
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
})
|
|
|
|
await expect(
|
|
service.setComputerCapabilityEnabled(
|
|
'linux-desktop-control',
|
|
true
|
|
)
|
|
).rejects.toThrow('不支持')
|
|
await expect(
|
|
service.getComputerCapabilityStatus('linux-desktop-control')
|
|
).resolves.toEqual({ enabled: false, supported: false })
|
|
})
|
|
|
|
it('allows Linux desktop enablement with available injected diagnostics', async () => {
|
|
const diagnostics = new CapabilityDiagnostics(
|
|
['linux-session', 'desktop-driver', 'desktop-permissions'].map(
|
|
(id) => ({
|
|
id,
|
|
run: async () => ({
|
|
status: 'available' as const,
|
|
summary: `${id} ready`
|
|
})
|
|
})
|
|
)
|
|
)
|
|
const { service } = await createService({
|
|
platform: 'linux',
|
|
architecture: 'arm64',
|
|
electronTarget: true,
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
),
|
|
diagnostics,
|
|
availableComputerCapabilityImplementations: [
|
|
'managed-browser-driver',
|
|
'managed-linux-desktop-driver'
|
|
]
|
|
})
|
|
|
|
await expect(
|
|
service.setComputerCapabilityEnabled(
|
|
'linux-desktop-control',
|
|
true
|
|
)
|
|
).resolves.toMatchObject({
|
|
computerCapabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'linux-desktop-control',
|
|
enabled: true
|
|
})
|
|
])
|
|
})
|
|
})
|
|
})
|