370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* ui-deliverables browser half: the derivation contract of
|
|
* `producedForClosing` over engine-published Turn data, the row's rendering
|
|
* and opener wiring, and the plugin registrations' fiber-teardown removal
|
|
* (HMR safety) against the real SlotsService.
|
|
*/
|
|
import { Context } from 'cordis'
|
|
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
|
|
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
|
|
ConversationViewNode, ToolResultNode, TurnLocation,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
|
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
|
import {
|
|
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
|
type DeliverablesTurnData,
|
|
} from '../src/client/turn-deliverables.ts'
|
|
import { apply, inject } from '../src/client/index.ts'
|
|
import { apply as applyNode } from '../src/index.ts'
|
|
import { apply as applyInvariant } from '../src/invariant.ts'
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
|
private readonly values = new Map<string, unknown>()
|
|
|
|
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
|
key: Key,
|
|
): Readonly<ConversationTurnDataMap[Key]> | undefined {
|
|
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
|
|
}
|
|
|
|
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
|
key: Key,
|
|
value: ConversationTurnDataMap[Key],
|
|
): void {
|
|
this.values.set(key, value)
|
|
}
|
|
}
|
|
|
|
const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => {
|
|
const data = new TestTurnDataStore()
|
|
if (deliverables !== undefined) data.set('deliverables', deliverables)
|
|
return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data }
|
|
}
|
|
|
|
const produced = (...values: ReadonlyArray<readonly [seq: number, path: string]>): DeliverablesTurnData => ({
|
|
produced: values.map(([seq, path]) => ({ seq, path })),
|
|
})
|
|
|
|
function tailOwner(
|
|
data: DeliverablesTurnData | undefined,
|
|
seq: number,
|
|
openFile: (path: string) => void = () => {},
|
|
turn = 1,
|
|
): TurnTailOwnerProps {
|
|
return { seq, openFile, turn: turnLocation(turn, data) }
|
|
}
|
|
|
|
interface TimelineSnapshot {
|
|
readonly timeline: ConversationTimelineSnapshot
|
|
}
|
|
|
|
class TestEventDefinitions {
|
|
entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] }
|
|
fallbackEntry(): undefined { return undefined }
|
|
}
|
|
|
|
class TestViewDefinitions {
|
|
entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] }
|
|
}
|
|
|
|
const timelineViewDefinition: ConversationViewDefinition<ConversationViewNode, TimelineSnapshot> = {
|
|
target: 'test',
|
|
create: () => {
|
|
let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } }
|
|
return {
|
|
empty: current,
|
|
replace: ({ timeline }) => (current = { timeline }),
|
|
apply: ({ timeline }) => (current = { timeline }),
|
|
}
|
|
},
|
|
}
|
|
|
|
function at(
|
|
seq: number,
|
|
type: string,
|
|
data: unknown,
|
|
view?: ConversationEventInput['view'],
|
|
): ConversationEventInput {
|
|
return {
|
|
event: {
|
|
seq, time: seq * 1_000, type, data,
|
|
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
|
|
} as ConversationEventInput['event'],
|
|
view,
|
|
}
|
|
}
|
|
|
|
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
|
|
return { ...input, role, location: { kind: 'unresolved' } }
|
|
}
|
|
|
|
function call(
|
|
seq: number,
|
|
callId: string,
|
|
view: ToolResultNode['callView'],
|
|
turn = 1,
|
|
): ConversationEventInput {
|
|
return at(
|
|
seq,
|
|
'tool/call',
|
|
{ turn, step: 1, callId, name: 'fixture', arguments: '{}' },
|
|
{ for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
|
|
)
|
|
}
|
|
|
|
function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput {
|
|
return at(seq, 'tool/result', {
|
|
turn,
|
|
step: 1,
|
|
message: {
|
|
source: { type: 'tool-result', callId },
|
|
content: [{ type: 'tool-result', content: [], isError }],
|
|
},
|
|
})
|
|
}
|
|
|
|
function diff(...paths: string[]): ToolResultNode['callView'] {
|
|
return {
|
|
card: 'diff', title: `Write ${paths[0] ?? ''}`,
|
|
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
|
|
locations: paths.map(path => ({ path })),
|
|
}
|
|
}
|
|
|
|
function edit(path: string): ToolResultNode['callView'] {
|
|
return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
|
|
}
|
|
|
|
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
|
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
|
value.replaceWindow(entries, hasMore)
|
|
value.flush()
|
|
return value
|
|
}
|
|
|
|
function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly<DeliverablesTurnData> | undefined {
|
|
const snapshot = value.snapshot('test') as TimelineSnapshot
|
|
return snapshot.timeline.turns.get(turn)?.data.get('deliverables')
|
|
}
|
|
|
|
describe('produced-file Turn data', () => {
|
|
it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => {
|
|
const data = produced(
|
|
[3, 'out/index.html'],
|
|
[4, 'out/app.css'],
|
|
[4, 'out/index.html'],
|
|
[8, 'after.txt'],
|
|
)
|
|
expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css'])
|
|
expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css'])
|
|
expect(producedForClosing(undefined)).toEqual([])
|
|
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
|
|
})
|
|
|
|
it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
call(2, 'write', diff('out/index.html', 'out/app.css')),
|
|
result(3, 'write'),
|
|
call(4, 'edit', edit('notes.md')),
|
|
result(5, 'edit'),
|
|
call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
|
|
result(7, 'read'),
|
|
call(8, 'failed', diff('broken.txt')),
|
|
result(9, 'failed', true),
|
|
call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
|
|
result(11, 'locationless'),
|
|
])
|
|
|
|
expect(producedForClosing(deliverablesOf(value))).toEqual([
|
|
'out/index.html', 'out/app.css', 'notes.md',
|
|
])
|
|
})
|
|
|
|
it('ignores calls without mutation locations, orphan results, and replacement results', () => {
|
|
const replacement = result(8, 'replacement')
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
|
|
result(3, 'no-view'),
|
|
call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
|
|
result(5, 'locationless-edit'),
|
|
result(6, 'orphan'),
|
|
call(7, 'replacement', diff('replaced.txt')),
|
|
{
|
|
...replacement,
|
|
event: {
|
|
...replacement.event,
|
|
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
|
} as ConversationEventInput['event'],
|
|
},
|
|
at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
|
])
|
|
|
|
expect(producedForClosing(deliverablesOf(value))).toEqual([])
|
|
})
|
|
|
|
it('rejects an invalid start match and preserves state for an unrelated update', () => {
|
|
const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start')
|
|
const emptyContext: Parameters<typeof deliverablesDefinition.start>[0] = {
|
|
key: 'deliverables:1',
|
|
kind: 'deliverables',
|
|
id: '1',
|
|
matches: [startMatch],
|
|
start: startMatch,
|
|
state: undefined,
|
|
current: new Map(),
|
|
}
|
|
const reader: Parameters<typeof deliverablesDefinition.start>[2] = { previous: () => undefined }
|
|
const state = deliverablesDefinition.start(emptyContext, startMatch, reader)
|
|
const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update')
|
|
const context: Parameters<typeof deliverablesDefinition.update>[0] = { ...emptyContext, state }
|
|
|
|
expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader))
|
|
.toThrow('deliverables start requires turn/start')
|
|
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
|
|
})
|
|
|
|
it('replays a tail page once prepend supplies its missing Turn start', () => {
|
|
const value = assembler([
|
|
call(10, 'late', diff('history.txt')),
|
|
result(11, 'late'),
|
|
], true)
|
|
expect(deliverablesOf(value)).toBeUndefined()
|
|
|
|
value.prepend([at(1, 'turn/start', { turn: 1 })], false)
|
|
value.flush()
|
|
expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt'])
|
|
})
|
|
|
|
it('extends the same Turn data incrementally on live append', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
call(2, 'first', diff('first.txt')),
|
|
result(3, 'first'),
|
|
])
|
|
const first = deliverablesOf(value)
|
|
expect(producedForClosing(first)).toEqual(['first.txt'])
|
|
|
|
value.append(call(4, 'second', diff('second.txt')))
|
|
value.append(result(5, 'second'))
|
|
value.flush()
|
|
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
|
|
})
|
|
})
|
|
|
|
describe('ProducedFiles row', () => {
|
|
const t = makeTranslate(zh)
|
|
|
|
it('renders capped chips with the full path reachable and opens one on click', () => {
|
|
// Seven files: six chips plus an explicit remainder — the row bounds what
|
|
// it shows and says so rather than dropping the rest silently.
|
|
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
|
|
const openFile = vi.fn<(path: string) => void>()
|
|
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
|
|
expect(view.getByText('产物')).toBeTruthy()
|
|
// Chips carry the basename; the full path stays reachable as the title.
|
|
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
|
|
expect(chip.textContent).toBe('a.html')
|
|
expect(chip.getAttribute('title')).toBe('deep/a.html')
|
|
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
|
|
expect(view.getByText('还有 1 个')).toBeTruthy()
|
|
fireEvent.click(chip)
|
|
expect(openFile).toHaveBeenCalledWith('deep/a.html')
|
|
})
|
|
})
|
|
|
|
describe('producedFileMentions resolver', () => {
|
|
const label = (path: string) => `打开 ${path}`
|
|
|
|
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
|
|
const opened: string[] = []
|
|
const resolver = producedFileMentions(
|
|
['out/index.html', 'a/style.css', 'b/style.css'],
|
|
(path) => { opened.push(path) },
|
|
label,
|
|
)
|
|
// Unique basename resolves to its full path; the full path rides title.
|
|
const byBasename = resolver.resolve('index.html')
|
|
expect(byBasename?.label).toBe('打开 out/index.html')
|
|
expect(byBasename?.title).toBe('out/index.html')
|
|
byBasename?.open()
|
|
expect(opened).toEqual(['out/index.html'])
|
|
// An exact path resolves even when its basename is ambiguous.
|
|
const exact = resolver.resolve('a/style.css')
|
|
expect(exact?.title).toBe('a/style.css')
|
|
// A basename two paths share stays unresolved rather than guessing,
|
|
// and so does a token naming nothing the turn wrote.
|
|
expect(resolver.resolve('style.css')).toBeUndefined()
|
|
expect(resolver.resolve('notes.md')).toBeUndefined()
|
|
expect(basename('a\\b\\c.txt')).toBe('c.txt')
|
|
})
|
|
})
|
|
|
|
describe('package shells', () => {
|
|
it('the node half mounts inert and the invariant companion registers ownership', async () => {
|
|
// The node half is deliberately inert; mounting it must simply not throw.
|
|
applyNode()
|
|
const registered: string[] = []
|
|
const ctx = new Context()
|
|
ctx.provide('invariants')
|
|
ctx.set('invariants', {
|
|
register: (pkg: string) => { registered.push(pkg); return () => {} },
|
|
} as never)
|
|
const dispose = await applyInvariant(ctx)
|
|
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables'])
|
|
expect(dispose).toBeTypeOf('function')
|
|
})
|
|
})
|
|
|
|
describe('plugin registration', () => {
|
|
it('registers the tail entry and fiber disposal removes it', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SlotsService).await()
|
|
await ctx.plugin(ConversationEventRegistry).await()
|
|
// The owning view's child declaration, stood up by a bench root entry.
|
|
ctx.slots.register({
|
|
name: 'root',
|
|
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
|
} as never, () => null)
|
|
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
|
|
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
|
|
|
// The prose face is live while the plugin is: a produced turn yields a
|
|
// resolver whose matches open through the owner-supplied opener.
|
|
const opened: string[] = []
|
|
const owner = tailOwner(
|
|
produced([2, 'site/report.html']),
|
|
3,
|
|
(path) => { opened.push(path) },
|
|
)
|
|
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
|
|
const mentions = service?.forClosing(owner)
|
|
mentions?.resolve('report.html')?.open()
|
|
expect(opened).toEqual(['site/report.html'])
|
|
// A turn that produced nothing yields no vocabulary at all.
|
|
expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
|
|
|
|
await fiber.dispose()
|
|
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
|
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
|
|
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
|
|
})
|
|
})
|