cc-haha/desktop/src/pages/TraceSession.test.tsx
程序员阿江(Relakkes) 35f43e8289 fix: resolve post-release semantic conflicts
Fix cross-issue regressions found during post-0.4.4 merge review:\n\n- preserve permission mode across clear and empty-session replacement flows\n- keep provider effort passthrough and context-window estimates aligned with runtime metadata\n- invalidate recent project caches and trace message signatures when sessions change\n- recognize Windows ARM64 unpacked package-smoke output\n\nTested: bun test scripts/quality-gate/package-smoke/index.test.ts scripts/quality-gate/runner.test.ts\nTested: bun run check:desktop\nTested: bun run check:server\nConfidence: high\nScope-risk: moderate
2026-07-02 22:11:43 +08:00

555 lines
21 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceSession } from './TraceSession'
import { sessionsApi } from '../api/sessions'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { clearTraceCallCache } from '../lib/trace/callCache'
import { Section, resetTraceSectionState } from '../components/trace/detail/Section'
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceSession as TraceSessionData } from '../types/trace'
vi.mock('../api/sessions', () => ({
sessionsApi: {
getTrace: vi.fn(),
getMessages: vi.fn(),
getTraceCall: vi.fn(),
},
}))
const SESSION_ID = 'session-live'
const requestPreview = JSON.stringify({
model: 'claude-sonnet-4-5',
system: 'You are helpful.',
messages: [{ role: 'user', content: 'Hello world' }],
tools: [{ name: 'Bash', description: 'Run shell commands', input_schema: { type: 'object' } }],
max_tokens: 4096,
})
function makeCall(overrides: Partial<TraceCallRecord> = {}): TraceCallRecord {
return {
id: 'call-1',
sessionId: SESSION_ID,
source: 'anthropic',
provider: { id: 'provider-main', name: 'Anthropic Direct', format: 'anthropic' },
model: 'claude-sonnet-4-5',
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:00:03.000Z',
durationMs: 2000,
usage: { inputTokens: 1200, outputTokens: 847 },
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: requestPreview.length,
sha256: 'a'.repeat(64),
preview: requestPreview,
truncated: true,
},
},
response: {
status: 200,
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: 96,
sha256: 'b'.repeat(64),
preview: JSON.stringify({
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'Hi (preview)' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1200, output_tokens: 847 },
}),
truncated: true,
},
},
...overrides,
}
}
const fullCall = makeCall({
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: requestPreview.length,
sha256: 'a'.repeat(64),
preview: requestPreview,
truncated: false,
},
},
response: {
status: 200,
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: 128,
sha256: 'b'.repeat(64),
preview: JSON.stringify({
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'Hi from the full record' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1200, output_tokens: 847 },
}),
truncated: false,
},
},
})
const baseTrace: TraceSessionData = {
sessionId: SESSION_ID,
session: {
id: SESSION_ID,
title: 'Trace API title',
projectPath: '/tmp',
workDir: '/tmp',
},
summary: {
apiCalls: 1,
failedCalls: 0,
totalDurationMs: 2000,
totalInputTokens: 1200,
totalOutputTokens: 847,
models: [{ model: 'claude-sonnet-4-5', calls: 1 }],
updatedAt: '2026-06-09T10:00:06.000Z',
},
calls: [makeCall()],
}
const baseMessages: MessageEntry[] = [
{ id: 'msg-1', type: 'user', content: 'Hello world', timestamp: '2026-06-09T10:00:00.000Z' },
{
id: 'msg-2',
type: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
timestamp: '2026-06-09T10:00:04.000Z',
model: 'claude-sonnet-4-5',
},
{
id: 'msg-3',
type: 'tool_use',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls -la' } }],
timestamp: '2026-06-09T10:00:05.000Z',
},
{
id: 'msg-4',
type: 'tool_result',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file.txt' }],
timestamp: '2026-06-09T10:00:06.000Z',
},
]
async function renderReady(pollIntervalMs = 60_000) {
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={pollIntervalMs} />)
await screen.findByTestId('trace-split-layout')
}
describe('TraceSession', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
writable: true,
value: vi.fn(),
})
clearTraceCallCache()
resetTraceSectionState()
window.localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: baseMessages })
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: fullCall })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Live probe',
createdAt: '2026-06-09T10:00:00.000Z',
modifiedAt: '2026-06-09T10:10:00.000Z',
messageCount: baseMessages.length,
projectPath: '/tmp',
workDir: '/tmp',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useSettingsStore.setState({ locale: 'en' })
})
it('renders the two-pane layout with tree and detail', async () => {
await renderReady()
expect(screen.getByTestId('trace-header')).toBeInTheDocument()
expect(screen.getByTestId('trace-tree')).toBeInTheDocument()
expect(screen.getByTestId('trace-detail')).toBeInTheDocument()
expect(screen.getByTestId('trace-split-divider')).toBeInTheDocument()
// Session root is selected by default and shows the overview grid.
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByTestId('trace-overview')).toBeInTheDocument()
expect(detail.getByText('LLM calls')).toBeInTheDocument()
expect(detail.getAllByText('Wall time').length).toBeGreaterThan(0)
expect(detail.getAllByText('6.00s').length).toBeGreaterThan(0)
expect(detail.getAllByText('Model time').length).toBeGreaterThan(0)
expect(detail.getAllByText('2.00s').length).toBeGreaterThan(0)
expect(detail.getByText('Tool time')).toBeInTheDocument()
expect(detail.getAllByText('1.00s').length).toBeGreaterThan(0)
// Timeline rows for messages, the model call, and the tool call.
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('User message')).toBeInTheDocument()
expect(tree.getByText('claude-sonnet-4-5')).toBeInTheDocument()
expect(tree.getByText('Bash')).toBeInTheDocument()
})
it('groups timeline rows by turn with user message previews', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValue({
messages: [
...baseMessages,
{ id: 'msg-5', type: 'user', content: 'Second question', timestamp: '2026-06-09T10:05:00.000Z' },
],
})
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('Turn 1')).toBeInTheDocument()
expect(tree.getByText('Turn 2')).toBeInTheDocument()
// Preview text shows in both the turn header and the user message row.
expect(tree.getAllByText('Hello world').length).toBeGreaterThan(0)
expect(tree.getAllByText('Second question').length).toBeGreaterThan(0)
// Collapsing a turn hides its rows.
expect(tree.getByText('Bash')).toBeInTheDocument()
fireEvent.click(tree.getAllByRole('button', { name: 'Toggle turn' })[0]!)
expect(tree.queryByText('Bash')).not.toBeInTheDocument()
})
it('selecting a tree row drives the detail panel', async () => {
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
fireEvent.click(tree.getByText('Bash'))
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'Bash' })).toBeInTheDocument()
expect(detail.getByText('Duration')).toBeInTheDocument()
expect(detail.getByText('1.00s')).toBeInTheDocument()
expect(detail.getAllByText('Completed').length).toBeGreaterThan(0)
expect(detail.getByTestId('trace-tool-detail')).toBeInTheDocument()
expect(detail.getByText('Input')).toBeInTheDocument()
expect(detail.getByText('Result')).toBeInTheDocument()
expect(detail.getByText('file.txt')).toBeInTheDocument()
})
it('filters lifecycle noise events out of the tree but keeps error events', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
events: [
{
id: 'event-noise',
sessionId: SESSION_ID,
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:00:01.100Z',
phase: 'api_call_started',
severity: 'info',
},
{
id: 'event-failed',
sessionId: SESSION_ID,
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:00:02.000Z',
phase: 'api_call_failed',
severity: 'error',
message: 'network down',
},
],
})
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('API call failed')).toBeInTheDocument()
expect(tree.queryByText('API call started')).not.toBeInTheDocument()
})
it('loads the full call record on demand and renders semantic sections', async () => {
await renderReady()
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledWith(SESSION_ID, 'call-1'))
const detail = within(screen.getByTestId('trace-detail'))
expect(await detail.findByText('Hi from the full record')).toBeInTheDocument()
// Section flow: Response (open) / Messages (open) / System prompt / Tools / Parameters / Raw.
expect(detail.getByRole('button', { name: /^Response/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Messages/ })).toBeInTheDocument()
expect(detail.getByText('Hello world')).toBeInTheDocument()
expect(detail.getByText('end_turn')).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Tools/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Parameters/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: 'Raw' })).toBeInTheDocument()
// System prompt is collapsed by default; expanding reveals the text.
expect(detail.queryByText('You are helpful.')).not.toBeInTheDocument()
fireEvent.click(detail.getByRole('button', { name: /^System prompt/ }))
expect(detail.getByText('You are helpful.')).toBeInTheDocument()
// Header badge row carries the usage brief.
expect(detail.getByText('1.2k → 847')).toBeInTheDocument()
})
it('shows the aborted badge and abort guidance for an aborted call', async () => {
const abortedCall = makeCall({
id: 'call-aborted',
status: 'error',
completedAt: '2026-06-09T10:04:01.000Z',
durationMs: 240_000,
metadata: { phase: 'api_call_aborted', aborted: true },
error: { name: 'AbortError', message: 'Stream idle timeout: no chunks received for 240s' },
response: {
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: {
contentType: 'text',
bytes: 30,
sha256: 'c'.repeat(64),
preview: 'data: {"type":"message_start"}',
truncated: true,
},
},
})
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
summary: { ...baseTrace.summary, failedCalls: 1 },
calls: [abortedCall],
})
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: abortedCall })
await renderReady()
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
const detail = within(screen.getByTestId('trace-detail'))
expect(await detail.findByTestId('trace-call-error')).toBeInTheDocument()
expect(detail.getByTestId('trace-call-aborted-badge')).toHaveTextContent('Aborted')
expect(detail.getByText('AbortError')).toBeInTheDocument()
expect(detail.getByText('Stream idle timeout: no chunks received for 240s')).toBeInTheDocument()
expect(
detail.getByText('The request was aborted before the response completed (timeout or cancellation).'),
).toBeInTheDocument()
// The header pill shows the terminal error state, not pending.
expect(detail.getByText('error')).toBeInTheDocument()
expect(detail.queryByText('pending')).not.toBeInTheDocument()
})
it('falls back to Raw with a legacy notice when the body cannot be parsed', async () => {
const legacyCall = makeCall({
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: {},
body: {
contentType: 'json',
bytes: 4096,
sha256: 'c'.repeat(64),
preview: '{"model":"claude-sonnet-4-5","messages":[{"role"',
truncated: true,
},
},
})
vi.mocked(sessionsApi.getTrace).mockResolvedValue({ ...baseTrace, calls: [legacyCall] })
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: legacyCall })
await renderReady()
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
const detail = within(screen.getByTestId('trace-detail'))
expect(await detail.findByText('Legacy truncated record; the semantic view is unavailable. See Raw below.')).toBeInTheDocument()
// Raw section opens by default in fallback mode; semantic sections are skipped.
await waitFor(() => expect(detail.getByText('Request body')).toBeInTheDocument())
expect(detail.queryByRole('button', { name: /^Messages/ })).not.toBeInTheDocument()
})
it('applies poll updates and short-circuits identical snapshots', async () => {
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValue({
...baseTrace,
summary: {
...baseTrace.summary,
apiCalls: 2,
updatedAt: '2026-06-09T10:00:09.000Z',
models: [{ model: 'claude-sonnet-4-5', calls: 2 }],
},
calls: [baseTrace.calls[0]!, makeCall({ id: 'call-2', startedAt: '2026-06-09T10:00:08.000Z' })],
})
await renderReady(20)
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3))
await screen.findByText('claude-sonnet-4-5 x2')
expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1)
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
})
it('does not refetch full messages for identical trace polls', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
await renderReady(20)
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3))
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
})
it('refetches messages when only the trace message signature changes', async () => {
const pendingMessages = baseMessages.filter((message) => message.type !== 'tool_result')
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce({ ...baseTrace, messageSignature: '3:tool-use' })
.mockResolvedValue({ ...baseTrace, messageSignature: '4:tool-result' })
vi.mocked(sessionsApi.getMessages)
.mockResolvedValueOnce({ messages: pendingMessages })
.mockResolvedValue({ messages: baseMessages })
await renderReady(20)
const tree = within(screen.getByTestId('trace-tree'))
fireEvent.click(tree.getByText('Bash'))
expect(within(screen.getByTestId('trace-detail')).queryByText('file.txt')).not.toBeInTheDocument()
await waitFor(() => expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2))
expect(within(screen.getByTestId('trace-detail')).getByText('file.txt')).toBeInTheDocument()
})
it('applies poll updates when a call changes without changing row counts', async () => {
const pendingTrace: TraceSessionData = {
...baseTrace,
summary: {
...baseTrace.summary,
failedCalls: 0,
updatedAt: '2026-06-09T10:00:01.000Z',
},
calls: [makeCall({ status: 'pending', completedAt: undefined, durationMs: undefined, response: undefined, usage: undefined })],
}
const completedTrace: TraceSessionData = {
...baseTrace,
summary: {
...baseTrace.summary,
failedCalls: 1,
updatedAt: '2026-06-09T10:00:01.000Z',
},
calls: [makeCall({ status: 'error', error: { name: 'Error', message: 'rate limited' }, response: undefined })],
}
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce(pendingTrace)
.mockResolvedValue(completedTrace)
await renderReady(20)
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(2))
const diagnosis = within(screen.getByTestId('trace-diagnosis'))
expect(diagnosis.getByText('error')).toBeInTheDocument()
expect(diagnosis.getByText('Model call failed')).toBeInTheDocument()
})
it('keeps section collapse state scoped to each trace session instance', async () => {
const { rerender } = render(
<Section key="left" scopeId="left" sectionKey="llm.raw" title="Raw" defaultOpen>
left body
</Section>,
)
fireEvent.click(screen.getByRole('button', { name: /Raw/ }))
expect(screen.queryByText('left body')).not.toBeInTheDocument()
rerender(
<Section key="right" scopeId="right" sectionKey="llm.raw" title="Raw" defaultOpen>
right body
</Section>,
)
expect(screen.getByRole('button', { name: /Raw/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('right body')).toBeInTheDocument()
})
it('supports keyboard navigation in the tree', async () => {
await renderReady()
const tree = screen.getByRole('tree')
const detail = within(screen.getByTestId('trace-detail'))
// First ArrowDown lands on the turn header.
fireEvent.keyDown(tree, { key: 'ArrowDown' })
expect(detail.getByRole('heading', { level: 2, name: 'Hello world' })).toBeInTheDocument()
// Next ArrowDown moves to the first row inside the turn.
fireEvent.keyDown(tree, { key: 'ArrowDown' })
expect(detail.getByRole('heading', { level: 2, name: 'User message' })).toBeInTheDocument()
expect(screen.getByRole('treeitem', { name: /User message/ })).toHaveAttribute('aria-selected', 'true')
// ArrowUp returns to the turn header.
fireEvent.keyDown(tree, { key: 'ArrowUp' })
expect(detail.getByRole('heading', { level: 2, name: 'Hello world' })).toBeInTheDocument()
})
it('shows the error state with retry when the trace load fails', async () => {
vi.mocked(sessionsApi.getTrace)
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue(baseTrace)
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={60_000} />)
expect(await screen.findByText('Failed to load trace')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByTestId('trace-split-layout')).toBeInTheDocument()
})
it('shows the empty state when the session has no captured activity', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
summary: {
...baseTrace.summary,
apiCalls: 0,
totalDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [],
},
calls: [],
})
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={60_000} />)
expect(await screen.findByText('No trace calls yet')).toBeInTheDocument()
expect(screen.queryByTestId('trace-split-layout')).not.toBeInTheDocument()
})
it('uses trace session metadata when the sidebar store has not loaded the session', async () => {
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
render(<TraceSession sessionId={SESSION_ID} standalone pollIntervalMs={60_000} />)
expect(await screen.findByRole('heading', { level: 1, name: 'Trace API title' })).toBeInTheDocument()
})
})