cc-haha/desktop/src/__tests__/pages.test.tsx
程序员阿江(Relakkes) 5cd6b5d07b Prevent desktop Computer Use from stalling on missing approvals and unstable text input
Desktop sessions were missing a visible request_access approval path and could
mis-detect their own app window as an unapproved frontmost target, which caused
Computer Use clicks to fail even after opening the intended app. On macOS, text
entry was also split across inconsistent clipboard and keystroke paths, making
Electron inputs unreliable for Chinese and short strings.

This change adds a desktop approval bridge over the existing session websocket,
renders a dedicated desktop approval modal, threads the real desktop bundle id
into the Computer Use executor, and switches macOS clipboard typing onto the
native pasteboard plus system paste shortcut path. It also makes tool error
results expandable in the desktop chat UI so frontmost-gate failures are fully
visible during debugging.

Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there
Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis
Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge
Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action
Tested: python3 -m unittest runtime/test_helpers.py
Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts
Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore
Tested: cd desktop && bun run test chatBlocks
Tested: cd desktop && bun run lint
Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS
2026-04-20 12:12:02 +08:00

191 lines
6.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
// Import all pages
import { EmptySession } from '../pages/EmptySession'
import { ActiveSession } from '../pages/ActiveSession'
import { AgentTeams } from '../pages/AgentTeams'
import { ScheduledTasks } from '../pages/ScheduledTasks'
import { ToolInspection } from '../pages/ToolInspection'
// Layout components (chrome is now here, not in pages)
import { Sidebar } from '../components/layout/Sidebar'
import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
/**
* Core rendering tests: content-only pages must render without crashing
* and contain key structural elements from the prototype.
*/
describe('Content-only pages render without errors', () => {
it('EmptySession renders mascot and composer', () => {
const { container } = render(<EmptySession />)
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Ask anything')
})
it('EmptySession plus menu exposes uploads and slash commands before chat starts', () => {
render(<EmptySession />)
fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' }))
expect(screen.getByText('Add files or photos')).toBeInTheDocument()
expect(screen.getByText('Slash commands')).toBeInTheDocument()
})
it('ActiveSession renders with chat components', () => {
const SESSION_ID = 'test-active-session'
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const { container } = render(<ActiveSession />)
// With empty messages, the hero is shown
expect(container.innerHTML).toContain('New session')
// ChatInput has a textarea
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).not.toContain('Preview')
// Cleanup
useTabStore.setState({ tabs: [], activeTabId: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows a single primary action button while a turn is active', () => {
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({
sessions: {
'active-tab': {
messages: [],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument()
useChatStore.setState({ sessions: {} })
})
it('AgentTeams renders team strip and members', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')
expect(container.innerHTML).toContain('session-dev')
expect(container.innerHTML).toContain('groups')
})
it('ScheduledTasks renders (store-connected)', async () => {
const { container } = render(<ScheduledTasks />)
await screen.findByText('Scheduled tasks')
expect(container.innerHTML).toContain('Scheduled tasks')
})
it('ToolInspection renders diff viewer', () => {
const { container } = render(<ToolInspection />)
expect(container.innerHTML).toContain('edit_file')
expect(container.innerHTML).toContain('Split')
expect(container.innerHTML).toContain('Unified')
})
})
describe('Chat attachments', () => {
it('UserMessage opens image gallery when an attachment is clicked', () => {
render(
<UserMessage
content=""
attachments={[
{
type: 'image',
name: 'diagram.png',
data: 'data:image/png;base64,abc123',
},
]}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('diagram.png')).toBeInTheDocument()
})
})
describe('AppShell layout renders chrome', () => {
it('AppShell renders sidebar and session shell', () => {
const { container } = render(<Sidebar />)
expect(container.querySelector('aside')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Scheduled')
expect(container.innerHTML).toContain('All projects')
})
})
describe('Design system compliance', () => {
it('Pages use Material Symbols Outlined icons', () => {
const pages = [EmptySession, AgentTeams, ToolInspection]
for (const Page of pages) {
const { container, unmount } = render(<Page />)
const icons = container.querySelectorAll('.material-symbols-outlined')
expect(icons.length).toBeGreaterThan(0)
unmount()
}
})
it('Current brand color is used in content pages', () => {
const pages = [EmptySession]
for (const Page of pages) {
const { container, unmount } = render(<Page />)
const html = container.innerHTML
expect(
html.includes('C47A5A') ||
html.includes('8F482F') ||
html.includes('var(--color-brand)') ||
html.includes('bg-[var(--color-brand)]'),
).toBe(true)
unmount()
}
})
})
describe('Mock data integration', () => {
it('AgentTeams shows team members from mock data', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')
expect(container.innerHTML).toContain('Frontend Dev')
expect(container.innerHTML).toContain('Backend Dev')
expect(container.innerHTML).toContain('Tester')
})
})