Files
goodbuddy/src/renderer/src/SettingsPanel.test.tsx
T
mesalogo 5368e0ecf4
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
test: align fixtures with image tools and enabled browser defaults
Isolate Obsidian tests from the default browser capability, check the additional image-tool card, and center native overlay click targets below the frosted header. Retain existing permissions, identity, hit-testing and keyboard assertions; preserve unrelated uncommitted work.
2026-10-02 11:53:21 +08:00

6527 lines
212 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type {
CustomizableRuntimeProvider,
DesktopApi,
RuntimeSettings
} from '../../shared/contracts'
import {
builtinEmbeddingConnectionId,
legacyEmbeddingConnectionId
} from '../../shared/contracts'
import type {
CapabilityAssignments,
CapabilitySnapshot
} from '../../shared/capability-contracts'
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
GlobalShortcutSettingsSnapshot
} from '../../shared/shortcut'
import type {
EmbeddingDiagnosticResult,
EmbeddingModelSnapshot,
EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts'
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import type { DocumentParsingSnapshot } from '../../shared/document-parsing-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import {
SettingsPanel,
type SettingsLeaveRequester
} from './SettingsPanel'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
function harnessModelSourceRadios(): HTMLElement[] {
return screen
.queryAllByRole('radio')
.filter((radio) => radio.getAttribute('name') !== 'runtime-default-provider')
}
function openCapabilitySettingsTab(
tab: 'Skills' | 'MCP' | '工具执行环境'
): void {
fireEvent.click(
screen.getByRole('tab', { name: '能力与工具' })
)
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: tab })
)
}
class DecodableBrandImage {
naturalWidth = 64
naturalHeight = 64
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onload?.())
}
}
const browserProfileId = '00000000-0000-4000-8000-000000000201'
const runtimeSettings: RuntimeSettings = {
provider: 'auto',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
baseUrl: 'http://127.0.0.1:11434/v1/embeddings',
modelName: 'nomic-embed-text',
authentication: 'none',
apiKeyConfigured: false,
credentialSource: 'none'
}
],
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
knowledgeRerankApiKeyConfigured: false,
knowledgeRerankCredentialSource: 'none',
workspacePath: 'C:\\Workspace',
apiKeyConfigured: false,
credentialSource: 'none',
modelProfiles: [
{
id: modelProfileId,
name: '默认模型',
baseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
],
defaultModelProfileId: modelProfileId,
opencodeModelSource: {
kind: 'profile',
profileId: modelProfileId
},
continueModelSource: {
kind: 'profile',
profileId: modelProfileId
},
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: { source: 'unavailable' },
secureStorageAvailable: true,
toolApproval: 'always'
}
const getRuntime = vi.fn(async () => runtimeSettings)
const updateRuntime = vi.fn<DesktopApi['settings']['updateRuntime']>(
async (input) => ({
...runtimeSettings,
...input,
modelProfiles: (input.modelProfiles ?? []).map(
({ apiKey, ...profile }) => ({
...profile,
apiKeyConfigured: apiKey.action === 'replace',
credentialSource:
apiKey.action === 'replace'
? ('encrypted' as const)
: ('none' as const)
})
),
defaultModelProfileId:
input.defaultModelProfileId ?? modelProfileId,
opencodeModelSource:
input.opencodeModelSource ?? { kind: 'platform' },
continueModelSource:
input.continueModelSource ?? { kind: 'platform' },
apiKeyConfigured: false,
credentialSource: 'none',
secureStorageAvailable: true
})
)
const detectAgentRuntimes = vi.fn<
DesktopApi['settings']['detectAgentRuntimes']
>(async () => ({
opencode: {
available: true,
path: 'C:\\Tools\\opencode.exe',
version: '1.2.3',
source: 'automatic',
detail: '通过 PATH 检测'
},
continue: {
available: true,
path: 'bundled://continue',
version: '1.5.47',
source: 'bundled',
detail: '内置 Continue CLI 1.5.47 已就绪'
},
deepseekHarness: {
available: true,
path: 'bundled://deepseek-harness',
version: '0.1.7-rc.2',
source: 'bundled',
detail: '内置 Harness Adapter 已就绪'
}
}))
const selectRuntimeFile = vi.fn<
DesktopApi['settings']['selectRuntimeFile']
>(async (kind) =>
kind === 'continueBinary' ? 'C:\\Tools\\cn.exe' : undefined
)
const openRuntimeConfig = vi.fn<
DesktopApi['settings']['openRuntimeConfig']
>(async () => {})
const testModelConnection = vi.fn<
DesktopApi['settings']['testModelConnection']
>(async () => ({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
}))
const testRuntime = vi.fn<DesktopApi['settings']['testRuntime']>(
async () => ({
id: 'continue',
label: 'Continue',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
)
const capabilitySnapshot = {
skills: [
{
id: 'document-writing',
name: '文档写作',
description: '起草专业办公文档',
version: '1.0.0',
tags: ['文档', '办公'],
source: 'builtin' as const,
digest: 'a'.repeat(64),
enabled: true,
assignments: [
'model',
'opencode',
'continue',
'deepseek-harness'
] as (
| 'model'
| 'opencode'
| 'continue'
| 'deepseek-harness'
)[]
}
],
builtinMcpServers: builtinMcpServers.map(server => ({
id: server.id,
enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian',
assignments: [...server.supportedAssignments]
})),
mcpServers: [] as CapabilitySnapshot['mcpServers'],
obsidian: { vaultPath: '' },
webSearch: {
provider: 'exa' as const,
enabled: true,
availableIn: ['ask', 'execute'] as const,
tools: ['web_search', 'web_fetch'] as const
},
computerCapabilities: [
{
id: 'host-browser-control' as const,
name: '内置浏览器',
description: '使用 GoodBuddy 内置的临时隔离浏览器执行网页操作。',
enabled: false,
supported: true,
browserProfileId: null,
riskSummary:
'总开关关闭时不会向任何 Runtime 提供浏览器工具;开启后,已分配的 Runtime 可在 Execute 模式直接读取网页并操作网站,不再逐次询问。'
},
{
id: 'linux-desktop-control' as const,
name: 'Linux 桌面控制',
description: '在受支持的 Linux 桌面会话中执行桌面操作。',
enabled: false,
supported: false,
browserProfileId: null,
riskSummary: '可观察并操作桌面应用。'
}
],
browserProfiles: {
profiles: [
{
id: browserProfileId,
name: '工作网站',
mode: 'managed-isolated' as const
}
],
defaultProfileId: browserProfileId
}
} satisfies CapabilitySnapshot
const getCapabilitySnapshot = vi.fn(async () => capabilitySnapshot)
const importSkill = vi.fn<DesktopApi['capabilities']['importSkill']>(
async () => capabilitySnapshot
)
const saveMcpServer = vi.fn(async () => capabilitySnapshot)
const setWebSearchEnabled = vi.fn(async (enabled: boolean) => ({
...capabilitySnapshot,
webSearch: {
...capabilitySnapshot.webSearch,
enabled
}
}))
const testWebSearch = vi.fn(async () => ({
provider: 'exa' as const,
query: 'GoodBuddy desktop assistant',
durationMs: 321,
preview: 'GoodBuddy search result'
}))
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
...capabilitySnapshot,
skills: capabilitySnapshot.skills.map((skill) => ({
...skill,
enabled
}))
}))
const setBuiltinMcpServerEnabled = vi.fn(
async (serverId: string, enabled: boolean) => ({
...capabilitySnapshot,
builtinMcpServers: capabilitySnapshot.builtinMcpServers.map(
(server) =>
server.id === serverId ? { ...server, enabled } : server
)
})
)
const setBuiltinMcpServerAssignments = vi.fn(
async (serverId: string, assignments: CapabilityAssignments) => ({
...capabilitySnapshot,
builtinMcpServers: capabilitySnapshot.builtinMcpServers.map(
(server) =>
server.id === serverId ? { ...server, assignments } : server
)
})
)
const setComputerCapabilityEnabled = vi.fn(
async (_capabilityId: string, enabled: boolean) => ({
...capabilitySnapshot,
computerCapabilities: capabilitySnapshot.computerCapabilities.map(
(capability) =>
capability.id === 'host-browser-control'
? { ...capability, enabled }
: capability
)
})
)
const diagnoseComputerCapability = vi.fn(async () => ({
capabilityId: 'host-browser-control' as const,
status: 'degraded' as const,
checkedAt: '2026-08-05T12:00:00.000Z',
checks: [
{
id: 'browser-executable',
status: 'degraded' as const,
summary: '内置浏览器核心可用,但当前未启动会话。',
remedy: '开始一次浏览器任务后重试。'
}
]
}))
const createBrowserProfile = vi.fn(async () => capabilitySnapshot)
const renameBrowserProfile = vi.fn(async () => capabilitySnapshot)
const setDefaultBrowserProfile = vi.fn(async () => capabilitySnapshot)
const removeBrowserProfile = vi.fn(async () => capabilitySnapshot)
const heartbeatSettingsProps = {
onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test')
}),
projects: [],
remoteProjectsEnabled: true
}
const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101',
name: '研究分析专家',
description: '负责资料分析',
systemInstructions: 'Separate evidence from assumptions.',
routingKeywords: ['研究', '分析'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const listExperts = vi.fn<DesktopApi['experts']['list']>(
async () => [assistantExpert]
)
const createExpert = vi.fn<DesktopApi['experts']['create']>(
async (input) => ({
...input,
routingKeywords: input.routingKeywords ?? [],
id: '00000000-0000-4000-8000-000000000102',
enabled: true,
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
})
)
const updateExpert = vi.fn<DesktopApi['experts']['update']>(
async (expertId, input) => ({
...input,
routingKeywords: input.routingKeywords ?? [],
id: expertId,
enabled: true,
createdAt: assistantExpert.createdAt,
updatedAt: '2026-08-04T00:00:00.000Z'
})
)
const removeExpert = vi.fn<DesktopApi['experts']['remove']>(
async () => {}
)
const embeddingCatalogEntry = {
id: 'granite-embedding-97m-multilingual-r2-int8',
displayName: 'Granite Embedding 97M Multilingual R2',
description: '面向多语言检索的本地 INT8 向量模型。',
languages: ['200+ languages'],
runtime: 'onnxruntime-web/wasm' as const,
dimensions: 384,
contextTokens: 32_768,
quantization: 'INT8',
recommended: true,
available: true,
license: {
name: 'Apache License 2.0',
notice: 'Apache License 2.0',
url: 'https://example.com/license'
},
files: [
{
name: 'model.onnx',
role: 'model' as const,
size: 2 * 1024 * 1024,
sha256: 'a'.repeat(64)
}
],
downloadAvailability: [
{
source: 'modelscope' as const,
available: true as const,
totalBytes: 2 * 1024 * 1024
},
{
source: 'hugging-face' as const,
available: true as const,
totalBytes: 2 * 1024 * 1024
}
]
}
const embeddingSnapshot: EmbeddingSettingsSnapshot = {
configuration: {
provider: 'openai-compatible',
model: 'nomic-embed-text',
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
credentialConfigured: false
},
connections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin',
model: embeddingCatalogEntry.id,
credentialConfigured: false
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
model: 'nomic-embed-text',
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
authentication: 'none',
credentialConfigured: false
}
],
currentConnectionId: legacyEmbeddingConnectionId,
models: {
selectedDownloadSource: 'modelscope',
catalog: [embeddingCatalogEntry],
installed: [],
operations: []
}
}
const getEmbeddingSnapshot = vi.fn(async () => embeddingSnapshot)
const getEmbeddingModelProgress = vi.fn(async () => ({
operations: embeddingSnapshot.models.operations
}))
const diagnoseEmbedding = vi.fn(
async (): Promise<EmbeddingDiagnosticResult> => ({
status: 'available',
provider: 'openai-compatible',
model: 'nomic-embed-text',
dimensions: 768,
latencyMs: 128,
checkedAt: Date.UTC(2026, 7, 5, 12, 0, 0)
})
)
const setCurrentEmbedding = vi.fn(async () => embeddingSnapshot)
const installEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
const cancelEmbeddingModel = vi.fn(async () => true)
const importEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
const removeEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
magicNoteCanvasPageCount: 1
}
const agentPackageInventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: {
state: 'available',
checkedAt: '2026-08-24T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.29'
},
{
platform: 'linux',
architecture: 'arm64',
state: 'not-downloaded',
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
agentProtocol: null,
remoteRuntimeVersion: null
}
]
}
const getAgentPackageInventory = vi.fn<
NonNullable<DesktopApi['sshHosts']>['getAgentPackageInventory']
>(async (): Promise<AgentPackageInventory> => agentPackageInventory)
const downloadAgentPackage = vi.fn(async () => agentPackageInventory)
const importAgentPackage = vi.fn(async () => agentPackageInventory)
const exportAgentPackage = vi.fn(async () => {})
const onAgentPackageProgress = vi.fn(() => () => {})
const getApplicationSettings = vi.fn(async () => ({
...applicationSettings
}))
const updateApplicationSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
applicationSettings = {
...applicationSettings,
...input
}
return { ...applicationSettings }
})
const localToolEnvironmentSnapshot = {
settings: defaultLocalToolEnvironmentSettings,
candidates: [
{
kind: 'node' as const,
executablePath: 'C:\\Tools\\node.exe',
version: '22.14.0',
architecture: 'x64'
}
],
diagnostics: {},
managedPython: {
version: '3.12.8',
installed: false
}
}
const getLocalToolEnvironmentSnapshot = vi.fn(
async () => localToolEnvironmentSnapshot
)
const updateLocalToolEnvironmentSettings = vi.fn<
NonNullable<DesktopApi['localToolEnvironment']>['updateSettings']
>(async (settings) => ({
...localToolEnvironmentSnapshot,
settings
}))
const diagnoseLocalToolEnvironment = vi.fn(
async () => localToolEnvironmentSnapshot
)
const onLocalToolEnvironmentProgress = vi.fn(() => () => {})
let shortcutSettingsSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
const getShortcutSettings = vi.fn(async () => shortcutSettingsSnapshot)
const updateShortcutSettings = vi.fn<
NonNullable<DesktopApi['shortcuts']>['updateSettings']
>(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
const speechCatalog: SpeechModelSnapshot['catalog'] = [
{
id: 'sensevoice-small-int8',
displayName: 'SenseVoiceSmall INT8',
description: 'Fast multilingual local speech recognition.',
languages: ['中文', '英语'],
family: 'sensevoice',
quantization: 'int8',
quality: 'high',
speed: 'fast',
recommended: true,
license: {
name: 'Model License',
notice: 'Review the model license before use.',
url: 'https://example.com/license'
},
manualOnly: false,
files: [],
downloadAvailability: [
{
source: 'modelscope',
available: true,
totalBytes: 1
},
{
source: 'hugging-face',
available: true,
totalBytes: 1
}
]
},
{
id: 'paraformer-bilingual-zh-en-int8',
displayName: 'Paraformer 中英双语 INT8',
description: 'Fast local Mandarin and English recognition.',
languages: ['中文', '英语'],
family: 'paraformer',
quantization: 'int8',
quality: 'high',
speed: 'fast',
recommended: true,
license: {
name: 'MIT License',
notice: 'Review the model license before use.',
url: 'https://example.com/license'
},
manualOnly: false,
files: [],
downloadAvailability: [
{
source: 'modelscope',
available: true,
totalBytes: 1
},
{
source: 'hugging-face',
available: true,
totalBytes: 1
}
]
}
]
const createSpeechModelSnapshot = (
selectedModelId: string | null = 'sensevoice-small-int8'
): SpeechModelSnapshot => ({
rootDirectory: 'C:\\Users\\test\\models\\speech',
selectedDownloadSource: 'modelscope',
catalog: speechCatalog,
installed: speechCatalog.map((model) => ({
id: model.id,
displayName: model.displayName,
source: 'download',
installedAt: '2026-08-11T00:00:00.000Z',
files: []
})),
operations: [],
selectedModelId
})
let speechModelSnapshot = createSpeechModelSnapshot()
const getSpeechModelSnapshot = vi.fn(async () => speechModelSnapshot)
const selectSpeechModel = vi.fn<
NonNullable<DesktopApi['speechModels']>['select']
>(async (modelId) => {
speechModelSnapshot = createSpeechModelSnapshot(modelId)
return speechModelSnapshot
})
const runtimeExtensionSnapshot = {
marketplaceEnabled: true,
catalog: [],
installed: []
}
const getRuntimeExtensionSnapshot = vi.fn(
async () => runtimeExtensionSnapshot
)
const applyRuntimeExtension = vi.fn(
async () => runtimeExtensionSnapshot
)
const runtimeCustomizationSettings = {
opencode: {},
continue: { presets: [] }
}
const getRuntimeCustomizationSettings = vi.fn<
DesktopApi['runtimeCustomization']['getSettings']
>(
async () => runtimeCustomizationSettings
)
const updateRuntimeCustomizationSettings = vi.fn<
DesktopApi['runtimeCustomization']['updateSettings']
>(
async () => runtimeCustomizationSettings
)
const getRuntimeNativeSnapshot = vi.fn<
DesktopApi['runtimeCustomization']['getNativeSnapshot']
>(async (input) => ({
provider: input.provider,
available: true,
inventoryStatus: 'available',
detail: 'Ready',
agents: [],
tools: [],
toolsSupported: input.provider !== 'continue',
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: input.provider === 'opencode',
context: {
strategy:
input.provider === 'opencode'
? ('native' as const)
: input.provider === 'continue'
? ('goodbuddy-summary' as const)
: ('unsupported' as const),
manualCompact: input.provider !== 'deepseek-harness',
detail: 'Context status'
}
}))
function RuntimeCustomizationTestHarness({
provider
}: {
provider: CustomizableRuntimeProvider
}): React.JSX.Element {
const customizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
return (
<>
<RuntimeCustomizationSection
provider={provider}
ref={customizationRef}
/>
<button
onClick={() => void customizationRef.current?.save()}
type="button"
>
保存 Runtime 定制
</button>
</>
)
}
describe('SettingsPanel runtime files', () => {
beforeEach(async () => {
vi.clearAllMocks()
localStorage.removeItem('goodbuddy.ui-locale')
await changeUiLocale('zh-CN')
applicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
magicNoteCanvasPageCount: 1
}
getAgentPackageInventory.mockReset()
getAgentPackageInventory.mockResolvedValue(
agentPackageInventory
)
speechModelSnapshot = createSpeechModelSnapshot()
shortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
getShortcutSettings.mockImplementation(
async () => shortcutSettingsSnapshot
)
updateShortcutSettings.mockImplementation(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.11.0',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}))
},
feedback: {
submit: vi.fn(async () => ({
ok: true as const,
reference: 'GOODBUDDY-000001',
duplicate: false
}))
},
settings: {
getRuntime,
updateRuntime,
selectWorkspace: vi.fn(async () => undefined),
detectAgentRuntimes,
selectRuntimeFile,
openRuntimeConfig,
testModelConnection,
testRuntime
},
sshHosts: {
getSnapshot: vi.fn(async () => ({
hosts: [],
secureStorageAvailable: true
})),
getAgentPackageInventory,
downloadAgentPackage,
importAgentPackage,
exportAgentPackage,
onAgentPackageProgress,
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(async (hostId: string) => ({
hostId,
deletedProjects: []
})),
inspectHostKey: vi.fn(),
acceptHostKey: vi.fn(),
testConnection: vi.fn()
},
capabilities: {
getSnapshot: getCapabilitySnapshot,
importSkill,
removeSkill: vi.fn(async () => capabilitySnapshot),
setSkillEnabled,
setSkillAssignments: vi.fn(async () => capabilitySnapshot),
setBuiltinMcpServerEnabled,
setBuiltinMcpServerAssignments,
saveMcpServer,
removeMcpServer: vi.fn(async () => capabilitySnapshot),
testMcpServer: vi.fn(async () => ({
dynamicToolsSupported: false,
toolCount: 0,
tools: []
})),
setWebSearchEnabled,
testWebSearch,
updateObsidianSettings: vi.fn(async (obsidian) => ({
...capabilitySnapshot,
obsidian
})),
testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })),
selectObsidianVault: vi.fn(async () => null),
setComputerCapabilityEnabled,
setComputerCapabilityBrowserProfile: vi.fn(
async () => capabilitySnapshot
),
diagnoseComputerCapability,
createBrowserProfile,
renameBrowserProfile,
setDefaultBrowserProfile,
removeBrowserProfile
},
experts: {
list: listExperts,
create: createExpert,
update: updateExpert,
remove: removeExpert
},
embeddings: {
getSnapshot: getEmbeddingSnapshot,
getModelProgress: getEmbeddingModelProgress,
diagnose: diagnoseEmbedding,
setCurrent: setCurrentEmbedding,
installModel: installEmbeddingModel,
cancelModelOperation: cancelEmbeddingModel,
importModelArchive: importEmbeddingModel,
removeModel: removeEmbeddingModel
},
speechModels: {
getSnapshot: getSpeechModelSnapshot,
install: vi.fn(),
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: selectSpeechModel,
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
},
runtimeExtensions: {
getSnapshot: getRuntimeExtensionSnapshot,
apply: applyRuntimeExtension
},
runtimeCustomization: {
getSettings: getRuntimeCustomizationSettings,
updateSettings: updateRuntimeCustomizationSettings,
getNativeSnapshot: getRuntimeNativeSnapshot
},
updates: {
getSettings: getApplicationSettings,
updateSettings: updateApplicationSettings,
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
},
localToolEnvironment: {
getSnapshot: getLocalToolEnvironmentSnapshot,
updateSettings: updateLocalToolEnvironmentSettings,
refreshCandidates: vi.fn(
async () => localToolEnvironmentSnapshot
),
selectExecutable: vi.fn(
async () => localToolEnvironmentSnapshot
),
diagnose: diagnoseLocalToolEnvironment,
installPython: vi.fn(
async () => localToolEnvironmentSnapshot
),
cancelPython: vi.fn(async () => true),
removePython: vi.fn(
async () => localToolEnvironmentSnapshot
),
onProgress: onLocalToolEnvironmentProgress
},
shortcuts: {
getSettings: getShortcutSettings,
updateSettings: updateShortcutSettings
}
} as unknown as DesktopApi
})
})
afterEach(() => {
cleanup()
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: nativeImage
})
})
it('offers system, light, and dark appearance modes', async () => {
const onAppearanceThemeChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
appearanceTheme="system"
onAppearanceThemeChange={onAppearanceThemeChange}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
const themeOptions = screen.getByRole('radiogroup', {
name: '界面主题'
})
expect(
within(themeOptions).getByRole('radio', { name: /跟随系统/u })
).toBeChecked()
fireEvent.click(
within(themeOptions).getByRole('radio', { name: /暗色/u })
)
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('customizes and restores the sidebar brand from Appearance', async () => {
const onBrandingPreferencesChange = vi.fn<
(preferences: BrandingPreferences) => boolean
>(() => true)
function BrandingHarness(): React.JSX.Element {
const [preferences, setPreferences] = useState({
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
})
return (
<SettingsPanel
{...heartbeatSettingsProps}
brandingFallbackLogo="default-logo.png"
brandingPreferences={preferences}
onBrandingPreferencesChange={(next) => {
onBrandingPreferencesChange(next)
setPreferences(next)
return true
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(<BrandingHarness />)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('GoodBuddy')
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.change(screen.getByLabelText('英文副标题'), {
target: { value: 'Team Workspace' }
})
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('研发伙伴')
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: DecodableBrandImage
})
fireEvent.change(screen.getByLabelText('选择 Logo', { selector: 'input' }), {
target: {
files: [
new File(
[
Uint8Array.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
])
],
'brand.png',
{ type: 'image/png' }
)
]
}
})
await screen.findByRole('button', { name: '使用默认 Logo' })
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(onBrandingPreferencesChange).toHaveBeenCalledWith({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
expect(screen.getByLabelText('中文副标题')).toHaveValue(
'智能工作台'
)
})
it('configures direct model context compression with explicit token budgets', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="context-control"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
await screen.findByRole('heading', {
level: 2,
name: '上下文控制'
})
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '上下文控制' })
).toHaveTextContent(/^上下文控制$/)
const enabled = screen.getByRole('switch', {
name: '自动压缩较早的对话'
})
const trigger = screen.getByLabelText('压缩触发阈值')
const recent = screen.getByLabelText('最近原文预算')
expect(enabled).not.toBeChecked()
expect(trigger).toHaveValue(200)
expect(trigger).toBeDisabled()
expect(recent).toHaveValue(32)
fireEvent.click(enabled)
fireEvent.change(trigger, { target: { value: '240' } })
fireEvent.change(recent, { target: { value: '40' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
contextCompression: expect.objectContaining({
enabled: true,
triggerTokens: 240_000,
recentRawTokens: 40_000,
modelSource: { kind: 'current' }
})
})
)
)
})
it('allows editing context compression budgets through empty and single-digit states', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="context-control"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
await screen.findByRole('switch', {
name: '自动压缩较早的对话'
})
)
const trigger = screen.getByLabelText('压缩触发阈值')
const recent = screen.getByLabelText('最近原文预算')
fireEvent.change(trigger, { target: { value: '' } })
expect(trigger).toHaveValue(null)
fireEvent.blur(trigger)
expect(trigger).toHaveValue(200)
fireEvent.change(trigger, { target: { value: '8' } })
expect(trigger).toHaveValue(8)
expect(recent).toHaveValue(32)
expect(recent).toHaveAttribute('max', '7')
fireEvent.change(recent, { target: { value: '4' } })
expect(recent).toHaveValue(4)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
contextCompression: expect.objectContaining({
triggerTokens: 8_000,
recentRawTokens: 4_000
})
})
)
)
})
it('normalizes context compression limits only after editing finishes', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="context-control"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
await screen.findByRole('switch', {
name: '自动压缩较早的对话'
})
)
const trigger = screen.getByLabelText('压缩触发阈值')
const recent = screen.getByLabelText('最近原文预算')
fireEvent.change(trigger, { target: { value: '2' } })
expect(trigger).toHaveValue(2)
expect(recent).toHaveAttribute('max', '7')
fireEvent.blur(trigger)
expect(trigger).toHaveValue(8)
expect(recent).toHaveValue(7)
fireEvent.change(trigger, { target: { value: '1200' } })
fireEvent.blur(trigger)
expect(trigger).toHaveValue(1000)
fireEvent.change(recent, { target: { value: '999' } })
fireEvent.blur(recent)
expect(recent).toHaveValue(256)
})
it('stores an optional context window on direct text models', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const contextWindow = await screen.findByRole('spinbutton', { name: '上下文上限(可选)' })
expect(contextWindow).toHaveValue(null)
expect(contextWindow).toHaveAttribute('min', '32')
fireEvent.change(contextWindow, { target: { value: '256' } })
const maximumOutput = screen.getByRole('spinbutton', { name: 'Anthropic 最大输出' })
expect(maximumOutput).toHaveValue(32)
expect(maximumOutput).toHaveAttribute('min', '1')
fireEvent.change(maximumOutput, { target: { value: '48' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
})
])
})
)
)
})
it('applies and persists an English interface language immediately', async () => {
render(
<UiLocaleProvider initialPreference="zh-CN">
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
</UiLocaleProvider>
)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
fireEvent.click(
screen.getByRole('radio', {
name: /^English/u
})
)
await waitFor(() => {
expect(localStorage.getItem('goodbuddy.ui-locale')).toBe('en-US')
expect(document.documentElement.lang).toBe('en-US')
})
expect(
screen.getByRole('heading', { level: 1, name: 'Settings' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Close settings' })
).toBeInTheDocument()
expect(
screen.getByRole('tablist', { name: 'Settings categories' })
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: 'Appearance' })
).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('heading', { level: 2, name: 'Appearance' })
).toBeInTheDocument()
expect(
screen.getByRole('radiogroup', { name: 'Interface theme' })
).toBeInTheDocument()
expect(
screen.getByRole('radio', { name: /Use system theme/u })
).toBeInTheDocument()
expect(
screen.getByRole('radiogroup', { name: 'Interface language' })
).toBeInTheDocument()
expect(
screen.getByRole('radio', { name: /Use system language/u })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection Default model'
})
).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Default model')
fireEvent.click(
screen.getByRole('button', { name: 'Save settings' })
)
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
name: '默认模型'
})
])
})
)
)
fireEvent.click(
screen.getByRole('tab', { name: 'Agent Runtime' })
)
expect(
screen.getByRole('heading', {
level: 2,
name: 'Agent Runtime'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: /Platform Features/i })
)
expect(screen.getByText('Workspace')).toBeInTheDocument()
expect(
screen.getByLabelText('Default workspace folder')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Agent Runtime' })
)
expect(
screen.getByRole('button', { name: 'Save settings' })
).toBeInTheDocument()
expect(
screen.queryByText(/OpenCode and Continue are bundled with GoodBuddy/u)
).not.toBeInTheDocument()
expect(
screen.getByText(
'Automatically detected OpenCode 1.2.3'
)
).toBeInTheDocument()
expect(screen.queryByText('通过 PATH 检测')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
expect(
screen.getByText('Bundled Continue 1.5.47 is ready')
).toBeInTheDocument()
expect(
screen.queryByText('内置 Continue CLI 1.5.47 已就绪')
).not.toBeInTheDocument()
})
it('does not translate user-defined model connection names', async () => {
const userProfileId = '00000000-0000-4000-8000-000000000099'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
name: 'My renamed model'
},
{
...runtimeSettings.modelProfiles[0]!,
id: userProfileId,
name: '默认模型'
}
]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection My renamed model'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'Edit model connection 默认模型'
})
).toBeInTheDocument()
})
it('localizes structured Runtime recovery warnings', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
warnings: [{ code: 'runtime-settings-recovered' }]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
await screen.findByText(/The Runtime settings file was corrupt/u)
).toBeInTheDocument()
})
it('omits Magic Notes from global platform settings without changing stored settings', async () => {
const storedSettings = structuredClone(applicationSettings)
render(
<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(document.getElementById('platform-features-panel-magic-notes')).toBeNull()
expect(screen.queryByRole('switch', { name: '显示魔法笔记入口' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(applicationSettings).toEqual(storedSettings)
})
it('confirms the model download source beside the model types, below save actions', async () => {
const onNotify = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onNotify={onNotify}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const trigger = screen.getByRole('button', { name: '模型下载源' })
expect(trigger.closest('.model-type-navigation')).toContainElement(screen.getByRole('group', { name: '模型类型' }))
expect(trigger.closest('.settings-category-header__navigation')?.previousElementSibling).toHaveClass('settings-category-header__actions')
expect(await screen.findByText('当前来源:ModelScope')).toBeInTheDocument()
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
const modelScope = within(dialog).getByRole('radio', {
name: /ModelScope/u
})
const huggingFace = screen.getByRole('radio', {
name: /Hugging Face/u
})
expect(modelScope).toBeChecked()
expect(huggingFace).not.toBeChecked()
expect(
screen.queryByRole('switch', { name: '显示魔法笔记入口' })
).not.toBeInTheDocument()
fireEvent.click(huggingFace)
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
modelDownloadSource: 'hugging-face'
})
)
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(trigger).toHaveFocus()
})
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '模型下载源已切换为 Hugging Face。',
dedupeKey: 'model-download-source'
})
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(screen.queryByRole('radio', { name: /ModelScope/u })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '模型下载源' })).not.toBeInTheDocument()
})
it.each([
['zh-CN', '外观', '透明磨砂特效', '应用于顶栏和展开的运行待办卡片,保存后立即生效。'],
['en-US', 'Appearance', 'Frosted glass effect', 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.']
] as const)('exposes the frosted glass Switch under Appearance in %s', async (locale, category, label, description) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onTransparentFrostedEffectEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).toBeChecked()
expect(toggle).toHaveAccessibleDescription(description)
expect(toggle.closest('label')).toHaveClass('toggle-row')
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(false)
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled={false} applicationSettingsPending />)
expect(toggle).not.toBeChecked()
expect(toggle).toBeDisabled()
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled={false} />)
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(true)
})
it.each([
['zh-CN', '平台功能', '桌面通知'],
['en-US', 'Platform features', 'Desktop notifications']
] as const)('saves desktop notifications immediately in %s', async (locale, category, label) => {
await changeUiLocale(locale)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = await screen.findByRole('switch', { name: label })
expect(toggle).toBeChecked()
expect(toggle).toHaveAccessibleDescription()
fireEvent.click(toggle)
await waitFor(() => expect(toggle).not.toBeChecked())
expect(updateApplicationSettings).toHaveBeenLastCalledWith({
desktopNotificationsEnabled: false
})
fireEvent.click(toggle)
await waitFor(() => expect(toggle).toBeChecked())
expect(updateApplicationSettings).toHaveBeenLastCalledWith({
desktopNotificationsEnabled: true
})
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
expect(updateRuntime).not.toHaveBeenCalled()
})
it('loads disabled desktop notifications and groups related General settings', async () => {
applicationSettings.desktopNotificationsEnabled = false
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const conversation = await screen.findByRole('article', { name: '会话与通知' })
expect(within(conversation).getByRole('switch', { name: '桌面通知' })).not.toBeChecked()
expect(within(conversation).getByRole('switch', { name: '在会话中渲染 HTML' })).toBeChecked()
const workspace = screen.getByRole('article', { name: '工作目录' })
expect(within(workspace).getByRole('textbox')).toHaveValue(runtimeSettings.workspacePath)
expect(within(workspace).queryByRole('radio')).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it('keeps desktop notifications confirmed while saving and reports failure through onNotify', async () => {
const onNotify = vi.fn()
let rejectSave!: (reason: Error) => void
updateApplicationSettings.mockImplementationOnce(() => new Promise((_, reject) => {
rejectSave = reject
}))
render(
<SettingsPanel
{...heartbeatSettingsProps}
onNotify={onNotify}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', { name: '桌面通知' })
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
expect(toggle).toBeChecked()
fireEvent.click(toggle)
expect(updateApplicationSettings).toHaveBeenCalledTimes(1)
await act(async () => rejectSave(new Error('save failed')))
expect(toggle).toBeEnabled()
expect(toggle).toBeChecked()
expect(onNotify).toHaveBeenCalledExactlyOnceWith({
tone: 'error',
message: '保存桌面通知设置失败,请重试',
dedupeKey: 'desktop-notifications-save'
})
expect(screen.queryByText('保存桌面通知设置失败,请重试')).not.toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() => expect(toggle).not.toBeChecked())
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
})
it('disables conversation HTML rendering from General settings', async () => {
const onConversationHtmlRenderingEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
expect(
onConversationHtmlRenderingEnabledChange
).toHaveBeenCalledWith(false)
expect(toggle).not.toBeChecked()
})
it('does not guess platform settings when they fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
await screen.findByText('读取平台功能设置失败')
).toBeInTheDocument()
expect(
screen.queryByRole('radio', { name: /ModelScope/u })
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: '桌面通知' })
).not.toBeInTheDocument()
expect(
screen.queryByText('当前选择:ModelScope')
).not.toBeInTheDocument()
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(screen.getByLabelText('快捷键')).toHaveValue(
'CommandOrControl+Shift+Space'
)
})
it('keeps the source draft after a failed confirmation and allows retry', async () => {
updateApplicationSettings.mockRejectedValueOnce(
new Error('save failed')
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const modelScope = await screen.findByRole('radio', {
name: /ModelScope/u
})
const huggingFace = screen.getByRole('radio', {
name: /Hugging Face/u
})
fireEvent.click(huggingFace)
fireEvent.click(screen.getByRole('button', { name: '确认' }))
expect(
await screen.findByText('保存模型下载源失败,请重试')
).toBeInTheDocument()
expect(modelScope).not.toBeChecked()
expect(huggingFace).toBeChecked()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
})
})
it.each(['cancel', 'escape', 'close', 'backdrop'])('discards source selection on %s without saving', async (method) => {
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
const trigger = screen.getByRole('button', { name: '模型下载源' })
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
if (method === 'escape') fireEvent.keyDown(dialog, { key: 'Escape' })
else if (method === 'backdrop') fireEvent.mouseDown(dialog.parentElement!)
else fireEvent.click(within(dialog).getByRole('button', { name: method === 'cancel' ? '取消' : '关闭模型下载源' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
fireEvent.click(trigger)
expect(screen.getByRole('radio', { name: /ModelScope/u })).toBeChecked()
})
it('locks the source dialog during confirmation and keeps the confirmed label until saved', async () => {
let resolveSave!: (value: ApplicationSettings) => void
updateApplicationSettings.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
const onClose = vi.fn()
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={onClose} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(within(dialog).getByRole('button', { name: '保存中…' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '关闭模型下载源' })).toBeDisabled()
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeDisabled()
fireEvent.keyDown(dialog, { key: 'Escape' })
fireEvent.mouseDown(dialog.parentElement!)
expect(dialog).toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(updateApplicationSettings).toHaveBeenCalledTimes(1)
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
await act(async () => resolveSave({ ...applicationSettings, modelDownloadSource: 'hugging-face' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
})
it('retries loading the source without guessing a selected radio', async () => {
getApplicationSettings.mockRejectedValueOnce(new Error('read failed'))
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('读取模型下载源失败,请重试')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(within(dialog).queryByRole('radio')).not.toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeDisabled()
fireEvent.click(within(dialog).getByRole('button', { name: '重试' }))
await waitFor(() => {
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeEnabled()
})
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it.each(['embedding', 'speech'])('opens the shared source dialog from %s and updates download availability after confirm', async (kind) => {
const downloadAvailability = [
{ source: 'modelscope' as const, available: false, unavailableReason: '当前来源缺少完整文件' },
{ source: 'hugging-face' as const, available: true, totalBytes: 1 }
]
getEmbeddingSnapshot.mockResolvedValueOnce({
...embeddingSnapshot,
models: { ...embeddingSnapshot.models, catalog: [{ ...embeddingCatalogEntry, downloadAvailability }] }
})
speechModelSnapshot = {
...createSpeechModelSnapshot(null),
installed: [],
catalog: [{ ...speechCatalog[0]!, downloadAvailability }]
}
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: kind === 'embedding' ? '向量模型' : '语音输入' }))
if (kind === 'embedding') {
fireEvent.click(await screen.findByRole('button', { name: '编辑向量模型连接 GoodBuddy 内置向量模型' }))
}
await screen.findByText('当前来源不可下载')
const entry = screen.getAllByRole('button', { name: '模型下载源' }).find((button) => !button.closest('.model-type-navigation'))!
entry.focus()
fireEvent.click(entry)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
const modelName = kind === 'embedding' ? embeddingCatalogEntry.displayName : speechCatalog[0]!.displayName
const download = await screen.findByRole('button', { name: `下载 ${modelName}` })
expect(screen.queryByText('当前来源不可下载')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
fireEvent.click(download)
await waitFor(() => {
if (kind === 'embedding') expect(installEmbeddingModel).toHaveBeenCalledWith(embeddingCatalogEntry.id, 'hugging-face')
else expect(window.goodbuddy.speechModels!.install).toHaveBeenCalledWith(speechCatalog[0]!.id, 'hugging-face')
})
})
it.each([false, true])('opens the source dialog on the model page from OCR, respecting dirty draft = %s', async (dirty) => {
const snapshot: DocumentParsingSnapshot = {
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
status: { nativeParsingAvailable: true, conversionAvailable: false, localOcr: {
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: '模型尚未安装'
} },
ocrModels: {
rootDirectory: 'C:\\models\\ocr', selectedDownloadSource: 'modelscope', installed: [], operations: [],
catalog: [{
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', description: 'OCR', languages: ['中文'],
runtime: 'onnxruntime-web-wasm', quality: 'basic', speed: 'fast', recommended: true,
license: { name: 'Apache-2.0', notice: 'Apache-2.0', url: 'https://example.com/license' }, files: [],
downloadAvailability: [{ source: 'modelscope', available: false, unavailableReason: '当前来源缺少完整文件' }]
}]
}
}
Object.defineProperty(window.goodbuddy, 'documentParsing', {
configurable: true,
value: { getSnapshot: vi.fn(async () => snapshot), getOcrModelProgress: vi.fn(async () => ({ operations: [] })) }
})
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '文档解析' }))
const entry = await screen.findByRole('button', { name: '模型下载源' })
if (dirty) fireEvent.change(screen.getByRole('combobox', { name: '聊天与成果文件' }), { target: { value: 'fast-text' } })
entry.focus()
fireEvent.click(entry)
if (dirty) {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
expect(screen.getByRole('combobox', { name: '聊天与成果文件' })).toHaveValue('fast-text')
fireEvent.click(entry)
fireEvent.click(screen.getByRole('button', { name: '放弃更改并切换' }))
}
const dialog = await screen.findByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
expect(await within(dialog).findByRole('radio', { name: /ModelScope/u })).toBeChecked()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
const panel = (magicNotesEnabled: boolean): React.JSX.Element => (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const { rerender } = render(panel(false))
openCapabilitySettingsTab('MCP')
const noteServerToggle = await screen.findByRole('button', {
name: '展开服务器 笔记'
})
expect(noteServerToggle.closest('article')).toHaveClass(
'capability-card--disabled'
)
rerender(panel(true))
await waitFor(() =>
expect(
screen
.getByRole('button', { name: '展开服务器 笔记' })
.closest('article')
).not.toHaveClass('capability-card--disabled')
)
expect(
screen.getAllByText('按模式读写')
).not.toHaveLength(0)
})
it('keeps page navigation beside an independently scrollable panel', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
const navigation = screen.getByRole('tablist', {
name: '设置分类'
})
const content = screen.getByRole('tabpanel')
expect(navigation.parentElement).toHaveClass('settings-panel__body')
expect(content.parentElement).toBe(navigation.parentElement)
expect(content).toHaveClass('settings-panel__content')
expect(
screen.queryByText('管理模型、Runtime、平台能力、消息通道与本地数据。')
).not.toBeInTheDocument()
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
expect(runtimeTab).toHaveTextContent(/^Agent Runtime$/)
expect(
Array.from(navigation.querySelectorAll('.settings-tabs__group-label'), node => node.textContent)
).toEqual(['常规', '模型与执行', '功能与扩展', '系统'])
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
'settings-tab-ssh-hosts',
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-security', 'settings-tab-about'
])
})
it('omits the redundant close-only footer on passive settings pages', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('button', { name: '关闭设置' })
).toBeInTheDocument()
expect(
screen
.getByRole('region', { name: '设置中心' })
.querySelector('.settings-panel__footer')
).toBeNull()
})
it('uses one category header for titles and explicit actions', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
const settings = screen.getByRole('region', {
name: '设置中心'
})
const content = screen.getByRole('tabpanel')
const categoryHeader = content.querySelector(
'.settings-category-header'
)
expect(categoryHeader).toBe(content.firstElementChild)
expect(
within(categoryHeader as HTMLElement).getByRole('heading', {
level: 2,
name: 'Agent Runtime'
})
).toBeInTheDocument()
expect(
within(categoryHeader as HTMLElement).getByRole('button', {
name: '保存设置'
})
).toBeInTheDocument()
expect(
within(categoryHeader as HTMLElement).getByRole('button', {
name: '保存并测试 OpenCode'
})
).toBeInTheDocument()
expect(
settings.querySelector('.settings-panel__footer')
).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.queryByRole('button', { name: '保存设置' })
).not.toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '外观' })
).toBeInTheDocument()
})
it('routes save success through the transient app notification', async () => {
const onNotify = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await screen.findByLabelText('默认工作区目录')
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '设置已保存',
dedupeKey: 'runtime-settings-saved'
})
)
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
})
it('blocks draft edits while settings are being saved', async () => {
let finishSave!: (settings: RuntimeSettings) => void
updateRuntime.mockImplementationOnce(
async () =>
await new Promise<RuntimeSettings>((resolve) => {
finishSave = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
const settingsBody = screen
.getByRole('tabpanel')
.closest('.settings-panel__body')
await waitFor(() => {
expect(settingsBody).toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'true')
})
act(() => finishSave(runtimeSettings))
await waitFor(() => {
expect(settingsBody).not.toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'false')
})
})
it('submits configured model values while environment values are effective', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelBaseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
configured: {
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
opencodeBaseUrl: '',
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
workspacePath: 'C:\\Workspace',
opencodeModelSource: runtimeSettings.opencodeModelSource,
continueModelSource: runtimeSettings.continueModelSource,
deepseekHarnessModelSource:
runtimeSettings.deepseekHarnessModelSource
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
expect(
await screen.findByDisplayValue('https://environment.example/v1')
).toBeDisabled()
expect(screen.getByDisplayValue('environment-model')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelBaseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
modelProfiles: [
expect.objectContaining({
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKey: { action: 'keep' }
})
]
})
)
)
})
it('applies a speech model draft only when Settings is saved', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '语音输入' })
)
const speechModelSelector = await screen.findByRole('combobox', {
name: '当前语音模型'
})
fireEvent.change(speechModelSelector, {
target: { value: 'paraformer-bilingual-zh-en-int8' }
})
expect(selectSpeechModel).not.toHaveBeenCalled()
expect(screen.getByText('待保存')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '保存设置' })
)
await waitFor(() =>
expect(selectSpeechModel).toHaveBeenCalledWith(
'paraformer-bilingual-zh-en-int8'
)
)
expect(screen.queryByText('待保存')).not.toBeInTheDocument()
expect(screen.getByText('正在使用')).toBeInTheDocument()
expect(speechModelSelector).toHaveValue(
'paraformer-bilingual-zh-en-int8'
)
})
it('records and saves the global shortcut in General settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
await waitFor(() =>
expect(
screen.queryByText('正在读取快捷键状态…')
).not.toBeInTheDocument()
)
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(
screen.queryByText('读取快捷键设置失败,请重试')
).not.toBeInTheDocument()
const accelerator = await screen.findByLabelText('快捷键')
expect(accelerator).toHaveValue(
'CommandOrControl+Shift+Space'
)
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
altKey: true
})
expect(accelerator).toHaveValue('CommandOrControl+Alt+K')
const platformTab = screen.getByRole('tab', {
name: '平台功能'
})
fireEvent.keyDown(platformTab, { key: 'ArrowRight' })
expect(platformTab).toHaveAttribute('aria-selected', 'true')
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(updateShortcutSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'CommandOrControl+Alt+K'
})
)
expect(
await screen.findByText('已注册:CommandOrControl+Alt+K')
).toBeInTheDocument()
})
it('persists Remote Projects from its own tab and notifies its parent', async () => {
const onRemoteProjectsEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目'
})
expect(remoteProjectsSwitch).not.toBeChecked()
expect(
await screen.findByText('GoodBuddy Agent 包')
).toBeInTheDocument()
expect(
screen.getByText('当前 1 / 2 个平台与架构目标可用。')
).toBeInTheDocument()
expect(screen.getByText('Linux x64')).toBeInTheDocument()
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false)
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)
)
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true)
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
const arm64Card = screen
.getByText('Linux arm64')
.closest('[role="listitem"]')
expect(x64Card).not.toBeNull()
expect(arm64Card).not.toBeNull()
downloadAgentPackage.mockResolvedValueOnce({
...agentPackageInventory,
entries: agentPackageInventory.entries.map((entry) =>
entry.architecture === 'x64'
? {
...entry,
version: '0.11.4',
updateAvailable: false
}
: entry
)
})
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '更新到 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
expect(
await within(x64Card as HTMLElement).findByText('已是最新')
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).queryByRole('button', {
name: /更新到/u
})
).not.toBeInTheDocument()
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
)
await waitFor(() =>
expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
fireEvent.click(
within(arm64Card as HTMLElement).getByRole('button', {
name: '下载 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')
)
fireEvent.click(
screen.getByRole('button', { name: '导入离线包' })
)
await waitFor(() =>
expect(importAgentPackage).toHaveBeenCalledOnce()
)
fireEvent.click(remoteProjectsSwitch)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: true
})
)
expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true)
expect(remoteProjectsSwitch).toBeChecked()
})
it('shows Agent package loading and recovers from a refresh error', async () => {
let resolveInventory:
| ((inventory: AgentPackageInventory) => void)
| undefined
getAgentPackageInventory.mockReturnValueOnce(
new Promise<AgentPackageInventory>((resolve) => {
resolveInventory = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByText(
'正在校验本地 Agent 包并检查在线版本…'
)
).toBeInTheDocument()
await act(async () => {
resolveInventory?.(agentPackageInventory)
await Promise.resolve()
})
expect(
await screen.findByText('当前 1 / 2 个平台与架构目标可用。')
).toBeInTheDocument()
getAgentPackageInventory.mockRejectedValueOnce(
new Error('inventory unavailable')
)
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await screen.findByText('inventory unavailable')
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(
screen.queryByText('inventory unavailable')
).not.toBeInTheDocument()
)
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
})
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
getAgentPackageInventory.mockResolvedValueOnce({
...agentPackageInventory,
catalog: {
state: 'unavailable',
checkedAt: '2026-08-24T00:00:00.000Z',
error: 'network unavailable'
},
entries: agentPackageInventory.entries.map((entry) => ({
...entry,
latestVersion: null,
updateAvailable: false
}))
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByText(/network unavailable/u)
).toBeInTheDocument()
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
})
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
).toBeInTheDocument()
})
it('shows the SSH Hosts category and its local capability boundary only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
expect(
screen.getByText(
'管理 SSH 主机、固定主机密钥并验证远端执行环境;本机配置的 Skills 和 MCP 不会应用于远程主机'
)
).toBeInTheDocument()
})
it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => {
const commonProps = {
...heartbeatSettingsProps,
initialCategory: 'ssh-hosts' as const,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
await screen.findByText('尚未配置 SSH 主机')
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByRole('switch', {
name: '远程项目'
})
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
platform: 'darwin',
displayAccelerator: 'Command+Shift+Space'
}
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true
})
expect(accelerator).toHaveValue('Control+K')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
metaKey: true
})
expect(accelerator).toHaveValue('Control+Command+K')
})
it('preserves runtime drafts across navigation and protects them on close', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const workspace = await screen.findByLabelText('默认工作区目录')
fireEvent.change(workspace, {
target: { value: 'C:\\Unsaved workspace' }
})
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('tab', { name: '外观' })
).toHaveAttribute('aria-selected', 'true')
const content = screen.getByRole('tabpanel')
content.scrollTop = 320
const closeButton = screen.getByRole('button', {
name: '关闭设置'
})
fireEvent.click(closeButton)
expect(onClose).not.toHaveBeenCalled()
expect(
screen.getByRole('alert')
).toHaveTextContent('当前设置有未保存更改')
expect(
screen.getByRole('button', { name: '继续编辑' })
).toHaveFocus()
expect(content).toHaveProperty('scrollTop', 0)
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '关闭设置' })
).toHaveFocus()
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(await screen.findByLabelText('默认工作区目录')).toHaveValue(
'C:\\Unsaved workspace'
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(
screen.getByRole('button', { name: '放弃更改并关闭' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(onClose).toHaveBeenCalledOnce()
expect(closeButton).not.toHaveFocus()
})
it('routes external leave requests through the existing dirty confirmation', async () => {
let requestLeave: SettingsLeaveRequester | undefined
const proceed = vi.fn()
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onLeaveRequestReady={(requester) => {
requestLeave = requester
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Pending external navigation' }
})
act(() => requestLeave?.(proceed))
expect(proceed).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(proceed).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('protects unsaved SSH host drafts across settings navigation', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="ssh-hosts"
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
)
fireEvent.change(screen.getByLabelText('主机名称'), {
target: { value: '尚未保存的主机' }
})
const appearanceTab = screen.getByRole('tab', { name: '外观' })
fireEvent.click(appearanceTab)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
expect(screen.getByLabelText('主机名称')).toHaveValue(
'尚未保存的主机'
)
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(appearanceTab)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并切换' })
)
expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
await waitFor(() => expect(appearanceTab).toHaveFocus())
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
await screen.findByText('尚未配置 SSH 主机')
expect(screen.queryByLabelText('主机名称')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
error: 'conflict',
snapshot: shortcutSettingsSnapshot
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.change(accelerator, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
expect(
await screen.findByText(/已被其他应用占用/u)
).toBeInTheDocument()
expect(accelerator).toHaveValue('Control+Alt+K')
})
it('keeps a speech model draft when saving the selection fails', async () => {
selectSpeechModel.mockRejectedValueOnce(
new Error('语音模型切换失败')
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '语音输入' })
)
const speechModelSelector = await screen.findByRole('combobox', {
name: '当前语音模型'
})
fireEvent.change(speechModelSelector, {
target: { value: 'paraformer-bilingual-zh-en-int8' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存设置' })
)
expect(
await screen.findByText('语音模型切换失败')
).toBeInTheDocument()
expect(screen.getByText('待保存')).toBeInTheDocument()
expect(speechModelSelector).toHaveValue(
'paraformer-bilingual-zh-en-int8'
)
})
it('uses one first-level heading for the settings page', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
expect(
screen.getAllByRole('heading', { level: 1 })
).toHaveLength(1)
expect(
screen.getByRole('heading', { level: 1, name: '设置中心' })
).toBeInTheDocument()
})
it('keeps local progress but does not duplicate clear-data success', async () => {
let finishClear: (() => void) | undefined
const onClearLocalData = vi.fn(
() =>
new Promise<void>((resolve) => {
finishClear = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={onClearLocalData}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
fireEvent.click(
screen.getByRole('button', { name: '清除本地数据' })
)
fireEvent.click(
screen.getByRole('button', { name: '清除本地数据' })
)
expect(
screen.getByRole('button', { name: '正在清除…' })
).toBeDisabled()
await act(async () => finishClear?.())
await waitFor(() =>
expect(
screen.queryByText('本地数据已清除')
).not.toBeInTheDocument()
)
})
it('supports keyboard navigation between settings tabs', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
expect(runtimeTab).toHaveAttribute('tabindex', '0')
runtimeTab.focus()
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
const sshHostsTab = screen.getByRole('tab', {
name: '主机与远程执行'
})
expect(sshHostsTab).toHaveFocus()
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true')
expect(sshHostsTab).toHaveAttribute('tabindex', '0')
expect(runtimeTab).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(sshHostsTab, { key: 'End' })
const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
expect(aboutTab).toHaveFocus()
expect(aboutTab).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('tabpanel')
).toHaveAttribute('aria-labelledby', 'settings-tab-about')
fireEvent.keyDown(aboutTab, { key: 'Home' })
expect(
screen.getByRole('tab', { name: '外观' })
).toHaveFocus()
})
it('explains automatic Execute authorization and the deny-all policy', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
const policy = await screen.findByLabelText(
'直连模型工具安全策略'
)
expect(policy).toHaveValue('always')
expect(
within(policy).getByRole('option', {
name: 'Execute 自动授权已启用的工具'
})
).toBeInTheDocument()
expect(
within(policy).getByRole('option', {
name: '禁止所有工具执行'
})
).toBeInTheDocument()
expect(
screen.getByText(/选择 Execute 即授权当前交互运行自动调用这些工具/)
).toBeInTheDocument()
expect(
screen.getByText(/禁止策略会拒绝所有工具调用/)
).toBeInTheDocument()
fireEvent.change(policy, { target: { value: 'policy' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({ toolApproval: 'policy' })
)
)
})
it('saves the accessible Subagent smart routing switch', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
expect(
screen.queryByRole('switch', {
name: '启用 Subagent 智能路由'
})
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '角色与提示词' }))
const smartRouting = await screen.findByRole('switch', {
name: '启用 Subagent 智能路由'
})
expect(smartRouting).not.toBeChecked()
expect(screen.getByText(/仅在 Ask 模式/)).not.toBeVisible()
expect(smartRouting).toHaveAccessibleDescription(/仅在 Ask 模式/)
const routingHelp = screen.getByRole('button', { name: 'Subagent 智能路由' })
expect(routingHelp.closest('label, summary')).toBeNull()
fireEvent.click(routingHelp)
expect(smartRouting).not.toBeChecked()
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
'自动选择 1 位专家'
)
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
'继承当前模式和已启用工具'
)
fireEvent.click(smartRouting)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
subagentSmartRoutingEnabled: true
})
)
)
})
it('offers roles only model connections that have been saved', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '尚未保存的角色模型' }
})
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://role-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'role-model' }
})
fireEvent.click(screen.getByRole('tab', { name: '角色与提示词' }))
const roleModel = await screen.findByLabelText('角色模型连接')
expect(
within(roleModel).queryByRole('option', {
name: '尚未保存的角色模型'
})
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(
within(roleModel).getByRole('option', {
name: '尚未保存的角色模型'
})
).toBeInTheDocument()
)
})
it('places Runtime detection details in the semantic overview card', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(detectAgentRuntimes).toHaveBeenCalledOnce()
const runtimeLabel = await screen.findByText('Runtime:', {
selector: 'dt'
})
const overview = runtimeLabel.closest<HTMLElement>(
'.runtime-overview'
)
if (!overview) {
throw new Error('Missing OpenCode Runtime overview')
}
expect(
within(overview).getByText('GoodBuddy 内置 OpenCode')
).toBeInTheDocument()
expect(
within(overview).getByText('模型配置:', { selector: 'dt' })
).toBeInTheDocument()
const status = within(overview).getByText('已就绪')
expect(status.tagName).toBe('DD')
expect(status).toHaveAttribute('aria-live', 'polite')
expect(
within(overview).getByText('C:\\Tools\\opencode.exe')
).toHaveClass('runtime-overview__path')
expect(within(overview).getByText('1.2.3')).toBeInTheDocument()
expect(
within(overview).getByText('已自动检测到 OpenCode 1.2.3')
).toBeInTheDocument()
expect(
screen.queryByText(
'已就绪 · C:\\Tools\\opencode.exe · 1.2.3 · 通过 PATH 检测'
)
).not.toBeInTheDocument()
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
const continueOverview = screen
.getByText('GoodBuddy 内置 Continue')
.closest<HTMLElement>('.runtime-overview')
if (!continueOverview) {
throw new Error('Missing Continue Runtime overview')
}
expect(
within(continueOverview).getByText('已就绪')
).toBeInTheDocument()
expect(
within(continueOverview).getByText('1.5.47')
).toBeInTheDocument()
expect(
within(continueOverview).getByText('bundled://continue')
).toHaveClass('runtime-overview__path')
expect(
screen.queryByRole('button', { name: '重新检测 Continue' })
).not.toBeVisible()
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByText(
'普通使用无需修改。这里可以切换模型来源、复用 Runtime 自有配置,或覆盖内置程序。'
)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '重新检测 Continue' })
)
await waitFor(() =>
expect(detectAgentRuntimes).toHaveBeenCalledTimes(2)
)
})
it('configures DeepSeek Harness with an OpenAI-compatible gateway', async () => {
const harnessProfileId =
'00000000-0000-4000-8000-000000000051'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
runtimeSettings.modelProfiles[0]!,
{
...runtimeSettings.modelProfiles[0]!,
id: harnessProfileId,
name: 'Compatible Gateway',
baseUrl: 'https://gateway.example/openai/v1',
modelName: 'qwen-plus',
protocol: 'openai-chat-completions'
}
],
deepseekHarnessModelSource: {
kind: 'profile',
profileId: harnessProfileId
}
} as unknown as RuntimeSettings)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
await screen.findByRole('button', {
name: 'DeepSeek Harness(预览)'
})
)
expect(
screen.getByText('开发者预览 · OpenAI 兼容')
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: 'DSH 插件市场' })
).toBeInTheDocument()
expect(
await screen.findByText(
/第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行/
)
).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: '启用 DSH 插件市场'
})
).toBeChecked()
const harnessOverview = screen
.getByText('GoodBuddy 内置 DeepSeek Harness')
.closest<HTMLElement>('.runtime-overview')
if (!harnessOverview) {
throw new Error('Missing DeepSeek Harness overview')
}
expect(
within(harnessOverview).getByText('已就绪')
).toHaveAttribute('aria-live', 'polite')
expect(
within(harnessOverview).getByText(
'bundled://deepseek-harness'
)
).toHaveClass('runtime-overview__path')
expect(
within(harnessOverview).getByText('0.1.7-rc.2')
).toBeInTheDocument()
expect(
within(harnessOverview).getByText(
'内置 DeepSeek Harness 0.1.7-rc.2 已就绪'
)
).toBeInTheDocument()
expect(
screen.queryByText(/自定义 Harness Host/)
).not.toBeInTheDocument()
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByText(
/已启用的市场插件由 GoodBuddy 托管并随 Host 启动/
)
).toBeInTheDocument()
expect(
screen.queryByRole('button', {
name: /选择.*Harness/
})
).not.toBeInTheDocument()
const source = screen.getByLabelText(
'DeepSeek Harness OpenAI 兼容模型连接'
)
// Only the global execution-mode choice uses radios; Harness has no model-source radios.
expect(harnessModelSourceRadios()).toHaveLength(0)
expect(
screen.queryByText('使用管理员预置模型连接')
).not.toBeInTheDocument()
expect(
within(source).getByRole('option', { name: '默认模型(不兼容)' })
).toBeDisabled()
expect(
within(source).getByRole('option', {
name: 'Compatible Gateway'
})
).not.toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
deepseekHarnessModelSource: {
kind: 'profile',
profileId: harnessProfileId
}
})
)
)
})
it.each(['environment', 'profile'] as const)(
'uses the Main-projected Harness %s source instead of guessing from environment credentials',
async (sourceKind) => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelBaseUrl: 'https://gateway.example/openai/v1',
modelName: 'qwen-plus',
modelProtocol: 'openai-chat-completions',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://gateway.example/openai/v1',
modelName: 'qwen-plus',
protocol: 'openai-chat-completions',
credentialSource: 'environment'
}
],
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: sourceKind === 'environment'
? { source: 'environment', name: '管理员预置模型', modelName: 'actual-administrator-model' }
: { source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'qwen-plus' },
configured: {
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
protocol: 'openai-chat-completions',
credentialSource: 'environment'
}
],
opencodeBaseUrl: '',
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
workspacePath: 'C:\\Workspace',
opencodeModelSource: runtimeSettings.opencodeModelSource,
continueModelSource: runtimeSettings.continueModelSource,
deepseekHarnessModelSource: { kind: 'platform' }
}
} as unknown as RuntimeSettings)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
await screen.findByRole('button', {
name: 'DeepSeek Harness(预览)'
})
)
expect(harnessModelSourceRadios()).toHaveLength(0)
if (sourceKind === 'environment') {
expect(screen.getByText('此选项实际使用管理员预置:actual-administrator-model。')).toBeInTheDocument()
expect(screen.queryByText(/已保存配置中的实际回退/)).not.toBeInTheDocument()
} else {
expect(screen.getByText(/未配置完整的管理员预置;已保存配置中的实际回退:默认模型 · qwen-plus。/)).toBeInTheDocument()
expect(screen.queryByText(/此选项实际使用管理员预置/)).not.toBeInTheDocument()
}
const source = screen.getByLabelText(
'DeepSeek Harness OpenAI 兼容模型连接'
)
expect(source).toHaveValue('platform')
expect(screen.queryByText(/当前环境生效模型/)).not.toBeInTheDocument()
expect(
within(source).getByRole('option', {
name: '管理员环境优先,回退 GoodBuddy 兼容连接'
})
).toHaveValue('platform')
expect(
within(source).queryByRole('option', { name: /自有配置/ })
).not.toBeInTheDocument()
fireEvent.change(source, {
target: { value: runtimeSettings.modelProfiles[0]!.id }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
deepseekHarnessModelSource: { kind: 'profile', profileId: runtimeSettings.defaultModelProfileId }
})
)
)
})
it.each([
['zh-CN', true],
['zh-CN', false],
['en-US', true],
['en-US', false]
] as const)('shows the saved Harness fallback in %s (compatible default: %s)', async (locale, compatibleDefault) => {
await changeUiLocale(locale)
const firstCompatible = {
...runtimeSettings.modelProfiles[0]!,
id: '00000000-0000-4000-8000-000000000052',
name: 'First compatible',
modelName: 'first-model',
protocol: 'openai-chat-completions' as const
}
const preferred = {
...firstCompatible,
id: modelProfileId,
name: 'Preferred',
modelName: 'preferred-model',
protocol: compatibleDefault
? 'openai-chat-completions' as const
: 'openai-images-generations' as const
}
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [firstCompatible, preferred],
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: {
source: 'profile',
profileId: compatibleDefault ? preferred.id : firstCompatible.id,
name: compatibleDefault ? preferred.name : firstCompatible.name,
modelName: compatibleDefault ? preferred.modelName : firstCompatible.modelName
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', {
name: locale === 'zh-CN' ? 'DeepSeek Harness(预览)' : 'DeepSeek Harness (Preview)'
}))
const source = screen.getByLabelText(locale === 'zh-CN'
? 'DeepSeek Harness OpenAI 兼容模型连接'
: 'DeepSeek Harness OpenAI-compatible model connection')
expect(source).toHaveValue('platform')
expect(within(source).getByRole('option', {
name: locale === 'zh-CN'
? '管理员环境优先,回退 GoodBuddy 兼容连接'
: 'Administrator environment, then GoodBuddy fallback'
})).not.toBeDisabled()
const resolvedName = compatibleDefault ? 'Preferred · preferred-model' : 'First compatible · first-model'
expect(screen.getByText(locale === 'zh-CN'
? `未配置完整的管理员预置;已保存配置中的实际回退:${resolvedName}。`
: `No complete administrator preset; actual fallback in saved settings: ${resolvedName}.`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: locale === 'zh-CN'
? 'OpenAI 兼容模型连接' : 'OpenAI-compatible model connection' }))
expect(source).toHaveAccessibleDescription(/Harness/)
})
it.each([true, false])('keeps the Harness platform option usable without compatible saved profiles (administrator available: %s)', async (available) => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings, deepseekHarnessModelSource: { kind: 'default' },
deepseekHarnessPlatformModel: available
? { source: 'environment', name: '管理员预置模型', modelName: 'administrator-model' }
: { source: 'unavailable' }
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
const source = screen.getByLabelText('DeepSeek Harness OpenAI 兼容模型连接')
expect(source).not.toBeDisabled()
expect(within(source).getByRole('option', { name: '跟随全局默认模型' })).toBeDisabled()
fireEvent.change(source, { target: { value: 'platform' } })
expect(source).toHaveValue('platform')
expect(screen.getByText(available
? '此选项实际使用管理员预置:administrator-model。'
: '没有可用的管理员预置或兼容的 GoodBuddy 连接。')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({ deepseekHarnessModelSource: { kind: 'platform' } })
))
})
it('keeps the actual saved Harness fallback separate from the unsaved draft preview', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [{
...runtimeSettings.modelProfiles[0]!,
protocol: 'openai-chat-completions', modelName: 'saved-model'
}],
deepseekHarnessPlatformModel: {
source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'saved-model'
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()} onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.change(screen.getByLabelText('名称'), { target: { value: 'Draft connection' } })
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
expect(screen.getByText(/已保存配置中的实际回退:默认模型 · saved-model。/)).toBeInTheDocument()
expect(screen.getByText(/草稿兼容候选(未保存):Draft connection · saved-model。/)).toBeInTheDocument()
expect(updateRuntime).not.toHaveBeenCalled()
})
it('selects, warns about, clears, and saves a custom binary', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
fireEvent.click(screen.getByText('高级设置'))
const input = await screen.findByLabelText('Continue 可执行文件路径')
const field = input.closest('label')
if (!field) {
throw new Error('Missing Continue binary field')
}
fireEvent.click(within(field).getByRole('button', { name: '选择' }))
await waitFor(() =>
expect(selectRuntimeFile).toHaveBeenCalledWith('continueBinary')
)
await waitFor(() => expect(input).toHaveValue('C:\\Tools\\cn.exe'))
expect(
screen.getByText(/自定义 Continue 可执行文件将以当前用户权限运行/)
).toBeInTheDocument()
expect(
screen.getByText(/Ask 仅可调用当前 Runtime 允许的只读能力/)
).toBeInTheDocument()
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
expect(input).toHaveValue('')
fireEvent.change(input, { target: { value: 'C:\\Tools\\cn.exe' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
continueBinaryPath: 'C:\\Tools\\cn.exe',
continueConfigPath: '',
continueMode: 'chat',
opencodeBinaryPath: '',
opencodeConfigPath: ''
})
)
)
})
it('separates bundled Agent Runtimes and collapses low-level overrides', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
expect(
screen.queryByText(/配置可兼容的直连文本模型后即可使用/)
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'OpenCode' }))
.toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByText('默认 Runtime')).not.toBeInTheDocument()
expect(
screen.getAllByText(/GoodBuddy 内置 OpenCode/).length
).toBeGreaterThan(0)
expect(
screen.getByText(/模型配置:/).closest('.runtime-note')
).toHaveTextContent(
'跟随 GoodBuddy · 默认模型(sonnet-5)'
)
expect(screen.getByText('已就绪')).toBeInTheDocument()
expect(screen.getByText('高级设置').closest('details'))
.not.toHaveAttribute('open')
expect(
screen.queryByLabelText('OpenCode GoodBuddy 模型连接')
).not.toBeVisible()
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByText(
'普通使用无需修改。这里可以切换模型来源、复用 Runtime 自有配置,或覆盖内置程序与服务。'
)
).toBeInTheDocument()
expect(
screen.getByLabelText('OpenCode 可执行文件路径')
).toBeInTheDocument()
expect(
screen.getByLabelText('OpenCode GoodBuddy 模型连接')
).toHaveValue(modelProfileId)
expect(
screen.getByText(/留空时自动启动 GoodBuddy 内置的本机 OpenCode/)
).toBeInTheDocument()
expect(
screen.queryByRole('checkbox', {
name: /自动启动内置 OpenCode/
})
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
expect(
screen.getAllByText(/GoodBuddy 内置 Continue/).length
).toBeGreaterThan(0)
expect(
screen.getByText(/模型配置:/).closest('.runtime-note')
).toHaveTextContent(
'跟随 GoodBuddy · 默认模型(sonnet-5)'
)
expect(screen.getByText('已就绪')).toBeInTheDocument()
expect(screen.getByText('1.5.47')).toBeInTheDocument()
expect(screen.getByText('高级设置').closest('details'))
.not.toHaveAttribute('open')
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByText(
'普通使用无需修改。这里可以切换模型来源、复用 Runtime 自有配置,或覆盖内置程序。'
)
).toBeInTheDocument()
})
it('selects a native OpenCode Agent and excludes GoodBuddy assignments from inventory', async () => {
const settings = {
opencode: { defaultAgent: 'planner' },
continue: { presets: [] }
}
getRuntimeCustomizationSettings.mockResolvedValueOnce(settings)
getRuntimeNativeSnapshot.mockResolvedValueOnce({
provider: 'opencode',
available: true,
inventoryStatus: 'available',
detail: 'OpenCode 原生能力已就绪',
agents: [
{
id: 'planner',
name: 'Planner',
mode: 'primary',
native: true,
hidden: false
},
{
id: 'reviewer',
name: 'Reviewer',
mode: 'all',
native: true,
hidden: false
},
{
id: 'explorer',
name: 'Explorer',
mode: 'subagent',
native: true,
hidden: false
}
],
tools: [
{
id: 'edit',
name: 'edit',
description: 'Edit a file',
kind: 'write',
source: 'runtime',
ask: 'blocked',
execute: 'allowed'
}
],
toolsSupported: true,
commands: [],
lsp: [],
formatters: [],
mcpServers: [
{
id: 'native-mcp',
name: 'Native MCP',
status: 'connected'
}
],
skills: [
{
id: 'native-skill',
name: 'Native Skill',
source: 'runtime'
}
],
rules: [],
prompts: [],
resources: [],
resourcesSupported: true,
context: {
strategy: 'native',
manualCompact: true,
detail: '由 OpenCode 原生管理'
}
})
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
render(<RuntimeCustomizationTestHarness provider="opencode" />)
const agent = await screen.findByRole('combobox', { name: '默认 Agent' })
expect(agent).toHaveValue('planner')
expect(
screen.queryByText('OpenCode 原生能力已就绪')
).not.toBeInTheDocument()
expect(screen.getByText('能力与默认配置')).toBeInTheDocument()
expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument()
expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument()
expect(
screen.queryByText('Runtime 原生能力可用')
).not.toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', {
name: '能力清单'
})
expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11)
const agentsTab = within(inventoryTabs).getByRole('tab', {
name: /^Agents/u
})
const agentsPanel = screen.getByRole('tabpanel')
expect(agentsTab).toHaveAttribute('aria-selected', 'true')
expect(agentsTab).toHaveAttribute('aria-controls', agentsPanel.id)
expect(agentsPanel).toHaveAttribute(
'aria-labelledby',
agentsTab.id
)
expect(screen.getAllByRole('tabpanel')).toHaveLength(1)
expect(screen.getByText('Explorer')).toBeInTheDocument()
fireEvent.keyDown(agentsTab, { key: 'ArrowRight' })
const toolsTab = within(inventoryTabs).getByRole('tab', {
name: /^Tools/u
})
expect(toolsTab).toHaveAttribute('aria-selected', 'true')
expect(toolsTab).toHaveFocus()
expect(screen.getByText('edit')).toBeInTheDocument()
expect(
screen.getByText(
'Edit a file · 文件修改 · Runtime 内置 · Ask:不可用 · Execute:可用'
)
).toBeInTheDocument()
const commandsTab = within(inventoryTabs).getByRole('tab', {
name: /Commands/u
})
fireEvent.click(commandsTab)
expect(commandsTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('未发现')).toBeInTheDocument()
expect(
screen.getByText(
'当前 Runtime 未报告此类别中的可用能力。'
)
).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /^MCP/u
})
)
expect(screen.getByText('Native MCP')).toBeInTheDocument()
expect(screen.queryByText('Explorer')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /^Skills/u
})
)
expect(screen.getByText('Native Skill')).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument()
fireEvent.change(agent, { target: { value: 'reviewer' } })
expect(
screen.getByText(/有未保存的 Runtime 定制更改/u)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith({
opencode: { defaultAgent: 'reviewer' },
continue: { presets: [] }
})
)
await waitFor(() =>
expect(
screen.queryByText(/有未保存的 Runtime 定制更改/u)
).not.toBeInTheDocument()
)
})
it.each([
['opencode', 'OpenCode 原生能力已就绪'],
[
'continue',
'内置 Continue CLI 已就绪;Rules 与 Prompts 来自原始静态配置;MCP Prompt 仅在 MCPService 运行并连接后可发现,非运行快照不会启动服务器。Continue MCPService 不提供 Resources。'
],
[
'deepseek-harness',
'显示 DeepSeek Harness Host 与插件原生能力;GoodBuddy 分配的 Skill 和 MCP 不在此清单中。'
]
] as const)(
'hides the redundant ready detail for %s',
async (provider, detail) => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider
})
getRuntimeNativeSnapshot.mockResolvedValueOnce({
...fallbackSnapshot,
detail
})
render(<RuntimeCustomizationSection provider={provider} />)
await screen.findByRole('tablist', { name: '能力清单' })
expect(screen.queryByText(detail)).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider: 'opencode'
})
getRuntimeNativeSnapshot.mockResolvedValueOnce({
...fallbackSnapshot,
provider: 'opencode',
available: true,
inventoryStatus: 'connection-only',
detail: 'External OpenCode connection only',
toolsSupported: false
})
render(<RuntimeCustomizationSection provider="opencode" />)
expect(await screen.findByRole('status')).toHaveTextContent(
'External OpenCode connection only'
)
expect(
screen.queryByText('仅确认 Runtime 连接')
).not.toBeInTheDocument()
})
it('uses a guided empty state without a second save action', async () => {
render(<RuntimeCustomizationSection provider="continue" />)
expect(
await screen.findByText('还没有 Continue 预设')
).toBeInTheDocument()
expect(
screen.getByText('使用上方“添加预设”创建 Rules 与 Prompt 模板。')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '添加预设' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '保存 Runtime 定制' })
).not.toBeInTheDocument()
})
it('edits Continue presets, Rules, Prompt metadata, and merged native Rules', async () => {
const presetId = '00000000-0000-4000-8000-000000000701'
const ruleId = '00000000-0000-4000-8000-000000000702'
const promptId = '00000000-0000-4000-8000-000000000703'
const settings = {
opencode: {},
continue: {
defaultPresetId: presetId,
presets: [
{
id: presetId,
name: '代码审查',
rules: [
{
id: ruleId,
name: '安全优先',
content: '先检查安全边界。',
enabled: true
}
],
prompts: [
{
id: promptId,
name: '审查变更',
prompt: '请审查当前变更。'
}
]
}
]
}
}
getRuntimeCustomizationSettings.mockResolvedValueOnce(settings)
getRuntimeNativeSnapshot.mockResolvedValueOnce({
provider: 'continue',
available: true,
inventoryStatus: 'available',
detail: 'Continue 原生能力已就绪',
agents: [],
tools: [],
toolsSupported: false,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [
{
id: 'configuration-rule-1',
name: 'Native Rule',
content: '遵循原生规则。',
source: 'configuration'
}
],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: 'goodbuddy-summary',
manualCompact: true,
detail: '由 GoodBuddy 摘要压缩'
}
})
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
render(<RuntimeCustomizationTestHarness provider="continue" />)
expect(
await screen.findByLabelText('默认配置预设')
).toHaveValue(presetId)
expect(
screen.getByText('查看最终合并的 2 条 Rule')
).toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', {
name: '能力清单'
})
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /^Tools/u
})
)
expect(
screen.getByText('当前 Runtime 不支持静态发现 Tools')
).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /^Skills/u
})
)
expect(screen.getByText('未发现')).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /^Resources/u
})
)
expect(
screen.getByText('当前 Runtime 不支持')
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('安全优先 内容'), {
target: { value: '先检查权限和数据边界。' }
})
fireEvent.change(screen.getByLabelText('审查变更 说明'), {
target: { value: '用于提交前检查' }
})
fireEvent.change(screen.getByLabelText('审查变更 内容'), {
target: { value: '请审查当前提交。' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({
rules: [
expect.objectContaining({
content: '先检查权限和数据边界。'
})
],
prompts: [
expect.objectContaining({
description: '用于提交前检查',
prompt: '请审查当前提交。'
})
]
})
]
})
})
)
)
})
it('preserves Continue drafts across inventory refreshes and failed-save retries', async () => {
const presetId = '00000000-0000-4000-8000-000000000704'
getRuntimeCustomizationSettings.mockResolvedValueOnce({
opencode: {},
continue: {
presets: [
{
id: presetId,
name: 'Draft preset',
rules: [],
prompts: []
}
]
}
})
updateRuntimeCustomizationSettings
.mockRejectedValueOnce(new Error('保存失败'))
.mockImplementationOnce(async (input) => input)
render(<RuntimeCustomizationTestHarness provider="continue" />)
const nameInput = await screen.findByLabelText('预设名称')
fireEvent.change(nameInput, {
target: { value: 'Unsaved draft' }
})
fireEvent.click(
screen.getByRole('button', {
name: '刷新能力清单'
})
)
await waitFor(() =>
expect(getRuntimeNativeSnapshot).toHaveBeenCalledTimes(2)
)
expect(nameInput).toHaveValue('Unsaved draft')
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'保存失败'
)
fireEvent.click(
screen.getByRole('button', { name: '重试' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledTimes(2)
)
expect(
updateRuntimeCustomizationSettings
).toHaveBeenLastCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({ name: 'Unsaved draft' })
]
})
})
)
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce()
})
it('saves Runtime customization with the page action and protects unsaved drafts', async () => {
const presetId = '00000000-0000-4000-8000-000000000705'
const customization = {
opencode: {},
continue: {
presets: [
{
id: presetId,
name: 'Saved preset',
rules: [],
prompts: []
}
]
}
}
getRuntimeCustomizationSettings
.mockResolvedValueOnce(customization)
.mockResolvedValueOnce(customization)
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }))
const presetName = await screen.findByLabelText('预设名称')
fireEvent.change(presetName, {
target: { value: 'Unsaved preset' }
})
expect(
screen.getByText(/有未保存的 Runtime 定制更改/u)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(presetName).toHaveValue('Unsaved preset')
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).not.toHaveBeenCalled()
expect(
await screen.findByText(
'请先保存或撤销 Runtime 定制更改,再关闭设置中心。'
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({ name: 'Unsaved preset' })
]
})
})
)
)
expect(updateRuntime).toHaveBeenCalled()
await waitFor(() =>
expect(
screen.queryByText(/有未保存的 Runtime 定制更改/u)
).not.toBeInTheDocument()
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('opens only saved Runtime-owned config files or fixed config directories', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
continueModelSource: { kind: 'platform' },
continueConfigPath: 'C:\\Users\\test\\.continue\\config.yaml'
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByRole('radio', {
name: /使用 Continue 自有配置/
})
).toBeChecked()
expect(
screen.getByLabelText('Continue 配置文件路径')
).toHaveValue('C:\\Users\\test\\.continue\\config.yaml')
fireEvent.click(
screen.getByRole('button', { name: '打开配置文件' })
)
fireEvent.click(
screen.getByRole('button', { name: '在文件夹中显示' })
)
await waitFor(() =>
expect(openRuntimeConfig).toHaveBeenNthCalledWith(1, {
runtime: 'continue',
action: 'open-file'
})
)
expect(openRuntimeConfig).toHaveBeenNthCalledWith(2, {
runtime: 'continue',
action: 'show-file'
})
fireEvent.click(screen.getByRole('button', { name: 'OpenCode' }))
fireEvent.click(screen.getByText('高级设置'))
fireEvent.click(
screen.getByRole('radio', {
name: /使用 OpenCode 自有配置/
})
)
fireEvent.click(
screen.getByRole('button', {
name: '打开 OpenCode 配置目录'
})
)
await waitFor(() =>
expect(openRuntimeConfig).toHaveBeenLastCalledWith({
runtime: 'opencode',
action: 'open-directory'
})
)
})
it('saves blank OpenCode Server addresses as bundled local mode', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByText('高级设置'))
expect(screen.getByLabelText('OpenCode Server 地址')).toHaveValue('')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
opencodeBaseUrl: '',
opencodeEmbedded: true
})
)
)
})
it('adds model connections and assigns one to OpenCode', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
expect(
screen.getByLabelText('模型连接列表')
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: '编辑模型连接 默认模型'
})
).toHaveAttribute('aria-current', 'page')
fireEvent.click(
screen.getByRole('button', { name: '添加自定义' })
)
expect(screen.getAllByLabelText('名称')).toHaveLength(1)
expect(screen.getByLabelText('模型接口 URL')).toHaveValue('')
expect(screen.getByLabelText('模型接口 URL')).toHaveAttribute(
'placeholder',
'https://api.example.com/v1'
)
expect(screen.getByLabelText('模型')).toHaveValue('')
expect(screen.getByLabelText('模型')).toHaveAttribute(
'placeholder',
'model-name'
)
expect(
screen.getByLabelText('接口协议 模型连接 2')
).toHaveValue('openai-chat-completions')
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: 'OpenCode 独立模型' }
})
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://opencode-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'opencode-model' }
})
expect(
screen.getByRole('button', {
name: '编辑模型连接 OpenCode 独立模型'
})
).toHaveAttribute('aria-current', 'page')
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(
screen.getByRole('button', {
name: '编辑模型连接 默认模型'
})
)
expect(screen.getByLabelText('名称')).toHaveValue('默认模型')
fireEvent.click(
screen.getByRole('button', {
name: '编辑模型连接 OpenCode 独立模型'
})
)
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
fireEvent.click(screen.getByText('高级设置'))
const profileOption = (
await screen.findAllByRole('option', {
name: 'OpenCode 独立模型'
})
)[0]!
const sourceSelect = profileOption.closest('select')
if (!sourceSelect) {
throw new Error('Missing OpenCode model source select')
}
const sourceOptions = within(sourceSelect).getAllByRole('option')
fireEvent.change(sourceSelect, {
target: {
value: (sourceOptions.at(-1) as HTMLOptionElement).value
}
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({ name: '默认模型' }),
expect.objectContaining({ name: 'OpenCode 独立模型' })
]),
opencodeModelSource: expect.objectContaining({
kind: 'profile'
})
})
)
)
})
it('identifies the model connection when its endpoint URL is invalid', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
const endpoint = screen.getByLabelText('模型接口 URL')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
(await screen.findAllByText(
'模型连接“模型连接 2”的模型接口 URL 无效,请填写完整的 HTTP 或 HTTPS 地址(最多 2048 个字符)。'
)).length
).toBe(2)
expect(endpoint).toHaveAttribute('aria-invalid', 'true')
expect(endpoint).toHaveAccessibleDescription(
/模型连接“模型连接 2”的模型接口 URL 无效/u
)
expect(updateRuntime).not.toHaveBeenCalled()
})
it('keeps the model category title and type tabs sticky together', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const content = await screen.findByRole('tabpanel')
const header = content.querySelector('.settings-category-header')
expect(header).toHaveClass('settings-category-header--sticky')
expect(
header?.querySelector('.model-type-navigation')
).toBeInTheDocument()
expect(
header?.querySelector('.segmented-control')
).toBeInTheDocument()
})
it('keeps other category titles and sub-tabs in the sticky header', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const platformHeader = screen
.getByRole('heading', { level: 2, name: '平台功能' })
.closest('.settings-category-header')
expect(platformHeader).toHaveClass('settings-category-header--sticky')
expect(
platformHeader?.querySelector('.platform-features-tabs')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
const capabilitiesHeader = screen
.getByRole('heading', { level: 2, name: '能力与工具' })
.closest('.settings-category-header')
expect(capabilitiesHeader).toHaveClass(
'settings-category-header--sticky'
)
expect(
capabilitiesHeader?.querySelector('.capabilities-settings__tabs')
).toBeInTheDocument()
})
it('copies a model connection and schedules its saved API key for copying', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '复制模型连接 默认模型' })
)
expect(screen.getByDisplayValue('默认模型(副本 1)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: '默认模型(副本 1)',
apiKey: {
action: 'copy',
sourceProfileId: modelProfileId
}
})
])
})
)
)
})
it('orders model protocols by the preferred connection flow', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const protocol = await screen.findByLabelText('接口协议 默认模型')
expect(
within(protocol)
.getAllByRole('option')
.map((option) => (option as HTMLOptionElement).value)
).toEqual([
'openai-chat-completions',
'openai-responses',
'anthropic-messages',
'openai-images-generations'
])
})
it('assigns an OpenAI Responses connection to both Agent Runtimes', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.change(
screen.getByLabelText('接口协议 默认模型'),
{
target: { value: 'openai-responses' }
}
)
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
for (const runtimeName of ['OpenCode', 'Continue']) {
fireEvent.click(screen.getByRole('button', { name: runtimeName }))
fireEvent.click(screen.getByText('高级设置'))
const option = await screen.findByRole('option', {
name: '默认模型'
})
expect(option).not.toBeDisabled()
const select = option.closest('select')
if (!select) {
throw new Error('Missing Agent Runtime model source select')
}
fireEvent.change(select, {
target: { value: modelProfileId }
})
}
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
id: modelProfileId,
protocol: 'openai-responses'
})
],
opencodeModelSource: {
kind: 'profile',
profileId: modelProfileId
},
continueModelSource: {
kind: 'profile',
profileId: modelProfileId
}
})
)
)
})
it('saves the image input capability for a model connection', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const imageInput = await screen.findByRole('switch', {
name: '支持图像输入'
})
expect(imageInput).not.toBeChecked()
fireEvent.click(imageInput)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
id: modelProfileId,
supportsImageInput: true
})
]
})
)
)
})
it('edits and saves bounded custom model request headers and body', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
const body = screen.getByRole('textbox', { name: '自定义请求体' })
expect(headers.closest('details')).not.toHaveAttribute('open')
expect(body.closest('details')).not.toHaveAttribute('open')
expect(headers).toHaveValue('{}')
expect(body).toHaveValue('{}')
expect(headers).toHaveAttribute('maxlength')
expect(body).toHaveAttribute('maxlength')
const bodyHelp = screen.getByRole('button', { name: '自定义请求体' })
expect(bodyHelp.closest('label, summary')).toBeNull()
expect(
document.getElementById(
`model-request-body-help-${modelProfileId}`
)
).toHaveAttribute('hidden')
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
fireEvent.click(bodyHelp)
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
expect(
screen.getAllByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
).toHaveLength(1)
fireEvent.change(headers, {
target: {
value: JSON.stringify({ 'x-tenant-id': 'tenant-a' })
}
})
fireEvent.change(body, {
target: {
value: JSON.stringify({
temperature: 0.2,
metadata: { source: 'goodbuddy' }
})
}
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
id: modelProfileId,
requestHeaders: {
'x-tenant-id': 'tenant-a'
},
requestBody: {
temperature: 0.2,
metadata: { source: 'goodbuddy' }
}
})
]
})
)
)
})
it('keeps invalid custom request JSON in the editor and blocks saving', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
fireEvent.change(headers, {
target: { value: '{"Authorization":"replacement"}' }
})
expect(headers).toHaveAttribute('aria-invalid', 'true')
expect(
screen.getByText(/不能包含认证或传输保留字段/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
await screen.findByText(/自定义请求头无效/)
).toBeInTheDocument()
expect(updateRuntime).not.toHaveBeenCalled()
expect(headers).toHaveValue('{"Authorization":"replacement"}')
})
it.each([false, true])('resolves a fresh persisted store after adding a text connection (default: %s)', async (makeDefault) => {
const { mkdtemp, rm } = await import('node:fs/promises')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
// Load main-process code only in this integration test, outside the web TS project.
const storeModule = '../../main/runtime-settings-store'
const selectionModule = '../../main/agent/runtime-selection'
const { RuntimeSettingsStore } = await import(storeModule)
const { applyRuntimeSelection } = await import(selectionModule)
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-fresh-selection-'))
const cipher = {
isAvailable: () => false,
encrypt: () => { throw new Error('No credentials in this test') },
decrypt: () => { throw new Error('No credentials in this test') }
}
const path = join(root, 'runtime-settings.json')
const store = new RuntimeSettingsStore(path, cipher, {})
try {
const fresh = await store.getPublicSettings()
expect(fresh.opencodeModelSource).toEqual({ kind: 'default' })
expect(fresh.modelName).toBe('qwen3')
getRuntime.mockResolvedValueOnce(fresh)
updateRuntime.mockImplementationOnce((input) => store.update(input))
render(<SettingsPanel {...heartbeatSettingsProps} open
onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('qwen3')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'http://127.0.0.1:12345/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'isolated-new-model' } })
if (makeDefault) fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(updateRuntime).toHaveBeenCalled())
await updateRuntime.mock.results[0]!.value
const reloaded = new RuntimeSettingsStore(path, cipher, {})
const selected = applyRuntimeSelection(await reloaded.getResolvedSettings(), { provider: 'opencode' })
expect(selected.settings.opencodeEmbedded).toBe(true)
expect(selected.settings.opencodeModelProfile).toMatchObject({
modelName: makeDefault ? 'isolated-new-model' : 'qwen3',
baseUrl: makeDefault ? 'http://127.0.0.1:12345/v1' : 'http://127.0.0.1:11434/v1',
authentication: 'none'
})
expect((await reloaded.getPublicSettings()).opencodeModelSource).toEqual({ kind: 'default' })
} finally {
cleanup()
await rm(root, { recursive: true, force: true })
}
})
it('keeps saved Runtime sources valid when defaulting a new text profile', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://runtime-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'runtime-model' }
})
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '新的默认文本模型' }
})
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(updateRuntime).toHaveBeenCalled())
const savedInput = updateRuntime.mock.lastCall?.[0]
const nextDefault = savedInput?.modelProfiles?.find(
(profile) => profile.name === '新的默认文本模型'
)
expect(nextDefault).toBeDefined()
expect(savedInput).toEqual(
expect.objectContaining({
defaultModelProfileId: nextDefault?.id,
opencodeModelSource: {
kind: 'profile',
profileId: runtimeSettings.defaultModelProfileId
},
continueModelSource: {
kind: 'profile',
profileId: runtimeSettings.defaultModelProfileId
}
})
)
})
it('preserves explicit profile and platform overrides when defaulting', async () => {
const explicitProfileId = '00000000-0000-4000-8000-000000000002'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
runtimeSettings.modelProfiles[0]!,
{
...runtimeSettings.modelProfiles[0]!,
id: explicitProfileId,
name: 'Runtime 专用模型',
modelName: 'runtime-model'
}
],
opencodeModelSource: {
kind: 'profile',
profileId: explicitProfileId
},
continueModelSource: { kind: 'platform' }
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://runtime-default.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'runtime-default' }
})
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
opencodeModelSource: {
kind: 'profile',
profileId: explicitProfileId
},
continueModelSource: { kind: 'platform' }
})
)
)
})
it('repoints a removed Runtime profile to a compatible text profile', async () => {
const removedProfileId = '00000000-0000-4000-8000-000000000003'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
runtimeSettings.modelProfiles[0]!,
{
...runtimeSettings.modelProfiles[0]!,
id: removedProfileId,
name: '待删除 Runtime 模型',
modelName: 'runtime-model'
}
],
opencodeModelSource: {
kind: 'profile',
profileId: removedProfileId
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', {
name: '编辑模型连接 待删除 Runtime 模型'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '删除模型连接 待删除 Runtime 模型'
})
)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
opencodeModelSource: {
kind: 'profile',
profileId: modelProfileId
}
})
)
)
})
it('tests the selected model instead of a selected Continue Runtime', async () => {
const onNotify = vi.fn()
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
provider: 'continue',
modelAuthentication: 'none',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
authentication: 'none',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '保存并测试模型' })
)
await waitFor(() =>
expect(testModelConnection).toHaveBeenCalledWith(modelProfileId)
)
expect(testRuntime).not.toHaveBeenCalled()
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '真实生成测试通过:sonnet-5',
dedupeKey: 'model-connection-tested'
})
)
expect(
screen.queryByText('真实生成测试通过:sonnet-5')
).not.toBeInTheDocument()
})
it('places the API Key directly after authentication and keeps it when the model URL changes', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
apiKeyConfigured: true,
credentialSource: 'encrypted',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const authentication = screen.getByLabelText(
'认证方式 默认模型'
)
const apiKey = screen.getByLabelText('API Key')
expect(
authentication.closest('label')?.nextElementSibling
).toBe(apiKey.closest('label'))
expect(apiKey).toHaveAttribute(
'placeholder',
'已配置,留空保持不变'
)
fireEvent.change(screen.getByDisplayValue('https://bigtoken.ai'), {
target: { value: 'https://new-model.example/v1' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
baseUrl: 'https://new-model.example/v1',
apiKey: { action: 'keep' }
})
]
})
)
)
})
it('does not clear a saved API Key when authentication is temporarily disabled', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
apiKeyConfigured: true,
credentialSource: 'encrypted',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.change(screen.getByLabelText('认证方式 默认模型'), {
target: { value: 'none' }
})
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
expect(
screen.getByText(
'当前无需认证;已保存的 API Key 仍保留在此连接中'
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
authentication: 'none',
apiKey: { action: 'keep' }
})
]
})
)
)
})
it('shows an actionable model error without Electron IPC prefixes', async () => {
testModelConnection.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'settings:runtime:test-model': Error: 模型连接“默认模型”未配置 API Key"
)
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '保存并测试模型' })
)
expect(
await screen.findByText('模型连接“默认模型”未配置 API Key')
).toBeInTheDocument()
expect(screen.queryByText(/Error invoking remote method/u))
.not.toBeInTheDocument()
})
it('shows the first settings validation issue without IPC wrappers', async () => {
updateRuntime.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'settings:runtime:update': [ { \"code\": \"custom\", \"path\": [ \"modelProfiles\", 0, \"baseUrl\" ], \"message\": \"模型服务地址必须使用 HTTP 或 HTTPS\" } ]"
)
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
await screen.findByText('模型服务地址必须使用 HTTP 或 HTTPS')
).toBeInTheDocument()
expect(screen.queryByText(/Error invoking remote method/u))
.not.toBeInTheDocument()
})
it('tests the active Runtime with its saved model source', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
opencodeModelSource: {
kind: 'profile',
profileId: modelProfileId
},
continueModelSource: { kind: 'platform' }
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
await screen.findByRole('button', {
name: '保存并测试 OpenCode'
})
)
await waitFor(() =>
expect(testRuntime).toHaveBeenCalledWith({
provider: 'opencode',
profileId: modelProfileId
})
)
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
fireEvent.click(
screen.getByRole('button', {
name: '保存并测试 Continue'
})
)
await waitFor(() =>
expect(testRuntime).toHaveBeenLastCalledWith({
provider: 'continue',
runtimeConfig: true
})
)
expect(testModelConnection).not.toHaveBeenCalled()
})
it('moves the detail selection after deleting a model connection', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '添加自定义' })
)
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '备用模型' }
})
fireEvent.click(
screen.getByRole('button', { name: '添加自定义' })
)
expect(screen.getByLabelText('名称')).toHaveValue('模型连接 3')
fireEvent.click(
screen.getByRole('button', {
name: '删除模型连接 模型连接 3'
})
)
expect(screen.getByLabelText('名称')).toHaveValue('备用模型')
expect(
screen.queryByRole('button', {
name: '编辑模型连接 模型连接 3'
})
).not.toBeInTheDocument()
expect(
screen.getByRole('button', {
name: '编辑模型连接 备用模型'
})
).toHaveAttribute('aria-current', 'page')
})
it('uses only custom model connections and supports image generation', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
expect(screen.queryByLabelText('模型预设')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '从预设添加' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', {
name: '支持图片输出 默认模型'
})
).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('接口协议 默认模型'), {
target: { value: 'openai-images-generations' }
})
const qualitySelect = screen.getByLabelText('图片质量 默认模型')
const invocationSwitch = screen.getByRole('switch', { name: '允许 AI 在会话中调用' })
expect(invocationSwitch).not.toBeChecked()
expect(screen.queryByRole('radio', { name: '设为默认生图模型' })).not.toBeInTheDocument()
fireEvent.click(invocationSwitch)
fireEvent.click(screen.getByRole('radio', { name: '设为默认生图模型' }))
expect(qualitySelect).toHaveValue('auto')
fireEvent.change(qualitySelect, {
target: { value: 'high' }
})
expect(
screen.getByText('图像生成', {
selector: '.model-capability-badge'
})
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
protocol: 'openai-images-generations',
allowConversationInvocation: true,
imageGenerationQuality: 'high'
})
],
imageGenerationQuality: 'high',
defaultImageModelProfileId: modelProfileId
})
)
)
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
})
)
})
it('renders vector settings synchronously under model connections instead of security', async () => {
getEmbeddingSnapshot
.mockResolvedValueOnce(embeddingSnapshot)
.mockResolvedValue({
...embeddingSnapshot,
configuration: {
provider: 'openai-compatible',
model: 'bge-m3',
endpoint: 'https://vectors.example/v1/embeddings',
credentialConfigured: true
},
connections: embeddingSnapshot.connections.map((connection) =>
connection.id === legacyEmbeddingConnectionId
? {
...connection,
model: 'bge-m3',
endpoint:
'https://vectors.example/v1/embeddings',
authentication: 'api-key' as const,
credentialConfigured: true
}
: connection
)
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
expect(
screen.queryByRole('switch', { name: '启用向量检索' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
expect(
screen.getByText('向量模型连接', {
selector: 'strong'
})
).toBeInTheDocument()
expect(
screen.queryByText('正在加载向量模型设置…')
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '保存并测试模型' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 自定义向量模型'
})
)
fireEvent.change(screen.getByLabelText('向量接口 URL'), {
target: { value: 'https://vectors.example/v1/embeddings' }
})
fireEvent.change(screen.getByLabelText('模型名称'), {
target: { value: 'bge-m3' }
})
fireEvent.change(screen.getByLabelText('认证方式'), {
target: { value: 'api-key' }
})
fireEvent.change(screen.getByLabelText('API Key(可选)'), {
target: { value: 'vector-secret' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
knowledgeEmbeddingEnabled: true,
embeddingConnections: expect.arrayContaining([
expect.objectContaining({
id: legacyEmbeddingConnectionId,
baseUrl:
'https://vectors.example/v1/embeddings',
modelName: 'bge-m3',
apiKey: {
action: 'replace',
value: 'vector-secret'
}
})
])
})
)
)
const section = screen.getByRole('region', { name: '向量模型' })
await waitFor(() => {
expect(
within(section).getAllByText('自定义向量模型', {
selector: 'strong'
})
).toHaveLength(2)
expect(
within(section).getByDisplayValue(
'https://vectors.example/v1/embeddings'
)
).toBeInTheDocument()
expect(
within(section).queryByText(
/https:\/\/vectors\.example\/v1\/embeddings/u
)
).not.toBeInTheDocument()
})
})
it('configures learned reranking as an accessible model subtype', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '重排模型' }))
expect(
screen
.getByLabelText('API Key(可选)')
.closest('.runtime-note')
).toHaveClass('model-service-form')
const rerankSwitch = screen.getByRole('switch', {
name: '启用学习型重排'
})
expect(rerankSwitch).not.toBeChecked()
fireEvent.click(rerankSwitch)
fireEvent.change(screen.getByRole('textbox', { name: '重排接口 URL' }), {
target: { value: 'https://rerank.example/v1/rerank' }
})
fireEvent.change(screen.getByLabelText('模型名称'), {
target: { value: 'vendor/rerank-large' }
})
fireEvent.change(screen.getByLabelText('API Key(可选)'), {
target: { value: 'rerank-secret' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
knowledgeRerankEnabled: true,
knowledgeRerankEndpoint:
'https://rerank.example/v1/rerank',
knowledgeRerankModel: 'vendor/rerank-large',
knowledgeRerankApiKey: {
action: 'replace',
value: 'rerank-secret'
}
})
)
)
})
it('tests vector generation without exposing index controls', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
await waitFor(() => expect(getEmbeddingSnapshot).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
const section = screen.getByRole('region', { name: '向量模型' })
const testButton = within(section).getByRole('button', {
name: '测试向量模型连接 自定义向量模型'
})
expect(
testButton
).toBeDisabled()
fireEvent.click(
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
within(section).getByRole('button', {
name: '测试向量模型连接 自定义向量模型'
})
)
await waitFor(() => expect(diagnoseEmbedding).toHaveBeenCalledTimes(1))
expect(
await within(section).findByText(/服务返回 768 维向量/u)
).toBeInTheDocument()
expect(
within(section).queryByRole('button', { name: '重建向量索引' })
).not.toBeInTheDocument()
})
it('shows immediate progress when downloading the built-in vector model', async () => {
let finishInstall:
| ((snapshot: EmbeddingModelSnapshot) => void)
| undefined
installEmbeddingModel.mockImplementationOnce(
() =>
new Promise<EmbeddingModelSnapshot>((resolve) => {
finishInstall = resolve
})
)
const onNotify = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 GoodBuddy 内置向量模型'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '下载 Granite Embedding 97M Multilingual R2'
})
)
await waitFor(() =>
expect(installEmbeddingModel).toHaveBeenCalledWith(
embeddingCatalogEntry.id,
'modelscope'
)
)
expect(
screen.getByText('正在准备从 ModelScope 下载')
).toBeInTheDocument()
expect(
screen.getByRole('progressbar', {
name: 'Granite Embedding 97M Multilingual R2下载进度'
})
).toBeInTheDocument()
await waitFor(() =>
expect(getEmbeddingModelProgress).toHaveBeenCalled()
)
await act(async () => {
finishInstall?.({
...embeddingSnapshot.models,
installed: [
{
id: embeddingCatalogEntry.id,
displayName: embeddingCatalogEntry.displayName,
source: 'download',
installedAt: '2026-08-25T00:00:00.000Z',
files: embeddingCatalogEntry.files
}
]
})
})
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: 'Granite Embedding 97M Multilingual R2 已安装',
dedupeKey: `embedding-model-${embeddingCatalogEntry.id}`
})
)
})
it('preserves embedding credentials after a failed save, clears them after success, and sets the current connection explicitly', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 自定义向量模型'
})
)
fireEvent.change(await screen.findByLabelText('认证方式'), {
target: { value: 'api-key' }
})
const credential = screen.getByLabelText('API Key(可选)')
fireEvent.change(credential, {
target: { value: 'embedding-secret' }
})
updateRuntime.mockRejectedValueOnce(new Error('保存失败'))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await screen.findByText('保存失败')
expect(credential).toHaveValue('embedding-secret')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(credential).toHaveValue(''))
fireEvent.click(
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 GoodBuddy 内置向量模型'
})
)
fireEvent.click(screen.getByRole('radio', { name: '当前连接' }))
expect(setCurrentEmbedding).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
})
)
)
expect(setCurrentEmbedding).not.toHaveBeenCalled()
})
it('keeps Smart Heartbeat configuration out of Settings', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
screen.queryByRole('tab', { name: '自动化' })
).not.toBeInTheDocument()
expect(screen.queryByText('智能心跳')).not.toBeInTheDocument()
})
it.each([false, true])('shows immutable image assignments and source navigation when enabled=%s', async (enabled) => {
const snapshot = {
...capabilitySnapshot,
imageGeneration: {
modelProfiles: enabled ? [{ id: modelProfileId, name: 'Image' }] : [],
assignments: enabled ? ['model', 'opencode', 'continue', 'deepseek-harness'] : []
}
}
getCapabilitySnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce(snapshot)
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
await screen.findByText('文档写作')
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
const card = screen.getByRole('article', { name: '图片生成与编辑' })
await waitFor(() => {
const checkboxes = within(card).getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
for (const checkbox of checkboxes) {
expect(checkbox).toBeDisabled()
if (enabled) expect(checkbox).toBeChecked()
else expect(checkbox).not.toBeChecked()
}
})
expect(within(card).getByText('内置 · 自动管理')).toBeInTheDocument()
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
expect(within(card).queryByText(/暂不支持/)).not.toBeInTheDocument()
for (const checkbox of within(card).getAllByRole('checkbox')) {
expect(checkbox).toHaveAccessibleDescription('由图片模型的「允许 AI 在会话中调用」设置自动管理。')
fireEvent.click(checkbox)
fireEvent.click(checkbox.closest('label')!)
fireEvent.keyDown(checkbox, { key: ' ' })
}
expect(setBuiltinMcpServerAssignments).not.toHaveBeenCalled()
expect(setBuiltinMcpServerEnabled).not.toHaveBeenCalled()
expect(saveMcpServer).not.toHaveBeenCalled()
const navigation = within(card).getByRole('button', { name: '前往图片模型设置' })
navigation.focus()
expect(navigation).toHaveFocus()
fireEvent.click(navigation)
expect(screen.getByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByRole('button', { name: '添加自定义' })).toBeEnabled()
})
it('traps modal focus and protects dirty drafts on Escape', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const workspace = await screen.findByLabelText('默认工作区目录')
const dialog = screen.getByRole('dialog', { name: '设置中心' })
const closeButton = screen.getByRole('button', { name: '关闭设置' })
closeButton.focus()
fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true })
expect(dialog).toContainElement(
document.activeElement as HTMLElement
)
expect(document.activeElement).not.toBe(closeButton)
fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
expect(dialog).toContainElement(
document.activeElement as HTMLElement
)
fireEvent.change(workspace, { target: { value: 'C:\\Escape draft' } })
workspace.focus()
fireEvent.keyDown(workspace, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: '继续编辑' }), { key: 'Escape' })
await waitFor(() => expect(workspace).toHaveFocus())
expect(workspace).toHaveValue('C:\\Escape draft')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.keyDown(workspace, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '放弃更改并关闭' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('retains a disabled or deleted image default and keeps drafts on save failure', async () => {
const imageId = '00000000-0000-4000-8000-000000000099'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
defaultImageModelProfileId: imageId,
modelProfiles: [runtimeSettings.modelProfiles[0]!, {
...runtimeSettings.modelProfiles[0]!, id: imageId, name: 'Image',
protocol: 'openai-images-generations', allowConversationInvocation: true
}]
})
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
fireEvent.click(await screen.findByRole('button', { name: '编辑模型连接 Image' }))
fireEvent.click(screen.getByRole('switch', { name: '允许 AI 在会话中调用' }))
expect(screen.getByText('默认生图模型不可用,请开启该模型或明确选择其他图片模型。')).toBeInTheDocument()
updateRuntime.mockRejectedValueOnce(new Error('Image settings save failed'))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await screen.findByText('Image settings save failed')
expect(screen.getByRole('switch', { name: '允许 AI 在会话中调用' })).not.toBeChecked()
expect(updateRuntime).toHaveBeenLastCalledWith(expect.objectContaining({ defaultImageModelProfileId: imageId }))
fireEvent.click(screen.getByRole('button', { name: '删除模型连接 Image' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(updateRuntime).toHaveBeenLastCalledWith(expect.objectContaining({
defaultImageModelProfileId: imageId,
modelProfiles: [expect.objectContaining({ id: modelProfileId })]
})))
})
it('groups Skills and MCP under Capabilities and tools', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const categoryTabs = screen.getByRole('tablist', {
name: '设置分类'
})
expect(
within(categoryTabs).queryByRole('tab', { name: 'Skills' })
).not.toBeInTheDocument()
expect(
within(categoryTabs).queryByRole('tab', { name: 'MCP' })
).not.toBeInTheDocument()
fireEvent.click(
within(categoryTabs).getByRole('tab', { name: '能力与工具' })
)
const capabilityTabs = screen.getByRole('tablist', {
name: '能力与工具设置'
})
expect(
within(capabilityTabs)
.getAllByRole('tab')
.map((tab) => tab.textContent)
).toEqual(['Skills', 'MCP', '工具执行环境'])
expect(
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('文档写作')).toBeInTheDocument()
expect(
screen.getByText(/新导入的 Skill 默认启用/)
).toHaveTextContent(
'分配给直连模型、OpenCode、Continue 和 DeepSeek Harness'
)
expect(
screen.getByLabelText('DeepSeek Harness')
).toBeChecked()
fireEvent.click(
screen.getByRole('button', { name: '导入 Skill 目录' })
)
await waitFor(() =>
expect(importSkill).toHaveBeenCalledWith('directory')
)
fireEvent.click(
screen.getByRole('button', { name: '导入 Skill ZIP' })
)
await waitFor(() => expect(importSkill).toHaveBeenCalledWith('zip'))
fireEvent.click(
screen.getByRole('switch', { name: '启用 文档写作' })
)
await waitFor(() =>
expect(setSkillEnabled).toHaveBeenCalledWith(
'document-writing',
false
)
)
fireEvent.click(
within(capabilityTabs).getByRole('tab', { name: 'MCP' })
)
const mcpTabs = screen.getByRole('tablist', {
name: 'MCP 设置分类'
})
expect(
within(mcpTabs)
.getAllByRole('tab')
.map((tab) => tab.textContent)
).toEqual(['内置 MCP', '直连模型', '自定义 MCP', '电脑控制'])
expect(
within(mcpTabs).getByRole('tab', { name: '内置 MCP' })
).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('GoodBuddy 内置 MCP')).toBeInTheDocument()
expect(
screen.queryByRole('switch', {
name: '启用 Linux 桌面控制'
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /添加 Server/ })
).not.toBeInTheDocument()
const knowledgeBuiltinCard = screen
.getByRole('switch', { name: '启用 知识库 内置 MCP' })
.closest('article')
expect(knowledgeBuiltinCard).not.toBeNull()
expect(
within(knowledgeBuiltinCard!).getByLabelText(
/知识库 无法分配给 DeepSeek Harness/
)
).toBeDisabled()
expect(
within(knowledgeBuiltinCard!).getByLabelText(
/知识库 无法分配给 DeepSeek Harness/
)
).not.toBeChecked()
fireEvent.click(
within(knowledgeBuiltinCard!).getByLabelText('Continue')
)
await waitFor(() =>
expect(setBuiltinMcpServerAssignments).toHaveBeenCalledWith(
'knowledge-base',
['model', 'opencode']
)
)
fireEvent.click(
screen.getByRole('switch', { name: '启用 知识库 内置 MCP' })
)
await waitFor(() =>
expect(setBuiltinMcpServerEnabled).toHaveBeenCalledWith(
'knowledge-base',
false
)
)
const browserBuiltinCard = screen
.getByRole('switch', {
name: '启用 内置浏览器 内置 MCP'
})
.closest('article')
expect(browserBuiltinCard).not.toBeNull()
expect(
within(browserBuiltinCard!).getByLabelText(
/内置浏览器 无法分配给 DeepSeek Harness/
)
).toBeDisabled()
fireEvent.click(
within(browserBuiltinCard!).getByLabelText('Continue')
)
await waitFor(() =>
expect(setBuiltinMcpServerAssignments).toHaveBeenCalledWith(
'builtin-browser',
['model', 'opencode']
)
)
fireEvent.click(
screen.getByRole('switch', {
name: '启用 内置浏览器 内置 MCP'
})
)
await waitFor(() =>
expect(setBuiltinMcpServerEnabled).toHaveBeenCalledWith(
'builtin-browser',
true
)
)
expect(
within(browserBuiltinCard!).getByText('仅 Execute')
).toBeInTheDocument()
const builtinBrowserToggle = screen.getByRole('button', {
name: '展开服务器 内置浏览器'
})
fireEvent.click(builtinBrowserToggle)
expect(builtinBrowserToggle).toHaveAttribute(
'aria-expanded',
'true'
)
expect(
screen.getByText(/不会控制客户端已安装的浏览器/)
).toBeInTheDocument()
expect(
screen.getByText(/关闭时不会向任何 Runtime 提供浏览器工具/)
).toHaveTextContent('不再逐次询问')
expect(
screen.getByRole('region', { name: '内置浏览器 工具' })
).toContainElement(screen.getByText('browser_navigate'))
fireEvent.click(
screen.getByRole('button', { name: '诊断内置浏览器' })
)
expect(
await screen.findByText('诊断结果:部分可用')
).toBeInTheDocument()
expect(diagnoseComputerCapability).toHaveBeenCalledWith(
'host-browser-control'
)
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '电脑控制' })
)
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect(
screen.queryByText('仅显示实际操作客户端电脑的能力')
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', {
name: '启用 内置浏览器 内置 MCP'
})
).not.toBeInTheDocument()
expect(screen.queryByText('托管浏览器配置')).not.toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: '启用 Linux 桌面控制'
})
).toBeDisabled()
const desktopCapabilityCard = screen
.getByRole('switch', { name: '启用 Linux 桌面控制' })
.closest('article')
expect(desktopCapabilityCard).not.toBeNull()
expect(
within(desktopCapabilityCard!).queryByText('已停用')
).not.toBeInTheDocument()
expect(
within(desktopCapabilityCard!).getByText('启用此能力')
).toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '直连模型' })
)
expect(await screen.findByText('工作区文件')).toBeInTheDocument()
expect(screen.queryByText('内置浏览器')).not.toBeInTheDocument()
expect(screen.getByText('联网搜索')).toBeInTheDocument()
expect(screen.getByText('开发工具')).toBeInTheDocument()
const programmingToggle = screen.getByRole('button', {
name: '展开工具组 开发工具'
})
fireEvent.click(programmingToggle)
expect(screen.getByText('进程执行')).toBeInTheDocument()
expect(screen.getByText('编程 Subagent')).toBeInTheDocument()
expect(
within(programmingToggle.closest('article')!)
.queryByRole('switch')
).not.toBeInTheDocument()
expect(screen.queryByText('托管浏览器配置')).not.toBeInTheDocument()
expect(screen.queryByText('内置浏览器配置')).not.toBeInTheDocument()
expect(screen.queryByLabelText('新配置名称')).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', {
name: '启用 Linux 桌面控制'
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', {
name: '启用 内置浏览器 内置 MCP'
})
).not.toBeInTheDocument()
expect(screen.queryByText('web_search')).not.toBeInTheDocument()
expect(screen.queryByText('web_fetch')).not.toBeInTheDocument()
const webSearchToggle = screen.getByRole('button', {
name: '展开工具组 联网搜索'
})
expect(webSearchToggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(webSearchToggle)
expect(webSearchToggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('web_search')).toBeInTheDocument()
expect(screen.getByText('web_fetch')).toBeInTheDocument()
expect(
screen.getByText(/查询词和公开网页地址会发送给第三方 Exa/)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('switch', {
name: '启用直连模型联网搜索'
})
)
await waitFor(() =>
expect(setWebSearchEnabled).toHaveBeenCalledWith(false)
)
fireEvent.click(
screen.getByRole('button', { name: '测试真实搜索' })
)
expect(
await screen.findByText('真实搜索成功 · 321 毫秒')
).toBeInTheDocument()
expect(screen.getByText('GoodBuddy search result')).toBeInTheDocument()
expect(testWebSearch).toHaveBeenCalledOnce()
expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '内置 MCP' })
)
expect(screen.getByText('知识库')).toBeInTheDocument()
expect(screen.queryByText('knowledge_list')).not.toBeInTheDocument()
expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument()
expect(screen.queryByText('note_search')).not.toBeInTheDocument()
const knowledgeServerToggle = screen.getByRole('button', {
name: '展开服务器 知识库'
})
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(knowledgeServerToggle)
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'true')
const knowledgeTools = screen.getByRole('region', {
name: '知识库 工具'
})
expect(knowledgeTools).toContainElement(
screen.getByText('knowledge_list')
)
expect(knowledgeTools).toContainElement(
screen.getByText('knowledge_search')
)
expect(within(knowledgeTools).queryByText(/可用于:/u))
.not.toBeInTheDocument()
const noteServerToggle = screen.getByRole('button', {
name: '展开服务器 笔记'
})
expect(
await screen.findByText(
'未启用 · 需要开启魔法笔记'
)
).toBeInTheDocument()
expect(noteServerToggle.closest('article')).toHaveClass(
'capability-card--disabled'
)
fireEvent.click(noteServerToggle)
expect(
screen.getByRole('region', { name: '笔记 工具' })
).toContainElement(screen.getByText('note_search'))
expect(
screen.getByText(/此内置能力当前不会向任何 Runtime 提供工具/)
).toBeInTheDocument()
expect(
screen.getAllByRole('button', {
name: /^(?:展开|收起)服务器 /u
})
).toHaveLength(builtinMcpServers.length + 1)
const serverNames = [
...builtinMcpServers.map(server => server.name),
'图片生成与编辑'
]
for (const serverName of serverNames) {
expect(screen.getByRole('button', {
name: name => name === `展开服务器 ${serverName}` || name === `收起服务器 ${serverName}`
})).toBeInTheDocument()
}
expect(
screen.queryByText(/按请求提供,可分别控制启停与 Runtime 分配/)
).not.toBeInTheDocument()
expect(
screen.queryByText(/不公开服务地址或凭据/)
).not.toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '直连模型' })
)
const filesystemToggle = screen.getByRole('button', {
name: '展开工具组 工作区文件'
})
fireEvent.click(filesystemToggle)
expect(screen.getByText('搜索工作区')).toBeInTheDocument()
expect(screen.getByText('读取工作区文本')).toBeInTheDocument()
expect(screen.getByText('应用工作区补丁')).toBeInTheDocument()
expect(screen.queryByText('浏览器导航')).not.toBeInTheDocument()
expect(
screen.getAllByRole('button', { name: /工具组/u })
).toHaveLength(builtinModelToolGroups.length)
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
)
expect(
screen.getByText(
/可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness/
)
).toHaveTextContent('新建时默认分配给直连模型')
expect(
screen.getByText(/服务地址、命令和凭据由 GoodBuddy 主进程保管/)
).toBeInTheDocument()
expect(screen.getByText(/具有当前用户权限/)).toHaveTextContent(
'请仅添加可信服务'
)
expect(
screen.getByText(/远程访问令牌由系统安全存储加密/)
).toHaveTextContent('工具调用前仍需 GoodBuddy 审批')
expect(
await screen.findByText('尚未配置 MCP Server')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /添加 Server/ })
).toBeInTheDocument()
expect(
screen.getByText(/stdio Server 会以不含桌面会话变量的受限环境启动/)
).toHaveTextContent('需要电脑控制时请使用经过诊断的内置能力')
expect(
document.querySelectorAll(
'#mcp-settings-panel-custom > .settings-notice'
)
).toHaveLength(1)
const addServer = screen.getByRole('button', { name: /添加 Server/ })
fireEvent.click(addServer)
const dialog = screen.getByRole('dialog', {
name: '添加 MCP Server'
})
expect(
within(dialog).getByRole('switch', {
name: '启用此 MCP Server'
})
).toBeChecked()
expect(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
).not.toBeChecked()
expect(within(dialog).getByLabelText('模型')).toBeChecked()
expect(
within(dialog).getByLabelText('DeepSeek Harness')
).not.toBeChecked()
expect(within(dialog).getByLabelText('OpenCode')).not.toBeChecked()
expect(within(dialog).getByLabelText('Continue')).not.toBeChecked()
await waitFor(() =>
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(addServer).toHaveFocus())
expect(
screen.queryByRole('dialog', { name: '添加 MCP Server' })
).not.toBeInTheDocument()
})
it('lazy mounts and retains the real tool environment tab independently from MCP', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: '能力与工具' })
)
expect(getLocalToolEnvironmentSnapshot).not.toHaveBeenCalled()
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: '工具执行环境' })
)
expect(
await screen.findByRole('heading', { name: 'Node.js' })
).toBeVisible()
expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', { name: '诊断全部' })
)
await waitFor(() =>
expect(diagnoseLocalToolEnvironment).toHaveBeenCalledWith('all')
)
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: 'MCP' })
)
expect(
await screen.findByRole('tablist', { name: 'MCP 设置分类' })
).toBeVisible()
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: '工具执行环境' })
)
expect(
screen.getByRole('heading', { name: 'Node.js' })
).toBeVisible()
expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce()
})
it('traps MCP editor focus and restores it after saving or backdrop dismissal', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const addServer = await screen.findByRole('button', {
name: '添加 Server'
})
const settingsBackdrop = addServer.closest<HTMLElement>('.settings-backdrop')!
fireEvent.click(addServer)
const dialog = screen.getByRole('dialog', {
name: '添加 MCP Server'
})
const closeButton = within(dialog).getByRole('button', {
name: '关闭 MCP 编辑器'
})
const saveButton = within(dialog).getByRole('button', {
name: '保存 MCP Server'
})
expect(settingsBackdrop.inert).toBe(true)
expect(dialog.parentElement?.parentElement).toBe(document.body)
closeButton.focus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(saveButton).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(closeButton).toHaveFocus()
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: '本地文件工具' }
})
fireEvent.change(within(dialog).getByLabelText('传输方式'), {
target: { value: 'http' }
})
fireEvent.change(within(dialog).getByLabelText('Server URL'), {
target: { value: 'https://mcp.example.com/mcp' }
})
fireEvent.change(within(dialog).getByLabelText('Bearer Token'), {
target: { value: ' token-with-significant-spaces ' }
})
fireEvent.click(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
)
fireEvent.click(saveButton)
await waitFor(() =>
expect(saveMcpServer).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
name: '本地文件工具',
transport: 'http',
url: 'https://mcp.example.com/mcp',
allowDynamicTools: true,
secret: {
action: 'replace',
value: ' token-with-significant-spaces '
}
})
)
)
await waitFor(() => expect(addServer).toHaveFocus())
expect(settingsBackdrop.inert).toBe(false)
expect(
screen.queryByRole('dialog', { name: '添加 MCP Server' })
).not.toBeInTheDocument()
fireEvent.click(addServer)
const reopenedDialog = screen.getByRole('dialog', {
name: '添加 MCP Server'
})
const backdrop = reopenedDialog.parentElement
if (!backdrop) {
throw new Error('Missing MCP editor backdrop')
}
fireEvent.mouseDown(backdrop)
await waitFor(() => expect(addServer).toHaveFocus())
expect(settingsBackdrop.inert).toBe(false)
expect(
screen.queryByRole('dialog', { name: '添加 MCP Server' })
).not.toBeInTheDocument()
})
it('opens an existing MCP Server in the same modal and returns focus on Escape', async () => {
getCapabilitySnapshot
.mockResolvedValueOnce(capabilitySnapshot)
.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000301',
name: '团队知识服务',
description: '公司内部 MCP',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: true,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const editButton = await screen.findByRole('button', {
name: '编辑 团队知识服务'
})
fireEvent.click(editButton)
const dialog = screen.getByRole('dialog', {
name: '编辑 MCP Server'
})
expect(within(dialog).getByLabelText('名称')).toHaveValue(
'团队知识服务'
)
expect(within(dialog).getByLabelText('Bearer Token')).toHaveValue('')
expect(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
).toBeChecked()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(editButton).toHaveFocus())
expect(
screen.queryByRole('dialog', { name: '编辑 MCP Server' })
).not.toBeInTheDocument()
})
it('shows custom MCP tools under their expandable server after testing', async () => {
getCapabilitySnapshot
.mockResolvedValueOnce(capabilitySnapshot)
.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000302',
name: '团队工具服务',
description: '公司内部工具',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: false,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
vi.mocked(
window.goodbuddy.capabilities.testMcpServer
).mockResolvedValueOnce({
serverName: 'Team MCP',
serverVersion: '1.2.0',
dynamicToolsSupported: true,
toolCount: 1,
tools: [
{
name: 'team_search',
description: '搜索团队资料'
}
],
promptsSupported: true,
promptCount: 1,
prompts: [
{
name: 'prepare_review',
description: '准备审查 Prompt',
arguments: [
{
name: 'scope',
description: '审查范围',
required: true
}
]
}
],
resourcesSupported: true,
resourceCount: 1,
resources: [
{
uri: 'mcp://team/review-guide',
name: 'Review Guide',
description: '团队审查指南',
mimeType: 'text/markdown'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const serverToggle = await screen.findByRole('button', {
name: '展开服务器 团队工具服务'
})
expect(serverToggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('team_search')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '测试 团队工具服务' })
)
expect(await screen.findByText('team_search')).toBeInTheDocument()
expect(
screen.getByText('服务端支持动态更新工具列表')
).toBeInTheDocument()
expect(screen.getByText('prepare_review')).toBeInTheDocument()
expect(screen.getByText('参数:scope*(* 必填)')).toBeInTheDocument()
expect(screen.getByText('Review Guide')).toBeInTheDocument()
expect(
screen.getByText('mcp://team/review-guide')
).toBeInTheDocument()
expect(screen.getByText('text/markdown')).toBeInTheDocument()
expect(serverToggle).toHaveAttribute('aria-expanded', 'true')
expect(
screen.getByRole('region', { name: '团队工具服务 工具' })
).toHaveTextContent('搜索团队资料')
})
it('creates, updates, and removes roles with system prompts', async () => {
const onExpertsChanged = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onExpertsChanged={onExpertsChanged}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: '角色与提示词' })
)
await screen.findByRole('button', {
name: '编辑角色 研究分析专家'
})
fireEvent.change(screen.getByLabelText('系统提示词'), {
target: { value: 'Use evidence and state uncertainty.' }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(updateExpert).toHaveBeenCalledWith(
assistantExpert.id,
expect.objectContaining({
systemInstructions: 'Use evidence and state uncertainty.'
})
)
)
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
target: { value: 'x' }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
expect(
await screen.findByText('关键词“x”需为 2 至 48 个字符。')
).toBeInTheDocument()
expect(updateExpert).toHaveBeenCalledTimes(1)
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
target: {
value: ' TypeScript,代码 审查\nTYPESCRIPT '
}
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(updateExpert).toHaveBeenLastCalledWith(
assistantExpert.id,
expect.objectContaining({
routingKeywords: ['typescript', '代码 审查']
})
)
)
fireEvent.click(screen.getByRole('button', { name: '新建角色' }))
fireEvent.change(screen.getByLabelText('角色名称'), {
target: { value: '代码审查专家' }
})
fireEvent.change(screen.getByLabelText('角色说明'), {
target: { value: '检查代码正确性' }
})
fireEvent.change(screen.getByLabelText('系统提示词'), {
target: { value: 'Review code and report actionable bugs.' }
})
fireEvent.click(screen.getByRole('button', { name: '创建角色' }))
await waitFor(() =>
expect(createExpert).toHaveBeenCalledWith({
name: '代码审查专家',
description: '检查代码正确性',
systemInstructions: 'Review code and report actionable bugs.',
routingKeywords: []
})
)
expect(onExpertsChanged).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({ name: '代码审查专家' })
])
)
fireEvent.click(
screen.getByRole('button', {
name: '删除角色 代码审查专家'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '确认删除角色 代码审查专家'
})
)
await waitFor(() =>
expect(removeExpert).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000102'
)
)
})
})