Files
deepseek-harness/packages/ui/desktop/tests/renderer.spec.ts
T
NI0317 c2fdfae170 feat(ui): open Develop artifacts in the OS editor
Develop stays read-first: each artifact whose source is a repository
path gains an Open in editor action routed through a path-validated
main-process handler (shell.openPath), instead of an in-app write path.
Persona and prompt-assembly sources open the governing cordis.yml or
source file directly.
2026-07-19 21:39:08 +08:00

325 lines
15 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: {} }), openPath: async () => ({}) },
}
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)
})
it('starts a fresh live skeleton per turn and converges without user action when the persisted trace lags', async () => {
const prompts: Deferred<unknown>[] = [deferred(), deferred(), deferred()]
const promptQueue = [...prompts]
let traceRead: unknown
const turnEvents = (turn: number, userText: string, answer: string, base: number): unknown[] => [
{ type: 'turn/start', seq: base, time: base + 1, data: { turn, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: base + 1, time: base + 2, data: { content: [{ type: 'text', text: userText }] } },
{ type: 'step/start', seq: base + 2, time: base + 3, data: { turn, step: 1 } },
{ type: 'assistant/message', seq: base + 3, time: base + 4, data: { turn, step: 1, content: [{ type: 'text', text: answer }] } },
{ type: 'step/end', seq: base + 4, time: base + 5, data: { turn, step: 1 } },
{ type: 'turn/end', seq: base + 5, time: base + 6, data: { turn, reason: { kind: 'completed' } } },
]
const trace = (events: unknown[]): unknown => ({ found: true, sessionId: 's-lag', header: { id: 's-lag' }, rawText: '', feedback: [], events })
const turn1Trace = trace(turnEvents(1, 'first', 'first answer', 0))
const fullTrace = trace([
...turnEvents(1, 'first', 'first answer', 0),
...turnEvents(2, 'second', 'second answer', 10),
...turnEvents(3, 'third', 'third answer', 20),
])
traceRead = turn1Trace
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-lag', trace: trace([]) }),
load: async () => ({}),
prompt: async () => promptQueue.shift()!.promise,
cancel: async () => ({}),
reveal: async () => ({}),
onUpdate: () => () => {},
},
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
}
await import('../src/app.ts')
await vi.waitFor(() => {
expect(document.querySelector('#composerInput')).not.toBeNull()
})
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
const form = document.querySelector<HTMLFormElement>('#composerForm')!
const send = (text: string): void => {
composer.value = text
composer.dispatchEvent(new Event('input', { bubbles: true }))
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
}
document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!.click()
send('first')
prompts[0]!.resolve({ response: {}, trace: turn1Trace })
await vi.waitFor(() => {
expect(document.querySelector('#conversation')?.textContent).toContain('first answer')
expect(document.querySelector('#liveTurn')?.innerHTML).toBe('')
})
// Turn 2 finishes but the persisted trace still lags behind: the live turn stays.
send('second')
prompts[1]!.resolve({ response: {}, trace: turn1Trace })
await vi.waitFor(() => {
expect(document.querySelector<HTMLButtonElement>('#cancelButton')?.hidden).toBe(true)
})
expect(document.querySelector('#liveTurn .user-bubble')?.textContent).toBe('second')
// A third prompt must never reuse the previous turn's skeleton (swallowed-message regression).
send('third')
await vi.waitFor(() => {
expect(document.querySelector('#liveTurn .user-bubble')?.textContent).toBe('third')
})
// Once the persisted log catches up, the view converges with no user action.
prompts[2]!.resolve({ response: {}, trace: turn1Trace })
traceRead = fullTrace
await vi.waitFor(() => {
expect(document.querySelector('#conversation')?.textContent).toContain('third answer')
expect(document.querySelector('#liveTurn')?.innerHTML).toBe('')
}, { timeout: 4000 })
})
})