// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) const wid = (id: string) => id as WorkspaceId function workspace(id: string, title = id): WorkspaceView { return { workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } } function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } { const element = document.createElement('button') element.getBoundingClientRect = () => ({ top: 10, left: 20, width: 30, height: 40, right: 50, bottom: 50, x: 20, y: 10, toJSON: () => ({}), }) return { current: element } } function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), directoryPickerKind = vi.fn(async () => 'native'), ) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( ) const view = render( renderPicker(items), ) return { view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } // findByRole, not getByRole: the folder entry renders only after the advertised // picker kind resolves, one microtask after the menu opens. async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise { fireEvent.click(await screen.findByRole('menuitem', { name })) } describe('WorkspacePicker', () => { it('lists real Workspaces from useWorkspaces and forwards a selected id', () => { const b = mount() fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(b.onPick).toHaveBeenCalledWith(wid('alpha')) }) it('creates a real Workspace from a name and focuses its frontend Session target', async () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) it('opens a native directory picker, adopts its 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) await chooseItem('Open local folder…') expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) await chooseItem('Open local folder…') await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) 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) const createWorkspace = vi.fn(async () => { throw new WorkspaceCreateError({ code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, }) }) const b = mount([], createWorkspace, pickDirectory) await chooseItem('Open local folder…') 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.') fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) }) 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((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) await chooseItem('Open local folder…') expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('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 }) }) it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) expect(b.createWorkspace).not.toHaveBeenCalled() }) it('closes a creation modal when the user cancels', async () => { mount([]) await chooseItem('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', async () => { const b = mount([workspace('alpha', 'Alpha')]) await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('button', { name: 'Create workspace' }).disabled).toBe(true) fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' }) expect(b.createWorkspace).not.toHaveBeenCalled() }) it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => { let resolve!: (workspace: WorkspaceView) => void const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) b.rerenderItems([created]) expect(screen.getByRole('status').textContent).toBe('Creating workspace…') expect(screen.queryByRole('alert')).toBeNull() await act(async () => { resolve(created); await pending }) expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) it('exposes creation phase and error text while retaining the modal for retry', async () => { let reject!: (reason: unknown) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) await chooseItem('Create a new workspace') 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' })) await 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') }) expect(b.onPick).not.toHaveBeenCalled() }) it('waits to show its menu until an optional anchor is available', () => { render( 'native')} />, ) expect(screen.queryByRole('menu')).toBeNull() }) it('shows list loading through a stable status surface', () => { const state: WorkspaceListState = { ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, } render( 'native')} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) it('hides the folder affordance unless the Host advertises the dialog interaction', async () => { const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse')) await screen.findByRole('menuitem', { name: 'Create a new workspace' }) await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) it('hides the folder affordance when the Host cannot answer describe', async () => { const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => { throw new Error('host unreachable') })) await screen.findByRole('menuitem', { name: 'Create a new workspace' }) await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) it('does not read the picker kind while the flow is closed', () => { const directoryPickerKind = vi.fn(async () => 'native') render( , ) expect(directoryPickerKind).not.toHaveBeenCalled() }) /** Render the picker with an owner-controlled `open` and a scripted kind read. */ function togglable(directoryPickerKind: () => Promise) { const anchorRef = anchor() const props = (open: boolean) => ( ) const view = render(props(true)) return { setOpen: (open: boolean) => { view.rerender(props(open)) } } } it('discards a kind settlement from a superseded flow open', async () => { let resolveFirst!: (kind: string) => void const first = new Promise((settle) => { resolveFirst = settle }) const directoryPickerKind = vi.fn<() => Promise>() .mockImplementationOnce(() => first) .mockImplementation(async () => 'browse') const t = togglable(directoryPickerKind) // Close while the first read is in flight, then let it answer 'native': // the settlement is stale and must not leak into the next open. t.setOpen(false) await act(async () => { resolveFirst('native') }) t.setOpen(true) await screen.findByRole('menuitem', { name: 'Create a new workspace' }) await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => { const directoryPickerKind = vi.fn<() => Promise>() .mockImplementationOnce(async () => 'native') // The reopened read never settles: the assertion below sees the paint // that precedes any fresh answer. .mockImplementation(() => new Promise(() => {})) const t = togglable(directoryPickerKind) await screen.findByRole('menuitem', { name: 'Open local folder…' }) t.setOpen(false) t.setOpen(true) await screen.findByRole('menuitem', { name: 'Create a new workspace' }) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) it('discards a stale describe failure after a newer open already answered', async () => { let rejectFirst!: (reason: Error) => void const first = new Promise((_settle, reject) => { rejectFirst = reject }) const directoryPickerKind = vi.fn<() => Promise>() .mockImplementationOnce(() => first) .mockImplementation(async () => 'native') const t = togglable(directoryPickerKind) t.setOpen(false) t.setOpen(true) await screen.findByRole('menuitem', { name: 'Open local folder…' }) // The superseded read failing late must not hide the freshly shown entry. await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) }) expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) })