test(web): cover workspace sidebar behavior
This commit is contained in:
@@ -174,6 +174,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
|
||||
() => Promise.resolve(ok({ workspaceIds: [] }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
@@ -189,6 +192,8 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
archiveSession: (payload: unknown) =>
|
||||
|
||||
@@ -11,7 +11,7 @@ function summary(
|
||||
running = false,
|
||||
): SessionSummary {
|
||||
return {
|
||||
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
|
||||
id: sid(id), displayTitle: id, running, blank: false, createdAt: 0, updatedAt: 0,
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
@@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
|
||||
}
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
it('replays changed frames over hydration and keeps established order on refresh', async () => {
|
||||
it('replays changed frames over hydration and adopts the durable order on refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
@@ -36,7 +36,7 @@ describe('WorkspaceManager', () => {
|
||||
items: [workspace('old'), workspace('new')] as never[],
|
||||
}))
|
||||
await manager.refresh()
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
|
||||
@@ -77,6 +77,38 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
|
||||
}))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
|
||||
api.onWorkspaceInsertBefore = () => gate.promise
|
||||
const pending = manager.insertBefore(wid('three'), wid('one'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'newer-order' as never,
|
||||
payload: {
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [wid('one'), wid('three'), wid('two')],
|
||||
},
|
||||
})
|
||||
gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
|
||||
await pending
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
|
||||
}))
|
||||
const rejected = manager.insertBefore(wid('three'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
||||
await expect(rejected).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
})
|
||||
|
||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
@@ -309,6 +341,72 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two')] as never[],
|
||||
}))
|
||||
await workspaces.refresh()
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
|
||||
workspaceIds: [wid('two'), wid('one')],
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
|
||||
expect(api.callsOf('workspace.insertBefore')).toEqual([{
|
||||
workspaceId: 'two', beforeWorkspaceId: 'one',
|
||||
}])
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
workspace('current-home', [sid('current')]),
|
||||
workspace('recent-home', [sid('recent')]),
|
||||
] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
{ sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
|
||||
{ sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
|
||||
] as never[] }))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
sessions.open(sid('current'))
|
||||
const unresolved = new Promise<SessionId>(() => {})
|
||||
const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
|
||||
|
||||
workspaces.startSession(wid('recent-home'))
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
|
||||
|
||||
sessions.clear()
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
const emptyCtx = new Context()
|
||||
const emptyApi = new FakeApiClient()
|
||||
const emptySessions = new SessionsService(emptyCtx, emptyApi)
|
||||
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
|
||||
const clear = vi.spyOn(emptySessions, 'clear')
|
||||
emptyWorkspaces.startSession()
|
||||
expect(clear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
Reference in New Issue
Block a user