table.spec.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. // @vitest-environment jsdom
  2. /** Trajectory ledger selection, details, status, and fold behavior. */
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  5. import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
  6. import type { TrajectoryTurnModel } from '../src/client/layout.ts'
  7. afterEach(cleanup)
  8. const TURNS: readonly TrajectoryTurnModel[] = [{
  9. turn: 1,
  10. groups: [{
  11. title: 'Step 1',
  12. description: '1.5s bash×2',
  13. cells: [
  14. {
  15. index: 1,
  16. kind: 'message',
  17. text: 'Checking files',
  18. outputDetail: 'Checking files',
  19. input: 10,
  20. output: 20,
  21. think: 5,
  22. timeSeconds: 1.5,
  23. assistantMetrics: {
  24. timingRecorded: true,
  25. stepStartTime: 1_000,
  26. firstTokenTime: 1_500,
  27. completedTime: 2_500,
  28. usageProvided: true,
  29. outputTokens: 20,
  30. },
  31. },
  32. {
  33. index: 2,
  34. kind: 'tool',
  35. text: 'bash · {"command":"pwd"}',
  36. inputDetail: '{"command":"pwd"}',
  37. timeSeconds: null,
  38. },
  39. {
  40. index: 3,
  41. kind: 'tool',
  42. text: 'bash · {"command":"false"}',
  43. inputDetail: '{"command":"false"}',
  44. outputDetail: 'ToolError: non_zero_exit',
  45. result: 'non_zero_exit',
  46. isError: true,
  47. timeSeconds: 0.2,
  48. },
  49. ],
  50. }],
  51. }]
  52. const FOLD_PROPS = {
  53. collapsedTurns: new Set<number>(),
  54. onToggleTurn: () => {},
  55. collapsedAssistants: new Set<number>(),
  56. onToggleAssistant: () => {},
  57. }
  58. describe('TrajectoryTable', () => {
  59. it('shows assistant timing facts after keyboard selection', () => {
  60. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  61. fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
  62. fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
  63. expect(screen.getByText('500 ms')).toBeTruthy()
  64. expect(screen.getByText('1.00 s')).toBeTruthy()
  65. expect(screen.getByText('20.0 tok/s')).toBeTruthy()
  66. })
  67. it('breaks output tokens into labeled reasoning and content rows', () => {
  68. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  69. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  70. expect(screen.getByText('Tokens')).toBeTruthy()
  71. expect(screen.getByText('20 tok')).toBeTruthy()
  72. expect(screen.getByText('Reasoning')).toBeTruthy()
  73. expect(screen.getByText('5 tok')).toBeTruthy()
  74. expect(screen.getByText('Content')).toBeTruthy()
  75. expect(screen.getByText('15 tok')).toBeTruthy()
  76. })
  77. it('keeps long thinking collapsed until the user asks to render it', () => {
  78. const thinking = 'private chain '.repeat(1_000)
  79. const turns: readonly TrajectoryTurnModel[] = [{
  80. turn: 1,
  81. groups: [{
  82. title: 'Step 1',
  83. cells: [{
  84. index: 1,
  85. kind: 'message',
  86. text: 'private chain…',
  87. thinkingDetail: thinking,
  88. timeSeconds: 1,
  89. }],
  90. }],
  91. }]
  92. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  93. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  94. const toggle = screen.getByRole('button', { name: 'Thinking ...' })
  95. expect(screen.queryByText(thinking)).toBeNull()
  96. fireEvent.click(toggle)
  97. expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
  98. })
  99. it('keeps raw HTML tags in a Markdown-derived context preview', () => {
  100. const html = [
  101. '<background-task-complete id="trajectory-ui-watch">',
  102. 'Command: pnpm test',
  103. 'Exit code: 0',
  104. '</background-task-complete>',
  105. ].join('\n')
  106. const turns: readonly TrajectoryTurnModel[] = [{
  107. turn: 1,
  108. groups: [{
  109. title: 'Message',
  110. cells: [{
  111. index: 1,
  112. kind: 'context',
  113. text: '',
  114. inputDetail: html,
  115. timeSeconds: 0,
  116. }],
  117. }],
  118. }]
  119. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  120. expect(screen.getByText(
  121. '<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
  122. )).toBeTruthy()
  123. })
  124. it('clears the selected row when ledger whitespace is clicked', () => {
  125. const onClearSelection = vi.fn()
  126. render(
  127. <TrajectoryTable
  128. turns={TURNS}
  129. {...FOLD_PROPS}
  130. onClearSelection={onClearSelection}
  131. />,
  132. )
  133. const row = screen.getByRole('row', { name: /ASSISTANT/ })
  134. fireEvent.click(row)
  135. expect(row.getAttribute('aria-selected')).toBe('true')
  136. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  137. const tablePane = screen.getByRole('table').parentElement
  138. expect(tablePane).not.toBeNull()
  139. fireEvent.click(tablePane as HTMLElement)
  140. expect(row.getAttribute('aria-selected')).toBe('false')
  141. expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
  142. expect(onClearSelection).toHaveBeenCalledOnce()
  143. })
  144. it('keeps running and failure semantics distinct from record roles', () => {
  145. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  146. expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
  147. expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
  148. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"pwd"\}/ }))
  149. expect(screen.getByText('Pending')).toBeTruthy()
  150. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
  151. expect(screen.getByText('Failed')).toBeTruthy()
  152. expect(screen.getByText('Failed').className).toContain('error')
  153. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  154. const errorResult = screen.getByText('ToolError: non_zero_exit')
  155. expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
  156. })
  157. it('renders responsive role icons with a custom tooltip', () => {
  158. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  159. const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
  160. expect(toolTag).not.toBeNull()
  161. expect(toolTag?.getAttribute('title')).toBeNull()
  162. expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
  163. fireEvent.mouseEnter(toolTag as HTMLElement)
  164. expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
  165. fireEvent.mouseLeave(toolTag as HTMLElement)
  166. expect(screen.queryByRole('tooltip')).toBeNull()
  167. })
  168. it('uses information and compression glyphs for injected and compacted context', () => {
  169. const turns: readonly TrajectoryTurnModel[] = [{
  170. turn: 1,
  171. groups: [{
  172. title: 'Context',
  173. cells: [
  174. { index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
  175. { index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
  176. ],
  177. }],
  178. }]
  179. const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  180. expect(view.container.querySelector(
  181. '[data-role-kind="context"] [data-role-icon="information"]',
  182. )).toBeTruthy()
  183. expect(view.container.querySelector(
  184. '[data-role-kind="compacted"] [data-role-icon="compacted"]',
  185. )).toBeTruthy()
  186. })
  187. it('keeps a compact turn label available for narrow layouts', () => {
  188. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  189. const turnLabel = screen.getByLabelText('Turn 1')
  190. expect(turnLabel.textContent).toContain('Turn 1')
  191. expect(turnLabel.textContent).toContain('#1')
  192. })
  193. it('renders a single-text JSON tool result as a JSON tree', () => {
  194. const turns: readonly TrajectoryTurnModel[] = [{
  195. turn: 1,
  196. groups: [{
  197. title: 'Step 1',
  198. cells: [{
  199. index: 1,
  200. kind: 'tool',
  201. text: 'read {"path":"result.json"}',
  202. outputDetail: '{"value":1,"nested":{"ok":true}}',
  203. outputBlocks: [{
  204. type: 'text',
  205. content: '{"value":1,"nested":{"ok":true}}',
  206. }],
  207. timeSeconds: 0.1,
  208. }],
  209. }],
  210. }]
  211. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  212. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  213. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  214. expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
  215. expect(screen.getByText('value:')).toBeTruthy()
  216. })
  217. it('keeps the first row and a compact summary when a turn is collapsed', () => {
  218. render(
  219. <TrajectoryTable
  220. turns={TURNS}
  221. {...FOLD_PROPS}
  222. collapsedTurns={new Set([1])}
  223. />,
  224. )
  225. expect(screen.queryByRole('columnheader')).toBeNull()
  226. expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
  227. expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
  228. })
  229. const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
  230. turn: 1,
  231. groups: [{
  232. title: 'Step 1',
  233. cells: [{
  234. index: 1,
  235. kind: 'tool',
  236. text: 'bash · {"command":"pwd"}',
  237. inputDetail: '{"command":"pwd"}',
  238. callId: 'call-1',
  239. timeSeconds: 0.1,
  240. }],
  241. }],
  242. }]
  243. it('an inspect target opens the matching record and acknowledges once', () => {
  244. const onInspectApplied = vi.fn()
  245. render(
  246. <TrajectoryTable
  247. turns={CALL_TURNS}
  248. {...FOLD_PROPS}
  249. inspectCallId="call-1"
  250. onInspectApplied={onInspectApplied}
  251. />,
  252. )
  253. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
  254. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  255. expect(onInspectApplied).toHaveBeenCalledOnce()
  256. })
  257. it('an unmatched inspect target stays pending without acknowledgement', () => {
  258. const onInspectApplied = vi.fn()
  259. render(
  260. <TrajectoryTable
  261. turns={CALL_TURNS}
  262. {...FOLD_PROPS}
  263. inspectCallId="call-missing"
  264. onInspectApplied={onInspectApplied}
  265. />,
  266. )
  267. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
  268. expect(onInspectApplied).not.toHaveBeenCalled()
  269. })
  270. })