The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`.
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx'
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
// Live rows render `now - startedAt`, so every assertion needs a pinned clock.
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(START)
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
const SESSION = 'session' as SessionId
|
|
const START = 1_700_000_000_000
|
|
const t: TaskListActionProps['t'] = makeTranslate(zh)
|
|
|
|
function task(over: Partial<TaskView> = {}): TaskView {
|
|
return {
|
|
id: 'bash-1' as TaskView['id'],
|
|
kind: 'bash',
|
|
label: 'pnpm run build',
|
|
status: 'running',
|
|
startedAt: START,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
function props(tasks: readonly TaskView[] | undefined): TaskListActionProps {
|
|
const state = {
|
|
ids: [SESSION],
|
|
byId: {},
|
|
current: SESSION,
|
|
phase: 'ready',
|
|
subagentsByParent: {},
|
|
tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks },
|
|
currentAddress: undefined,
|
|
} satisfies SessionListState
|
|
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
|
return select(state)
|
|
}
|
|
return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps
|
|
}
|
|
|
|
/**
|
|
* Rows in render order as `[kind, label, status, duration]`. Adjacent spans
|
|
* carry no whitespace between them, so the cells are read one element at a
|
|
* time rather than split out of a flattened string.
|
|
*/
|
|
function rowCells(): string[][] {
|
|
return within(screen.getByRole('list', { name: zh['list.aria'] }))
|
|
.getAllByRole('listitem')
|
|
.map(row => [...row.children]
|
|
.map(cell => cell.textContent ?? '')
|
|
.filter(text => text !== ''))
|
|
}
|
|
|
|
describe('TaskListAction visibility', () => {
|
|
it('renders nothing while the session has no tasks', () => {
|
|
const { container } = render(<TaskListAction {...props(undefined)} />)
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
|
|
it('counts only live tasks, and falls back to the total when none are live', () => {
|
|
const { rerender } = render(<TaskListAction {...props([task(), task({ id: 'bash-2' as TaskView['id'] })])} />)
|
|
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
|
|
|
|
rerender(<TaskListAction {...props([task({ status: 'completed', finishedAt: START + 3_000 })])} />)
|
|
expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
|
|
})
|
|
|
|
it('closes and unmounts when the last task disappears while the list is open', () => {
|
|
const { container, rerender } = render(<TaskListAction {...props([task()])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
|
|
|
|
rerender(<TaskListAction {...props([])} />)
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('TaskListAction rows', () => {
|
|
it('orders live tasks by start, then settled tasks newest-first', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-3' as TaskView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
|
|
task({ id: 'bash-4' as TaskView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'later live', startedAt: START + 5_000 }),
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'earlier live', startedAt: START }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells()).toEqual([
|
|
['bash', 'earlier live', '运行中', '0秒'],
|
|
['bash', 'later live', '运行中', '0秒'],
|
|
['bash', 'new done', '已失败', '9秒'],
|
|
['bash', 'old done', '已完成', '1秒'],
|
|
])
|
|
})
|
|
|
|
it('breaks a settled tie on start order so map iteration never decides it', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
|
|
})
|
|
|
|
it('prefers the producer detail over the generic status word', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells()[0]).toContain('signal: SIGTERM')
|
|
})
|
|
|
|
it('renders every status word, including the stopping transition', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'a', status: 'running' }),
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'b', status: 'stopping' }),
|
|
task({ id: 'bash-3' as TaskView['id'], label: 'c', status: 'completed', finishedAt: START }),
|
|
task({ id: 'bash-4' as TaskView['id'], label: 'd', status: 'killed', finishedAt: START }),
|
|
task({ id: 'bash-5' as TaskView['id'], label: 'e', status: 'failed', finishedAt: START }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
const words = rowCells().map(cells => cells[2])
|
|
expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
|
|
})
|
|
})
|
|
|
|
describe('TaskListAction duration', () => {
|
|
it('advances a live row once per second and freezes a settled one', () => {
|
|
vi.setSystemTime(START + 1_000)
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'live' }),
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells()[0]).toContain('1秒')
|
|
expect(rowCells()[1]).toContain('4秒')
|
|
|
|
act(() => { vi.advanceTimersByTime(2_000) })
|
|
expect(rowCells()[0]).toContain('3秒')
|
|
expect(rowCells()[1]).toContain('4秒')
|
|
})
|
|
|
|
it('widens to minutes and then hours, and never shows a negative figure', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
|
|
// A clock that moved backwards must not render a negative duration.
|
|
task({ id: 'bash-3' as TaskView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
|
|
})
|
|
|
|
it('runs no clock while the list is closed', () => {
|
|
const interval = vi.spyOn(globalThis, 'setInterval')
|
|
render(<TaskListAction {...props([task()])} />)
|
|
expect(interval).not.toHaveBeenCalled()
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(interval).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('runs no clock for an open list holding only settled tasks', () => {
|
|
const interval = vi.spyOn(globalThis, 'setInterval')
|
|
render(<TaskListAction {...props([task({ status: 'completed', finishedAt: START })])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(interval).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('TaskListAction dismissal', () => {
|
|
it('closes on Escape and returns focus to the trigger', () => {
|
|
render(<TaskListAction {...props([task()])} />)
|
|
const trigger = screen.getByRole('button')
|
|
fireEvent.click(trigger)
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
|
|
fireEvent.keyDown(trigger, { key: 'Escape' })
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
expect(document.activeElement).toBe(trigger)
|
|
})
|
|
|
|
it('ignores other keys and a closed-list Escape', () => {
|
|
render(<TaskListAction {...props([task()])} />)
|
|
const trigger = screen.getByRole('button')
|
|
fireEvent.keyDown(trigger, { key: 'Escape' })
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
fireEvent.click(trigger)
|
|
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
})
|
|
|
|
it('closes on an outside pointer press but not on one inside', () => {
|
|
render(<TaskListAction {...props([task()])} />)
|
|
const trigger = screen.getByRole('button')
|
|
fireEvent.click(trigger)
|
|
|
|
fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
|
|
fireEvent.pointerDown(document.body)
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
})
|
|
|
|
describe('TaskListAction wire tolerance', () => {
|
|
it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => {
|
|
// `finishedAt` is optional on the wire; the Host always sets it, so this
|
|
// covers a producer or carrier that ever stops doing so.
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'no finish', status: 'completed' }),
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
|
|
['finished', '3秒'],
|
|
['no finish', '0秒'],
|
|
])
|
|
})
|
|
|
|
it('falls back to start order when neither settled task carries a finish time', () => {
|
|
render(<TaskListAction {...props([
|
|
task({ id: 'bash-2' as TaskView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
|
|
task({ id: 'bash-1' as TaskView['id'], label: 'earlier', status: 'failed', startedAt: START }),
|
|
])} />)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
|
|
})
|
|
})
|