mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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).
238 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|