workspaces.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
  2. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  3. import type {
  4. DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
  5. } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { workspaceListState } from './fixtures.ts'
  7. import type { Stabilizer } from './fixtures.ts'
  8. /**
  9. * Workspaces test double. Implements the same IWorkspaces face features
  10. * receive as `ctx.workspaces`, so a production face change breaks this
  11. * double at compile time. Every action records into {@link
  12. * TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
  13. * richer behavior replace them via {@link TestWorkspaces.stub}.
  14. */
  15. export class TestWorkspaces implements IWorkspaces {
  16. /** The useWorkspaces standard feed. */
  17. readonly list: SnapshotStore<WorkspaceListState>
  18. /** Calls observed on the action face, newest last. */
  19. readonly calls: { method: string; args: unknown[] }[] = []
  20. /** Replaceable action seat: feature tests may stub richer behavior. */
  21. private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
  22. /**
  23. * @param stabilize - the owning runtime's act wrapper.
  24. */
  25. constructor(private readonly stabilize: Stabilizer) {
  26. this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
  27. }
  28. /**
  29. * Update the workspace list state through an immer draft.
  30. * @param mutate - draft mutator.
  31. */
  32. async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
  33. await this.stabilize(() => { this.list.update(mutate) })
  34. }
  35. /**
  36. * Replace an action's behavior (the recorded call is still appended first).
  37. * @param method - action name (e.g. 'connectWorkspace').
  38. * @param impl - replacement behavior.
  39. */
  40. stub(method: string, impl: (...args: unknown[]) => unknown): void {
  41. this.stubs.set(method, impl)
  42. }
  43. /**
  44. * Connect a workspace to its reusable/new blank session (recorded). The
  45. * default resolves the workspace id back as the session id; stub for
  46. * cross-session flows.
  47. * @param workspaceId - target workspace.
  48. * @returns the connected session id.
  49. */
  50. async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
  51. this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
  52. const stub = this.stubs.get('connectWorkspace')
  53. if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
  54. return `session-of-${workspaceId}` as SessionId
  55. }
  56. /**
  57. * New-session flow (recorded; stubbed behavior runs when installed).
  58. * @param workspaceId - optional explicit workspace target.
  59. */
  60. startSession(workspaceId?: WorkspaceId): void {
  61. this.calls.push({ method: 'startSession', args: [workspaceId] })
  62. this.stubs.get('startSession')?.(workspaceId)
  63. }
  64. /**
  65. * Create a Workspace (recorded). The default echoes a view derived from
  66. * the input; stub for failure or list-coupled flows.
  67. * @param input - exactly one Host create spelling.
  68. * @returns the created Workspace view.
  69. */
  70. async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
  71. this.calls.push({ method: 'create', args: [input] })
  72. const stub = this.stubs.get('create')
  73. if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
  74. const title = 'name' in input ? input.name : input.path
  75. return {
  76. workspaceId: `ws-${title}` as WorkspaceId,
  77. title,
  78. path: 'path' in input ? input.path : `/${input.name}`,
  79. sessionIds: [],
  80. } as unknown as WorkspaceView
  81. }
  82. /**
  83. * Open a path with the host OS default application (recorded; default no-op).
  84. * @param path - host-resolvable path.
  85. */
  86. async openPath(path: string): Promise<void> {
  87. this.calls.push({ method: 'openPath', args: [path] })
  88. await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
  89. }
  90. /**
  91. * Directory picker (recorded). The default cancels (null); stub to select.
  92. * @returns the picked path, or null.
  93. */
  94. async pickDirectory(): Promise<string | null> {
  95. this.calls.push({ method: 'pickDirectory', args: [] })
  96. const stub = this.stubs.get('pickDirectory')
  97. if (stub !== undefined) return await (stub() as Promise<string | null>)
  98. return null
  99. }
  100. /**
  101. * Browse listing (recorded). The default serves an empty home level; stub
  102. * to shape a tree.
  103. * @param path - absolute directory to list; absent lists the home level.
  104. * @returns the level's listing.
  105. */
  106. async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
  107. // The signal is recorded and forwarded like the production face passes
  108. // it to the wire, so cancellation integration tests can observe or
  109. // reject on a superseded scan.
  110. this.calls.push({ method: 'listDirectory', args: [path, signal] })
  111. const stub = this.stubs.get('listDirectory')
  112. if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
  113. // The chain runs root-to-target inclusive, per the DirectoryListing
  114. // contract — a bare root crumb would mislabel the level in browsers
  115. // driven by this double.
  116. return {
  117. path: '/home/test',
  118. home: '/home/test',
  119. crumbs: [
  120. { name: '/', path: '/', hidden: false },
  121. { name: 'home', path: '/home', hidden: false },
  122. { name: 'test', path: '/home/test', hidden: false },
  123. ],
  124. entries: [],
  125. truncated: false,
  126. }
  127. }
  128. /**
  129. * Browse child creation (recorded). The default joins parent and name.
  130. * @param path - absolute existing parent directory.
  131. * @param name - single path segment.
  132. * @returns the created directory's absolute path.
  133. */
  134. async createDirectory(path: string, name: string): Promise<string> {
  135. this.calls.push({ method: 'createDirectory', args: [path, name] })
  136. const stub = this.stubs.get('createDirectory')
  137. if (stub !== undefined) return await (stub(path, name) as Promise<string>)
  138. return `${path}/${name}`
  139. }
  140. /**
  141. * Rename a Workspace (recorded). The default echoes a minimal view.
  142. * @param workspaceId - target workspace.
  143. * @param title - new title.
  144. * @returns the updated view.
  145. */
  146. async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
  147. this.calls.push({ method: 'rename', args: [workspaceId, title] })
  148. const stub = this.stubs.get('rename')
  149. if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
  150. return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
  151. }
  152. /**
  153. * Delete a Workspace (recorded; default no-op).
  154. * @param workspaceId - target workspace.
  155. */
  156. async delete(workspaceId: WorkspaceId): Promise<void> {
  157. this.calls.push({ method: 'delete', args: [workspaceId] })
  158. await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
  159. }
  160. /**
  161. * Move an accounted session (recorded). The default echoes a minimal view.
  162. * @param workspaceId - target workspace.
  163. * @param sessionId - session to move.
  164. * @param beforeSessionId - anchor; omitted appends.
  165. * @returns the updated view.
  166. */
  167. async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
  168. this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
  169. const stub = this.stubs.get('insertSessionBefore')
  170. if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
  171. return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
  172. }
  173. }