Files
deepseek-harness/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
T

511 lines
19 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ISessions, SessionListState, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import {
RpcId,
type IApiClient,
type RpcError,
type RpcResponse,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
const sid = (id: string): SessionId => SessionId(id)
const wid = (id: string): WorkspaceId => id as WorkspaceId
afterEach(() => {
vi.restoreAllMocks()
})
function workspace(
id: string,
sessionIds: readonly SessionId[] = [],
createdAt = '2026-01-01T00:00:00.000Z',
): WorkspaceView {
return {
workspaceId: wid(id),
path: `/w/${id}`,
title: id,
sessionIds,
createdAt,
updatedAt: createdAt,
}
}
function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
id: sid(id),
displayTitle: id,
running: false,
blank: false,
updatedAt: 0,
...overrides,
}
}
function sessionState(
summaries: readonly SessionSummary[] = [],
current?: SessionId,
phase: SessionListState['phase'] = 'ready',
): SessionListState {
return {
ids: summaries.map(item => item.id),
byId: Object.fromEntries(summaries.map(item => [item.id, item])),
current,
phase,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
}
function workspaceState(
items: WorkspaceSnapshot['items'] = [],
archivedSessionIds: readonly SessionId[] = [],
phase: WorkspaceSnapshot['phase'] = 'ready',
): WorkspaceSnapshot {
return {
items,
archivedSessionIds,
phase,
state: phase === 'ready' ? 'idle' : 'loading',
error: null,
}
}
class MutableSource<T> {
private readonly listeners = new Set<() => void>()
constructor(private value: T) {}
getSnapshot(): T {
return this.value
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
set(value: T): void {
this.value = value
for (const listener of [...this.listeners]) listener()
}
update(update: (value: T) => T): void {
this.set(update(this.value))
}
listenersSnapshot(): readonly (() => void)[] {
return [...this.listeners]
}
}
class FakeSessions {
readonly list: MutableSource<SessionListState>
readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
readonly clear: ReturnType<typeof vi.fn<() => void>>
constructor(initial: SessionListState) {
this.list = new MutableSource(initial)
this.create = vi.fn<ISessions['create']>(async options =>
options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
this.open = vi.fn((id: SessionId) => {
this.list.update(state => ({ ...state, current: id }))
})
this.clear = vi.fn(() => {
this.list.update(state => ({ ...state, current: undefined }))
})
}
}
class FakeWorkspaces implements IWorkspaces {
readonly list: MutableSource<WorkspaceSnapshot>
readonly archiveCalls: SessionId[] = []
onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
this.list.update(state => ({
...state,
archivedSessionIds: [...state.archivedSessionIds, sessionId],
}))
}
declare readonly create: IWorkspaces['create']
declare readonly rename: IWorkspaces['rename']
declare readonly delete: IWorkspaces['delete']
declare readonly insertBefore: IWorkspaces['insertBefore']
declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
constructor(initial: WorkspaceSnapshot) {
this.list = new MutableSource(initial)
}
archiveSession(sessionId: SessionId): Promise<void> {
this.archiveCalls.push(sessionId)
return this.onArchive(sessionId)
}
}
let nextRpcId = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
}
function failed<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
}
const listing: DirectoryListing = {
path: '/home/u',
home: '/home/u',
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
truncated: false,
}
class FakeApiClient implements IApiClient {
readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
version: 'test',
cwd: '/home/u',
attachedSessions: 0,
home: '/home/u',
canOpenPath: true,
}))
onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
declare readonly skills: IApiClient['skills']
declare readonly agentPresets: IApiClient['agentPresets']
declare readonly settings: IApiClient['settings']
declare readonly credentials: IApiClient['credentials']
declare readonly llm: IApiClient['llm']
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
}
callsOf(method: string): unknown[] {
return this.calls.filter(call => call.method === method).map(call => call.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
}
/** The directory-picking Remote namespace, recorded and scripted per case. */
class FakeDirectoryPicker {
readonly calls: { method: string; payload: unknown }[] = []
onPick: () => Promise<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
onCreateDirectory: () => Promise<RemoteResult<string>> =
() => Promise.resolve({ ok: true, value: '/home/u/new' })
readonly remote: ClientRemote['directoryPicker'] = {
pick: () => this.record('pick', {}, this.onPick()),
list: (path?: string) => this.record('list', { path }, this.onList()),
createDirectory: (path: string, name: string) =>
this.record('createDirectory', { path, name }, this.onCreateDirectory()),
}
callsOf(method: string): unknown[] {
return this.calls.filter(call => call.method === method).map(call => call.payload)
}
private record<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return result
}
}
interface BenchOptions {
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
}
function bench(options: BenchOptions = {}) {
const ctx = new Context()
const api = new FakeApiClient()
const directoryPicker = new FakeDirectoryPicker()
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
const uiWorkspace = new UiWorkspaceService(
ctx,
api,
directoryPicker.remote,
workspaces,
sessions as unknown as ISessions,
)
return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces }
}
async function flush(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('UiWorkspaceService', () => {
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
const b = bench()
const memberBlank = sid('member-blank')
const archivedBlank = sid('archived-blank')
const summaries: readonly SessionSummary[] = [
summary('stray', { blank: true, cwd: '/w/alpha' }),
summary('member-blank', { blank: true, cwd: '/w/alpha' }),
summary('active', { cwd: '/w/beta' }),
summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
]
b.workspaces.list.set(workspaceState([
workspace('alpha', [memberBlank]),
workspace('beta', [sid('active')]),
workspace('gamma', [archivedBlank]),
], [archivedBlank]))
b.sessions.list.set({
...sessionState(summaries, memberBlank),
ids: [sid('missing'), ...summaries.map(item => item.id)],
})
await expect(Promise.all([
b.uiWorkspace.connectWorkspace(wid('alpha')),
b.uiWorkspace.connectWorkspace(wid('alpha')),
])).resolves.toEqual([memberBlank, memberBlank])
expect(b.sessions.create).not.toHaveBeenCalled()
const creation = Promise.withResolvers<SessionId>()
b.sessions.create.mockImplementation(() => creation.promise)
const first = b.uiWorkspace.connectWorkspace(wid('beta'))
const second = b.uiWorkspace.connectWorkspace(wid('beta'))
expect(b.sessions.create).toHaveBeenCalledTimes(1)
creation.resolve(sid('fresh-beta'))
await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
.rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
})
it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
const b = bench({
sessions: sessionState([current, recent], current.id),
workspaces: workspaceState([
workspace('current-home', [current.id]),
workspace('recent-home', [recent.id]),
]),
})
b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
b.uiWorkspace.startSession(wid('recent-home'))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
b.sessions.open(current.id)
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
})
b.sessions.clear()
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
const empty = bench()
empty.uiWorkspace.startSession()
expect(empty.sessions.clear).toHaveBeenCalledOnce()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
b.uiWorkspace.startSession(wid('recent-home'))
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
})
})
it('opens the recent Workspace after both baselines arrive', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
b.workspaces.list.set(workspaceState([stableFirst, recent]))
expect(b.sessions.create).not.toHaveBeenCalled()
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
wid('stable-first'), wid('recent'),
])
})
it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
b.workspaces.list.set(workspaceState([
workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
workspace('older', [], '2026-01-01T00:00:00.000Z'),
]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
})
it('retries failed initial selection and never overwrites a later selection', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const b = bench()
let attempts = 0
b.sessions.create.mockImplementation(() => ++attempts === 1
? Promise.reject(new Error('attach exploded'))
: Promise.resolve(sid('retry')))
b.workspaces.list.set(workspaceState([workspace('recent')]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
})
b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
})
expect(attempts).toBe(2)
const changed = bench()
const pending = Promise.withResolvers<SessionId>()
changed.sessions.create.mockImplementation(() => pending.promise)
changed.workspaces.list.set(workspaceState([workspace('recent')]))
changed.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
changed.sessions.open(sid('manual'))
pending.resolve(sid('automatic'))
await flush()
expect(changed.sessions.open).toHaveBeenCalledTimes(1)
expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
})
it('stops initial navigation when its Cordis lifetime is disposed', async () => {
const success = bench()
const resolved = Promise.withResolvers<SessionId>()
success.sessions.create.mockImplementation(() => resolved.promise)
success.workspaces.list.set(workspaceState([workspace('recent')]))
success.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
await success.ctx.fiber.dispose()
resolved.resolve(sid('late'))
await flush()
expect(success.sessions.open).not.toHaveBeenCalled()
success.workspaces.list.set(workspaceState([workspace('ignored')]))
expect(success.sessions.create).toHaveBeenCalledOnce()
const failure = bench()
const rejected = Promise.withResolvers<SessionId>()
failure.sessions.create.mockImplementation(() => rejected.promise)
failure.workspaces.list.set(workspaceState([workspace('recent')]))
failure.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
const staleReconciles = failure.workspaces.list.listenersSnapshot()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await failure.ctx.fiber.dispose()
rejected.reject(new Error('late failure'))
await flush()
for (const reconcile of staleReconciles) reconcile()
expect(warning).not.toHaveBeenCalled()
expect(failure.sessions.create).toHaveBeenCalledOnce()
})
it('clears a current Session only after it enters the archive baseline', () => {
const current = summary('current')
const idle = summary('idle')
const b = bench({
sessions: sessionState([current, idle], current.id),
workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
})
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).not.toHaveBeenCalled()
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
expect(b.sessions.clear).toHaveBeenCalledOnce()
b.sessions.open(idle.id)
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).toHaveBeenCalledTimes(2)
const archived = bench({
sessions: sessionState([current], current.id),
workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
})
expect(archived.sessions.clear).toHaveBeenCalledOnce()
})
it('forwards archive commands and preserves failures', async () => {
const idle = sid('idle')
const b = bench()
await b.uiWorkspace.archiveSession(idle)
expect(b.workspaces.archiveCalls).toEqual([idle])
b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
expect(b.workspaces.archiveCalls).toEqual([idle, idle])
})
it('passes directory operations to the Host and preserves structured browse failures', async () => {
const b = bench()
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })
await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null })
await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}])
await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }])
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
b.directoryPicker.onPick = () => Promise.resolve({
ok: false, error: { code: 'internal', message: 'no chooser', details: {} },
})
await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
b.directoryPicker.onList = () => Promise.resolve({
ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } },
})
const listFailure = b.uiWorkspace.listDirectory('/private')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
b.directoryPicker.onCreateDirectory = () => Promise.resolve({
ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } },
})
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
rpcError: { code: 'directory-exists' },
})
b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
})
})