From 6a02570e8fb64eaacff0c7740541088cb6772ec9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 10:06:14 +0800 Subject: [PATCH] test(web): close rebased subagent coverage gaps --- apps/web/tests/navigation-panes.e2e.ts | 4 + .../test-runtime/tests/runtime.spec.tsx | 21 ++- .../ui-subagent/tests/browser-plugin.spec.ts | 80 ++++++++- .../tests/conversation-ui.spec.tsx | 152 +++++++++++++++++- 4 files changed, 247 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d8d6de9036..35f77b96ec 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -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 () => { diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 3675671f26..f92d21b4b5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -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 }] }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 8a33a6b41d..0db157a5a7 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -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 & { 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 { + 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', () => { diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 878ceb0bbe..90508f392d 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -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 = {}): SubagentCatalogSn function props( value: SubagentCatalogSnapshot | undefined, nested: Readonly> = {}, + summaries?: Readonly>, ) { 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() + 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 + const input = props(catalog({ entries }), {}, summaries) + render() + 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() + 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() + expect(screen.queryByRole('button')).toBeNull() + absent.unmount() + const empty = props(catalog({ entries: [] })) const view = render() 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() + 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() + 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() + 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({