4433 lines
133 KiB
TypeScript
4433 lines
133 KiB
TypeScript
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, I’m 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()
|
||
})
|
||
})
|