|
|
@@ -1,35 +1,14 @@
|
|
|
// @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.
|
|
|
- */
|
|
|
+/** Conversation assembly acceptance independent of Tool presentation. */
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
|
|
import { useState } from '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 { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
|
|
-// The service reads its initial locale from the browser; these specs assert
|
|
|
-// the shipped Chinese copy, so they state the browser they assume.
|
|
|
usePinnedBrowserLanguages('zh-CN')
|
|
|
|
|
|
const SID = 's1' as SessionId
|
|
|
@@ -50,30 +29,6 @@ beforeEach(() => {
|
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
|
})
|
|
|
|
|
|
-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>): 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', {})}</>
|
|
|
@@ -84,7 +39,6 @@ const LAYOUT_CHILDREN = {
|
|
|
'details': { kind: 'single', scope: 'session' },
|
|
|
} as const
|
|
|
|
|
|
-/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
|
|
|
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
|
|
const [count, setCount] = useState(0)
|
|
|
return (
|
|
|
@@ -94,7 +48,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
|
|
+async function bench(opts?: { blank?: boolean }) {
|
|
|
const runtime = await SlotTestRuntime.create()
|
|
|
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
|
|
const locale = new LocaleService(runtime.ctx)
|
|
|
@@ -104,7 +58,7 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
|
|
id: SID,
|
|
|
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
|
|
snapshot: {
|
|
|
- nodes,
|
|
|
+ nodes: [],
|
|
|
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
|
|
|
},
|
|
|
session: {
|
|
|
@@ -117,69 +71,6 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
|
|
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-tool="todo_write"]')
|
|
|
- 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 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
|
|
- 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-tool="todo_write"]')).not.toBeNull()
|
|
|
- await runtime.dispose()
|
|
|
- })
|
|
|
-})
|
|
|
-
|
|
|
-describe('terminal card assembly', () => {
|
|
|
- it('both the keyed bash row and the fallback row reach the terminal card through the whole-row 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: collapsed by default, the whole summary row is the toggle.
|
|
|
- const keyedRow = view.container.querySelector('[data-sample="bash"]')
|
|
|
- const keyed = keyedRow?.parentElement
|
|
|
- expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
|
|
- fireEvent.click(keyedRow!)
|
|
|
- await waitFor(() => {
|
|
|
- expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- // Fallback row: same unified expand interaction.
|
|
|
- const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
|
|
- expect(fallback).not.toBeNull()
|
|
|
- expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
|
|
- fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
|
|
- 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()
|
|
|
@@ -190,8 +81,6 @@ describe('resident composer', () => {
|
|
|
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)
|
|
|
@@ -242,12 +131,8 @@ describe('resident composer', () => {
|
|
|
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).
|
|
|
+ const runtime = await bench({ blank: true })
|
|
|
await runtime.workspaces.update((draft) => {
|
|
|
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
|
|
})
|
|
|
@@ -256,13 +141,11 @@ describe('resident composer', () => {
|
|
|
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)
|
|
|
+ expect(view.container.querySelector('textarea')).toBe(hero)
|
|
|
await runtime.dispose()
|
|
|
})
|
|
|
})
|
|
|
@@ -291,8 +174,6 @@ describe('prompt rejection through the assembled composer', () => {
|
|
|
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',
|
|
|
@@ -301,7 +182,6 @@ describe('prompt rejection through the assembled composer', () => {
|
|
|
})
|
|
|
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')
|
|
|
})
|
|
|
@@ -311,7 +191,7 @@ describe('prompt rejection through the assembled composer', () => {
|
|
|
|
|
|
describe('title projection across assembled surfaces', () => {
|
|
|
it('one summary update re-labels the current-session crumb', async () => {
|
|
|
- const runtime = await bench([])
|
|
|
+ const runtime = await bench()
|
|
|
const view = runtime.renderRoot()
|
|
|
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
|
|
|
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)
|