| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- // @vitest-environment jsdom
- /** Trajectory ledger selection, details, status, and fold behavior. */
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { cleanup, fireEvent, render, screen } from '@testing-library/react'
- import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
- import type { TrajectoryTurnModel } from '../src/client/layout.ts'
- afterEach(cleanup)
- const TURNS: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Step 1',
- description: '1.5s bash×2',
- cells: [
- {
- index: 1,
- kind: 'message',
- text: 'Checking files',
- outputDetail: 'Checking files',
- input: 10,
- output: 20,
- think: 5,
- timeSeconds: 1.5,
- assistantMetrics: {
- timingRecorded: true,
- stepStartTime: 1_000,
- firstTokenTime: 1_500,
- completedTime: 2_500,
- usageProvided: true,
- outputTokens: 20,
- },
- },
- {
- index: 2,
- kind: 'tool',
- text: 'bash · {"command":"pwd"}',
- inputDetail: '{"command":"pwd"}',
- timeSeconds: null,
- },
- {
- index: 3,
- kind: 'tool',
- text: 'bash · {"command":"false"}',
- inputDetail: '{"command":"false"}',
- outputDetail: 'ToolError: non_zero_exit',
- result: 'non_zero_exit',
- isError: true,
- timeSeconds: 0.2,
- },
- ],
- }],
- }]
- const FOLD_PROPS = {
- collapsedTurns: new Set<number>(),
- onToggleTurn: () => {},
- collapsedAssistants: new Set<number>(),
- onToggleAssistant: () => {},
- }
- describe('TrajectoryTable', () => {
- it('shows assistant timing facts after keyboard selection', () => {
- render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
- fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
- fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
- expect(screen.getByText('500 ms')).toBeTruthy()
- expect(screen.getByText('1.00 s')).toBeTruthy()
- expect(screen.getByText('20.0 tok/s')).toBeTruthy()
- })
- it('breaks output tokens into labeled reasoning and content rows', () => {
- render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
- fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
- expect(screen.getByText('Tokens')).toBeTruthy()
- expect(screen.getByText('20 tok')).toBeTruthy()
- expect(screen.getByText('Reasoning')).toBeTruthy()
- expect(screen.getByText('5 tok')).toBeTruthy()
- expect(screen.getByText('Content')).toBeTruthy()
- expect(screen.getByText('15 tok')).toBeTruthy()
- })
- it('keeps long thinking collapsed until the user asks to render it', () => {
- const thinking = 'private chain '.repeat(1_000)
- const turns: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{
- index: 1,
- kind: 'message',
- text: 'private chain…',
- thinkingDetail: thinking,
- timeSeconds: 1,
- }],
- }],
- }]
- render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
- fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
- const toggle = screen.getByRole('button', { name: 'Thinking ...' })
- expect(screen.queryByText(thinking)).toBeNull()
- fireEvent.click(toggle)
- expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
- })
- it('keeps raw HTML tags in a Markdown-derived context preview', () => {
- const html = [
- '<background-task-complete id="trajectory-ui-watch">',
- 'Command: pnpm test',
- 'Exit code: 0',
- '</background-task-complete>',
- ].join('\n')
- const turns: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Message',
- cells: [{
- index: 1,
- kind: 'context',
- text: '',
- inputDetail: html,
- timeSeconds: 0,
- }],
- }],
- }]
- render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
- expect(screen.getByText(
- '<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
- )).toBeTruthy()
- })
- it('clears the selected row when ledger whitespace is clicked', () => {
- const onClearSelection = vi.fn()
- render(
- <TrajectoryTable
- turns={TURNS}
- {...FOLD_PROPS}
- onClearSelection={onClearSelection}
- />,
- )
- const row = screen.getByRole('row', { name: /ASSISTANT/ })
- fireEvent.click(row)
- expect(row.getAttribute('aria-selected')).toBe('true')
- expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
- const tablePane = screen.getByRole('table').parentElement
- expect(tablePane).not.toBeNull()
- fireEvent.click(tablePane as HTMLElement)
- expect(row.getAttribute('aria-selected')).toBe('false')
- expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
- expect(onClearSelection).toHaveBeenCalledOnce()
- })
- it('keeps running and failure semantics distinct from record roles', () => {
- const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
- expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
- expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
- fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"pwd"\}/ }))
- expect(screen.getByText('Pending')).toBeTruthy()
- fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
- expect(screen.getByText('Failed')).toBeTruthy()
- expect(screen.getByText('Failed').className).toContain('error')
- fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
- const errorResult = screen.getByText('ToolError: non_zero_exit')
- expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
- })
- it('renders responsive role icons with a custom tooltip', () => {
- const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
- const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
- expect(toolTag).not.toBeNull()
- expect(toolTag?.getAttribute('title')).toBeNull()
- expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
- fireEvent.mouseEnter(toolTag as HTMLElement)
- expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
- fireEvent.mouseLeave(toolTag as HTMLElement)
- expect(screen.queryByRole('tooltip')).toBeNull()
- })
- it('uses information and compression glyphs for injected and compacted context', () => {
- const turns: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Context',
- cells: [
- { index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
- { index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
- ],
- }],
- }]
- const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
- expect(view.container.querySelector(
- '[data-role-kind="context"] [data-role-icon="information"]',
- )).toBeTruthy()
- expect(view.container.querySelector(
- '[data-role-kind="compacted"] [data-role-icon="compacted"]',
- )).toBeTruthy()
- })
- it('keeps a compact turn label available for narrow layouts', () => {
- render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
- const turnLabel = screen.getByLabelText('Turn 1')
- expect(turnLabel.textContent).toContain('Turn 1')
- expect(turnLabel.textContent).toContain('#1')
- })
- it('renders a single-text JSON tool result as a JSON tree', () => {
- const turns: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{
- index: 1,
- kind: 'tool',
- text: 'read {"path":"result.json"}',
- outputDetail: '{"value":1,"nested":{"ok":true}}',
- outputBlocks: [{
- type: 'text',
- content: '{"value":1,"nested":{"ok":true}}',
- }],
- timeSeconds: 0.1,
- }],
- }],
- }]
- render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
- fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
- fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
- expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
- expect(screen.getByText('value:')).toBeTruthy()
- })
- it('keeps the first row and a compact summary when a turn is collapsed', () => {
- render(
- <TrajectoryTable
- turns={TURNS}
- {...FOLD_PROPS}
- collapsedTurns={new Set([1])}
- />,
- )
- expect(screen.queryByRole('columnheader')).toBeNull()
- expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
- expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
- })
- const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{
- index: 1,
- kind: 'tool',
- text: 'bash · {"command":"pwd"}',
- inputDetail: '{"command":"pwd"}',
- callId: 'call-1',
- timeSeconds: 0.1,
- }],
- }],
- }]
- it('an inspect target opens the matching record and acknowledges once', () => {
- const onInspectApplied = vi.fn()
- render(
- <TrajectoryTable
- turns={CALL_TURNS}
- {...FOLD_PROPS}
- inspectCallId="call-1"
- onInspectApplied={onInspectApplied}
- />,
- )
- expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
- expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
- expect(onInspectApplied).toHaveBeenCalledOnce()
- })
- it('an unmatched inspect target stays pending without acknowledgement', () => {
- const onInspectApplied = vi.fn()
- render(
- <TrajectoryTable
- turns={CALL_TURNS}
- {...FOLD_PROPS}
- inspectCallId="call-missing"
- onInspectApplied={onInspectApplied}
- />,
- )
- expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
- expect(onInspectApplied).not.toHaveBeenCalled()
- })
- })
|