Files
goodbuddy/src/renderer/src/App.test.tsx
T

4433 lines
133 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
act,
cleanup,
fireEvent,
render,
screen,
within,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentEvent,
BrowserLiveState,
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({
startPcmRecording: vi.fn()
}))
vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal<
typeof import('./speech-recognition')
>()),
startPcmRecording: speechRecognitionMocks.startPcmRecording
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
let agentListener: ((event: AgentEvent) => void) | undefined
let browserListener: ((state: BrowserLiveState) => void) | undefined
let newConversationListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
const removeMaximizedChangedListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101'
const project = {
id: projectId,
name: '默认项目',
description: '测试项目',
rootPath: 'C:\\Users\\test',
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
status: 'active' as const,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
}
const api: DesktopApi = {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.1.0',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
})),
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
minimize: vi.fn(async () => {}),
toggleMaximize: vi.fn(async () => {}),
close: vi.fn(async () => {}),
isMaximized: vi.fn(async () => false),
onMaximizedChanged: vi.fn((listener) => {
maximizedChangedListener = listener
return removeMaximizedChangedListener
}),
clearLocalData: vi.fn(async () => {}),
onNewConversation: vi.fn((listener) => {
newConversationListener = listener
return () => {
newConversationListener = undefined
}
}),
onOpenSettings: vi.fn(() => () => {})
},
agent: {
getStatus: vi.fn<DesktopApi['agent']['getStatus']>(async () => ({
id: 'model' as const,
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})),
run,
cancel: vi.fn(async () => {}),
respondApproval: vi.fn(async () => {}),
respondQuestion: vi.fn(async () => {}),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
agentListener = undefined
}
})
},
browser: {
interact: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
onState: vi.fn((listener) => {
browserListener = listener
return () => {
browserListener = undefined
}
})
},
speech: {
transcribe: vi.fn(async () => ({ text: '本地语音结果' })),
cancel: vi.fn(async () => true)
},
settings: {
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
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',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
workspacePath: 'C:\\Users\\test',
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
},
secureStorageAvailable: true,
toolApproval: 'always'
})),
updateRuntime: vi.fn<DesktopApi['settings']['updateRuntime']>(
async (input) => ({
provider: input.provider,
modelBaseUrl: input.modelBaseUrl,
modelName: input.modelName,
modelProtocol: input.modelProtocol,
modelAuthentication: input.modelAuthentication,
imageGenerationQuality: input.imageGenerationQuality,
opencodeBaseUrl: input.opencodeBaseUrl,
opencodeEmbedded: input.opencodeEmbedded,
opencodeBinaryPath: input.opencodeBinaryPath,
opencodeConfigPath: input.opencodeConfigPath,
continueBinaryPath: input.continueBinaryPath,
continueConfigPath: input.continueConfigPath,
continueMode: input.continueMode,
runtimeSandboxMode: input.runtimeSandboxMode,
subagentSmartRoutingEnabled:
input.subagentSmartRoutingEnabled ?? false,
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: input.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: input.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKeyConfigured:
input.knowledgeEmbeddingApiKey?.action === 'replace',
knowledgeEmbeddingCredentialSource:
input.knowledgeEmbeddingApiKey?.action === 'replace'
? 'encrypted'
: 'none',
workspacePath: input.workspacePath,
apiKeyConfigured: input.apiKey.action === 'replace',
credentialSource:
input.apiKey.action === 'replace' ? 'encrypted' : 'none',
modelProfiles: (
input.modelProfiles ?? [
{
id: modelProfileId,
name: '默认模型',
baseUrl: input.modelBaseUrl,
modelName: input.modelName,
protocol: input.modelProtocol,
authentication: input.modelAuthentication,
imageGenerationQuality:
input.imageGenerationQuality,
apiKey: input.apiKey
}
]
).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' },
secureStorageAvailable: true,
toolApproval: input.toolApproval
})
),
selectWorkspace: vi.fn(async () => undefined),
detectAgentRuntimes: vi.fn<
DesktopApi['settings']['detectAgentRuntimes']
>(async () => ({
opencode: {
available: false,
detail: '未检测到 OpenCode'
},
continue: {
available: false,
detail: '未检测到 Continue'
}
})),
selectRuntimeFile: vi.fn(async () => undefined),
openRuntimeConfig: vi.fn(async () => {}),
testModelConnection: vi.fn<
DesktopApi['settings']['testModelConnection']
>(async () => ({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})),
testRuntime: vi.fn<DesktopApi['settings']['testRuntime']>(
async () => ({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
)
},
projects: {
list: vi.fn(async () => [project]),
create: vi.fn(async (input) => ({
...project,
...input,
id: crypto.randomUUID()
})),
update: vi.fn(async (_projectId, input) => ({
...project,
...input,
id: _projectId
})),
setArchived: vi.fn(async () => {}),
delete: vi.fn(async () => {})
},
conversations: {
list: vi.fn(async () => []),
replace: vi.fn(async () => {}),
onChanged: vi.fn(() => () => undefined)
},
workspace: {
getChanges: vi.fn(async () => ({
rootPath: 'C:\\Workspace',
available: true,
status: '',
patch: '',
files: [],
truncated: false
})),
listDirectory: vi.fn(async (path: string) => ({
path,
entries: [],
truncated: false
})),
readFile: vi.fn(async (path: string) => ({
path,
name: path.split('/').at(-1) ?? path,
content: '',
mimeType: 'text/plain' as const,
size: 0
})),
openPath: vi.fn(async () => {})
},
tasks: {
list: vi.fn(async () => []),
setStatus: vi.fn(async () => {})
},
usage: {
getTokenSummary: vi.fn(async () => ({
totals: {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0
},
records: []
}))
},
artifacts: {
list: vi.fn(async () => []),
get: vi.fn(async () => {
throw new Error('Artifact not found')
}),
importFiles: vi.fn(async () => [])
},
memory: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
confidence: 1,
salience: 1,
status: 'confirmed' as const,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
})),
setStatus: vi.fn(async () => {}),
remove: vi.fn(async () => {})
},
schedules: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
})),
setEnabled: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
runNow: vi.fn(async () => {})
},
heartbeats: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
nextRunAt: '2026-08-01T09:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
update: vi.fn(async (heartbeatId, input) => ({
...input,
id: heartbeatId,
nextRunAt: '2026-08-01T09:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
setPaused: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
runNow: vi.fn(async (heartbeatId) => ({
id: crypto.randomUUID(),
configId: heartbeatId,
trigger: 'manual' as const,
scheduledFor: '2026-08-01T00:00:00.000Z',
status: 'completed' as const,
attemptCount: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
history: vi.fn(async () => ({ runs: [], entries: [] }))
},
experts: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
})),
update: vi.fn(async (expertId, input) => ({
...input,
id: expertId,
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
})),
remove: vi.fn(async () => {})
},
capabilities: {
getSnapshot: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
importSkill: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
removeSkill: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
setSkillEnabled: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
setSkillAssignments: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
saveMcpServer: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
removeMcpServer: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
testMcpServer: vi.fn(async () => ({
toolCount: 0,
tools: []
}))
},
context: {
selectFiles: vi.fn(async () => []),
addPastedImage: vi.fn(async () => {
throw new Error('not used')
}),
captureScreen: vi.fn(async () => {
throw new Error('not used')
}),
listWindows: vi.fn(async () => []),
captureWindow: vi.fn(async () => {
throw new Error('not used')
}),
readClipboard: vi.fn(async () => {
throw new Error('not used')
}),
remove: vi.fn(async () => {})
},
magicNotes: {
list: vi.fn(async () => ({ notes: [] })),
get: vi.fn(async () => {
throw new Error('not used')
}),
create: vi.fn(async () => {
throw new Error('not used')
}),
update: vi.fn(async () => {
throw new Error('not used')
}),
remove: vi.fn(async () => {}),
createEntry: vi.fn(async () => {
throw new Error('not used')
}),
updateEntry: vi.fn(async () => {
throw new Error('not used')
}),
removeEntry: vi.fn(async () => {
throw new Error('not used')
}),
analyze: vi.fn(async () => {
throw new Error('not used')
}),
analyzeDraft: vi.fn(async () => {
throw new Error('not used')
}),
listTodos: vi.fn(async () => ({ todos: [] })),
updateTodo: vi.fn(async () => {
throw new Error('not used')
}),
analyzeTodo: vi.fn(async () => {
throw new Error('not used')
}),
onAnalysisEvent: vi.fn(() => vi.fn())
},
knowledge: {
getSnapshot: vi.fn(async () => ({
libraries: [],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})),
createLibrary: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
})),
updateLibrary: vi.fn(async () => {}),
deleteLibrary: vi.fn(async () => {}),
reextractGraph: vi.fn(async () => {}),
selectFiles: vi.fn(async () => {}),
selectDirectory: vi.fn(async () => {}),
importDroppedFiles: vi.fn(async () => {}),
importUrl: vi.fn(async () => {}),
syncSource: vi.fn(async () => {}),
pauseSource: vi.fn(async () => {}),
retrySource: vi.fn(async () => {}),
removeSource: vi.fn(async () => {}),
search: vi.fn(async () => []),
createEntity: vi.fn(async () => {}),
updateEntity: vi.fn(async () => {}),
moveEntity: vi.fn(async () => {}),
deleteEntity: vi.fn(async () => {}),
mergeEntities: vi.fn(async () => {}),
createRelation: vi.fn(async () => {}),
updateRelation: vi.fn(async () => {}),
deleteRelation: vi.fn(async () => {})
}
}
function composerMenuTrigger(
label: '专家角色' | '工作模式'
): HTMLButtonElement {
return screen.getByRole('button', {
name: new RegExp(`^${label}`, 'u')
})
}
function openComposerMenu(
label: '专家角色' | '工作模式'
): HTMLElement {
fireEvent.click(composerMenuTrigger(label))
return screen.getByRole('menu', { name: label })
}
function selectComposerOption(
label: '专家角色' | '工作模式',
optionLabel: string
): void {
const menu = openComposerMenu(label)
const option = within(menu)
.getByText(optionLabel, { selector: 'span' })
.closest<HTMLButtonElement>('button')
if (!option) {
throw new Error(`Missing ${label} option: ${optionLabel}`)
}
fireEvent.click(option)
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.clearAllMocks()
newConversationListener = undefined
browserListener = undefined
maximizedChangedListener = undefined
speechRecognitionMocks.startPcmRecording.mockResolvedValue({
result: Promise.resolve({
audio: new Float32Array([0, 0.25, -0.25]).buffer,
sampleRate: 16_000
}),
stop: vi.fn(),
cancel: vi.fn()
})
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn() }
})
Object.defineProperty(window, 'AudioContext', {
configurable: true,
value: class AudioContextMock {}
})
vi.mocked(api.speech!.transcribe).mockResolvedValue({
text: '本地语音结果'
})
vi.mocked(api.speech!.cancel).mockResolvedValue(true)
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: api
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('provides custom minimize, maximize, and close controls', async () => {
const { unmount } = render(<App />)
fireEvent.click(screen.getByLabelText('最小化窗口'))
fireEvent.click(screen.getByLabelText('最大化窗口'))
fireEvent.click(screen.getByLabelText('关闭窗口'))
await waitFor(() => {
expect(api.app.minimize).toHaveBeenCalledOnce()
expect(api.app.toggleMaximize).toHaveBeenCalledOnce()
expect(api.app.close).toHaveBeenCalledOnce()
})
act(() => maximizedChangedListener?.(true))
expect(await screen.findByLabelText('还原窗口')).toBeInTheDocument()
act(() => maximizedChangedListener?.(false))
expect(screen.getByLabelText('最大化窗口')).toBeInTheDocument()
unmount()
expect(removeMaximizedChangedListener).toHaveBeenCalledOnce()
})
it('renders the core app shell in English', async () => {
await changeUiLocale('en-US')
try {
render(<App />)
expect(
await screen.findByText('New conversation', {
selector: '.new-chat span'
})
).toBeInTheDocument()
expect(
screen.getByRole('navigation', {
name: 'Main navigation'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Chat' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', {
name: 'What would you like to accomplish today?'
})
).toBeInTheDocument()
expect(
screen.getByText(/Hi, Im GoodBuddy/u)
).toBeInTheDocument()
expect(
screen.getByLabelText('Message GoodBuddy')
).toHaveAttribute(
'placeholder',
'Message GoodBuddy…\nEnter to send · Shift+Enter for a new line · Ctrl+V to paste an image or text'
)
} finally {
cleanup()
await changeUiLocale('zh-CN')
}
})
it('keeps Settings open when the interface language changes', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(),
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
}
try {
render(
<UiLocaleProvider initialPreference="zh-CN">
<App />
</UiLocaleProvider>
)
fireEvent.click(
await screen.findByRole('button', {
name: /本地工作区/u
})
)
fireEvent.click(
await screen.findByRole('tab', { name: '外观' })
)
const projectsList = vi.mocked(api.projects.list)
const expertsList = vi.mocked(api.experts.list)
const tasksList = vi.mocked(api.tasks.list)
await waitFor(() => {
expect(projectsList).toHaveBeenCalled()
expect(expertsList).toHaveBeenCalled()
expect(tasksList).toHaveBeenCalled()
})
const loadCounts = {
projects: projectsList.mock.calls.length,
experts: expertsList.mock.calls.length,
tasks: tasksList.mock.calls.length
}
fireEvent.click(
screen.getByRole('radio', {
name: /^English/u
})
)
expect(
await screen.findByRole('region', {
name: 'Settings'
})
).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 1,
name: 'Settings'
})
).toBeInTheDocument()
expect(api.updates.getSettings).toHaveBeenCalledOnce()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(projectsList).toHaveBeenCalledTimes(loadCounts.projects)
expect(expertsList).toHaveBeenCalledTimes(loadCounts.experts)
expect(tasksList).toHaveBeenCalledTimes(loadCounts.tasks)
} finally {
delete api.updates
cleanup()
await changeUiLocale('zh-CN')
}
})
it('checks for updates silently on startup and only reports a new version', async () => {
const check = vi.fn(async () => ({
updateAvailable: true,
currentVersion: '0.8.4',
latestVersion: '0.9.0',
releaseUrl:
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0',
target: {
platform: 'windows' as const,
arch: 'x64' as const,
formats: ['nsis', 'portable'],
files: [
{
name: 'GoodBuddy-0.9.0-windows-x64-portable.zip',
size: 1,
sha256: 'a'.repeat(64)
}
]
}
}))
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
await waitFor(() => expect(check).toHaveBeenCalledOnce())
expect(
await screen.findByText(
'发现 GoodBuddy 0.9.0,可在“关于与更新”中查看'
)
).toBeInTheDocument()
expect(api.updates.onResult).not.toHaveBeenCalled()
} finally {
delete api.updates
}
})
it('does not disturb startup when updates are current or offline', async () => {
const currentResult = {
updateAvailable: false,
currentVersion: '0.8.4',
latestVersion: '0.8.4',
releaseUrl:
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.4',
target: {
platform: 'windows' as const,
arch: 'x64' as const,
formats: ['nsis', 'portable'],
files: []
}
}
const check = vi
.fn()
.mockResolvedValueOnce(currentResult)
.mockRejectedValueOnce(new Error('offline'))
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
const first = render(<App />)
await waitFor(() => expect(check).toHaveBeenCalledTimes(1))
expect(
screen.queryByText(/发现 GoodBuddy|版本检查失败/u)
).not.toBeInTheDocument()
first.unmount()
render(<App />)
await waitFor(() => expect(check).toHaveBeenCalledTimes(2))
expect(
screen.queryByText(/发现 GoodBuddy|版本检查失败/u)
).not.toBeInTheDocument()
} finally {
delete api.updates
}
})
it('keeps rendering when an older preload has no browser bridge', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
...api,
browser: undefined
}
})
render(<App />)
expect(
await screen.findByLabelText('向 GoodBuddy 提问')
).toBeInTheDocument()
})
it('uses local transcription when Electron has no Web Speech API', async () => {
Object.defineProperty(window, 'SpeechRecognition', {
configurable: true,
value: undefined
})
Object.defineProperty(window, 'webkitSpeechRecognition', {
configurable: true,
value: undefined
})
render(<App />)
fireEvent.click(await screen.findByLabelText('语音输入'))
await waitFor(() =>
expect(api.speech?.transcribe).toHaveBeenCalledWith(
expect.objectContaining({
sampleRate: 16_000,
audio: expect.any(ArrayBuffer)
})
)
)
expect(await screen.findByDisplayValue('本地语音结果')).toBeInTheDocument()
expect(
screen.getByText('快捷唤起:', { exact: false })
).toHaveTextContent('快捷唤起:Ctrl+Shift+Space')
expect(
screen.queryByText(/CommandOrControl/)
).not.toBeInTheDocument()
})
it('shows a red recording state until microphone capture stops', async () => {
let resolveRecording!: (value: {
audio: ArrayBuffer
sampleRate: 16_000
}) => void
const stop = vi.fn()
speechRecognitionMocks.startPcmRecording.mockResolvedValueOnce({
result: new Promise((resolve) => {
resolveRecording = resolve
}),
stop,
cancel: vi.fn()
})
render(<App />)
const input = await screen.findByLabelText('向 GoodBuddy 提问')
expect(input).toHaveAttribute('rows', '3')
expect(input).toHaveStyle({ height: '72px' })
fireEvent.click(screen.getByLabelText('语音输入'))
const recordingButton = await screen.findByRole('button', {
name: '停止录音'
})
expect(recordingButton).toHaveAttribute('data-state', 'recording')
expect(recordingButton).toHaveAttribute('aria-pressed', 'true')
expect(recordingButton).toHaveClass(
'composer__voice-button--recording'
)
fireEvent.click(recordingButton)
expect(stop).toHaveBeenCalledOnce()
resolveRecording({
audio: new Float32Array([0, 0.25, -0.25]).buffer,
sampleRate: 16_000
})
expect(
await screen.findByDisplayValue('本地语音结果')
).toBeInTheDocument()
})
it('keeps conversation actions in the conversation list', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
const { container } = render(<App />)
const topbar = container.querySelector<HTMLElement>('.topbar')
const conversationList =
container.querySelector<HTMLElement>('.conversation-list')
expect(topbar).not.toBeNull()
expect(conversationList).not.toBeNull()
if (!topbar || !conversationList) {
return
}
expect(
within(topbar).queryByRole('button', {
name: /^专家角色:/u
})
).not.toBeInTheDocument()
expect(composerMenuTrigger('专家角色').closest('.composer')).not.toBeNull()
const themeToggle = within(topbar).getByRole('button', {
name: '切换深色主题'
})
fireEvent.click(themeToggle)
await waitFor(() =>
expect(document.documentElement.dataset.theme).toBe('dark')
)
expect(
screen.queryByRole('menuitem', { name: '重命名会话' })
).not.toBeInTheDocument()
expect(
within(topbar).getByRole('button', {
name: '切换浅色主题'
})
).toBe(themeToggle)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
const conversationMenuTrigger = within(
conversationList
).getByLabelText('更多会话操作 新对话')
fireEvent.click(conversationMenuTrigger)
const renameButton = within(conversationList).getByRole('button', {
name: '重命名会话'
})
expect(renameButton).toBeVisible()
expect(
within(conversationList).getByRole('button', {
name: '复制完整会话'
})
).toBeVisible()
expect(
within(conversationList).getByRole('button', {
name: '导出 Markdown'
})
).toBeVisible()
fireEvent.click(renameButton)
const renameInput = within(conversationList).getByLabelText(
'重命名会话 新对话'
)
fireEvent.change(renameInput, {
target: { value: '重命名后的会话' }
})
fireEvent.submit(renameInput.closest('form')!)
expect(
within(conversationList).getByText('重命名后的会话')
).toBeInTheDocument()
await waitFor(() => expect(conversationMenuTrigger).toHaveFocus())
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
fireEvent.click(
within(conversationList).getByLabelText(
'更多会话操作 重命名后的会话'
)
)
fireEvent.click(
within(conversationList).getByRole('button', {
name: '复制完整会话'
})
)
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
expect(await screen.findByRole('status')).toBeVisible()
})
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render(<App />)
const menuTrigger = screen.getByLabelText(
'更多会话操作 新对话'
)
fireEvent.click(menuTrigger)
const deleteTrigger = screen.getByRole('button', {
name: '删除对话 新对话'
})
fireEvent.click(deleteTrigger)
const dialog = screen.getByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
expect(dialog).toHaveTextContent('将永久删除此会话的全部内容')
expect(dialog).toHaveTextContent(
'如果此会话有正在运行的任务,也会同时停止'
)
expect(dialog).toHaveTextContent('此操作不可恢复')
const cancel = screen.getByRole('button', {
name: '取消删除对话 新对话'
})
const confirm = screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(confirm).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
expect(
screen.getByRole('button', { name: '删除对话 新对话' })
).toHaveFocus()
)
expect(
screen.queryByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '删除对话 新对话' })
)
fireEvent.click(
screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
)
await waitFor(() =>
expect(
screen.queryByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
).not.toBeInTheDocument()
)
})
it('keeps a conversation when cancelling its active task fails', async () => {
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
new Error('cancel failed')
)
render(<App />)
const title = '取消失败时保留会话'
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: title }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
fireEvent.click(
await screen.findByLabelText(`更多会话操作 ${title}`)
)
fireEvent.click(
screen.getByRole('button', { name: `删除对话 ${title}` })
)
const confirm = screen.getByRole('button', {
name: `确认永久删除对话 ${title}`
})
fireEvent.click(confirm)
await waitFor(() =>
expect(api.agent.cancel).toHaveBeenCalledOnce()
)
expect(
await screen.findByText('停止会话中的运行任务失败,尚未删除对话')
).toBeInTheDocument()
expect(
screen.getByRole('alertdialog', {
name: `确认永久删除对话 ${title}`
})
).toBeInTheDocument()
await waitFor(() => expect(confirm).toBeEnabled())
vi.mocked(api.agent.cancel).mockResolvedValueOnce()
fireEvent.click(confirm)
await waitFor(() =>
expect(
screen.queryByRole('alertdialog', {
name: `确认永久删除对话 ${title}`
})
).not.toBeInTheDocument()
)
})
it('renders streamed reasoning, text, and tools in event order', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '帮我分析项目' }
})
await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled())
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.prompt).toBe('帮我分析项目')
expect(request?.runtimeSelection).toEqual({
provider: 'model',
profileId: modelProfileId
})
expect(
screen
.getByText('正在连接 Agent Runtime')
.querySelector('.message__status-dot')
).toHaveClass('message__status-dot--active')
const userMessage = screen
.getAllByText('帮我分析项目')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userMessage).toHaveClass('message--user')
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '这是'
})
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '回答内容'
})
agentListener?.({
requestId: request.requestId,
type: 'reasoning',
delta: '先检查项目结构'
})
})
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
const streamingReasoning = screen
.getByText('正在推理')
.closest('details')
expect(streamingReasoning).toHaveAttribute('open')
expect(screen.getByText('先检查项目结构')).toBeInTheDocument()
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'read',
state: 'running',
summary: 'OpenCode 工具:read',
input: '{"path":"README.md"}'
})
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-2',
name: 'grep',
state: 'completed',
summary: 'OpenCode 工具:grep',
input: '{"pattern":"runtime"}',
output: 'src/main/agent/runtime.ts'
})
agentListener?.({
requestId: request.requestId,
type: 'reasoning',
delta: '再检查关键文件'
})
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '最终结论'
})
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'read',
state: 'completed',
summary: 'OpenCode 工具:read',
input: '{"path":"README.md"}',
output:
'README contents\nAuthorization: Bearer visible-token'
})
})
const assistantArticle = screen
.getByText('最终结论')
.closest('article')
const orderedBlocks = [
...assistantArticle!.querySelectorAll('.message-blocks > *')
].map((element) => element.textContent)
expect(orderedBlocks).toEqual([
expect.stringContaining('这是回答内容'),
expect.stringContaining('先检查项目结构'),
expect.stringContaining('OpenCode 工具:read'),
expect.stringContaining('再检查关键文件'),
expect.stringContaining('最终结论')
])
expect(
screen.getAllByText('OpenCode 工具:read')
).toHaveLength(1)
expect(
screen.getByRole('region', { name: '工具执行,共 2 项' })
).toBeInTheDocument()
const readTool = screen.getByText('read').closest('details')
const rawToolOutput =
'README contents\nAuthorization: Bearer visible-token'
expect(readTool).not.toHaveAttribute('open')
expect(
screen.getByText(
(_, element) => element?.textContent === rawToolOutput
)
).not.toBeVisible()
fireEvent.click(screen.getByText('read').closest('summary')!)
expect(readTool).toHaveAttribute('open')
expect(within(readTool!).getByText('调用参数')).toBeVisible()
expect(
within(readTool!).getByText(
(_, element) => element?.textContent === rawToolOutput
)
).toBeVisible()
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
const completedReasoning = await screen.findAllByText('推理过程')
expect(completedReasoning).toHaveLength(2)
for (const reasoning of completedReasoning) {
expect(reasoning.closest('details')).not.toHaveAttribute('open')
}
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a tool failure in details and hides retry after continuing', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '读取演示文稿' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
const toolError =
'Cannot read binary file: D:\\workspace\\presentation.pptx'
const runtimeError = `OpenCode 工具执行失败(call-1):${toolError}`
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'read',
state: 'failed',
summary: 'OpenCode 工具:read',
input: '{"path":"D:\\\\workspace\\\\presentation.pptx"}',
error: toolError
})
agentListener?.({
requestId: request.requestId,
type: 'error',
status: 'failed',
message: runtimeError
})
})
expect(screen.getByText(toolError)).toBeInTheDocument()
expect(screen.queryByText(runtimeError)).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '重新编辑并发送' })
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '继续处理' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(
screen.queryByRole('button', { name: '重新编辑并发送' })
).not.toBeInTheDocument()
})
it('submits knowledge scope without eager search or prompt injection and merges runtime references', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
libraries: [
{
id: libraryId,
name: '产品知识',
description: '',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules',
sourceCount: 1,
documentCount: 1,
indexedDocumentCount: 1
}
],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
render(<App />)
await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '发布流程是什么?' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request).toMatchObject({
prompt: '发布流程是什么?',
knowledgeLibraryIds: [libraryId]
})
expect(api.knowledge.search).not.toHaveBeenCalled()
expect(
screen.queryByText(/查看 \d+ 条证据引用/u)
).not.toBeInTheDocument()
act(() => {
if (!request) {
throw new Error('Missing request')
}
for (let batch = 0; batch < 5; batch += 1) {
agentListener?.({
requestId: request.requestId,
type: 'source-references',
references: Array.from({ length: 25 }, (_, index) => ({
libraryId,
libraryName: '产品知识',
documentId: crypto.randomUUID(),
documentName: `发布手册 ${batch}-${index}`,
sourceName: `release-${batch}-${index}.md`,
locator: `第 ${batch}-${index} 节`,
snippet: `证据 ${batch}-${index}`,
rank: index + 1
}))
})
}
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
expect(
await screen.findByText('查看 20 条证据引用')
).toBeInTheDocument()
await waitFor(
() => {
const persistedMessages = vi
.mocked(api.conversations.replace)
.mock.calls.flatMap(([conversations]) =>
conversations.flatMap((conversation) => conversation.messages)
)
const persisted = persistedMessages
.filter((message) => message.role === 'assistant')
.slice()
.reverse()
.find(
(message) => message.sourceReferences?.length === 20
)
expect(persisted?.sourceReferences).toHaveLength(20)
expect(persisted?.sources).toHaveLength(100)
},
{ timeout: 2_000 }
)
})
it('keeps a running response visible when cancellation fails', async () => {
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
new Error('cancel failed')
)
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '开始一个长任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
fireEvent.click(await screen.findByLabelText('停止生成'))
await waitFor(() =>
expect(api.agent.cancel).toHaveBeenCalledOnce()
)
expect(
await screen.findByText(/停止生成失败,请重试/u)
).toBeInTheDocument()
expect(screen.getByLabelText('停止生成')).toBeInTheDocument()
vi.useFakeTimers()
try {
act(() => vi.advanceTimersByTime(10_000))
expect(
screen.getByText(/停止生成失败,请重试/u)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '关闭通知'
})
)
expect(
screen.queryByText(/停止生成失败,请重试/u)
).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('keeps sent documents and images in conversation history', async () => {
const documentAttachment = {
id: '00000000-0000-4000-8000-000000000301',
name: '需求说明.md',
size: 2_048,
preview: '需要保留在用户消息中的文档',
kind: 'text' as const
}
const imageAttachment = {
id: '00000000-0000-4000-8000-000000000302',
name: '页面截图.png',
size: 4_096,
preview: '1280 × 720',
kind: 'image' as const,
thumbnailUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
contentUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q=='
}
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([
documentAttachment,
imageAttachment
])
render(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
const composer = screen
.getByLabelText('向 GoodBuddy 提问')
.closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
if (!composer) {
return
}
expect(
await within(composer).findByText('需求说明.md')
).toBeInTheDocument()
expect(
within(composer).getByText('页面截图.png')
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析这些附件' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]?.[0].contextIds).toEqual([
documentAttachment.id,
imageAttachment.id
])
const userArticle = screen
.getAllByText('分析这些附件')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userArticle).not.toBeNull()
if (!userArticle) {
return
}
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
expect(within(userArticle).getByText('需求说明.md')).toBeInTheDocument()
expect(within(userArticle).getByText('2 KB')).toBeInTheDocument()
expect(
within(userArticle).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
)
const imageDialog = await screen.findByRole('dialog', {
name: '页面截图.png'
})
expect(
within(imageDialog).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
})
)
expect(
screen.queryByRole('dialog', { name: '页面截图.png' })
).not.toBeInTheDocument()
fireEvent.click(
within(userArticle).getByRole('button', {
name: '下载图片 页面截图.png'
})
)
expect(anchorClick).toHaveBeenCalledOnce()
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
attachments: [
documentAttachment,
imageAttachment
]
})
])
})
])
),
{ timeout: 2_000 }
)
})
it('sends and renders five selected images together', async () => {
const imageAttachments = Array.from({ length: 5 }, (_, index) => ({
id: `00000000-0000-4000-8000-00000000031${index}`,
name: `参考图-${index + 1}.png`,
size: 4_096,
preview: '640 × 480',
kind: 'image' as const,
thumbnailUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==',
contentUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q=='
}))
vi.mocked(api.context.selectFiles).mockResolvedValueOnce(imageAttachments)
render(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
const composer = screen
.getByLabelText('向 GoodBuddy 提问')
.closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
if (!composer) {
return
}
await waitFor(() =>
expect(
within(composer).getAllByText(/^参考图-\d\.png$/u)
).toHaveLength(5)
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '比较这五张图片' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]?.[0].contextIds).toEqual(
imageAttachments.map((attachment) => attachment.id)
)
const userArticle = screen
.getAllByText('比较这五张图片')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userArticle).not.toBeNull()
if (!userArticle) {
return
}
expect(within(userArticle).getAllByRole('img')).toHaveLength(5)
expect(within(userArticle).getByLabelText('消息附件')).toHaveClass(
'message-attachments'
)
})
it('accepts pasted images without intercepting pasted text', async () => {
vi.mocked(api.context.addPastedImage).mockResolvedValueOnce({
id: '00000000-0000-4000-8000-000000000303',
name: '粘贴图片.jpg',
size: 120_000,
preview: '1280 × 800',
kind: 'image',
thumbnailUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'
})
const pastedImage = new File(
[Uint8Array.from([0x89, 0x50, 0x4e, 0x47])],
'pasted.png',
{ type: 'image/png' }
)
render(<App />)
const input = await screen.findByLabelText('向 GoodBuddy 提问')
expect(
fireEvent.paste(input, {
clipboardData: {
items: [
{
getAsFile: () => null,
kind: 'string',
type: 'text/plain'
}
]
}
})
).toBe(true)
expect(api.context.addPastedImage).not.toHaveBeenCalled()
fireEvent.paste(input, {
clipboardData: {
items: [
{
getAsFile: () => pastedImage,
kind: 'file',
type: 'image/png'
}
]
}
})
await waitFor(() =>
expect(api.context.addPastedImage).toHaveBeenCalledWith({
data: Uint8Array.from([0x89, 0x50, 0x4e, 0x47]),
mimeType: 'image/png'
})
)
expect(api.context.addPastedImage).toHaveBeenCalledTimes(1)
expect(api.context.readClipboard).not.toHaveBeenCalled()
const composer = input.closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
if (!composer) {
return
}
expect(
await within(composer).findByText('粘贴图片.jpg')
).toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '截取当前屏幕'
})
).not.toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '捕获应用窗口'
})
).not.toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '读取剪贴板'
})
).not.toBeInTheDocument()
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
vi.mocked(api.agent.getStatus).mockReturnValue(
new Promise(() => {})
)
render(<App />)
const composer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.change(composer, {
target: { value: '等待 Runtime' }
})
fireEvent.keyDown(composer, { key: 'Enter' })
expect(composer).toHaveValue('等待 Runtime')
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
expect(
await screen.findByText('Agent Runtime 正在加载,请稍后重试')
).toBeInTheDocument()
expect(run).not.toHaveBeenCalled()
})
it('keeps a new-conversation draft in chat when the runtime is unavailable', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'setup',
label: '需要配置模型',
available: false,
supportsToolExecution: false,
detail: '请配置模型'
})
render(<App />)
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
const newConversation = screen.getByRole('button', {
name: /新建对话/u
})
fireEvent.click(newConversation)
const composer = screen.getByLabelText('向 GoodBuddy 提问')
await waitFor(() => expect(composer).toHaveFocus())
fireEvent.change(composer, {
target: { value: '保留这条草稿' }
})
fireEvent.keyDown(composer, { key: 'Enter' })
expect(composer).toHaveValue('保留这条草稿')
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
expect(
await screen.findByText(/请先配置可用的模型或 Agent Runtime/u)
).toBeInTheDocument()
expect(run).not.toHaveBeenCalled()
})
it('opens chat and focuses the composer for tray conversations', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
act(() => newConversationListener?.())
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
await waitFor(() => expect(composer).toHaveFocus())
})
it('reuses the active empty conversation and preserves its draft', async () => {
render(<App />)
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
fireEvent.change(composer, {
target: { value: '尚未发送的草稿' }
})
const newConversation = screen.getByRole('button', {
name: /新建对话/u
})
fireEvent.click(newConversation)
fireEvent.click(newConversation)
expect(composer).toHaveValue('尚未发送的草稿')
expect(
screen.getAllByRole('button', {
name: '更多会话操作 新对话'
})
).toHaveLength(1)
})
it('coalesces batched new-conversation requests after a used conversation', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '已有内容' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const requestId = run.mock.calls[0]?.[0].requestId
act(() => {
if (requestId) {
agentListener?.({ requestId, type: 'done' })
}
newConversationListener?.()
newConversationListener?.()
})
expect(
screen.getAllByRole('button', {
name: /^更多会话操作/u
})
).toHaveLength(2)
})
it('opens a workspace Markdown file in the right-side preview', async () => {
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
path: '',
entries: [
{
name: 'README.md',
path: 'README.md',
type: 'file'
}
],
truncated: false
})
vi.mocked(api.workspace.readFile).mockResolvedValue({
path: 'README.md',
name: 'README.md',
content: '# 工作区说明',
mimeType: 'text/markdown',
size: 19
})
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
fireEvent.click(
await screen.findByRole('button', { name: 'README.md' })
)
expect(
await screen.findByRole('heading', { name: '工作区说明' })
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '工作区' })
).toHaveAttribute('aria-selected', 'true')
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
expect(api.workspace.readFile).toHaveBeenCalledWith(
projectId,
'README.md'
)
fireEvent.click(
screen.getByRole('button', { name: '返回工作区' })
)
expect(
await screen.findByRole('button', { name: 'README.md' })
).toBeInTheDocument()
})
it('opens workspace entries from their row actions', async () => {
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
path: '',
entries: [
{ name: 'docs', path: 'docs', type: 'directory' },
{ name: 'README.md', path: 'README.md', type: 'file' }
],
truncated: false
})
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
fireEvent.click(
await screen.findByRole('button', {
name: '在系统资源管理器中打开文件夹 docs'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '使用默认应用打开文件 README.md'
})
)
await waitFor(() =>
expect(api.workspace.openPath).toHaveBeenCalledWith(
projectId,
'docs',
'directory'
)
)
expect(api.workspace.openPath).toHaveBeenCalledWith(
projectId,
'README.md',
'file'
)
})
it('refreshes generated workspace files when a run completes', async () => {
vi.mocked(api.workspace.getChanges)
.mockResolvedValueOnce({
rootPath: project.rootPath,
available: true,
status: '',
patch: '',
files: [],
truncated: false
})
.mockResolvedValueOnce({
rootPath: project.rootPath,
available: true,
status: '?? generated.md',
patch: '',
files: [{ path: 'generated.md', status: '??' }],
truncated: false
})
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledOnce()
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '生成文件' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const requestId = run.mock.calls[0]?.[0].requestId
act(() => {
if (requestId) {
agentListener?.({ requestId, type: 'done' })
}
})
expect(await screen.findByText('generated.md')).toBeInTheDocument()
})
it('ignores stale Git changes after switching projects', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
let resolveFirst:
| ((value: Awaited<ReturnType<DesktopApi['workspace']['getChanges']>>) => void)
| undefined
let resolveSecond:
| ((value: Awaited<ReturnType<DesktopApi['workspace']['getChanges']>>) => void)
| undefined
vi.mocked(api.workspace.getChanges)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
})
)
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(
secondProject.id
)
)
resolveSecond?.({
rootPath: secondProject.rootPath,
available: true,
status: '?? second.md',
patch: '',
files: [{ path: 'second.md', status: '??' }],
truncated: false
})
expect(await screen.findByText('second.md')).toBeInTheDocument()
resolveFirst?.({
rootPath: project.rootPath,
available: true,
status: '?? stale.md',
patch: '',
files: [{ path: 'stale.md', status: '??' }],
truncated: false
})
await waitFor(() =>
expect(screen.queryByText('stale.md')).not.toBeInTheDocument()
)
expect(screen.getByText('second.md')).toBeInTheDocument()
})
it('applies and persists a dark appearance from Settings', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
fireEvent.click(screen.getByRole('radio', { name: /暗色/u }))
await waitFor(() =>
expect(document.documentElement.dataset.theme).toBe('dark')
)
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(localStorage.getItem('goodbuddy.appearance-theme')).toBe(
'dark'
)
})
it('loads token usage in activity and refreshes it when a run finishes', async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 1,
input: 100,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 120
},
records: [
{
requestId: 'usage-request-1',
projectId,
projectName: project.name,
conversationId: 'usage-conversation-1',
conversationTitle: '用量会话',
runtime: 'model',
provider: 'anthropic',
model: 'sonnet-5',
callCount: 1,
input: 100,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 120
}
]
})
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '统计用量' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
fireEvent.click(screen.getByText('任务与活动'))
const stats = await screen.findByLabelText('Token 用量统计')
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^专家角色:/u })
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
)
expect(within(stats).getByText('120')).toBeInTheDocument()
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 2,
input: 300,
output: 45,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 360
},
records: [
{
requestId: request.requestId,
projectId,
projectName: project.name,
conversationId: request.conversationId,
conversationTitle: '用量会话',
runtime: 'model',
provider: 'anthropic',
model: 'sonnet-5',
callCount: 2,
input: 300,
output: 45,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 360
}
]
})
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2)
)
expect(within(stats).getByText('345')).toBeInTheDocument()
})
it('offers only Ask and Execute in visible work mode controls', async () => {
render(<App />)
await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
const modeMenu = openComposerMenu('工作模式')
expect(
within(modeMenu)
.getAllByRole('menuitemradio')
.map((option) => option.querySelector('span')?.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const defaultMode = within(dialog).getByRole('combobox', {
name: '默认模式'
})
expect(
within(defaultMode)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
it('matches expert and work mode keyboard menus to the model picker', async () => {
render(<App />)
const expertTrigger = await screen.findByRole('button', {
name: '专家角色:通用助手'
})
expect(expertTrigger).toHaveClass('model-button')
fireEvent.keyDown(expertTrigger, { key: 'ArrowDown' })
const expertMenu = screen.getByRole('menu', {
name: '专家角色'
})
expect(expertMenu).toHaveClass('runtime-picker__menu')
const generalExpert = within(expertMenu).getByRole(
'menuitemradio',
{ name: /^通用助手/u }
)
const expertTeam = within(expertMenu).getByRole(
'menuitemradio',
{ name: /^专家团队(并行)/u }
)
await waitFor(() => expect(generalExpert).toHaveFocus())
fireEvent.keyDown(generalExpert, { key: 'ArrowDown' })
expect(expertTeam).toHaveFocus()
fireEvent.keyDown(expertTeam, { key: 'Escape' })
expect(expertTrigger).toHaveFocus()
expect(
screen.queryByRole('menu', { name: '专家角色' })
).not.toBeInTheDocument()
const modeTrigger = composerMenuTrigger('工作模式')
fireEvent.click(modeTrigger)
const modeMenu = screen.getByRole('menu', { name: '工作模式' })
expect(modeMenu).toHaveClass('runtime-picker__menu')
fireEvent.pointerDown(screen.getByLabelText('向 GoodBuddy 提问'))
expect(
screen.queryByRole('menu', { name: '工作模式' })
).not.toBeInTheDocument()
})
it('groups composer tools and exposes clear control descriptions', async () => {
render(<App />)
const composer = (await screen.findByLabelText(
'向 GoodBuddy 提问'
)).closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
const contentTools = within(composer!).getByRole('group', {
name: '添加内容'
})
expect(
within(contentTools).getByRole('button', { name: '添加附件' })
).toHaveAttribute('title', '添加附件')
expect(
within(contentTools).getByRole('button', { name: '语音输入' })
).toHaveAttribute(
'title',
'语音转文字,转写后可编辑再发送'
)
const conversationSettings = within(composer!).getByRole(
'group',
{ name: '对话设置' }
)
expect(
within(conversationSettings).getByRole('button', {
name: '专家角色:通用助手'
})
).toBeInTheDocument()
expect(
within(conversationSettings).getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toBeInTheDocument()
expect(
within(conversationSettings).getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toHaveTextContent(/^Ask$/u)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
)
expect(
within(conversationSettings).getByRole('button', {
name: /默认模型/u
})
).toHaveAttribute(
'title',
expect.stringContaining('Runtime 和模型')
)
})
it('normalizes a legacy Plan project default to Ask', async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
...project,
defaultWorkMode: 'plan'
}
])
render(<App />)
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布方案' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '制定发布方案',
workMode: 'ask'
})
)
)
})
it('restores and persists the last active project', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second',
defaultWorkMode: 'execute' as const
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
localStorage.setItem(
'goodbuddy.active-project.v1',
secondProject.id
)
render(<App />)
expect(await screen.findByLabelText('当前项目')).toHaveValue(
secondProject.id
)
expect(
screen.getByRole('button', {
name: '工作模式:Execute · 受控执行'
})
).toBeEnabled()
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: project.id }
})
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
).toBe(project.id)
)
})
it('keeps channel projects empty until a client message creates a remote conversation', async () => {
const channelProject = {
...project,
id: '00000000-0000-4000-8000-000000000201',
name: '微信 ClawBot',
kind: 'channel' as const,
channel: 'weixin' as const,
runtimeSelection: {
provider: 'model' as const,
profileId: modelProfileId
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
channelProject
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
expect(
screen.queryByRole('button', { name: /新建对话/u })
).not.toBeInTheDocument()
expect(screen.queryByText('Ctrl N')).not.toBeInTheDocument()
expect(
screen.getAllByText('尚无远程会话').length
).toBeGreaterThan(0)
expect(
screen.getByText(
'请先连接微信 ClawBot,远程用户发送第一条消息后,会话会自动出现在这里。'
)
).toBeInTheDocument()
fireEvent.keyDown(document, {
key: 'n',
ctrlKey: true
})
expect(
await screen.findByText(
'通道项目的会话由客户端收到新消息后自动创建'
)
).toBeInTheDocument()
await act(
() =>
new Promise((resolve) => {
setTimeout(resolve, 550)
})
)
expect(api.conversations.replace).not.toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
projectId: channelProject.id,
remote: undefined
})
])
)
expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0)
})
it('shows client-created remote conversations without obsolete approval copy', async () => {
const channelProject = {
...project,
id: '00000000-0000-4000-8000-000000000201',
name: '微信 ClawBot',
kind: 'channel' as const,
channel: 'weixin' as const,
runtimeSelection: {
provider: 'model' as const,
profileId: modelProfileId
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
channelProject
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000301',
projectId: channelProject.id,
runtimeSelection: channelProject.runtimeSelection,
remote: {
channel: 'weixin',
accountDisplay: '发送者 ****0001',
conversationType: 'direct'
},
title: '微信 ClawBot · ****0001',
updatedAt: 1_775_000_000_000,
messages: []
}
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
expect(
screen.getAllByRole('button', {
name: /微信 ClawBot · \*{4}0001/u
}).length
).toBeGreaterThan(0)
expect(
screen.getByText(
'请在 微信 ClawBot 客户端继续发送消息。本窗口用于查看历史、任务与执行结果。'
)
).toBeInTheDocument()
expect(screen.queryByText(/审批执行/u)).not.toBeInTheDocument()
})
it('falls back when the last active project is no longer available', async () => {
localStorage.setItem(
'goodbuddy.active-project.v1',
'00000000-0000-4000-8000-000000000999'
)
render(<App />)
expect(await screen.findByLabelText('当前项目')).toHaveValue(
project.id
)
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
).toBe(project.id)
)
})
it.each([
['opencode', 'OpenCode'],
['continue', 'Continue CLI']
] as const)(
'lets %s select Ask or Execute',
async (runtimeId, label) => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: runtimeId,
label,
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
render(<App />)
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
expect(mode.closest('.composer')).not.toBeNull()
expect(
await screen.findByText(
new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`)
)
).toBeInTheDocument()
selectComposerOption('工作模式', 'Execute · 受控执行')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
)
expect(mode).toHaveTextContent(/^Execute$/u)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '执行任务',
workMode: 'execute'
})
)
)
}
)
it('restores the direct-model mode after leaving an Agent Runtime', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'opencode',
opencodeEmbedded: true,
opencodeModelSource: { kind: 'platform' }
})
vi.mocked(api.agent.getStatus)
.mockResolvedValueOnce({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
.mockResolvedValueOnce({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: false,
detail: 'Ready'
})
render(<App />)
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
selectComposerOption('工作模式', 'Execute · 受控执行')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
)
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^默认模型.*sonnet-5$/u
})
)
await waitFor(() => {
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
expect(mode).toBeEnabled()
})
})
it('disables Execute for a runtime without tool support', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'legacy-model',
available: true,
supportsToolExecution: false,
detail: 'Ready'
})
render(<App />)
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
const modeMenu = openComposerMenu('工作模式')
expect(
within(modeMenu).getByRole('menuitemradio', {
name: /^Execute · 受控执行/u
})
).toBeDisabled()
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
})
it('allows a direct model to submit Execute with GoodBuddy approvals', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
render(<App />)
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
selectComposerOption('工作模式', 'Execute · 受控执行')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '读取项目文件' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '读取项目文件',
workMode: 'execute'
})
)
)
expect(mode).toBeEnabled()
})
it('terminalizes tools and activity when a request is cancelled', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行长任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'bash',
state: 'running',
summary: 'OpenCode 工具:bash'
})
agentListener?.({
requestId: request.requestId,
type: 'error',
status: 'cancelled',
message: '请求已取消'
})
})
expect(await screen.findByText('已取消')).toBeInTheDocument()
const cancelledStatus = screen
.getAllByText('请求已取消')
.find((element) => element.classList.contains('message__status'))
expect(cancelledStatus).toBeDefined()
const cancelledDot = cancelledStatus?.querySelector(
'.message__status-dot'
)
expect(cancelledDot).toHaveClass('message__status-dot')
expect(cancelledDot).not.toHaveClass('message__status-dot--active')
fireEvent.click(screen.getByText('任务与活动'))
expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
expect(
screen.getByText('当前没有等待中或正在运行的活动。')
).toBeInTheDocument()
})
it('keeps persisted completed message statuses static', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000210',
projectId,
title: '已完成会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000211',
role: 'assistant',
content: '任务结果',
createdAt: 1_775_000_000_000,
state: 'complete',
status: '任务已完成'
}
]
}
])
render(<App />)
const completedStatus = await screen.findByText('任务已完成')
expect(
completedStatus.querySelector('.message__status-dot')
).toHaveClass('message__status-dot')
expect(
completedStatus.querySelector('.message__status-dot')
).not.toHaveClass('message__status-dot--active')
})
it('switches runtime profiles from the composer dropdown', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
fireEvent.click(runtimeButton)
expect(
await screen.findByRole('menu', { name: 'Runtime 和模型' })
).toBeInTheDocument()
expect(
screen.queryByText('自动选择')
).not.toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^默认模型.*sonnet-5$/u
})
).toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
).toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^Continue · 默认模型.*sonnet-5$/u
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^默认模型.*sonnet-5$/u
})
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'model',
profileId: modelProfileId
})
)
expect(api.settings.updateRuntime).not.toHaveBeenCalled()
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
})
it('shows Runtime switches globally without replacing composer guidance', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
vi.useFakeTimers()
try {
fireEvent.click(runtimeButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
)
await act(async () => {
await Promise.resolve()
})
const notification = screen.getByRole('status')
expect(notification).toHaveTextContent(
'当前对话已切换到 OpenCode · 默认模型'
)
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: /OpenCode · 默认模型/u
})
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^Continue · 默认模型.*sonnet-5$/u
})
)
await act(async () => {
await Promise.resolve()
})
expect(screen.getAllByRole('status')).toHaveLength(1)
expect(screen.getByRole('status')).toHaveTextContent(
'当前对话已切换到 Continue · 默认模型'
)
act(() => vi.advanceTimersByTime(4_500))
expect(
screen.queryByText(
'当前对话已切换到 Continue · 默认模型'
)
).not.toBeInTheDocument()
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('shows one configured choice per Agent Runtime in a flat keyboard menu', async () => {
const settings = await api.settings.getRuntime()
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
fireEvent.click(runtimeButton)
const runtimeMenu = screen.getByRole('menu', {
name: 'Runtime 和模型'
})
const directModel = screen.getByRole('menuitemradio', {
name: /^默认模型.*sonnet-5$/u
})
const secondDirectModel = screen.getByRole('menuitemradio', {
name: /^第二模型.*qwen3$/u
})
const openCodeModel = screen.getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
const continueModel = screen.getByRole('menuitemradio', {
name: /^Continue · 默认模型.*sonnet-5$/u
})
expect(directModel).toBeEnabled()
expect(secondDirectModel).toBeEnabled()
expect(openCodeModel).toBeEnabled()
expect(continueModel).toBeEnabled()
expect(screen.getAllByRole('menuitemradio')).toHaveLength(4)
expect(within(runtimeMenu).getAllByRole('separator')).toHaveLength(3)
expect(within(runtimeMenu).queryByRole('menu')).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitemradio', {
name: /^OpenCode · 第二模型/u
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitemradio', {
name: /^Continue · 第二模型/u
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitem', { name: /Agent Runtime/u })
).not.toBeInTheDocument()
await waitFor(() => expect(directModel).toHaveFocus())
expect(directModel).toHaveAttribute('tabindex', '0')
expect(secondDirectModel).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(directModel, { key: 'ArrowDown' })
expect(secondDirectModel).toHaveFocus()
expect(directModel).toHaveAttribute('tabindex', '-1')
expect(secondDirectModel).toHaveAttribute('tabindex', '0')
fireEvent.keyDown(secondDirectModel, { key: 'ArrowDown' })
expect(openCodeModel).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(runtimeButton).toHaveFocus()
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
fireEvent.click(runtimeButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
)
expect(runtimeButton).toHaveFocus()
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode',
profileId: modelProfileId
})
)
const selectedRuntimeButton = await screen.findByRole('button', {
name: /OpenCode · 默认模型/u
})
fireEvent.click(selectedRuntimeButton)
const selectedOpenCodeModel = screen.getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
await waitFor(() => expect(selectedOpenCodeModel).toHaveFocus())
expect(selectedOpenCodeModel).toHaveAttribute('aria-checked', 'true')
expect(selectedOpenCodeModel).toHaveAttribute('tabindex', '0')
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^Continue · 默认模型.*sonnet-5$/u
})
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'continue',
profileId: modelProfileId
})
)
})
it('dismisses the Runtime menu on outside pointer and focus changes', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
const composer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.click(runtimeButton)
expect(
screen.getByRole('menu', { name: 'Runtime 和模型' })
).toBeInTheDocument()
fireEvent.pointerDown(composer)
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
fireEvent.click(runtimeButton)
const selectedModel = screen.getByRole('menuitemradio', {
name: /^默认模型.*sonnet-5$/u
})
await waitFor(() => expect(selectedModel).toHaveFocus())
fireEvent.keyDown(selectedModel, { key: 'Tab' })
composer.focus()
expect(composer).toHaveFocus()
await waitFor(() =>
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
)
})
it('labels explicitly configured Runtime-owned model sources', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
})
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
expect(
screen.getByRole('menuitemradio', {
name: /^OpenCode · 自身配置.*使用 OpenCode 自身配置$/u
})
).toBeInTheDocument()
const continueChoice = screen.getByRole('menuitemradio', {
name: /^Continue · 自身配置.*使用 Continue 自身配置$/u
})
fireEvent.click(continueChoice)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'continue'
})
)
})
it('normalizes a legacy Auto conversation to the explicit default Runtime', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: { provider: 'auto' },
title: '旧自动对话',
updatedAt: 1,
messages: [
{
id: '00000000-0000-4000-8000-000000000021',
role: 'assistant',
content: '旧消息',
createdAt: 1,
state: 'complete'
}
]
}
])
render(<App />)
expect(
await screen.findByRole('button', { name: /默认模型.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
})
])
)
)
})
it('rebinds a loaded conversation when its model profile was removed', async () => {
const removedProfileId =
'00000000-0000-4000-8000-000000000099'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: removedProfileId
},
title: '旧模型对话',
updatedAt: 1,
messages: [
{
id: '00000000-0000-4000-8000-000000000023',
role: 'assistant',
content: '旧消息',
createdAt: 1,
state: 'complete'
}
]
}
])
render(<App />)
expect(
await screen.findByRole('button', { name: /默认模型.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
})
])
)
)
})
it('keeps model Runtime selection scoped to its conversation', async () => {
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
vi.mocked(api.agent.getStatus).mockImplementation(
async (selection) => ({
id: 'model',
label:
selection?.provider === 'model' &&
selection.profileId === secondProfileId
? 'qwen3'
: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
)
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^第二模型.*qwen3$/u
})
)
expect(
await screen.findByRole('button', { name: /第二模型/u })
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第二模型对话' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.runtimeSelection).toEqual({
provider: 'model',
profileId: secondProfileId
})
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
fireEvent.click(
screen.getByRole('button', { name: /新建对话/u })
)
expect(
await screen.findByRole('button', { name: /默认模型/u })
).toBeInTheDocument()
const previousConversation = screen
.getAllByText('第二模型对话')
.map((element) => element.closest('button'))
.find((button) => button?.classList.contains('conversation-item'))
if (!previousConversation) {
throw new Error('Missing previous conversation')
}
fireEvent.click(previousConversation)
await waitFor(() =>
expect(
screen
.getAllByRole('button', { name: /第二模型/u })
.find((button) => button.classList.contains('model-button'))
).toBeInTheDocument()
)
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
title: '第二模型对话',
runtimeSelection: {
provider: 'model',
profileId: secondProfileId
}
})
])
),
{ timeout: 2_000 }
)
})
it('ignores a stale picker status after rapidly changing conversations', async () => {
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
const thirdProfileId =
'00000000-0000-4000-8000-000000000003'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
},
{
id: thirdProfileId,
name: '第三模型',
baseUrl: 'http://127.0.0.1:11435/v1',
modelName: 'llama3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
let resolveThird!: (status: {
id: 'model'
label: string
available: boolean
supportsToolExecution: boolean
detail: string
}) => void
const thirdStatus = new Promise<{
id: 'model'
label: string
available: boolean
supportsToolExecution: boolean
detail: string
}>((resolve) => {
resolveThird = resolve
})
vi.mocked(api.agent.getStatus).mockImplementation(async (selection) => {
if (
selection?.provider === 'model' &&
selection.profileId === thirdProfileId
) {
return thirdStatus
}
return {
id: 'model',
label:
selection?.provider === 'model' &&
selection.profileId === secondProfileId
? 'qwen3'
: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
}
})
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^第二模型.*qwen3$/u
})
)
await screen.findByRole('button', { name: /第二模型/u })
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '保留第二模型会话' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({ requestId: request.requestId, type: 'done' })
})
const secondModelButton = screen
.getAllByRole('button', { name: /第二模型/u })
.find((button) => button.classList.contains('model-button'))
if (!secondModelButton) {
throw new Error('Missing second model picker')
}
fireEvent.click(secondModelButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^第三模型.*llama3$/u
})
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenCalledWith({
provider: 'model',
profileId: thirdProfileId
})
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '状态未完成时不能发送' }
})
expect(screen.getByLabelText('发送')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /新建对话/u }))
await screen.findByRole('button', { name: /默认模型/u })
await act(async () => {
resolveThird({
id: 'model',
label: 'llama3',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
await thirdStatus
})
expect(
screen.getByRole('button', { name: /默认模型/u })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /第三模型/u })
).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '新对话仍可发送' }
})
await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled())
})
it('opens project creation as an unobscured dialog', async () => {
render(<App />)
const newProjectButton = await screen.findByLabelText('新建项目')
fireEvent.click(newProjectButton)
let dialog = screen.getByRole('dialog', { name: '新建项目' })
expect(dialog).toHaveClass('project-create-card')
expect(within(dialog).getByRole('button', { name: '创建' }))
.toBeDisabled()
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
expect(
screen.queryByRole('dialog', { name: '新建项目' })
).not.toBeInTheDocument()
expect(newProjectButton).toHaveFocus()
fireEvent.click(newProjectButton)
dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: '新项目' }
})
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }))
await waitFor(() =>
expect(api.projects.create).toHaveBeenCalledWith(
expect.objectContaining({
name: '新项目',
rootPath: ''
})
)
)
})
it('keeps project input when creation fails', async () => {
vi.mocked(api.projects.create).mockRejectedValueOnce(
new Error('项目目录不可用')
)
render(<App />)
fireEvent.click(await screen.findByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const nameInput = within(dialog).getByLabelText('名称')
fireEvent.change(nameInput, {
target: { value: '保留的项目名称' }
})
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'项目目录不可用'
)
expect(nameInput).toHaveValue('保留的项目名称')
expect(
screen.getByRole('dialog', { name: '新建项目' })
).toBeInTheDocument()
})
it('edits and safely deletes the current project from project settings', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
render(<App />)
fireEvent.click(await screen.findByLabelText('项目设置'))
let dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(within(dialog).getByLabelText('名称')).toHaveValue(
project.name
)
expect(within(dialog).getByLabelText('根目录')).toHaveValue(
project.rootPath
)
expect(
within(dialog).getByLabelText('新对话默认 Runtime')
).toHaveValue('model')
fireEvent.change(within(dialog).getByLabelText('说明'), {
target: { value: '更新后的说明' }
})
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{ target: { value: 'continue' } }
)
fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' })
)
await waitFor(() =>
expect(api.projects.update).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
description: '更新后的说明',
rootPath: project.rootPath,
runtimeSelection: {
provider: 'continue',
profileId: modelProfileId
}
})
)
)
fireEvent.click(screen.getByLabelText('项目设置'))
dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(dialog).toHaveTextContent('不会删除磁盘上的项目目录或文件')
fireEvent.click(
within(dialog).getByRole('button', { name: '删除项目' })
)
const confirmation = within(dialog).getByLabelText(
`输入“${project.name}”确认删除`
)
const deleteButton = within(dialog).getByRole('button', {
name: '永久删除项目'
})
expect(deleteButton).toBeDisabled()
fireEvent.change(confirmation, {
target: { value: project.name }
})
expect(deleteButton).toBeEnabled()
fireEvent.click(deleteButton)
await waitFor(() =>
expect(api.projects.delete).toHaveBeenCalledWith(
project.id,
project.name
)
)
expect(screen.getByLabelText('当前项目')).toHaveValue(
secondProject.id
)
})
it('uses the project default Runtime for new conversations', async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
...project,
runtimeSelection: {
provider: 'opencode',
profileId: modelProfileId
}
}
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000220',
projectId,
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
},
title: '已有对话',
updatedAt: 1,
messages: []
}
])
render(<App />)
await screen.findAllByText('已有对话')
fireEvent.click(
screen.getByRole('button', { name: /新建对话/u })
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode',
profileId: modelProfileId
})
)
expect(
screen.getByRole('button', {
name: /OpenCode · 默认模型/u
})
).toBeInTheDocument()
})
it('uses a message icon for conversation navigation', async () => {
render(<App />)
const conversationNavigation = await screen.findByRole('button', {
name: '对话'
})
expect(
conversationNavigation.querySelector('.lucide-message-square')
).not.toBeNull()
expect(
conversationNavigation.querySelector('.lucide-history')
).toBeNull()
})
it('marks an image model and renders its generated artifact', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'model',
label: 'gpt-image-2',
available: true,
supportsToolExecution: false,
detail: 'OpenAI Images Generations',
capability: 'image-generation'
})
render(<App />)
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '生成一只蓝色的猫' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
const artifactId = '00000000-0000-4000-8000-000000000301'
vi.mocked(api.artifacts.get).mockResolvedValueOnce(
{
id: artifactId,
projectId,
taskId: request.requestId,
kind: 'image',
title: '生成一只蓝色的猫',
mimeType: 'image/png',
content:
'data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAA',
byteSize: 42,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
)
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'artifact',
artifactId,
kind: 'image',
title: '生成一只蓝色的猫'
})
})
expect(
await screen.findByRole('img', { name: '生成一只蓝色的猫' })
).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u))
fireEvent.click(
screen.getByRole('button', {
name: '下载图片 生成一只蓝色的猫'
})
)
expect(anchorClick).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', {
name: '查看图片 生成一只蓝色的猫'
})
)
const imageDialog = await screen.findByRole('dialog', {
name: '生成一只蓝色的猫'
})
expect(
within(imageDialog).getByRole('img', {
name: '生成一只蓝色的猫'
})
).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u))
fireEvent.click(
within(imageDialog).getByRole('button', { name: '下载图片' })
)
expect(anchorClick).toHaveBeenCalledTimes(2)
fireEvent.keyDown(imageDialog, { key: 'Escape' })
expect(
screen.queryByRole('dialog', { name: '生成一只蓝色的猫' })
).not.toBeInTheDocument()
anchorClick.mockRestore()
})
it('can dispatch a request to the parallel expert team', async () => {
render(<App />)
selectComposerOption('专家角色', '专家团队(并行)')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布计划' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
teamMode: true,
expertId: undefined,
prompt: '制定发布计划'
})
)
)
})
it('requests smart routing only when enabled without an explicit expert', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
subagentSmartRoutingEnabled: true
})
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析发布风险' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
smartRouting: true,
expertId: undefined,
teamMode: false,
workMode: 'ask'
})
)
)
})
it('gives an explicitly selected expert priority over smart routing', async () => {
const expertId = '00000000-0000-4000-8000-000000000501'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
subagentSmartRoutingEnabled: true
})
vi.mocked(api.experts.list).mockResolvedValueOnce([
{
id: expertId,
name: '发布专家',
description: '检查发布风险',
systemInstructions: 'Review release risks.',
routingKeywords: ['发布', '风险'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
render(<App />)
await waitFor(() => expect(api.experts.list).toHaveBeenCalled())
const expertMenu = openComposerMenu('专家角色')
fireEvent.click(
(await within(expertMenu).findByText('发布专家', {
selector: 'span'
}))
.closest<HTMLButtonElement>('button')!
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查发布方案' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
expertId,
smartRouting: undefined,
teamMode: false
})
)
)
})
it('shows bounded Subagent states and records child expert activity', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析复杂问题' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
const events = [
{
childTaskId: '00000000-0000-4000-8000-000000000601',
expertId: '00000000-0000-4000-8000-000000000701',
expertName: '研究专家',
routingMode: 'smart' as const,
state: 'queued' as const
},
{
childTaskId: '00000000-0000-4000-8000-000000000602',
expertId: '00000000-0000-4000-8000-000000000702',
expertName: '代码专家',
routingMode: 'manual' as const,
state: 'running' as const
},
{
childTaskId: '00000000-0000-4000-8000-000000000603',
expertId: '00000000-0000-4000-8000-000000000703',
expertName: '安全专家',
routingMode: 'smart' as const,
state: 'failed' as const,
error: '无法读取必要上下文'
},
{
childTaskId: '00000000-0000-4000-8000-000000000604',
expertId: '00000000-0000-4000-8000-000000000704',
expertName: '第四位专家',
routingMode: 'smart' as const,
state: 'completed' as const
}
]
act(() => {
for (const event of events) {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...event
})
}
})
const statusRegion = await screen.findByLabelText('子专家状态')
expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('等待中')).toBeInTheDocument()
expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('进行中')).toBeInTheDocument()
expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('失败')).toBeInTheDocument()
expect(
within(statusRegion).getByText('无法读取必要上下文')
).toBeInTheDocument()
expect(
within(statusRegion).queryByText('第四位专家')
).not.toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...events[0]!,
state: 'completed'
})
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...events[1]!,
state: 'cancelled',
reason: '父任务已停止'
})
})
expect(within(statusRegion).getByText('已完成')).toBeInTheDocument()
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument()
fireEvent.click(screen.getByText('任务与活动'))
expect(await screen.findAllByText('子专家')).toHaveLength(4)
expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0)
expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0)
})
it('offers once, session, permanent, and deny for a tool call', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '运行工具' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'approval',
approvalId: crypto.randomUUID(),
title: 'Continue 请求调用 Bash',
description: '确认工具调用',
toolName: 'Bash',
argumentSummary: 'echo safe',
allowPermanent: true
})
})
expect(await screen.findByText('仅此次')).toBeInTheDocument()
expect(screen.getByText('此会话')).toBeInTheDocument()
expect(screen.getByText('永久允许')).toBeInTheDocument()
expect(screen.getAllByText('拒绝')).toHaveLength(2)
fireEvent.click(screen.getByText('此会话'))
await waitFor(() =>
expect(api.agent.respondApproval).toHaveBeenCalledWith(
expect.any(String),
'session'
)
)
})
it('renders and answers an OpenCode question request', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '需要确认的任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
options: [
{
label: '直接修改',
description: '立即更新现有实现'
},
{
label: '先写测试',
description: '先增加回归测试'
}
],
multiple: false,
custom: true
}
]
})
})
expect(
await screen.findByText('OpenCode 需要补充信息')
).toBeInTheDocument()
fireEvent.click(screen.getByLabelText(/先写测试/u))
fireEvent.click(screen.getByRole('button', { name: '提交回答' }))
await waitFor(() =>
expect(api.agent.respondQuestion).toHaveBeenCalledWith(
'question-1',
[['先写测试']]
)
)
expect(
screen.queryByText('OpenCode 需要补充信息')
).not.toBeInTheDocument()
})
it('configures a runtime without reading an existing API key', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
expect(
await screen.findByRole('heading', {
name: '设置中心'
})
).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: '设置中心' }))
.toBeInTheDocument()
expect(
screen.getByRole('tab', { name: 'Agent Runtime' })
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '安全与数据' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const apiKeyInput = screen.getByLabelText('API Key')
expect(apiKeyInput).toHaveValue('')
fireEvent.change(apiKeyInput, {
target: { value: 'test-api-key' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: {
action: 'replace',
value: 'test-api-key'
}
})
)
)
await waitFor(() => expect(apiKeyInput).toHaveValue(''))
})
it('opens the global assistant sidebar and switches work tabs', async () => {
render(<App />)
const sidebar = screen.getByLabelText('助手工作栏')
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
expect(sidebar).toHaveClass('assistant-sidebar--open')
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('自动化')).toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '上下文' }))
expect(
screen.getByText('尚未添加文件、截图或剪贴板内容。')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
expect(
screen.getByText(/选择文件后在当前工作区内预览/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '浏览器' }))
expect(
screen.getByText(/Agent 打开网页后/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '成果' }))
expect(screen.getByText('对话与导入成果')).toBeInTheDocument()
expect(
screen.getByText(/查看并预览由对话生成或手动导入/)
).toBeInTheDocument()
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('opens the live browser tab for the active conversation and can stop it', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '打开示例网页' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const conversationId = run.mock.calls[0]?.[0].conversationId
expect(conversationId).toBeTruthy()
act(() => {
browserListener?.({
conversationId: conversationId ?? '',
status: 'ready',
url: 'https://example.com/',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==',
updatedAt: Date.now()
})
})
expect(screen.getByLabelText('助手工作栏')).toHaveClass(
'assistant-sidebar--open'
)
expect(
screen.getByRole('tab', { name: '浏览器' })
).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByAltText('Agent 实时浏览器画面')
).toHaveAttribute(
'src',
'data:image/jpeg;base64,/9j/2Q=='
)
fireEvent.click(
screen.getByRole('button', { name: '交互' })
)
await waitFor(() =>
expect(api.browser.interact).toHaveBeenCalledWith(conversationId)
)
fireEvent.click(
screen.getByRole('button', { name: '停止浏览器' })
)
await waitFor(() =>
expect(api.browser.stop).toHaveBeenCalledWith(conversationId)
)
})
it('opens Smart Heartbeat as a first-class workspace', async () => {
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(
await screen.findByRole('tab', { name: '成长概览' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '配置智能心跳' })
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
})
it('shows retryable page-local knowledge errors without an empty-state flash', async () => {
vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce(
new Error('知识数据库暂时不可用')
)
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(
await screen.findByText('知识库加载失败')
).toBeInTheDocument()
expect(screen.getAllByText('知识数据库暂时不可用')).toHaveLength(1)
expect(screen.queryByText('建立第一个知识库')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(
await screen.findByText('建立第一个知识库')
).toBeInTheDocument()
})
it('retries the knowledge library whose selection failed', async () => {
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
const libraries = [
{
id: firstLibraryId,
name: '产品知识',
description: '',
storageMode: 'managed' as const,
graphEnabled: false,
graphStrategy: 'rules' as const,
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
},
{
id: secondLibraryId,
name: '工程知识',
description: '',
storageMode: 'managed' as const,
graphEnabled: false,
graphStrategy: 'rules' as const,
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
}
]
const snapshot = {
libraries,
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
}
vi.mocked(api.knowledge.getSnapshot)
.mockResolvedValueOnce({
...snapshot,
selectedLibraryId: firstLibraryId
})
.mockRejectedValueOnce(new Error('工程知识暂时不可用'))
.mockResolvedValueOnce({
...snapshot,
selectedLibraryId: secondLibraryId
})
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
fireEvent.click(
await screen.findByRole('button', {
name: /^工程知识 0 个文档/u
})
)
expect(
await screen.findByText('知识库刷新失败')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() =>
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(
secondLibraryId
)
)
expect(
screen.getByRole('button', {
name: /^工程知识 0 个文档/u
})
).toHaveAttribute('aria-current', 'page')
})
it('shows retryable page-local heartbeat errors without first-time guidance', async () => {
vi.mocked(api.heartbeats.list).mockRejectedValue(
new Error('心跳数据库暂时不可用')
)
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByText('智能心跳加载失败')
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '配置智能心跳' })
).not.toBeInTheDocument()
const retry = await screen.findByRole('button', { name: '重试' })
vi.mocked(api.heartbeats.list).mockResolvedValue([])
fireEvent.click(retry)
expect(
await screen.findByRole('button', { name: '配置智能心跳' })
).toBeInTheDocument()
})
it('does not expose the previous project heartbeat after a switch fails', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000701',
projectId,
name: '旧项目心跳',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30,
nextRunAt: '2026-08-05T01:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
fireEvent.click(
await screen.findByRole('tab', { name: '心跳计划' })
)
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
vi.mocked(api.heartbeats.list).mockRejectedValue(
new Error('第二项目心跳读取失败')
)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByText('智能心跳加载失败')
).toBeInTheDocument()
expect(screen.queryAllByText('旧项目心跳')).toHaveLength(0)
expect(
screen.queryByRole('button', { name: '立即运行旧项目心跳' })
).not.toBeInTheDocument()
})
it('automatically snapshots the conversation project on new activity', async () => {
render(<App />)
await screen.findByText('项目:默认项目')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '记录项目范围' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(
loadActivityRecords().find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
).toEqual({
kind: 'project',
projectId,
projectName: project.name
})
)
})
it('marks the current primary navigation page and hides decorative icons', async () => {
render(<App />)
const navigation = screen.getByRole('navigation', {
name: '主导航'
})
const chat = within(navigation).getByRole('button', { name: '对话' })
const knowledge = within(navigation).getByRole('button', {
name: '知识库'
})
expect(chat).toHaveAttribute('aria-current', 'page')
expect(knowledge).not.toHaveAttribute('aria-current')
for (const button of within(navigation).getAllByRole('button')) {
expect(button.querySelector('svg')).toHaveAttribute(
'aria-hidden',
'true'
)
}
fireEvent.click(knowledge)
expect(knowledge).toHaveAttribute('aria-current', 'page')
expect(chat).not.toHaveAttribute('aria-current')
})
it('collapses the primary sidebar into an inert narrow-window overlay', async () => {
const originalWidth = window.innerWidth
const { container } = render(<App />)
const sidebar = container.querySelector<HTMLElement>('.sidebar')
const workspace = container.querySelector<HTMLElement>('.workspace')
expect(sidebar).not.toBeNull()
expect(workspace).not.toBeNull()
if (!sidebar || !workspace) {
return
}
try {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 680
})
window.dispatchEvent(new Event('resize'))
await waitFor(() =>
expect(sidebar).toHaveClass('sidebar--closed')
)
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
expect(sidebar).toHaveAttribute('inert')
const toggle = screen.getByRole('button', { name: '切换侧栏' })
fireEvent.click(toggle)
expect(sidebar).not.toHaveClass('sidebar--closed')
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
expect(sidebar).toHaveAttribute('aria-modal', 'true')
expect(sidebar).not.toHaveAttribute('inert')
expect(workspace).toHaveAttribute('aria-hidden', 'true')
expect(workspace).toHaveAttribute('inert')
await waitFor(() =>
expect(
within(sidebar).getByRole('button', { name: '对话' })
).toHaveFocus()
)
fireEvent.click(
screen.getByRole('button', { name: '关闭侧栏' })
)
await waitFor(() => expect(toggle).toHaveFocus())
expect(sidebar).toHaveClass('sidebar--closed')
expect(workspace).not.toHaveAttribute('aria-hidden')
expect(workspace).not.toHaveAttribute('inert')
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalWidth
})
}
})
it('opens Magic Notes as a global first-class workspace', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
await screen.findByText('项目:默认项目')
const magicNotesEntry = await screen.findByRole('button', {
name: '魔法笔记'
})
fireEvent.click(magicNotesEntry)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
).toBeInTheDocument()
expect(screen.getByText('全局')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建笔记' })
).toBeInTheDocument()
expect(api.magicNotes.list).toHaveBeenCalled()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
} finally {
delete api.updates
}
})
it('hides Magic Notes when the platform feature is disabled', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
await waitFor(() =>
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
)
} finally {
delete api.updates
}
})
it('hides Magic Notes by default without an explicit setting', () => {
render(<App />)
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
})
it('keeps platform-feature switches in Settings without navigating', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings: vi.fn(async (input) => {
applicationSettings = {
...applicationSettings,
...input
}
return { ...applicationSettings }
}),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
const { container } = render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
fireEvent.click(
screen.getByRole('tab', { name: '平台功能' })
)
const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
fireEvent.click(toggle)
await waitFor(() => expect(toggle).toBeChecked())
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '魔法笔记' })
).toBeInTheDocument()
expect(
container.querySelector('.magic-notes-page')
).not.toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() => expect(toggle).not.toBeChecked())
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
} finally {
delete api.updates
}
})
it('gives the knowledge workspace the full content width', async () => {
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(
await screen.findByLabelText('知识工作区')
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^专家角色:/u })
).not.toBeInTheDocument()
})
it('keeps Smart Heartbeat available when the runtime is not configured', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'setup',
label: '需要配置模型',
available: false,
supportsToolExecution: false,
detail: '请配置模型'
})
render(<App />)
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenCalledOnce()
)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(api.agent.getStatus).toHaveBeenCalledOnce()
})
})