workspaces-service.client.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import type {
  4. ISessions, SessionListState, SessionSummary,
  5. } from '@deepseek-ai/dsh-api-session-controller/client'
  6. import type {
  7. IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
  8. } from '@deepseek-ai/dsh-api-workspace-controller/client'
  9. import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
  10. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  11. import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
  12. import { SessionId } from '@deepseek-ai/dsh-session/types'
  13. import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
  14. import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
  15. import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
  16. const sid = (id: string): SessionId => SessionId(id)
  17. const wid = (id: string): WorkspaceId => id as WorkspaceId
  18. afterEach(() => {
  19. vi.restoreAllMocks()
  20. })
  21. function workspace(
  22. id: string,
  23. sessionIds: readonly SessionId[] = [],
  24. createdAt = '2026-01-01T00:00:00.000Z',
  25. ): WorkspaceView {
  26. return {
  27. workspaceId: wid(id),
  28. path: `/w/${id}`,
  29. title: id,
  30. sessionIds,
  31. createdAt,
  32. updatedAt: createdAt,
  33. }
  34. }
  35. function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
  36. return {
  37. id: sid(id),
  38. displayTitle: id,
  39. running: false,
  40. blank: false,
  41. updatedAt: 0,
  42. ...overrides,
  43. }
  44. }
  45. function sessionState(
  46. summaries: readonly SessionSummary[] = [],
  47. current?: SessionId,
  48. phase: SessionListState['phase'] = 'ready',
  49. ): SessionListState {
  50. return {
  51. ids: summaries.map(item => item.id),
  52. byId: Object.fromEntries(summaries.map(item => [item.id, item])),
  53. current,
  54. phase,
  55. subagentsByParent: {},
  56. jobsBySession: {},
  57. currentAddress: undefined,
  58. }
  59. }
  60. function workspaceState(
  61. items: WorkspaceSnapshot['items'] = [],
  62. archivedSessionIds: readonly SessionId[] = [],
  63. phase: WorkspaceSnapshot['phase'] = 'ready',
  64. ): WorkspaceSnapshot {
  65. return {
  66. items,
  67. archivedSessionIds,
  68. phase,
  69. state: phase === 'ready' ? 'idle' : 'loading',
  70. error: null,
  71. }
  72. }
  73. class MutableSource<T> {
  74. private readonly listeners = new Set<() => void>()
  75. constructor(private value: T) {}
  76. getSnapshot(): T {
  77. return this.value
  78. }
  79. subscribe(listener: () => void): () => void {
  80. this.listeners.add(listener)
  81. return () => { this.listeners.delete(listener) }
  82. }
  83. set(value: T): void {
  84. this.value = value
  85. for (const listener of [...this.listeners]) listener()
  86. }
  87. update(update: (value: T) => T): void {
  88. this.set(update(this.value))
  89. }
  90. listenersSnapshot(): readonly (() => void)[] {
  91. return [...this.listeners]
  92. }
  93. }
  94. class FakeSessions {
  95. readonly list: MutableSource<SessionListState>
  96. readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
  97. readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
  98. readonly clear: ReturnType<typeof vi.fn<() => void>>
  99. readonly fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
  100. constructor(initial: SessionListState) {
  101. this.list = new MutableSource(initial)
  102. this.create = vi.fn<ISessions['create']>(async options =>
  103. options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
  104. this.open = vi.fn((id: SessionId) => {
  105. this.list.update(state => ({ ...state, current: id }))
  106. })
  107. this.clear = vi.fn(() => {
  108. this.list.update(state => ({ ...state, current: undefined }))
  109. })
  110. }
  111. }
  112. class FakeWorkspaces implements IWorkspaces {
  113. readonly list: MutableSource<WorkspaceSnapshot>
  114. readonly archiveCalls: SessionId[] = []
  115. onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
  116. this.list.update(state => ({
  117. ...state,
  118. archivedSessionIds: [...state.archivedSessionIds, sessionId],
  119. }))
  120. }
  121. declare readonly create: IWorkspaces['create']
  122. declare readonly rename: IWorkspaces['rename']
  123. declare readonly delete: IWorkspaces['delete']
  124. declare readonly insertBefore: IWorkspaces['insertBefore']
  125. declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
  126. constructor(initial: WorkspaceSnapshot) {
  127. this.list = new MutableSource(initial)
  128. }
  129. archiveSession(sessionId: SessionId): Promise<void> {
  130. this.archiveCalls.push(sessionId)
  131. return this.onArchive(sessionId)
  132. }
  133. }
  134. const listing: DirectoryListing = {
  135. path: '/home/u',
  136. home: '/home/u',
  137. crumbs: [{ name: '/', path: '/', hidden: false }],
  138. entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
  139. truncated: false,
  140. }
  141. /** The directory-picking Remote namespace, recorded and scripted per case. */
  142. class FakeDirectoryPicker {
  143. readonly calls: { method: string; payload: unknown }[] = []
  144. onPick: () => Promise<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
  145. onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
  146. onCreateDirectory: () => Promise<RemoteResult<string>> =
  147. () => Promise.resolve({ ok: true, value: '/home/u/new' })
  148. readonly remote: ClientRemote['directoryPicker'] = {
  149. pick: () => this.record('pick', {}, this.onPick()),
  150. list: (path?: string) => this.record('list', { path }, this.onList()),
  151. createDirectory: (path: string, name: string) =>
  152. this.record('createDirectory', { path, name }, this.onCreateDirectory()),
  153. }
  154. callsOf(method: string): unknown[] {
  155. return this.calls.filter(call => call.method === method).map(call => call.payload)
  156. }
  157. private record<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
  158. this.calls.push({ method, payload })
  159. return result
  160. }
  161. }
  162. interface BenchOptions {
  163. readonly workspaces?: WorkspaceSnapshot
  164. readonly sessions?: SessionListState
  165. }
  166. function bench(options: BenchOptions = {}) {
  167. const ctx = new Context()
  168. const layout = new LayoutController({
  169. selectPanel: vi.fn(), retainMainPanels: vi.fn(),
  170. setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
  171. setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(),
  172. }, () => true)
  173. const selectPanel = vi.spyOn(layout, 'selectPanel')
  174. ctx.provide('layout', layout)
  175. ctx.effect(() => () => { layout.dispose() })
  176. const directoryPicker = new FakeDirectoryPicker()
  177. const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
  178. const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
  179. const uiWorkspace = new UiWorkspaceService(
  180. ctx,
  181. directoryPicker.remote,
  182. workspaces,
  183. sessions as unknown as ISessions,
  184. )
  185. return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel }
  186. }
  187. async function flush(): Promise<void> {
  188. await Promise.resolve()
  189. await Promise.resolve()
  190. }
  191. describe('UiWorkspaceService', () => {
  192. it('selects a Session before revealing its Conversation, including the current Session', () => {
  193. const current = sid('current')
  194. const b = bench({ sessions: sessionState([summary('current')], current) })
  195. b.selectPanel.mockImplementation(() => {
  196. expect(b.sessions.list.getSnapshot().current).toBe(current)
  197. })
  198. b.uiWorkspace.openSession(current)
  199. expect(b.sessions.open).toHaveBeenCalledWith(current)
  200. expect(b.selectPanel).toHaveBeenCalledWith(null)
  201. expect(b.sessions.open.mock.invocationCallOrder[0]).toBeLessThan(b.selectPanel.mock.invocationCallOrder[0]!)
  202. })
  203. it('keeps the current panel when selecting a Session throws', () => {
  204. const b = bench()
  205. b.sessions.open.mockImplementationOnce(() => { throw new Error('selection failed') })
  206. expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('selection failed')
  207. expect(b.selectPanel).not.toHaveBeenCalled()
  208. })
  209. it('leaves a later panel selection in place when New Session finishes', async () => {
  210. const b = bench({
  211. sessions: sessionState([summary('current')], sid('current')),
  212. workspaces: workspaceState([workspace('alpha')]),
  213. })
  214. const created = Promise.withResolvers<SessionId>()
  215. b.sessions.create.mockReturnValue(created.promise)
  216. const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
  217. b.uiWorkspace.startSession(wid('alpha'))
  218. b.layout.selectPanel('panel-a' as MainPanelId)
  219. created.resolve(sid('late'))
  220. await opening.mock.results[0]!.value
  221. expect(b.sessions.open).not.toHaveBeenCalled()
  222. expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a')
  223. expect(b.sessions.list.getSnapshot().current).toBe(sid('current'))
  224. })
  225. it('opens only the latest Workspace request when creation completes out of order', async () => {
  226. const b = bench({
  227. sessions: sessionState([summary('current')], sid('current')),
  228. workspaces: workspaceState([workspace('alpha'), workspace('beta')]),
  229. })
  230. const older = Promise.withResolvers<SessionId>()
  231. const newer = Promise.withResolvers<SessionId>()
  232. b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise)
  233. const oldDraft = vi.fn()
  234. const newDraft = vi.fn()
  235. const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft)
  236. const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft)
  237. newer.resolve(sid('newer'))
  238. await second
  239. older.resolve(sid('older'))
  240. await first
  241. expect(oldDraft).not.toHaveBeenCalled()
  242. expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer'))
  243. expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer'))
  244. })
  245. it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => {
  246. const b = bench({
  247. sessions: sessionState([summary('current')], sid('current')),
  248. workspaces: workspaceState([workspace('alpha')]),
  249. })
  250. const created = Promise.withResolvers<SessionId>()
  251. b.sessions.create.mockReturnValue(created.promise)
  252. const moveDraft = vi.fn()
  253. const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft)
  254. b.uiWorkspace.openSession(sid('current'))
  255. created.resolve(sid('late'))
  256. await pending
  257. expect(moveDraft).not.toHaveBeenCalled()
  258. expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current'))
  259. })
  260. it('keeps navigation performed by preparation and propagates preparation failures', async () => {
  261. const b = bench({
  262. sessions: sessionState([summary('current')], sid('current')),
  263. workspaces: workspaceState([workspace('alpha')]),
  264. })
  265. await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) })
  266. expect(b.sessions.open).not.toHaveBeenCalled()
  267. await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed')
  268. expect(b.sessions.open).not.toHaveBeenCalled()
  269. })
  270. it('opens a fork only while its navigation is current and preserves fork failures', async () => {
  271. const b = bench()
  272. await b.uiWorkspace.forkSession(sid('source'))
  273. expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
  274. expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked'))
  275. const forked = Promise.withResolvers<SessionId>()
  276. b.sessions.fork.mockReturnValueOnce(forked.promise)
  277. const pending = b.uiWorkspace.forkSession(sid('source'))
  278. b.layout.selectPanel('panel-a' as MainPanelId)
  279. forked.resolve(sid('late-fork'))
  280. await pending
  281. expect(b.sessions.open).toHaveBeenCalledOnce()
  282. b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
  283. await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
  284. expect(b.sessions.open).toHaveBeenCalledOnce()
  285. })
  286. it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => {
  287. for (const kind of ['workspace', 'fork'] as const) {
  288. const b = bench({
  289. sessions: sessionState([summary('current')], sid('current')),
  290. workspaces: workspaceState([workspace('alpha')]),
  291. })
  292. const resolved = Promise.withResolvers<SessionId>()
  293. b.sessions.create.mockReturnValue(resolved.promise)
  294. b.sessions.fork.mockReturnValue(resolved.promise)
  295. const pending = kind === 'workspace'
  296. ? b.uiWorkspace.openWorkspace(wid('alpha'))
  297. : b.uiWorkspace.forkSession(sid('current'))
  298. await b.ctx.fiber.dispose()
  299. resolved.resolve(sid('late'))
  300. await pending
  301. expect(b.sessions.open).not.toHaveBeenCalled()
  302. }
  303. })
  304. it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
  305. const b = bench()
  306. const memberBlank = sid('member-blank')
  307. const archivedBlank = sid('archived-blank')
  308. const summaries: readonly SessionSummary[] = [
  309. summary('stray', { blank: true, cwd: '/w/alpha' }),
  310. summary('member-blank', { blank: true, cwd: '/w/alpha' }),
  311. summary('active', { cwd: '/w/beta' }),
  312. summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
  313. ]
  314. b.workspaces.list.set(workspaceState([
  315. workspace('alpha', [memberBlank]),
  316. workspace('beta', [sid('active')]),
  317. workspace('gamma', [archivedBlank]),
  318. ], [archivedBlank]))
  319. b.sessions.list.set({
  320. ...sessionState(summaries, memberBlank),
  321. ids: [sid('missing'), ...summaries.map(item => item.id)],
  322. })
  323. await expect(Promise.all([
  324. b.uiWorkspace.connectWorkspace(wid('alpha')),
  325. b.uiWorkspace.connectWorkspace(wid('alpha')),
  326. ])).resolves.toEqual([memberBlank, memberBlank])
  327. expect(b.sessions.create).not.toHaveBeenCalled()
  328. const creation = Promise.withResolvers<SessionId>()
  329. b.sessions.create.mockImplementation(() => creation.promise)
  330. const first = b.uiWorkspace.connectWorkspace(wid('beta'))
  331. const second = b.uiWorkspace.connectWorkspace(wid('beta'))
  332. expect(b.sessions.create).toHaveBeenCalledTimes(1)
  333. creation.resolve(sid('fresh-beta'))
  334. await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
  335. b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
  336. await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
  337. expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
  338. await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
  339. .rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
  340. })
  341. it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
  342. const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
  343. const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
  344. const b = bench({
  345. sessions: sessionState([current, recent], current.id),
  346. workspaces: workspaceState([
  347. workspace('current-home', [current.id]),
  348. workspace('recent-home', [recent.id]),
  349. ]),
  350. })
  351. b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
  352. b.uiWorkspace.startSession(wid('recent-home'))
  353. await vi.waitFor(() => {
  354. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
  355. })
  356. b.sessions.open(current.id)
  357. b.uiWorkspace.startSession()
  358. await vi.waitFor(() => {
  359. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
  360. })
  361. b.sessions.clear()
  362. b.uiWorkspace.startSession()
  363. await vi.waitFor(() => {
  364. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
  365. })
  366. const empty = bench()
  367. empty.uiWorkspace.startSession()
  368. expect(empty.sessions.clear).toHaveBeenCalledOnce()
  369. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  370. b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
  371. b.uiWorkspace.startSession(wid('recent-home'))
  372. await vi.waitFor(() => {
  373. expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
  374. })
  375. })
  376. it('opens the recent Workspace after both baselines arrive', async () => {
  377. const b = bench()
  378. b.sessions.create.mockResolvedValue(sid('initial'))
  379. const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
  380. const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
  381. b.workspaces.list.set(workspaceState([stableFirst, recent]))
  382. expect(b.sessions.create).not.toHaveBeenCalled()
  383. b.sessions.list.set(sessionState())
  384. await vi.waitFor(() => {
  385. expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
  386. })
  387. expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
  388. expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
  389. wid('stable-first'), wid('recent'),
  390. ])
  391. })
  392. it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
  393. const b = bench()
  394. b.sessions.create.mockResolvedValue(sid('initial'))
  395. b.workspaces.list.set(workspaceState([
  396. workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
  397. workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
  398. workspace('older', [], '2026-01-01T00:00:00.000Z'),
  399. ]))
  400. b.sessions.list.set(sessionState())
  401. await vi.waitFor(() => {
  402. expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
  403. })
  404. expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
  405. })
  406. it('retries failed initial selection and never overwrites a later selection', async () => {
  407. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  408. const b = bench()
  409. let attempts = 0
  410. b.sessions.create.mockImplementation(() => ++attempts === 1
  411. ? Promise.reject(new Error('attach exploded'))
  412. : Promise.resolve(sid('retry')))
  413. b.workspaces.list.set(workspaceState([workspace('recent')]))
  414. b.sessions.list.set(sessionState())
  415. await vi.waitFor(() => {
  416. expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
  417. })
  418. b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
  419. await vi.waitFor(() => {
  420. expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
  421. })
  422. expect(attempts).toBe(2)
  423. const changed = bench()
  424. const pending = Promise.withResolvers<SessionId>()
  425. changed.sessions.create.mockImplementation(() => pending.promise)
  426. changed.workspaces.list.set(workspaceState([workspace('recent')]))
  427. changed.sessions.list.set(sessionState())
  428. await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
  429. changed.sessions.open(sid('manual'))
  430. pending.resolve(sid('automatic'))
  431. await flush()
  432. expect(changed.sessions.open).toHaveBeenCalledTimes(1)
  433. expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
  434. })
  435. it('stops initial navigation when its Cordis lifetime is disposed', async () => {
  436. const success = bench()
  437. const resolved = Promise.withResolvers<SessionId>()
  438. success.sessions.create.mockImplementation(() => resolved.promise)
  439. success.workspaces.list.set(workspaceState([workspace('recent')]))
  440. success.sessions.list.set(sessionState())
  441. await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
  442. await success.ctx.fiber.dispose()
  443. resolved.resolve(sid('late'))
  444. await flush()
  445. expect(success.sessions.open).not.toHaveBeenCalled()
  446. success.workspaces.list.set(workspaceState([workspace('ignored')]))
  447. expect(success.sessions.create).toHaveBeenCalledOnce()
  448. const failure = bench()
  449. const rejected = Promise.withResolvers<SessionId>()
  450. failure.sessions.create.mockImplementation(() => rejected.promise)
  451. failure.workspaces.list.set(workspaceState([workspace('recent')]))
  452. failure.sessions.list.set(sessionState())
  453. await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
  454. const staleReconciles = failure.workspaces.list.listenersSnapshot()
  455. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  456. await failure.ctx.fiber.dispose()
  457. rejected.reject(new Error('late failure'))
  458. await flush()
  459. for (const reconcile of staleReconciles) reconcile()
  460. expect(warning).not.toHaveBeenCalled()
  461. expect(failure.sessions.create).toHaveBeenCalledOnce()
  462. })
  463. it('clears a current Session only after it enters the archive baseline', () => {
  464. const current = summary('current')
  465. const idle = summary('idle')
  466. const b = bench({
  467. sessions: sessionState([current, idle], current.id),
  468. workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
  469. })
  470. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
  471. expect(b.sessions.clear).not.toHaveBeenCalled()
  472. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
  473. expect(b.sessions.clear).toHaveBeenCalledOnce()
  474. b.sessions.open(idle.id)
  475. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
  476. expect(b.sessions.clear).toHaveBeenCalledTimes(2)
  477. const archived = bench({
  478. sessions: sessionState([current], current.id),
  479. workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
  480. })
  481. expect(archived.sessions.clear).toHaveBeenCalledOnce()
  482. })
  483. it('forwards archive commands and preserves failures', async () => {
  484. const idle = sid('idle')
  485. const b = bench()
  486. await b.uiWorkspace.archiveSession(idle)
  487. expect(b.workspaces.archiveCalls).toEqual([idle])
  488. b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
  489. await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
  490. expect(b.workspaces.archiveCalls).toEqual([idle, idle])
  491. })
  492. it('passes directory operations to the Host and preserves structured browse failures', async () => {
  493. const b = bench()
  494. b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })
  495. await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
  496. b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null })
  497. await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
  498. expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}])
  499. await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
  500. await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
  501. expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }])
  502. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
  503. expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
  504. b.directoryPicker.onPick = () => Promise.resolve({
  505. ok: false, error: new RemoteError('gateway/internal', 'no chooser', {}),
  506. })
  507. await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
  508. b.directoryPicker.onList = () => Promise.resolve({
  509. ok: false, error: new RemoteError('directory-picker/unreadable', 'denied', { path: '/private' }),
  510. })
  511. const listFailure = b.uiWorkspace.listDirectory('/private')
  512. await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
  513. await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-picker/unreadable' } })
  514. b.directoryPicker.onCreateDirectory = () => Promise.resolve({
  515. ok: false, error: new RemoteError('directory-picker/exists', 'taken', { path: '/home/u/new' }),
  516. })
  517. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
  518. rpcError: { code: 'directory-picker/exists' },
  519. })
  520. })
  521. })