task-list-action.spec.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
  4. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  5. import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx'
  7. import { zh } from '../src/client/locales.ts'
  8. // Live rows render `now - startedAt`, so every assertion needs a pinned clock.
  9. beforeEach(() => {
  10. vi.useFakeTimers()
  11. vi.setSystemTime(START)
  12. })
  13. afterEach(() => {
  14. cleanup()
  15. vi.useRealTimers()
  16. vi.restoreAllMocks()
  17. })
  18. const SESSION = 'session' as SessionId
  19. const START = 1_700_000_000_000
  20. const t: TaskListActionProps['t'] = makeTranslate(zh)
  21. function task(over: Partial<TaskView> = {}): TaskView {
  22. return {
  23. id: 'bash-1' as TaskView['id'],
  24. kind: 'bash',
  25. label: 'pnpm run build',
  26. status: 'running',
  27. startedAt: START,
  28. ...over,
  29. }
  30. }
  31. function props(tasks: readonly TaskView[] | undefined): TaskListActionProps {
  32. const state = {
  33. ids: [SESSION],
  34. byId: {},
  35. current: SESSION,
  36. phase: 'ready',
  37. subagentsByParent: {},
  38. tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks },
  39. currentAddress: undefined,
  40. } satisfies SessionListState
  41. function useSessions<T>(select: (snapshot: SessionListState) => T): T {
  42. return select(state)
  43. }
  44. return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps
  45. }
  46. /**
  47. * Rows in render order as `[kind, label, status, duration]`. Adjacent spans
  48. * carry no whitespace between them, so the cells are read one element at a
  49. * time rather than split out of a flattened string.
  50. */
  51. function rowCells(): string[][] {
  52. return within(screen.getByRole('list', { name: zh['list.aria'] }))
  53. .getAllByRole('listitem')
  54. .map(row => [...row.children]
  55. .map(cell => cell.textContent ?? '')
  56. .filter(text => text !== ''))
  57. }
  58. describe('TaskListAction visibility', () => {
  59. it('renders nothing while the session has no tasks', () => {
  60. const { container } = render(<TaskListAction {...props(undefined)} />)
  61. expect(container.innerHTML).toBe('')
  62. })
  63. it('counts only live tasks, and falls back to the total when none are live', () => {
  64. const { rerender } = render(<TaskListAction {...props([task(), task({ id: 'bash-2' as TaskView['id'] })])} />)
  65. expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
  66. rerender(<TaskListAction {...props([task({ status: 'completed', finishedAt: START + 3_000 })])} />)
  67. expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
  68. })
  69. it('closes and unmounts when the last task disappears while the list is open', () => {
  70. const { container, rerender } = render(<TaskListAction {...props([task()])} />)
  71. fireEvent.click(screen.getByRole('button'))
  72. expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
  73. rerender(<TaskListAction {...props([])} />)
  74. expect(container.innerHTML).toBe('')
  75. })
  76. })
  77. describe('TaskListAction rows', () => {
  78. it('orders live tasks by start, then settled tasks newest-first', () => {
  79. render(<TaskListAction {...props([
  80. task({ id: 'bash-3' as TaskView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
  81. task({ id: 'bash-4' as TaskView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
  82. task({ id: 'bash-2' as TaskView['id'], label: 'later live', startedAt: START + 5_000 }),
  83. task({ id: 'bash-1' as TaskView['id'], label: 'earlier live', startedAt: START }),
  84. ])} />)
  85. fireEvent.click(screen.getByRole('button'))
  86. expect(rowCells()).toEqual([
  87. ['bash', 'earlier live', '运行中', '0秒'],
  88. ['bash', 'later live', '运行中', '0秒'],
  89. ['bash', 'new done', '已失败', '9秒'],
  90. ['bash', 'old done', '已完成', '1秒'],
  91. ])
  92. })
  93. it('breaks a settled tie on start order so map iteration never decides it', () => {
  94. render(<TaskListAction {...props([
  95. task({ id: 'bash-2' as TaskView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
  96. task({ id: 'bash-1' as TaskView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
  97. ])} />)
  98. fireEvent.click(screen.getByRole('button'))
  99. expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
  100. })
  101. it('prefers the producer detail over the generic status word', () => {
  102. render(<TaskListAction {...props([
  103. task({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
  104. ])} />)
  105. fireEvent.click(screen.getByRole('button'))
  106. expect(rowCells()[0]).toContain('signal: SIGTERM')
  107. })
  108. it('renders every status word, including the stopping transition', () => {
  109. render(<TaskListAction {...props([
  110. task({ id: 'bash-1' as TaskView['id'], label: 'a', status: 'running' }),
  111. task({ id: 'bash-2' as TaskView['id'], label: 'b', status: 'stopping' }),
  112. task({ id: 'bash-3' as TaskView['id'], label: 'c', status: 'completed', finishedAt: START }),
  113. task({ id: 'bash-4' as TaskView['id'], label: 'd', status: 'killed', finishedAt: START }),
  114. task({ id: 'bash-5' as TaskView['id'], label: 'e', status: 'failed', finishedAt: START }),
  115. ])} />)
  116. fireEvent.click(screen.getByRole('button'))
  117. const words = rowCells().map(cells => cells[2])
  118. expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
  119. })
  120. })
  121. describe('TaskListAction duration', () => {
  122. it('advances a live row once per second and freezes a settled one', () => {
  123. vi.setSystemTime(START + 1_000)
  124. render(<TaskListAction {...props([
  125. task({ id: 'bash-1' as TaskView['id'], label: 'live' }),
  126. task({ id: 'bash-2' as TaskView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
  127. ])} />)
  128. fireEvent.click(screen.getByRole('button'))
  129. expect(rowCells()[0]).toContain('1秒')
  130. expect(rowCells()[1]).toContain('4秒')
  131. act(() => { vi.advanceTimersByTime(2_000) })
  132. expect(rowCells()[0]).toContain('3秒')
  133. expect(rowCells()[1]).toContain('4秒')
  134. })
  135. it('widens to minutes and then hours, and never shows a negative figure', () => {
  136. render(<TaskListAction {...props([
  137. task({ id: 'bash-1' as TaskView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
  138. task({ id: 'bash-2' as TaskView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
  139. // A clock that moved backwards must not render a negative duration.
  140. task({ id: 'bash-3' as TaskView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
  141. ])} />)
  142. fireEvent.click(screen.getByRole('button'))
  143. expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
  144. })
  145. it('runs no clock while the list is closed', () => {
  146. const interval = vi.spyOn(globalThis, 'setInterval')
  147. render(<TaskListAction {...props([task()])} />)
  148. expect(interval).not.toHaveBeenCalled()
  149. fireEvent.click(screen.getByRole('button'))
  150. expect(interval).toHaveBeenCalledTimes(1)
  151. })
  152. it('runs no clock for an open list holding only settled tasks', () => {
  153. const interval = vi.spyOn(globalThis, 'setInterval')
  154. render(<TaskListAction {...props([task({ status: 'completed', finishedAt: START })])} />)
  155. fireEvent.click(screen.getByRole('button'))
  156. expect(interval).not.toHaveBeenCalled()
  157. })
  158. })
  159. describe('TaskListAction dismissal', () => {
  160. it('closes on Escape and returns focus to the trigger', () => {
  161. render(<TaskListAction {...props([task()])} />)
  162. const trigger = screen.getByRole('button')
  163. fireEvent.click(trigger)
  164. expect(trigger.getAttribute('aria-expanded')).toBe('true')
  165. fireEvent.keyDown(trigger, { key: 'Escape' })
  166. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  167. expect(document.activeElement).toBe(trigger)
  168. })
  169. it('ignores other keys and a closed-list Escape', () => {
  170. render(<TaskListAction {...props([task()])} />)
  171. const trigger = screen.getByRole('button')
  172. fireEvent.keyDown(trigger, { key: 'Escape' })
  173. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  174. fireEvent.click(trigger)
  175. fireEvent.keyDown(trigger, { key: 'ArrowDown' })
  176. expect(trigger.getAttribute('aria-expanded')).toBe('true')
  177. })
  178. it('closes on an outside pointer press but not on one inside', () => {
  179. render(<TaskListAction {...props([task()])} />)
  180. const trigger = screen.getByRole('button')
  181. fireEvent.click(trigger)
  182. fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
  183. expect(trigger.getAttribute('aria-expanded')).toBe('true')
  184. fireEvent.pointerDown(document.body)
  185. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  186. })
  187. })
  188. describe('TaskListAction wire tolerance', () => {
  189. it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => {
  190. // `finishedAt` is optional on the wire; the Host always sets it, so this
  191. // covers a producer or carrier that ever stops doing so.
  192. render(<TaskListAction {...props([
  193. task({ id: 'bash-1' as TaskView['id'], label: 'no finish', status: 'completed' }),
  194. task({ id: 'bash-2' as TaskView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
  195. ])} />)
  196. fireEvent.click(screen.getByRole('button'))
  197. expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
  198. ['finished', '3秒'],
  199. ['no finish', '0秒'],
  200. ])
  201. })
  202. it('falls back to start order when neither settled task carries a finish time', () => {
  203. render(<TaskListAction {...props([
  204. task({ id: 'bash-2' as TaskView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
  205. task({ id: 'bash-1' as TaskView['id'], label: 'earlier', status: 'failed', startedAt: START }),
  206. ])} />)
  207. fireEvent.click(screen.getByRole('button'))
  208. expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
  209. })
  210. })