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
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.
6527 lines
212 KiB
TypeScript
6527 lines
212 KiB
TypeScript
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'
|
||
)
|
||
)
|
||
})
|
||
})
|