Files
deepseek-harness/packages/ui/desktop/tests/renderer.spec.ts
NI0317 c4584b5c19 feat(ui): rebuild desktop renderer on a shared trace graph and design tokens
Replace the full-innerHTML render loop with a static shell plus
per-region updates, so composer drafts, fold state, focus, and scroll
survive streaming turns. Fold session events into one trace graph
consumed by Chat, Trajectory, Waterfall, and the shared inspector
drawer, with live ACP updates patched into a keyed live-turn region.

Align the visual system with a tokenized design spec: a 4px spacing
base with fixed control/row height steps, foreground-derived text
tiers and borders (color-mix), neutral interaction overlays, tiered
motion durations with a reduced-motion collapse, hover-revealed
scrollbars, and drawer-aware layout elasticity. Localize trajectory
role chips and row previews.

The renderer entry (app.ts) joins the coverage exclude list as a
self-executing DOM bootstrap: jsdom lifecycle specs exercise its
behavior, and extractable logic lives in covered modules
(trace-graph.ts, renderer-content.ts).
2026-07-19 01:37:04 +08:00

238 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {} from '../src/global.d.ts'
interface Deferred<T> {
readonly promise: Promise<T>
resolve(value: T): void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('desktop renderer chat lifecycle', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
document.body.innerHTML = '<div id="app"></div>'
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { escape: (value: string) => value.replaceAll(':', '\\:') },
})
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
})
it('preserves drafts while streaming a single ACP block and exits the completed state', async () => {
const firstPrompt = deferred<unknown>()
const secondPrompt = deferred<unknown>()
const promptQueue = [firstPrompt, secondPrompt]
let update: ((payload: unknown) => void) | undefined
let sessions: unknown[] = []
let traceRead: unknown
const completedTrace = {
found: true,
sessionId: 's-new',
header: { id: 's-new' },
rawText: '',
feedback: [],
events: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hello' }] } },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'why' } } },
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'reasoning', text: 'why' }, { type: 'text', text: 'final answer' }] } },
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId: 'call-1', name: 'bash', arguments: '{"command":"pwd"}' } },
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId: 'call-1', content: [{ type: 'text', text: '/repo' }] } },
{ type: 'step/end', seq: 7, time: 8, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 8, time: 9, data: { turn: 1, reason: { kind: 'completed' } } },
],
}
const secondTrace = {
...completedTrace,
events: [
...completedTrace.events,
{ type: 'turn/start', seq: 9, time: 10, data: { turn: 2, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 10, time: 11, data: { content: [{ type: 'text', text: 'second' }] } },
{ type: 'step/start', seq: 11, time: 12, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 12, time: 13, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'second answer' }] } },
{ type: 'step/end', seq: 13, time: 14, data: { turn: 2, step: 1 } },
{ type: 'turn/end', seq: 14, time: 15, data: { turn: 2, reason: { kind: 'completed' } } },
],
}
traceRead = completedTrace
window.dshDesktop = {
runtime: {
start: async () => ({}),
stop: async () => ({}),
restart: async () => ({}),
status: async () => ({ state: 'running', repoRoot: '/repo' }),
onStatus: () => () => {},
onStderr: () => () => {},
},
sessions: {
list: async () => ({ sessions }),
create: async () => ({ sessionId: 's-new', trace: { ...completedTrace, events: [] } }),
load: async () => ({}),
prompt: async () => promptQueue.shift()!.promise,
cancel: async () => ({}),
reveal: async () => ({}),
onUpdate: (callback: (payload: unknown) => void) => {
update = callback
return () => {}
},
},
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) },
}
await import('../src/app.ts')
await vi.waitFor(() => {
expect(document.querySelector('#composerInput')).not.toBeNull()
})
const newSession = document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!
newSession.click()
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
composer.value = 'hello'
composer.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => {
expect(document.querySelector('#topbarTitle')?.textContent).toBe('hello')
})
const search = document.querySelector<HTMLInputElement>('#sessionSearch')!
search.value = 'keep search'
search.dispatchEvent(new Event('input', { bubbles: true }))
composer.value = 'next draft'
composer.dispatchEvent(new Event('input', { bubbles: true }))
const chatView = document.querySelector<HTMLElement>('#chatView')!
Object.defineProperties(chatView, {
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 400 },
scrollTop: { configurable: true, writable: true, value: 100 },
})
chatView.dispatchEvent(new Event('scroll'))
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'streamed' } } })
expect(document.querySelector('[data-live="answer"]')?.textContent).toBe('streamed')
expect(document.querySelector('#liveTurn .message.live')).not.toBeNull()
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(false)
expect(composer.value).toBe('next draft')
expect(search.value).toBe('keep search')
document.querySelector<HTMLButtonElement>('#liveJump')!.click()
expect(chatView.scrollTop).toBe(1000)
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(true)
sessions = [{
id: 's-new',
title: 'hello',
createdAt: 1,
lastActivity: 6,
eventCount: 6,
turnCount: 1,
stepCount: 1,
toolCallCount: 0,
live: true,
}]
firstPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
await vi.waitFor(() => {
expect(document.querySelector('#conversation')?.textContent).toContain('final answer')
})
expect(document.querySelector<HTMLButtonElement>('#cancelButton')?.hidden).toBe(true)
expect(document.querySelector('#liveTurn')?.textContent).not.toContain('正在生成')
expect(composer.value).toBe('next draft')
composer.value = 'second'
composer.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'second streamed' } } })
secondPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
await vi.waitFor(() => {
expect(document.querySelectorAll('#conversation .message')).toHaveLength(2)
expect(document.querySelector('#liveTurn')?.textContent).toContain('second streamed')
})
traceRead = secondTrace
document.querySelector<HTMLButtonElement>('[data-surface="trajectory"]')!.click()
await vi.waitFor(() => {
expect(document.querySelectorAll('#conversation .message')).toHaveLength(4)
})
expect([...document.querySelectorAll('#conversation .message')].map(node => node.textContent))
.toEqual(expect.arrayContaining([
expect.stringContaining('hello'),
expect.stringContaining('final answer'),
expect.stringContaining('second'),
expect.stringContaining('second answer'),
]))
expect(document.querySelector('#liveTurn')?.textContent).toBe('')
const firstAssistant = document.querySelectorAll<HTMLElement>('.message.assistant')[0]!
const thinking = firstAssistant.querySelector<HTMLElement>('.chat-activity.thinking')!
const thinkingButton = thinking.querySelector<HTMLButtonElement>('.activity-select')!
thinkingButton.click()
expect({
targetId: thinkingButton.dataset.targetId,
kind: document.querySelector('#inspectorKind')?.textContent,
title: document.querySelector('#inspectorTitle')?.textContent,
}).toEqual({ targetId: 'reasoning:1:1', kind: '思考', title: '思考' })
const tool = firstAssistant.querySelector<HTMLElement>('.chat-activity.tool-use')!
tool.querySelector<HTMLButtonElement>('.activity-select')!.click()
expect(document.querySelector('#inspectorKind')?.textContent).toBe('工具')
document.querySelectorAll<HTMLElement>('[data-target-id^="assistant:"]')[1]!.click()
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
expect(document.querySelector('#inspectorTitle')?.textContent).toBe('回复')
document.querySelector<HTMLButtonElement>('[data-action="close-inspector"]')!.click()
search.value = ''
search.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLButtonElement>('[data-module="develop"]')!.click()
const rail = document.querySelector<HTMLElement>('.develop-artifact-rail')!
const detail = document.querySelector<HTMLElement>('.develop-artifact-detail')!
rail.scrollTop = 180
detail.scrollTop = 220
document.querySelectorAll<HTMLButtonElement>('[data-dev-artifact]')[1]!.click()
expect(document.querySelector('.develop-artifact-rail')).toBe(rail)
expect(document.querySelector('.develop-artifact-detail')).toBe(detail)
expect(rail.scrollTop).toBe(180)
expect(detail.scrollTop).toBe(220)
document.querySelector<HTMLButtonElement>('[data-session="s-new"]')!.click()
await vi.waitFor(() => {
expect(document.querySelector<HTMLElement>('#sessionCanvas')?.hidden).toBe(false)
expect(document.querySelector<HTMLElement>('#devCanvas')?.hidden).toBe(true)
})
document.querySelector<HTMLButtonElement>('[data-surface="waterfall"]')!.click()
const chatToolTarget = document.querySelector<HTMLElement>('.chat-activity.tool-use [data-target-id]')?.dataset.targetId
const trajectoryToolTarget = document.querySelector<HTMLElement>('.traj-row.tool')?.dataset.targetId
const waterfallToolTarget = document.querySelector<HTMLElement>('.wf-bar.tool')?.dataset.targetId
expect(chatToolTarget).toBe('tool:call-1')
expect(trajectoryToolTarget).toBe(chatToolTarget)
expect(waterfallToolTarget).toBe(chatToolTarget)
document.querySelector<HTMLButtonElement>('.wf-bar')!.click()
expect(document.querySelector('#wfView')?.classList.contains('active')).toBe(true)
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(false)
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
expect(document.querySelector('#inspectorKind')?.textContent).toBe('轮次')
document.querySelector<HTMLButtonElement>('[data-action="jump-traj"]')!.click()
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(true)
})
})