Merge remote-tracking branch 'origin/master' into codex/basic-session-search
Conflict resolutions: - `session.list`: master's projection columns fold into the PR's cancellable, batched `listVisibleSessionSummaries`, which `session.search` shares as its visibility baseline; master's goal helpers stay beside it. - Client sessions face: master narrowed `ctx.sessions` to `ISessions`, so the search verb and its protocol-constant bound are declared there and the test-runtime double implements them (recorded, empty page unless a scenario stubs hits). - `WorkspaceBrowser`: master's per-row Rename wiring rides the PR's search results view; the tree keeps the PR's query-free derivations. - `dsh web` bin: the PR's shutdown-handlers-before-readiness order with master's boot-time LAN address snapshot. - `session-query-sqlite`: master's `SCHEMA_VERSION` 7 stands; the PR's bump carried no schema change. - Specs: master wraps assistant/steering message payloads and requires an `application/json` carrier request, so the search fixtures and tests follow. - Web aria goldens keep master's recording plus the PR's search placeholder; the navigation-panes inventory keeps master's terminal-card golden next to the PR's search-results golden.
This commit is contained in:
@@ -14,7 +14,6 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
@@ -25,14 +24,13 @@ async function bench() {
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.get('slots') as SlotsService,
|
||||
create,
|
||||
pickDirectory,
|
||||
startSession,
|
||||
rename,
|
||||
insertSessionBefore,
|
||||
@@ -95,14 +93,35 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
await browser.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await picker.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('declares the two directory-flow holes and reports their occupancy per surface', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// Registration declared the child holes (declaration = render authorization).
|
||||
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
// A flow occupant flips exactly its own surface, and the source notifies.
|
||||
const notified = vi.fn()
|
||||
const unsubscribe = browser.hooks.directoryFlow.subscribe(notified)
|
||||
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true)
|
||||
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
await Promise.resolve()
|
||||
expect(notified).toHaveBeenCalled()
|
||||
dispose()
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
|
||||
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
|
||||
* on disk: the session list reserves its scrollbar gutter so the scrollbar
|
||||
* cannot overlay row trailing content, and reserves it whether or not the list
|
||||
* currently overflows so expanding a group does not shift rows sideways.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Declarations of one class rule, keyed by property with whitespace collapsed.
|
||||
* Declaration order and trailing semicolons are normalized away.
|
||||
* @param className - local class name, without the leading dot.
|
||||
* @returns the rule's declarations, or undefined when no such rule exists.
|
||||
*/
|
||||
function declarations(className: string): Map<string, string> | undefined {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
|
||||
if (match === null) return undefined
|
||||
const found = new Map<string, string>()
|
||||
// The body group is unconditional in the pattern; the fallback only satisfies
|
||||
// noUncheckedIndexedAccess.
|
||||
for (const part of (match[2] ?? '').split(';')) {
|
||||
const colon = part.indexOf(':')
|
||||
if (colon === -1) continue
|
||||
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser.module.css list', () => {
|
||||
const list = declarations('list')
|
||||
|
||||
it('is the scrolling region', () => {
|
||||
expect(list).toBeDefined()
|
||||
expect(list!.get('overflow-y')).toBe('auto')
|
||||
})
|
||||
|
||||
it('reserves the scrollbar gutter unconditionally', () => {
|
||||
// Row trailing content sits flush against the row's right padding, so an
|
||||
// overlay scrollbar covers it. `stable` keeps the reservation when the list
|
||||
// is short enough not to scroll, so expanding a group does not shift rows.
|
||||
expect(list!.get('scrollbar-gutter')).toBe('stable')
|
||||
})
|
||||
})
|
||||
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
@@ -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<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()
|
||||
})
|
||||
})
|
||||
@@ -87,7 +87,8 @@ describe('workspace browser rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
@@ -107,7 +108,8 @@ describe('workspace browser rows', () => {
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
@@ -154,19 +156,29 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and closes on selection', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Fork and Delete stay visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -178,7 +190,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -189,7 +202,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -215,7 +229,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -232,7 +247,8 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -249,7 +265,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -262,7 +279,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
|
||||
@@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() })
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
|
||||
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
@@ -57,11 +57,13 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
searchResultLimit: 20,
|
||||
renameSession: vi.fn(async () => {}),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -442,25 +444,21 @@ describe('WorkspaceBrowser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
|
||||
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
rerender(b, { wide: true })
|
||||
// The picker menu is open (anchored on the +); picking starts a session.
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
|
||||
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Wide toggle: open and close without expand requests.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(expandSidebar).not.toHaveBeenCalled()
|
||||
// createOnly: existing workspaces are not listed, only the create actions.
|
||||
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
// Toggle: open and close in place.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Escape closes the picker through its own onClose.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -35,14 +37,49 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe occupant of the directory-flow hole: records the latest owner
|
||||
* conversation so tests drive onPicked/onCancel/onError like a composed flow
|
||||
* package would, and renders a marker element while the flow is open.
|
||||
*/
|
||||
function flowProbe() {
|
||||
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
|
||||
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
|
||||
probe.owner = owner
|
||||
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
|
||||
}) as never
|
||||
return { probe, renderSlot }
|
||||
}
|
||||
|
||||
/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */
|
||||
function occupancySource(initial = true) {
|
||||
let occupied = initial
|
||||
const listeners = new Set<() => void>()
|
||||
const useDirectoryFlow = bindSnapshotSelector({
|
||||
getSnapshot: () => occupied,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
})
|
||||
return {
|
||||
useDirectoryFlow,
|
||||
flip: (next: boolean) => {
|
||||
occupied = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
occupancy = occupancySource(),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
const { probe, renderSlot } = flowProbe()
|
||||
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
|
||||
<WorkspacePicker
|
||||
open
|
||||
@@ -52,14 +89,15 @@ function mount(
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
useDirectoryFlow={occupancy.useDirectoryFlow}
|
||||
renderSlot={renderSlot}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
view, onPick, onClose, createWorkspace, probe, occupancy,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
@@ -87,65 +125,75 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
const b = mount([], createWorkspace)
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
// Successful adoption withdraws the flow request.
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats native picker cancellation as a silent no-op', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||
it('treats flow cancellation as a silent no-op', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
act(() => { b.probe.owner!.onCancel() })
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a name conflict and retries through the native picker', async () => {
|
||||
const pickDirectory = vi.fn()
|
||||
.mockResolvedValueOnce('/one/project')
|
||||
.mockResolvedValueOnce(null)
|
||||
it('shows a name conflict and retries by reopening the flow', async () => {
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Open local folder…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
|
||||
// The failed adoption withdrew the flow; Choose again reopens it.
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.probe.owner!.open).toBe(true)
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the folder action while the native picker is already open', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
||||
it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
|
||||
let resolve!: (workspace: WorkspaceView) => void
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('adopted')
|
||||
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
// The flow is open but nothing is picked yet: a chooser pending on the
|
||||
// host display must already block concurrent workspace actions.
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
act(() => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(b.probe.owner!.busy).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(null); await pending })
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.probe.owner!.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
it('shows the flow-reported failure in the folder-error surface', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
act(() => { b.probe.owner!.onError('no chooser installed') })
|
||||
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -205,6 +253,8 @@ describe('WorkspacePicker', () => {
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseItem('Create a new workspace')
|
||||
// The name field starts empty (no prefill); a name is required to submit.
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
@@ -213,10 +263,12 @@ describe('WorkspacePicker', () => {
|
||||
})
|
||||
|
||||
it('waits to show its menu until an optional anchor is available', () => {
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -226,12 +278,54 @@ describe('WorkspacePicker', () => {
|
||||
const state: WorkspaceListState = {
|
||||
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
|
||||
}
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
})
|
||||
|
||||
it('hides the folder entry while the directory-flow hole is empty', () => {
|
||||
mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the folder entry when a flow package activates after the first paint', () => {
|
||||
const b = mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
// Registration changes flow through the subscription, no re-render needed.
|
||||
act(() => { b.occupancy.flip(true) })
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
|
||||
chooseItem('Open local folder…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() })
|
||||
// The occupant unloads while the error dialog is up: retrying would open
|
||||
// a flow nobody can serve or cancel, so the button goes inert.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
|
||||
// Cancel stays the way out, and the menu actions are usable again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
// The flow plugin unloads mid-interaction (HMR): nobody is left to
|
||||
// cancel, so the owner withdraws and the actions come back.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user