mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
test(web): close rebased subagent coverage gaps
This commit is contained in:
@@ -67,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -201,13 +201,29 @@ describe('sessions', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
|
||||
it('records service-face calls and retains catalog addresses only for addressed selection', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
await runtime.sessions.add({ id: 's2' })
|
||||
const address = {
|
||||
parentSessionId: 's2' as SessionId,
|
||||
childSessionId: 's1' as SessionId,
|
||||
mode: 'continuable' as const,
|
||||
}
|
||||
runtime.sessions.openSubagent(address)
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot()).toMatchObject({ current: 's1', currentAddress: address })
|
||||
expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
|
||||
expect(runtime.sessions.subagentAddress('s2' as SessionId)).toBeUndefined()
|
||||
await runtime.sessions.updateSummary('s1', { displayTitle: 'renamed', running: true })
|
||||
expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
|
||||
.toMatchObject({ displayTitle: 'renamed', running: true })
|
||||
runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
|
||||
await runtime.sessions.refreshSubagents('s2' as SessionId)
|
||||
runtime.sessions.open('s1' as SessionId)
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
expect(runtime.sessions.list.getSnapshot().currentAddress).toBeUndefined()
|
||||
runtime.sessions.clear()
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
@@ -215,6 +231,9 @@ describe('sessions', () => {
|
||||
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
|
||||
})).resolves.toBe('s1')
|
||||
expect(runtime.sessions.calls).toEqual([
|
||||
{ method: 'openSubagent', args: [address] },
|
||||
{ method: 'setSubagentCatalogOpen', args: ['s2', true] },
|
||||
{ method: 'refreshSubagents', args: ['s2'] },
|
||||
{ method: 'open', args: ['s1'] },
|
||||
{ method: 'clear', args: [] },
|
||||
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
|
||||
|
||||
@@ -11,9 +11,19 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
SlotsService, type ConversationSnapshot, type SessionId, type SessionListState,
|
||||
type SessionSummary, type SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import {
|
||||
SubagentCatalogAction, type SubagentCatalogInjected,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
import {
|
||||
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
|
||||
} from '../src/client/SubagentReadOnlyComposer.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
|
||||
@@ -33,6 +43,7 @@ function sessionsWith(sessions: SessionSummary[]) {
|
||||
for (const s of sessions) byId[s.id] = s
|
||||
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
|
||||
const subs = new Set<() => void>()
|
||||
const actionCalls: { method: string; args: unknown[] }[] = []
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => snapshot,
|
||||
@@ -40,12 +51,30 @@ function sessionsWith(sessions: SessionSummary[]) {
|
||||
},
|
||||
notify: () => { for (const fn of [...subs]) fn() },
|
||||
listenerCount: () => subs.size,
|
||||
actionCalls,
|
||||
openSubagent: (address: SubagentAddress) => {
|
||||
actionCalls.push({ method: 'openSubagent', args: [address] })
|
||||
},
|
||||
refreshSubagents: (parentSessionId: SessionId) => {
|
||||
actionCalls.push({ method: 'refreshSubagents', args: [parentSessionId] })
|
||||
return Promise.resolve()
|
||||
},
|
||||
setSubagentCatalogOpen: (parentSessionId: SessionId, open: boolean) => {
|
||||
actionCalls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function provideSlotFaces(ctx: Context): void {
|
||||
async function provideSlotFaces(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('slots', { register: () => () => {} })
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
@@ -55,9 +84,9 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', face)
|
||||
provideSlotFaces(ctx)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { source: captured!, face }
|
||||
return { source: captured!, face, ctx }
|
||||
}
|
||||
|
||||
/** Source-only bench for the behavior-contract suites. */
|
||||
@@ -89,7 +118,7 @@ describe('apply', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
provideSlotFaces(ctx)
|
||||
await provideSlotFaces(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
@@ -105,6 +134,45 @@ describe('apply', () => {
|
||||
await fiber.dispose()
|
||||
expect(() => slash.registerSource(rival)).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
|
||||
const { ctx, face } = await fullBench(FAMILY)
|
||||
const catalogEntry = ctx.slots.entries('conversation.session.header.actions')
|
||||
.find(entry => entry.component === SubagentCatalogAction)!
|
||||
const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
|
||||
const address: SubagentAddress = {
|
||||
parentSessionId: sid('parent'),
|
||||
childSessionId: sid('c1'),
|
||||
mode: 'continuable',
|
||||
}
|
||||
actions.openChild(address)
|
||||
actions.refresh(sid('parent'))
|
||||
actions.setCatalogOpen(sid('parent'), true)
|
||||
expect(face.actionCalls).toEqual([
|
||||
{ method: 'openSubagent', args: [address] },
|
||||
{ method: 'refreshSubagents', args: [sid('parent')] },
|
||||
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
|
||||
])
|
||||
|
||||
const composerEntry = ctx.slots.entries('conversation.composer')
|
||||
.find(entry => entry.component === SubagentReadOnlyComposer)!
|
||||
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
|
||||
const owner = (
|
||||
subagent: ConversationSnapshot['subagent'] | undefined,
|
||||
): ComposerChainProps => ({
|
||||
interactions: [],
|
||||
session: subagent === undefined
|
||||
? undefined
|
||||
: ({ subagent } as unknown as ConversationSnapshot),
|
||||
})
|
||||
expect(select(owner(undefined))).toBeNull()
|
||||
expect(select(owner(null))).toBeNull()
|
||||
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
|
||||
.toEqual({ reason: 'one-shot' })
|
||||
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
|
||||
expect(select(owner({ address, parentAvailable: false })))
|
||||
.toEqual({ reason: 'parent-unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
SessionId, SessionListState, SubagentCatalogSnapshot,
|
||||
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
SubagentCatalogAction, type SubagentCatalogActionProps,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const PARENT = 'parent' as SessionId
|
||||
const CHILD = 'child' as SessionId
|
||||
@@ -37,10 +40,11 @@ function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSn
|
||||
function props(
|
||||
value: SubagentCatalogSnapshot | undefined,
|
||||
nested: Readonly<Record<SessionId, SubagentCatalogSnapshot>> = {},
|
||||
summaries?: Readonly<Record<SessionId, SessionSummary>>,
|
||||
) {
|
||||
const state = {
|
||||
ids: [CHILD],
|
||||
byId: {
|
||||
byId: summaries ?? {
|
||||
[CHILD]: {
|
||||
id: CHILD,
|
||||
title: '正在扫描项目文件',
|
||||
@@ -67,6 +71,17 @@ function props(
|
||||
} as unknown as SubagentCatalogActionProps
|
||||
}
|
||||
|
||||
function summary(id: SessionId, updatedAt: number): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
displayTitle: id,
|
||||
running: false,
|
||||
blank: false,
|
||||
waitingApproval: false,
|
||||
updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SubagentCatalogAction', () => {
|
||||
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
|
||||
const input = props(catalog())
|
||||
@@ -98,16 +113,90 @@ describe('SubagentCatalogAction', () => {
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'End' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: 'Escape' })
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(screen.getByRole('tree'))
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
})
|
||||
|
||||
it('covers diagnostic variants, fallback labels, and keyboard row activation', () => {
|
||||
const unsupported = 'unsupported' as SessionId
|
||||
const unavailable = 'unavailable' as SessionId
|
||||
const unlabeled = 'unlabeled' as SessionId
|
||||
const input = props(catalog({
|
||||
entries: [
|
||||
{ kind: 'diagnostic', id: unsupported, reason: 'unsupported' },
|
||||
{ kind: 'diagnostic', id: unavailable, reason: 'unavailable' },
|
||||
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'running' },
|
||||
{ kind: 'child', id: unlabeled, mode: 'one-shot', activity: 'inactive' },
|
||||
],
|
||||
}))
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
|
||||
fireEvent.keyDown(trigger, { key: 'Tab' })
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('treeitem', { name: /子代理记录版本不受支持/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /会话记录暂不可用/ })).toBeTruthy()
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('treeitem', { name: /worker/ }), { key: 'Enter' })
|
||||
expect(input.openChild).toHaveBeenLastCalledWith({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(screen.getByRole('treeitem', { name: /unlabeled/ }), { key: ' ' })
|
||||
expect(input.openChild).toHaveBeenLastCalledWith({
|
||||
parentSessionId: PARENT, childSessionId: unlabeled, mode: 'one-shot',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders compact activity times across every unit and clamps future timestamps', () => {
|
||||
const now = 2_000_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const rows = [
|
||||
['future', now + minute],
|
||||
['minutes', now - 2 * minute],
|
||||
['hours', now - 2 * hour],
|
||||
['days', now - 2 * day],
|
||||
['months', now - 60 * day],
|
||||
['years', now - 2 * 365 * day],
|
||||
] as const
|
||||
const entries = rows.map(([id]) => ({
|
||||
kind: 'child' as const,
|
||||
id: id as SessionId,
|
||||
mode: 'continuable' as const,
|
||||
label: id,
|
||||
activity: 'inactive' as const,
|
||||
}))
|
||||
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
|
||||
id,
|
||||
summary(id as SessionId, updatedAt),
|
||||
])) as Record<SessionId, SessionSummary>
|
||||
const input = props(catalog({ entries }), {}, summaries)
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
|
||||
|
||||
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
const childCatalog = catalog({
|
||||
entries: [
|
||||
@@ -159,7 +248,34 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
})
|
||||
|
||||
it('closes expanded descendants even when their own catalogs have not arrived', () => {
|
||||
const input = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'running',
|
||||
},
|
||||
{ kind: 'diagnostic', id: 'nested-bad' as SessionId, reason: 'corrupt' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
|
||||
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(GRANDCHILD, false)
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('hides an arrived empty catalog and exposes retry for a failed one', () => {
|
||||
const absent = render(<SubagentCatalogAction {...props(undefined)} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
absent.unmount()
|
||||
|
||||
const empty = props(catalog({ entries: [] }))
|
||||
const view = render(<SubagentCatalogAction {...empty} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
@@ -177,6 +293,36 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
|
||||
})
|
||||
|
||||
it('renders empty loading and fallback error states without focusable rows', async () => {
|
||||
const loading = props(catalog({ entries: [], state: 'loading' }))
|
||||
const view = render(<SubagentCatalogAction {...loading} />)
|
||||
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
view.unmount()
|
||||
|
||||
const failed = props(catalog({ entries: [], state: 'error', error: null }))
|
||||
render(<SubagentCatalogAction {...failed} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
|
||||
expect(screen.getByText('无法加载子代理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
|
||||
const input = props(catalog())
|
||||
const view = render(<SubagentCatalogAction {...input} />)
|
||||
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
view.unmount()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('closes every observed catalog when the root becomes empty', () => {
|
||||
const populated = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
|
||||
Reference in New Issue
Block a user