workspaces-service.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { Context } from 'cordis'
  2. import { describe, expect, it } from 'vitest'
  3. import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
  4. import { SessionsService } from '../src/client/sessions/service.ts'
  5. import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
  6. import { WorkspacesService } from '../src/client/workspaces/service.ts'
  7. import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
  8. const sid = (id: string): SessionId => id as SessionId
  9. const wid = (id: string): WorkspaceId => id as WorkspaceId
  10. function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
  11. return {
  12. workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
  13. createdAt, updatedAt: createdAt,
  14. }
  15. }
  16. describe('WorkspaceManager', () => {
  17. it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => {
  18. const api = new FakeApiClient()
  19. const manager = new WorkspaceManager(api)
  20. manager.startIntent('first')
  21. expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' })
  22. api.onWorkspaceCreate = () => Promise.resolve(err({
  23. code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' },
  24. } as never))
  25. await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false })
  26. expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' })
  27. expect(typeof manager.getSnapshot().intent?.error).toBe('string')
  28. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceCreate']>>>()
  29. api.onWorkspaceCreate = () => gate.promise
  30. const stale = manager.materializeIntent()
  31. expect(manager.getSnapshot().intent?.phase).toBe('creating')
  32. manager.startIntent('replacement')
  33. gate.resolve(ok({ workspace: workspace('first'), created: true }))
  34. await stale
  35. expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' })
  36. api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true }))
  37. await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true })
  38. expect(manager.getSnapshot().intent).toBeUndefined()
  39. await expect(manager.materializeIntent()).resolves.toBeUndefined()
  40. manager.discardIntent()
  41. manager.startIntent('discarded')
  42. manager.discardIntent()
  43. expect(manager.getSnapshot().intent).toBeUndefined()
  44. })
  45. it('replays changed frames over hydration and keeps established order on refresh', async () => {
  46. const api = new FakeApiClient()
  47. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  48. api.onWorkspaceList = () => gate.promise
  49. const manager = new WorkspaceManager(api)
  50. const hydration = manager.refresh()
  51. manager.handleHostEnvelope({
  52. rpcId: 'changed' as never,
  53. payload: { type: 'host/workspace-changed', workspace: workspace('new') },
  54. })
  55. gate.resolve(ok({ items: [workspace('old')] as never[] }))
  56. await hydration
  57. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
  58. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
  59. api.onWorkspaceList = () => Promise.resolve(ok({
  60. items: [workspace('old'), workspace('new')] as never[],
  61. }))
  62. await manager.refresh()
  63. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
  64. })
  65. it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
  66. const api = new FakeApiClient()
  67. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  68. api.onWorkspaceList = () => gate.promise
  69. const manager = new WorkspaceManager(api)
  70. const first = manager.refresh()
  71. const second = manager.refresh()
  72. expect(manager.getSnapshot().state).toBe('loading')
  73. gate.resolve(ok({ items: [] }))
  74. await Promise.all([first, second])
  75. expect(api.callsOf('workspace.list')).toHaveLength(1)
  76. api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
  77. await manager.refresh()
  78. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
  79. api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
  80. await manager.refresh()
  81. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
  82. })
  83. it('creates by name/path, prepends a new row, and folds failures', async () => {
  84. const api = new FakeApiClient()
  85. const manager = new WorkspaceManager(api)
  86. api.onWorkspaceCreate = payload => Promise.resolve(ok({
  87. workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
  88. created: true,
  89. payload,
  90. } as never))
  91. await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
  92. expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
  93. expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
  94. api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
  95. await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
  96. ok: false, error: { code: 'internal', message: 'create transport' },
  97. })
  98. })
  99. })
  100. describe('WorkspacesService', () => {
  101. it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => {
  102. const ctx = new Context()
  103. const api = new FakeApiClient()
  104. const sessions = new SessionsService(ctx, api)
  105. const workspaces = new WorkspacesService(ctx, api, sessions)
  106. api.onWorkspaceList = () => Promise.resolve(ok({
  107. items: [
  108. workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
  109. workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
  110. ] as never[],
  111. }))
  112. await workspaces.refresh()
  113. await Promise.resolve()
  114. expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
  115. api.onList = () => Promise.resolve(ok({
  116. items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[],
  117. }))
  118. await sessions.refresh()
  119. await Promise.resolve()
  120. await Promise.resolve()
  121. expect(workspaces.list.getSnapshot()).toMatchObject({
  122. baselinesReady: true,
  123. recentWorkspaceId: 'active',
  124. })
  125. expect(sessions.list.getSnapshot().intent).toMatchObject({
  126. target: { kind: 'workspace', workspaceId: 'active' },
  127. })
  128. expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
  129. })
  130. it('returns created Workspaces and preserves Host business errors', async () => {
  131. const ctx = new Context()
  132. const api = new FakeApiClient()
  133. const sessions = new SessionsService(ctx, api)
  134. const workspaces = new WorkspacesService(ctx, api, sessions)
  135. await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
  136. expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
  137. api.onWorkspaceCreate = () => Promise.resolve(err({
  138. code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
  139. }))
  140. await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
  141. })
  142. })