test(web): cover workspace UI branches

This commit is contained in:
imccyu
2026-07-25 16:38:32 +08:00
parent c06cb2deec
commit 1a9c1596b8
6 changed files with 440 additions and 8 deletions

View File

@@ -377,6 +377,42 @@ describe('createFixtureApi', () => {
})
})
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-existing-ungrouped')
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
result: { ok: true, value: { sessionId } },
})
await expect(api.sessions.create(req({
sessionId,
workspaceId: 'fx-ws-fixture' as WorkspaceId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const workspaces = await api.workspace.list(req({}))
if (!workspaces.result.ok) throw new Error('workspace list failed')
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
})
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
const api = createFixtureApi()
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
if (existing === undefined) throw new Error('fixture Session missing')
delete existing.cwd
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
expect(conflict.result).toEqual({
ok: false,
error: {
code: 'session-conflict',
message: `session ${existing.sessionId} already uses no cwd`,
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
},
})
})
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
const api = createFixtureApi({ failWorkspaceAttach: true })
const sessionId = sid('fx-partial')

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
afterEach(cleanup)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
describe('sidebar rows', () => {
it('renders an active Workspace and keeps its create action separate from toggling', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(onCreate).toHaveBeenCalledOnce()
expect(onToggle).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Project'))
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders the frontend Intent placeholder as selected', () => {
render(<IntentRowItem />)
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
}
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} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
})
})

View File

@@ -1,13 +1,16 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.useRealTimers()
})
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
@@ -41,6 +44,43 @@ function mount(sessionState: SessionListState = sessions) {
return { view, startSession, open, pickerOwner: () => pickerOwner }
}
function mountSidebar({
sessionState = sessions,
workspaceState = workspaces,
collapsed = false,
width = 300,
}: {
sessionState?: SessionListState
workspaceState?: WorkspaceListState
collapsed?: boolean
width?: number
} = {}) {
const startSession = vi.fn()
const open = vi.fn()
const toggleSidebar = vi.fn()
let pickerOwner: unknown
let current = { sessionState, workspaceState, collapsed, width }
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>
)
const view = render(root())
return {
startSession,
open,
toggleSidebar,
pickerOwner: () => pickerOwner,
rerender(next: Partial<typeof current>) {
current = { ...current, ...next }
view.rerender(root())
},
}
}
describe('SidebarRoot', () => {
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
const b = mount()
@@ -79,4 +119,143 @@ describe('SidebarRoot', () => {
fireEvent.click(screen.getByText('First session'))
expect(b.open).toHaveBeenCalledWith(sid('s1'))
})
it('opens, selects, dismisses, and toggles the group-by menu', () => {
mount()
const button = screen.getByRole('button', { name: 'Group by' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(button)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(button)
fireEvent.click(button)
expect(screen.queryByRole('menu')).toBeNull()
})
it('routes every Workspace picker close path', () => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
expect(owner.open).toBe(true)
act(() => { owner.onClose() })
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
})
it('focuses, filters, and clears search while distinguishing both empty states', () => {
mount()
const input = screen.getByPlaceholderText('Search name, keywords...')
fireEvent.click(input.parentElement!)
expect(document.activeElement).toBe(input)
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
fireEvent.change(input, { target: { value: 'missing' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.queryByText('No matches')).toBeNull()
cleanup()
const emptySessions = listState()
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
expect(screen.getByText('No sessions yet')).toBeTruthy()
})
it('toggles Workspace and nested Session expansion in both directions', () => {
const parent = sid('parent')
const child = sid('child')
const nestedSessions: SessionListState = {
...sessions,
ids: [parent, child],
byId: {
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
},
}
const nestedWorkspace: WorkspaceListState = {
...workspaces,
items: [{ ...workspace, sessionIds: [parent, child] }],
}
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
fireEvent.click(screen.getByText('Project'))
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('Child')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('Child')).toBeNull()
fireEvent.click(screen.getByText('Project'))
expect(screen.queryByText('Parent')).toBeNull()
})
it('does not start a Session from an Ungrouped row create action', () => {
const loose = sid('loose')
const looseSessions: SessionListState = {
...listState(),
ids: [loose],
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
current: loose,
}
const b = mountSidebar({
sessionState: looseSessions,
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
})
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
expect(b.startSession).not.toHaveBeenCalled()
})
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
const b = mountSidebar()
fireEvent.click(screen.getByText('Project'))
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
b.rerender({
sessionState: { ...sessions, current: sid('s1') },
workspaceState: { ...workspaces, items: [other, workspace] },
})
expect(screen.getByText('First session')).toBeTruthy()
b.rerender({
sessionState: {
...sessions,
current: sid('draft'),
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
},
})
expect(screen.getByText('Project')).toBeTruthy()
})
it('renders the static collapsed rail and expands rail search into focused input', () => {
vi.useFakeTimers()
const b = mountSidebar({ collapsed: true })
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
b.rerender({ collapsed: false })
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
})
it('keeps wide content during live collapse, then settles to the rail', () => {
vi.useFakeTimers()
const b = mountSidebar({ width: 320 })
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
b.rerender({ collapsed: true, width: 56 })
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
})
})
function listState(): SessionListState {
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts'
import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
@@ -59,6 +59,90 @@ describe('deriveGroups', () => {
expect(groups[0]!.intentHere).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
})
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
const newChild = { ...summary('new-child', 20), parentId: parent.id }
const tieB = { ...summary('tie-b', 20), parentId: parent.id }
const tieA = { ...summary('tie-a', 20), parentId: parent.id }
const self = { ...summary('self', 2), parentId: sid('self') }
const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
)
expect(groups).toHaveLength(1)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
])
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
newChild.id, tieA.id, tieB.id, oldChild.id,
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
})
it('tolerates Workspace membership arriving before its Session summary', () => {
const partial: SessionListState = {
...list(),
ids: [sid('present')],
byId: { [sid('present')]: summary('present', 1) },
}
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
])
const labelOnly = deriveGroups(
list(summary('hidden', 1)),
[workspace('label-hit', ['hidden']), workspace('other', [])],
view([], 'label'),
)
expect(labelOnly).toEqual([
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
])
})
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
const owned = summary('owned', 1)
const loose = summary('loose', 2)
const ws = workspace('project', ['owned'])
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
})
})
describe('projectLabel', () => {
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
expect(projectLabel('/projects/demo/')).toBe('demo')
expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
expect(projectLabel('/')).toBe('/')
})
})
describe('formatRelativeTime', () => {

View File

@@ -79,12 +79,23 @@ describe('WorkspacePicker', () => {
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Use an existing folder')
fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } })
fireEvent.click(screen.getByRole('button', { name: 'Use folder' }))
const input = screen.getByLabelText('Existing folder path')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('closes a creation modal when the user cancels', () => {
mount([])
chooseCreateItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseCreateItem('Create a new workspace')
@@ -98,16 +109,43 @@ describe('WorkspacePicker', () => {
it('exposes creation phase and error text while retaining the modal for retry', async () => {
let reject!: (reason: unknown) => void
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const b = mount([], vi.fn(() => pending))
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
const input = screen.getByLabelText('New workspace name')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).toHaveBeenCalledTimes(1)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('dialog')).toBeTruthy()
await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
expect(b.view.getByRole('dialog')).toBeTruthy()
})
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseCreateItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
})
expect(b.onPick).not.toHaveBeenCalled()
})
it('waits to show its menu until an optional anchor is available', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows list loading through a stable status surface', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts'
import type { Config } from '../src/index.ts'
import type { DomainChanged } from '../src/events.ts'
import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts'
@@ -151,6 +151,25 @@ describe('DomainFacility.open', () => {
})
describe('plugin apply', () => {
it('uses only the default backend when routes are omitted', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
const backend = new MemoryStorageBackend()
ctx.storage.backend.register('memory', backend)
const disposeBackend = ctx.provide(storageBackendServiceKey('memory'), backend)
const fiber = await ctx.plugin({
name: 'storage-domain-routeless-test',
inject: ['storage'],
apply: (domainCtx: Context) => apply(domainCtx, { backend: 'memory' }),
})
await vi.waitFor(() => { expect(ctx.storageDomain).toBeInstanceOf(DomainFacility) })
disposeBackend()
await vi.waitFor(() => { expect(ctx.get('storageDomain')).toBeUndefined() })
await fiber.dispose()
})
it('waits for routed backends, then mounts one lifecycle-bound service and form', async () => {
const ctx = new Context()
await ctx.plugin(Storage)