/** 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 /** 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 unknown>() /** * @param stabilize - the owning runtime's act wrapper. */ constructor(private readonly stabilize: Stabilizer) { this.list = createSnapshotStore(workspaceListState()) } /** * Update the workspace list state through an immer draft. * @param mutate - draft mutator. */ async update(mutate: (draft: WorkspaceListState) => void): Promise { 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 { this.calls.push({ method: 'connectWorkspace', args: [workspaceId] }) const stub = this.stubs.get('connectWorkspace') if (stub !== undefined) return await (stub(workspaceId) as Promise) 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 { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) 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 { this.calls.push({ method: 'openPath', args: [path] }) await (this.stubs.get('openPath')?.(path) as Promise | undefined) } /** * Directory picker (recorded). The default cancels (null); stub to select. * @returns the picked path, or null. */ async pickDirectory(): Promise { this.calls.push({ method: 'pickDirectory', args: [] }) const stub = this.stubs.get('pickDirectory') if (stub !== undefined) return await (stub() as Promise) 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 { // The signal is recorded and forwarded like the production face passes // it to the wire, so cancellation integration tests can observe or // reject on a superseded scan. this.calls.push({ method: 'listDirectory', args: [path, signal] }) const stub = this.stubs.get('listDirectory') if (stub !== undefined) return await (stub(path, signal) as Promise) // 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 { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) 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 { this.calls.push({ method: 'rename', args: [workspaceId, title] }) const stub = this.stubs.get('rename') if (stub !== undefined) return await (stub(workspaceId, title) as Promise) 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 { this.calls.push({ method: 'delete', args: [workspaceId] }) await (this.stubs.get('delete')?.(workspaceId) as Promise | 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 { 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) return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView } }