Files
deepseek-harness/packages/client/ui-workspace/tests/rename-assembly.spec.tsx
T
imccyu 3980de695e test(client): pin assembled-app journeys at package level on SlotTestRuntime
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.
2026-07-29 23:51:21 +08:00

119 lines
5.7 KiB
TypeScript

// @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<ISession['rename']>(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<ISession['rename']>(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()
})
})