From 9fc8a616a9acc9ca303fdadb41313c7f6b1e0454 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:48:36 +0800 Subject: [PATCH] test(web): close the per-file coverage gate for the session-list surfaces New and touched sources reach the CI per-file 100% thresholds: HoverCard (timers, placement clamp, disabled guard), Menu label/danger/pointer-leave branches, WorkspaceBrowser (mode switch, search, rail icons, rename dialog, drag), rows and tree derivations, the workspace fixture stubs, the rename/ insertSessionBefore wire rows, and the entity move semantics. HoverCard's position state narrows to {left, top} (equivalent refactor, no behavior change). --- .../client/connection/tests/fixture.spec.ts | 63 +++ .../client/ui-primitives/src/HoverCard.tsx | 19 +- .../client/ui-primitives/tests/atoms.spec.tsx | 45 ++ .../ui-primitives/tests/hover-card.spec.tsx | 148 ++++++ .../src/client/WorkspaceBrowser.tsx | 5 + .../client/ui-workspace/tests/rows.spec.tsx | 177 ++++++- .../client/ui-workspace/tests/tree.spec.ts | 42 +- .../tests/workspace-browser.spec.tsx | 458 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 13 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 29 +- .../workspace/tests/workspace.spec.ts | 52 +- 11 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 packages/client/ui-primitives/tests/hover-card.spec.tsx create mode 100644 packages/client/ui-workspace/tests/workspace-browser.spec.tsx diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..0baabaf617 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -311,6 +311,60 @@ describe('createFixtureApi', () => { expect(rootPath.result.value.workspace.title).toBe('/') }) + it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + + await api.workspace.create(req({ name: 'occupied' })) + const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + + const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) + if (!noop.result.ok) throw new Error('no-op rename failed') + expect(noop.result.value.workspace.title).toBe('fixture') + + const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.workspace.title).toBe('renamed') + await consuming + // Only the create and the effective rename emit frames; the no-op stays silent. + expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + }) + + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { + const api = createFixtureApi() + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + + const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) + if (!moved.result.ok) throw new Error('move failed') + expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) + const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!appended.result.ok) throw new Error('append failed') + expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + const before = appended.result.value.workspace.updatedAt + const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!noop.result.ok) throw new Error('no-op move failed') + expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + expect(noop.result.value.workspace.updatedAt).toBe(before) + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const workspace = await client.workspace.create({ name: 'via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') + const wsid = workspace.result.value.workspace.workspaceId + const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + if (!renamed.result.ok) throw new Error('workspace rename failed') + expect(renamed.result.value.workspace.title).toBe('via-client-2') + const attached = await client.sessions.create({ workspaceId: wsid }) + if (!attached.result.ok) throw new Error('attached create failed') + const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + if (!moved.result.ok) throw new Error('workspace move failed') + expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 58e281778e..1720a0b79c 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -5,7 +5,7 @@ // and closes the instant the pointer leaves the anchor (no close delay). import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import css from './HoverCard.module.css' @@ -27,7 +27,7 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const cardRef = useRef(null) const timerRef = useRef | null>(null) const [open, setOpen] = useState(false) - const [pos, setPos] = useState(null) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const clearTimer = () => { if (timerRef.current !== null) { @@ -50,8 +50,10 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useLayoutEffect(() => { if (!open) { setPos(null); return } const place = () => { - const r = rootRef.current?.getBoundingClientRect() ?? null - if (r === null) return + const wrapper = rootRef.current + /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ + if (wrapper === null) return + const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) @@ -66,13 +68,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }, [open]) // The first placement ran before the card mounted (height read 0): once the - // card's real height is measurable, correct the bottom-edge clamp. + // card's real height is measurable, correct the bottom-edge clamp. The + // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || typeof pos.top !== 'number') return + if (!open || pos === null) return + /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { - const top = window.innerHeight - h - 8 - if (pos.top !== top) setPos({ ...pos, top }) + setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } }, [open, pos]) diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 440f4ba6ef..9e298d057a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -136,6 +136,51 @@ describe('Menu', () => { expect(screen.getByRole('separator')).toBeDefined() }) + it('renders a non-interactive heading label and a danger row', () => { + const onSelect = vi.fn() + render( + trigger} + items={[ + { type: 'label', id: 'h', text: 'Group by' }, + { id: 'del', label: 'Delete', danger: true }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const heading = screen.getByText('Group by') + expect(heading.getAttribute('role')).toBe('presentation') + // The heading is not a menu item — only the danger row is interactive. + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + const danger = screen.getByRole('menuitem', { name: 'Delete' }) + expect(danger.className).toMatch(/danger/) + fireEvent.click(danger) + expect(onSelect).toHaveBeenCalledWith('del') + }) + + it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { + const rowClick = vi.fn() + render( +
+ trigger} items={items} onSelect={() => {}} onClose={() => {}} /> +
) + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(rowClick).not.toHaveBeenCalled() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx new file mode 100644 index 0000000000..c7a95f49fb --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ +function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { + const wrapper = anchor.parentElement as HTMLElement + wrapper.getBoundingClientRect = () => ({ + top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, + width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), + } as DOMRect) +} + +function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { + const view = render( + row} content={
card body
} {...props} />, + ) + const anchor = screen.getByText('row') + stubAnchorRect(anchor, { top: 40, right: 200 }) + return { view, anchor, wrapper: anchor.parentElement as HTMLElement } +} + +describe('HoverCard', () => { + it('opens after the dwell delay, positioned right of the anchor', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.parentElement).toBe(document.body) + expect(card.style.left).toBe('208px') + expect(card.style.top).toBe('40px') + }) + + it('honors a custom openDelayMs', () => { + const { wrapper } = mount({ openDelayMs: 50 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(50) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('pointerleave before the delay cancels the pending open', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press inside the anchor dismisses the card without waiting for disabled', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(screen.getByText('row')) + expect(screen.queryByText('card body')).toBeNull() + // The pending timer is also cleared: no reopen after the dwell. + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('disabled suppresses opening entirely', () => { + const { wrapper } = mount({ disabled: true }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('flipping disabled true closes an open card', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(row} content={
card body
} disabled />) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('corrects the bottom-edge clamp once the mounted card height is measurable', () => { + // First placement reads height 0 (card not yet mounted) and keeps the + // anchor top; the post-mount correction re-clamps with the real height. + window.innerHeight = 300 + const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 }) + try { + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + // 300 - 120 - 8 = 172, instead of the anchor top 280. + expect(card.style.top).toBe('172px') + } finally { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) + } + }) + + it('clamps inside placement itself when the card is already measured (resize path)', () => { + window.innerHeight = 300 + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { value: 120 }) + act(() => { fireEvent.resize(window) }) + expect(card.style.top).toBe('172px') + }) + + it('repositions on capture-phase scroll while open and stops listening after close', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 }) + act(() => { fireEvent.scroll(document) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.left).toBe('308px') + expect(card.style.top).toBe('90px') + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('unmount clears a pending open timer', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + view.unmount() + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) +}) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 57de2cbaf5..d051df2fdb 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -48,6 +48,7 @@ function GroupByMenu({ groupBy, onPick }: { items={GROUP_BY_ITEMS} selectedId={groupBy} onSelect={(id) => { + /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ if (id === 'workspace' || id === 'flat') onPick(id) setOpen(false) }} @@ -137,6 +138,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onRename={group.workspaceId === undefined ? undefined : () => { + /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) }} /> @@ -154,9 +156,11 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen active: sameGroupDrag, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) }, drop: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return const roots = group.sessions // Anchor = the row the insert line points at ('after' means @@ -218,6 +222,7 @@ function FlatList({ useSessions, open, query }: Pick {}} flat /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index fdc5c43b8e..b90fb9a601 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { RowDragProps } from '../src/client/rows/Rows.tsx' import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' @@ -10,6 +11,32 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId +/** Half detection reads the row rect; jsdom rects are all-zero by default. */ +function stubRect(row: HTMLElement): void { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) +} + +function dragProps(overrides: Partial = {}): RowDragProps { + return { + start: vi.fn(), active: false, marker: null, + hover: vi.fn(), drop: vi.fn(), end: vi.fn(), + ...overrides, + } +} + +const dataTransfer = { effectAllowed: '', dropEffect: '' } + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } }) + fireEvent(row, event) +} + describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() @@ -73,4 +100,152 @@ describe('workspace browser rows', () => { expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') }) + + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { + const onRename = vi.fn() + const onToggle = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + // Opening the menu neither toggles the group nor renames yet. + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + expect(onRename).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + // Delete stays visual-only: selecting it just closes the menu. + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledOnce() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('ungrouped bucket renders no workspace menu', () => { + const group: GroupNode = { + key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() + }) + + it('session row menu opens without opening the session and closes on selection', () => { + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'One', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + 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' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('flat variant renders no twist even for a parent and ignores toggling', () => { + const node: SessionNode = { + id: sid('p'), title: 'Parent', children: [], hasChildren: true, + expanded: false, running: false, updatedAt: 0, + } + render() + expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() + }) + + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, + expanded: false, running: true, updatedAt: 0, + } + render() + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + // Card body: full title + relative time + running status. + expect(screen.getAllByText('Hovered')).toHaveLength(2) + expect(screen.getByText('1min ago')).toBeTruthy() + expect(screen.getByText('Running')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + // Menu open (disabled=true) suppresses the card for the same hover. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('1min ago')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('idle hover card shows the Idle status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Idle')).toBeTruthy() + expect(screen.getByText('now ago')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { + const node: SessionNode = { + id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const inactive = dragProps() + const { rerender } = render( + , + ) + const row = screen.getByRole('treeitem') + stubRect(row) + expect(row.getAttribute('draggable')).toBe('true') + fireEvent.dragStart(row, { dataTransfer }) + expect(inactive.start).toHaveBeenCalledOnce() + // Inactive drag: hover and drop are rejected. + fireEvent.dragOver(row, { dataTransfer }) + fireEvent.drop(row, { dataTransfer }) + expect(inactive.hover).not.toHaveBeenCalled() + expect(inactive.drop).not.toHaveBeenCalled() + fireEvent.dragEnd(row) + expect(inactive.end).toHaveBeenCalledOnce() + + const active = dragProps({ active: true, marker: 'before' }) + rerender( + , + ) + stubRect(screen.getByRole('treeitem')) + // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). + fireDrag(screen.getByRole('treeitem'), 'dragOver', 105) + expect(active.hover).toHaveBeenCalledWith('before') + fireDrag(screen.getByRole('treeitem'), 'dragOver', 130) + expect(active.hover).toHaveBeenCalledWith('after') + fireDrag(screen.getByRole('treeitem'), 'drop', 130) + expect(active.drop).toHaveBeenCalledWith('after') + + const after = dragProps({ active: true, marker: 'after' }) + rerender( + , + ) + expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) + }) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index d114d43dea..b314e14571 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -52,6 +53,12 @@ describe('deriveGroups', () => { expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) + it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view()) + expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false })) + }) + it('search filters real Sessions and omits the Intent placeholder', () => { const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) @@ -135,6 +142,39 @@ describe('deriveGroups', () => { }) }) +describe('deriveFlat', () => { + it('flattens every session — fork children included — newest-first with id tiebreak', () => { + const parent = summary('parent', 10) + const child = { ...summary('child', 30), parentId: parent.id } + const tieB = summary('tie-b', 20) + const tieA = summary('tie-a', 20) + const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) + // Rows are branch-free: no children, no expansion. + expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) + }) + + it('search filters by case-insensitive display-title substring', () => { + const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } + const miss = { ...summary('miss', 1), displayTitle: 'Other' } + expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) + }) + + it('tolerates ids whose summary has not landed yet', () => { + const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } + expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + }) +}) + +describe('createWorkspaceViewStore', () => { + it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().groupBy).toBe('workspace') + store.actions.setGroupBy('flat') + expect(store.getSnapshot().groupBy).toBe('flat') + }) +}) + describe('projectLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx new file mode 100644 index 0000000000..3e592c3168 --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -0,0 +1,458 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' + +afterEach(cleanup) +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 => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides, +}) +const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, + ...overrides, +}) +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } }) + fireEvent(row, event) +} + +function mount(overrides: Partial = {}) { + const store = createWorkspaceViewStore().create() + const props: WorkspaceBrowserProps = { + wide: true, + expandSidebar: vi.fn(), + useSessions: hook(sessionState([])), + useWorkspaces: hook(workspaceState([])), + useStore: bindSnapshotSelector(store), + actions: store.actions, + startSession: vi.fn(), + open: vi.fn(), + renameWorkspace: vi.fn(async () => {}), + insertSessionBefore: vi.fn(async () => {}), + createWorkspace: vi.fn(async () => workspace('created', [])), + ...overrides, + } + const view = render() + return { view, props, store } +} + +/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */ +function rerender(b: ReturnType, overrides: Partial) { + Object.assign(b.props, overrides) + b.view.rerender() +} + +describe('WorkspaceBrowser', () => { + it('renders the grouped tree by default and switches to the flat list via Group by', () => { + const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])), + }) + expect(screen.getByText('Workspaces')).toBeTruthy() + expect(screen.getByText('alpha')).toBeTruthy() + // Sessions hidden while their group is folded. + expect(screen.queryByText('alpha-s')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label + fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' })) + // Store-driven flip: title changes, rows flatten newest-first, headers gone. + expect(b.store.getSnapshot().groupBy).toBe('flat') + expect(screen.getByText('Sessions')).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(screen.getByText('alpha-s')).toBeTruthy() + expect(screen.getByText('beta-s')).toBeTruthy() + + // Back to workspace grouping through the same menu. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' })) + expect(b.store.getSnapshot().groupBy).toBe('workspace') + expect(screen.getByText('Workspaces')).toBeTruthy() + + // Escape closes the menu without picking. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.store.getSnapshot().groupBy).toBe('workspace') + }) + + it('expands a group on click and opens a session row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + open, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha-s')) + expect(open).toHaveBeenCalledWith(sid('alpha-s')) + // Collapse hides the row again. + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-s')).toBeNull() + }) + + it('unfolds a session subtree through the row twist', () => { + const parent = summary('parent-s', 2) + const child = { ...summary('child-s', 1), parentId: parent.id } + mount({ + useSessions: hook(sessionState([parent, child])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('child-s')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('child-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('child-s')).toBeNull() + }) + + it('auto-expands the selected session group and starts a session from the group +', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + startSession, + }) + // The current-group effect expanded the owning group without a click. + expect(screen.getByText('alpha-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + expect(startSession).toHaveBeenCalledWith(wid('alpha')) + }) + + it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + startSession, + }) + // The loose session's group is UNGROUPED_KEY: expanded by the effect. + expect(screen.getByText('loose')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(startSession).not.toHaveBeenCalled() + }) + + it('keeps an already-expanded group when the selection moves within it', () => { + const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') }) + const b = mount({ + useSessions: hook(first), + useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])), + }) + expect(screen.getByText('a')).toBeTruthy() + // Selection hop inside the same group: the effect re-runs and leaves the + // expansion list unchanged (no duplicate key, group still open). + rerender(b, { useSessions: hook({ ...first, current: sid('b') }) }) + expect(screen.getByText('b')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('b')).toBeNull() + }) + + it('renders the intent placeholder in both modes', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const } + const sessions = sessionState([], { intent, current: sid('intent') }) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + }) + // Grouped: the current-group effect expands the target group. + expect(screen.getByText('New session')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('New session')).toBeTruthy() + }) + + it('searches across groups, clears via the clear button, and shows the empty states', () => { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText('Search name, keywords...') + fireEvent.change(input, { target: { value: 'needle' } }) + // Search forces matches visible without expansion state. + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + fireEvent.change(input, { target: { value: 'zzz' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + }) + + it('shows the no-sessions empty state in both modes', () => { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + // Flat search misses show No matches. + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) + expect(screen.getByText('No matches')).toBeTruthy() + }) + + it('rail state renders icon controls that request expansion', () => { + vi.useFakeTimers() + try { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar }) + // No wide chrome in rail state. + expect(screen.queryByText('Workspaces')).toBeNull() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + // The wide flip mounts the input and focuses it after the slide. + rerender(b, { wide: true }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + // Wide search button is decorative (tabIndex -1, no expand call). + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + const expandSidebar = vi.fn() + const b = 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() + 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' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header + const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] + three.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + // Drop on the top half of "three": insert one before three. + fireDrag(three, 'dragOver', 205) + fireDrag(three, 'drop', 205) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three')) + + // Dropping right back onto its own position is a no-op — top half + // (anchor = itself) and bottom half (anchor = the next root) alike. + fireEvent.dragStart(one, { dataTransfer }) + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(one, 'drop', 130) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + }) + + it('still sends the reorder when the dragged row left the group mid-drag', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + // The host dropped "one" from the workspace account while the drag is in + // flight: the source index is gone but the drop still resolves its anchor. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + fireDrag(two, 'drop', 155) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) + }) + + it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireEvent.dragEnd(one) + // The drag ended: rows no longer accept drops. + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).not.toHaveBeenCalled() + + // Bottom half of the last row: append (anchor omitted). + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'dragOver', 180) + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + + it('logs and keeps the order when the reorder call rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') }) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'drop', 180) + await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) + } finally { + warn.mockRestore() + } + }) + + it('renames a workspace through the row menu dialog', async () => { + let resolveRename!: () => void + const renameWorkspace = vi.fn(() => new Promise((resolve) => { resolveRename = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + expect(input.value).toBe('Alpha') + // Unchanged and blank names stay blocked. + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + // A duplicate of another workspace's title shows the inline conflict. + fireEvent.change(input, { target: { value: ' Beta ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: 'Gamma' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') + // While renaming: input disabled, close blocked, Enter ignored. + expect(input.disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { resolveRename() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('rename via Enter, failure surfaces the error, Cancel closes', async () => { + const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + // Enter with a blocked draft (unchanged) does nothing. + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'Renamed' } }) + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed') + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') }) + // The dialog stays for retry; typing clears the error; Cancel closes. + fireEvent.change(input, { target: { value: 'Renamed2' } }) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports non-Error rename failures as text', async () => { + const renameWorkspace = vi.fn(async () => { throw 'denied' }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + }) + + it('search hides drag affordances (rows are not draggable during search)', () => { + const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), + }) + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25b095b99f..a81aadd94b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -68,6 +68,19 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } }) }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { + const api = scriptedApi() + const c = client(api) + const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) + expect(renamed.result.ok).toBe(true) + const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) + expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) + expect(anchored.result.ok).toBe(true) + const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) + expect(appended.result.ok).toBe(true) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d0f2ddd128..ebb7931e6e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RpcId } from '../src/api/rpc.ts' +import { RpcId, transportError } from '../src/api/rpc.ts' import { clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, @@ -13,8 +13,10 @@ import { } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, - workspaceListValueSchema, workspaceViewSchema, + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, + workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' @@ -30,6 +32,13 @@ describe('RpcId', () => { }) }) +describe('transportError', () => { + it('folds Error and non-Error throws into the internal error branch', () => { + expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) + expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) + }) +}) + describe('rpcErrorSchema', () => { it('accepts every code branch with its required details', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') @@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -154,6 +164,19 @@ describe('workspace domain schemas', () => { expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) + it('rename requires a non-blank title (both refine arms)', () => { + expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') + expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) + expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + }) describe('events frame schemas', () => { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index bdefc2ec82..8ce70cc7d5 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -449,6 +449,56 @@ describe('Workspace session ordering', () => { expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) + it('moves one id before an anchor or to the end, durably', async () => { + const dir = await makeDir('insert-before') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await workspace.attachSession(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s3', 's2', 's1']) + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s3', 's1', 's2']) + await workspace.insertSessionBefore(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3']) + }) + + it('treats self-anchored and already-in-place moves as no-ops without writing', async () => { + const dir = await makeDir('insert-noop') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + const written = result.changes.length + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s1')) + await workspace.detachSession(SessionId('absent')) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + }) + + it('rejects moves naming an unaccounted session or anchor', async () => { + const dir = await makeDir('insert-invalid') + const result = await harness() + result.setSessions([header('s1', dir, 1)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + const written = result.changes.length + + await expect(workspace.insertSessionBefore(SessionId('ghost'))) + .rejects.toBeInstanceOf(WorkspaceMoveInvalidError) + await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost'))) + .rejects.toThrow(/anchor session is not accounted/) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s1']) + }) + it('validates a lazy live session without requiring it in persistence.list()', async () => { const dir = await makeDir('live') const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] })