From 3980de695eb0f4288b112391d5f158ed6289240f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:45:21 +0800 Subject: [PATCH] test(client): pin assembled-app journeys at package level on SlotTestRuntime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sink the behavior previously held only by the apps/web jsdom snapshots into the owning packages, each bench mounting the real apply on the production slot machinery with fixture-fed data: - ui-conversation/assembly-surfaces: the todo_write turn reaches both product surfaces (keyed toolview row + dock plan strip via the todos projection) and the strip follows projection retirement; the keyed bash row carries its resident terminal card while the fallback row reaches one through expand; the locked no-session view state; the composer textarea surviving the blank→active conversion as the same DOM node; the promptError alert strip with the machine-restored draft; one summary update re-labeling the breadcrumb. - ui-workspace/rename-assembly: the session-rename chain (row menu → dialog → the injected renameSession hop → ISession.rename with the edge-trimmed draft → dialog close and row re-label from the list), plus the rejected arm keeping the dialog open with the error. - runtime/workspaces-service: startInitialSelection — connects the recent Workspace once both baselines are ready and opens the session, stays idle with a current session or no recent target (double start fails loud), and a failed connect returns to waiting and retries on the next list change. Component-level arms stay in the existing package suites; these files prove only the assembled wiring. --- .../runtime/tests/workspaces-service.spec.ts | 75 ++++++ .../tests/assembly-surfaces.spec.tsx | 245 ++++++++++++++++++ .../tests/rename-assembly.spec.tsx | 118 +++++++++ 3 files changed, 438 insertions(+) create mode 100644 packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx create mode 100644 packages/client/ui-workspace/tests/rename-assembly.spec.tsx diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 54ec218765..7fd6934827 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -286,3 +286,78 @@ describe('WorkspacesService', () => { await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) }) + +describe('startInitialSelection', () => { + function bench() { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + return { api, sessions, workspaces } + } + + it('connects the recent Workspace blank session once baselines are ready and opens it', async () => { + const b = bench() + const stop = b.workspaces.startInitialSelection() + // Nothing happens before both baselines land. + expect(b.api.callsOf('session.create')).toHaveLength(0) + + b.api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], + })) + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') })) + await b.workspaces.refresh() + await b.sessions.refresh() + // Store notifications and the connect round trip are microtask-batched. + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }]) + expect(b.sessions.list.getSnapshot().current).toBe('s-new') + stop() + }) + + it('stays idle when a session is already current or no recent Workspace exists', async () => { + const withCurrent = bench() + withCurrent.api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[], + })) + await withCurrent.sessions.refresh() + withCurrent.sessions.open(sid('s1')) + withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] })) + const stopCurrent = withCurrent.workspaces.startInitialSelection() + await withCurrent.workspaces.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(withCurrent.api.callsOf('session.create')).toHaveLength(0) + stopCurrent() + + const noRecent = bench() + const stopEmpty = noRecent.workspaces.startInitialSelection() + await noRecent.workspaces.refresh() + await noRecent.sessions.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(noRecent.api.callsOf('session.create')).toHaveLength(0) + expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/) + stopEmpty() + }) + + it('a failed connect returns to waiting and retries on the next list change', async () => { + const b = bench() + b.api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], + })) + b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} })) + const stop = b.workspaces.startInitialSelection() + await b.workspaces.refresh() + await b.sessions.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toHaveLength(1) + expect(b.sessions.list.getSnapshot().current).toBeUndefined() + + // Recovery: the next workspace-list change re-runs the reconcile. + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') })) + await b.workspaces.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toHaveLength(2) + expect(b.sessions.list.getSnapshot().current).toBe('s-retry') + stop() + }) +}) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx new file mode 100644 index 0000000000..5d7f4c05e7 --- /dev/null +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/** + * Assembly-level acceptance on SlotTestRuntime (real apply, real slot + * machinery, real renderer; data fed as fixtures) for surfaces that were + * previously pinned only by the assembled-app jsdom snapshots + * (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts): + * + * - the todo_write turn reaches BOTH surfaces through the product + * registrations (keyed toolview row in the flow, plan strip in the input + * dock via the 'todos' projection) and the strip follows projection + * retirement; + * - the bash keyed row carries its resident terminal card, and the fallback + * row reaches the same card through its expand control; + * - the resident composer textarea survives the blank→active conversion as + * the SAME DOM node (focus/IME continuity rides React reconciliation: + * component identity + tree position, which this assembled tree pins). + * + * Component-level behavior (collapse interaction, card model arms, summary + * derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this + * suite only proves the assembled wiring. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' + +const SID = 's1' as SessionId + +afterEach(cleanup) +beforeEach(() => { + localStorage.clear() +}) + +const TODOS: TodoItem[] = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, +] + +const todoResult = (seq: number): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, + call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) + +const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: seq * 1_000 - 500, + content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, + callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, + resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, + ...over, +}) + +/** Test-owned AppFrame role: declares and renders the resident conversation area. */ +type AppRootProps = PropsRenderSlots<'conversation' | 'details'> +function AppRoot({ renderSlot }: AppRootProps) { + return <>{renderSlot('conversation', {})} +} + +const LAYOUT_CHILDREN = { + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, +} as const + +async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, + snapshot: { + nodes, + ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}), + }, + session: { + loadOlder: vi.fn(), + prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), + }, + }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + return runtime +} + +describe('todo_write assembly (product registrations, no outlet twins)', () => { + it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => { + const runtime = await bench([todoResult(3)]) + // The dock strip reads the host-computed 'todos' projection. + runtime.sessions.behavior(SID).projections.set('todos', TODOS) + const view = runtime.renderRoot() + + // Keyed toolview registration took the row (summary derived from args). + const row = view.container.querySelector('[data-sample="todo-row"]') + expect(row).not.toBeNull() + expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本') + + // The plan strip sits in the input dock, fed by the projection + // (default-collapsed: the header summary shows; rows appear on expand). + const panel = view.container.querySelector('[data-testid="todo-panel"]') + expect(panel).not.toBeNull() + expect(panel!.textContent).toContain('1/3 tasks · 1 in progress') + fireEvent.click(panel!.querySelector('button')!) + expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status'))) + .toEqual(['completed', 'in_progress', 'pending']) + + // Next turn retires the standing plan (host pushes null): the strip + // clears while the historical row stays in the flow. + await runtime.flush() + runtime.sessions.behavior(SID).projections.set('todos', null) + await waitFor(() => { + expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull() + }) + expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull() + await runtime.dispose() + }) +}) + +describe('terminal card assembly', () => { + it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => { + const runtime = await bench([ + bashResult(3, 'c-keyed'), + // An unregistered tool with terminal views: GenericToolCard fallback. + bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }), + ]) + const view = runtime.renderRoot() + + // Keyed BashRow renders the card residently (no expand gesture). + const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement + expect(keyed?.querySelector('[data-terminal]')).not.toBeNull() + + // Fallback row: card appears only after its expand control. + const fallback = view.container.querySelector('[data-tool="fx-bash"]') + expect(fallback).not.toBeNull() + expect(fallback!.querySelector('[data-terminal]')).toBeNull() + fireEvent.click(fallback!.querySelector('button[aria-expanded]')!) + await waitFor(() => { + expect(fallback!.querySelector('[data-terminal]')).not.toBeNull() + }) + await runtime.dispose() + }) +}) + +describe('resident composer', () => { + it('renders the locked view state while no session exists at all', async () => { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + // No session entity: the inert twin renders (disabled textarea), and the + // workspace picker chip is the only live control. + const textarea = view.container.querySelector('textarea') + expect(textarea).not.toBeNull() + expect(textarea!.disabled).toBe(true) + expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy() + await runtime.dispose() + }) + + + it('the textarea survives the blank→active conversion as the same DOM node', async () => { + const runtime = await bench([], { blank: true }) + // The hero renders the LIVE composer only when the blank session's + // workspace resolves a chip title; an ownerless blank session shows the + // disabled twin instead (deleted-workspace semantics). + await runtime.workspaces.update((draft) => { + draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never + }) + const view = runtime.renderRoot() + const hero = view.container.querySelector('textarea') + expect(hero).not.toBeNull() + expect(hero!.disabled).toBe(false) + + // First acceptance: the session leaves blank and the composer docks. + await runtime.sessions.updateSnapshot(SID, (draft) => { + draft.blank = false + draft.composerPhase = 'active' + }) + const docked = view.container.querySelector('textarea') + expect(docked).toBe(hero) + await runtime.dispose() + }) +}) + +describe('prompt rejection through the assembled composer', () => { + it('renders the promptError alert strip and keeps the draft in the machine', async () => { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + const prompt = vi.fn(async () => ({ + ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, + session: { prompt, loadOlder: vi.fn() }, + }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + + const composer = view.container.querySelector('textarea')! + fireEvent.change(composer, { target: { value: 'do not lose this' } }) + fireEvent.keyDown(composer, { key: 'Enter' }) + await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() }) + + // The rejection lands in snapshot.promptError (the Session's own path); + // the fixture mirrors that hop — the assembled InputBar renders it. + await runtime.sessions.updateSnapshot(SID, (draft) => { + draft.promptError = { + op: 'send', + error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + } + }) + const alert = await view.findByRole('alert') + expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)') + // Failure restore: the machine returned the draft to the same textarea. + await waitFor(() => { + expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this') + }) + await runtime.dispose() + }) +}) + +describe('title projection across assembled surfaces', () => { + it('one summary update re-labels the breadcrumb and document.title consumers together', async () => { + const runtime = await bench([]) + const view = runtime.renderRoot() + // The strict session header breadcrumb reads useSessions ancestry. + const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement) + expect(crumb.getByText('S')).toBeTruthy() + + await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' }) + await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() }) + expect(crumb.queryByText('S')).toBeNull() + await runtime.dispose() + }) +}) diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx new file mode 100644 index 0000000000..bdbeee845f --- /dev/null +++ b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +/** + * The session-rename assembly chain on SlotTestRuntime (real apply, real + * WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog → + * the injected renameSession hop (sessions.binding → ISession.rename) → on + * the accepted unary response the dialog closes and the row re-labels from + * the list state — no push-frame wait. Previously pinned only by the + * assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the + * verb's wire behavior stays with the runtime package + * (session.spec.ts#rename), the dialog's own arms with rows.spec / + * workspace-browser.spec. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' + +const SID = 's1' as SessionId + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +/** Test-owned sidebar shell role: declares and renders the browsing region. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +function SidebarFrame({ renderSlot }: FrameProps) { + return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} +} + +describe('session rename through the assembled browser', () => { + it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => { + const runtime = await SlotTestRuntime.create() + const rename = vi.fn(async title => ({ + ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' }, + session: { rename }, + }) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', + sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + + // The current session's group auto-expands; open the row's action menu. + const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')! + fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题')) + fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true })) + + // The dialog seeds from the current title; submit a padded value. + const input = await view.findByLabelText('Session name') as HTMLInputElement + expect(input.value).toBe('旧标题') + fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } }) + fireEvent.click(view.getByRole('button', { name: 'Rename' })) + + // The injected hop reached the session face with the edge-trimmed draft + // (the dialog trims edges; interior normalization is host-side). + await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') }) + // Acceptance closes the dialog without any push-frame wait. + await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() }) + // The manager lands the unary echo in the list store (its own package + // tests own that hop); the row re-labels from list state alone. + await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' }) + await view.findByText('分叉 实验记录') + expect(view.queryByText('旧标题')).toBeNull() + await runtime.dispose() + }) + + it('a rejected rename keeps the dialog open with the error surfaced', async () => { + const runtime = await SlotTestRuntime.create() + const rename = vi.fn(async () => ({ + ok: false, error: { code: 'internal', message: 'title write failed', details: {} }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' }, + session: { rename }, + }) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', + sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + await runtime.flush() + + const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')! + fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题')) + fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true })) + const input = await view.findByLabelText('Session name') + fireEvent.change(input, { target: { value: '新名' } }) + fireEvent.click(view.getByRole('button', { name: 'Rename' })) + + // Failure: the injected hop rethrows the business error; the dialog + // stays open with the alert and the row keeps its title. + const alert = await view.findByRole('alert') + expect(alert.textContent).toContain('title write failed') + expect(view.getByLabelText('Session name')).toBeTruthy() + expect(view.getByText('旧标题')).toBeTruthy() + await runtime.dispose() + }) +})