Supersession (newer navigation, path editing, closing, unmount) now aborts the in-flight listing's request instead of only discarding its result: the browser mints an AbortController per listing, the signal rides the workspace face (IWorkspaces.listDirectory gains an optional signal) onto the fetch carrier, and the Host scan stops with it (817's cancellation chain). apps/cli keeps both picker packages as dependencies so the documented one-row cordis.yml swap to the native backend resolves at boot.
187 lines
7.4 KiB
TypeScript
187 lines
7.4 KiB
TypeScript
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { workspaceListState } from './fixtures.ts'
|
|
import type { Stabilizer } from './fixtures.ts'
|
|
|
|
/**
|
|
* Workspaces test double. Implements the same IWorkspaces face features
|
|
* receive as `ctx.workspaces`, so a production face change breaks this
|
|
* double at compile time. Every action records into {@link
|
|
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
|
|
* richer behavior replace them via {@link TestWorkspaces.stub}.
|
|
*/
|
|
export class TestWorkspaces implements IWorkspaces {
|
|
/** The useWorkspaces standard feed. */
|
|
readonly list: SnapshotStore<WorkspaceListState>
|
|
|
|
/** Calls observed on the action face, newest last. */
|
|
readonly calls: { method: string; args: unknown[] }[] = []
|
|
|
|
/** Replaceable action seat: feature tests may stub richer behavior. */
|
|
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
|
|
|
|
/**
|
|
* @param stabilize - the owning runtime's act wrapper.
|
|
*/
|
|
constructor(private readonly stabilize: Stabilizer) {
|
|
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
|
|
}
|
|
|
|
/**
|
|
* Update the workspace list state through an immer draft.
|
|
* @param mutate - draft mutator.
|
|
*/
|
|
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
|
|
await this.stabilize(() => { this.list.update(mutate) })
|
|
}
|
|
|
|
/**
|
|
* Replace an action's behavior (the recorded call is still appended first).
|
|
* @param method - action name (e.g. 'connectWorkspace').
|
|
* @param impl - replacement behavior.
|
|
*/
|
|
stub(method: string, impl: (...args: unknown[]) => unknown): void {
|
|
this.stubs.set(method, impl)
|
|
}
|
|
|
|
/**
|
|
* Connect a workspace to its reusable/new blank session (recorded). The
|
|
* default resolves the workspace id back as the session id; stub for
|
|
* cross-session flows.
|
|
* @param workspaceId - target workspace.
|
|
* @returns the connected session id.
|
|
*/
|
|
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
|
|
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
|
|
const stub = this.stubs.get('connectWorkspace')
|
|
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
|
|
return `session-of-${workspaceId}` as SessionId
|
|
}
|
|
|
|
/**
|
|
* New-session flow (recorded; stubbed behavior runs when installed).
|
|
* @param workspaceId - optional explicit workspace target.
|
|
*/
|
|
startSession(workspaceId?: WorkspaceId): void {
|
|
this.calls.push({ method: 'startSession', args: [workspaceId] })
|
|
this.stubs.get('startSession')?.(workspaceId)
|
|
}
|
|
|
|
/**
|
|
* Create a Workspace (recorded). The default echoes a view derived from
|
|
* the input; stub for failure or list-coupled flows.
|
|
* @param input - exactly one Host create spelling.
|
|
* @returns the created Workspace view.
|
|
*/
|
|
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'create', args: [input] })
|
|
const stub = this.stubs.get('create')
|
|
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
|
|
const title = 'name' in input ? input.name : input.path
|
|
return {
|
|
workspaceId: `ws-${title}` as WorkspaceId,
|
|
title,
|
|
path: 'path' in input ? input.path : `/${input.name}`,
|
|
sessionIds: [],
|
|
} as unknown as WorkspaceView
|
|
}
|
|
|
|
/**
|
|
* Open a path with the host OS default application (recorded; default no-op).
|
|
* @param path - host-resolvable path.
|
|
*/
|
|
async openPath(path: string): Promise<void> {
|
|
this.calls.push({ method: 'openPath', args: [path] })
|
|
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
|
|
}
|
|
|
|
/**
|
|
* Directory picker (recorded). The default cancels (null); stub to select.
|
|
* @returns the picked path, or null.
|
|
*/
|
|
async pickDirectory(): Promise<string | null> {
|
|
this.calls.push({ method: 'pickDirectory', args: [] })
|
|
const stub = this.stubs.get('pickDirectory')
|
|
if (stub !== undefined) return await (stub() as Promise<string | null>)
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Browse listing (recorded). The default serves an empty home level; stub
|
|
* to shape a tree.
|
|
* @param path - absolute directory to list; absent lists the home level.
|
|
* @returns the level's listing.
|
|
*/
|
|
async listDirectory(path?: string, _signal?: AbortSignal): Promise<DirectoryListing> {
|
|
this.calls.push({ method: 'listDirectory', args: [path] })
|
|
const stub = this.stubs.get('listDirectory')
|
|
if (stub !== undefined) return await (stub(path) as Promise<DirectoryListing>)
|
|
// The chain runs root-to-target inclusive, per the DirectoryListing
|
|
// contract — a bare root crumb would mislabel the level in browsers
|
|
// driven by this double.
|
|
return {
|
|
path: '/home/test',
|
|
home: '/home/test',
|
|
crumbs: [
|
|
{ name: '/', path: '/', hidden: false },
|
|
{ name: 'home', path: '/home', hidden: false },
|
|
{ name: 'test', path: '/home/test', hidden: false },
|
|
],
|
|
entries: [],
|
|
truncated: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Browse child creation (recorded). The default joins parent and name.
|
|
* @param path - absolute existing parent directory.
|
|
* @param name - single path segment.
|
|
* @returns the created directory's absolute path.
|
|
*/
|
|
async createDirectory(path: string, name: string): Promise<string> {
|
|
this.calls.push({ method: 'createDirectory', args: [path, name] })
|
|
const stub = this.stubs.get('createDirectory')
|
|
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
|
|
return `${path}/${name}`
|
|
}
|
|
|
|
/**
|
|
* Rename a Workspace (recorded). The default echoes a minimal view.
|
|
* @param workspaceId - target workspace.
|
|
* @param title - new title.
|
|
* @returns the updated view.
|
|
*/
|
|
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'rename', args: [workspaceId, title] })
|
|
const stub = this.stubs.get('rename')
|
|
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
|
|
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
|
|
}
|
|
|
|
/**
|
|
* Delete a Workspace (recorded; default no-op).
|
|
* @param workspaceId - target workspace.
|
|
*/
|
|
async delete(workspaceId: WorkspaceId): Promise<void> {
|
|
this.calls.push({ method: 'delete', args: [workspaceId] })
|
|
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
|
|
}
|
|
|
|
/**
|
|
* Move an accounted session (recorded). The default echoes a minimal view.
|
|
* @param workspaceId - target workspace.
|
|
* @param sessionId - session to move.
|
|
* @param beforeSessionId - anchor; omitted appends.
|
|
* @returns the updated view.
|
|
*/
|
|
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
|
|
const stub = this.stubs.get('insertSessionBefore')
|
|
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
|
|
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
|
|
}
|
|
}
|