face.client.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. /**
  2. * The tree's asynchronous half against a scripted listing, and the Remote
  3. * adapter under it.
  4. *
  5. * The face's contract is what reaches the store and when: a level is `loading`
  6. * before the listing settles, `ready` or `failed` after, never written once the
  7. * owner's signal aborted or a newer listing of the level was asked for, and a
  8. * tab whose record is gone leaves no bucket behind. The adapter's is what it
  9. * keeps and what it drops: entries and the
  10. * truncation flag reach the store, the endpoint's workspace-relative path does
  11. * not, and a failure passes through untouched.
  12. */
  13. import { describe, expect, it, vi } from 'vitest'
  14. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  15. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  16. import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types'
  17. import { childPath, createList, filesFace } from '../src/client/face.ts'
  18. import type { WorkspaceFilesListRemote } from '../src/client/face.ts'
  19. import { createFilesStore } from '../src/client/store.ts'
  20. import type { DirLevel } from '../src/client/store.ts'
  21. import { scriptedList } from './scripted-list.client.ts'
  22. import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
  23. const SESSION = 's-1' as SessionId
  24. const ROOT = '/work/app'
  25. const TAB = 'tab-1' as TabId
  26. const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false }
  27. function mount() {
  28. const instance = createFilesStore().create()
  29. const script = scriptedList()
  30. const face = filesFace(script.list)(SESSION, instance.actions)
  31. return { ...script, face, snapshot: () => instance.getSnapshot().byTab[TAB] }
  32. }
  33. describe('filesFace', () => {
  34. it('start seeds the tab and lists the root with the session and the absolute root path', async () => {
  35. const { face, list, settle, snapshot } = mount()
  36. const controller = new AbortController()
  37. face.start(TAB, ROOT, controller.signal)
  38. expect(list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal)
  39. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' })
  40. await settle({ ok: true, value: LEVEL })
  41. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
  42. })
  43. it('records a failed listing under its level', async () => {
  44. const { face, settle, snapshot } = mount()
  45. face.start(TAB, ROOT, new AbortController().signal)
  46. const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' })
  47. await settle({ ok: false, error })
  48. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'failed', failure: error })
  49. })
  50. it('toggle expands and lists a directory the first time, and only toggles afterwards', async () => {
  51. const { face, list, settle, snapshot } = mount()
  52. const signal = new AbortController().signal
  53. const child = `${ROOT}/src`
  54. face.start(TAB, ROOT, signal)
  55. await settle({ ok: true, value: LEVEL })
  56. face.toggle(TAB, child, false, signal)
  57. expect(list).toHaveBeenLastCalledWith(SESSION, child, signal)
  58. expect(snapshot()!.expanded).toEqual([ROOT, child])
  59. await settle({ ok: true, value: LEVEL })
  60. face.toggle(TAB, child, true, signal)
  61. expect(snapshot()!.expanded).toEqual([ROOT])
  62. expect(list).toHaveBeenCalledTimes(2)
  63. })
  64. it('abort forgets the bucket and a late settlement writes nothing', async () => {
  65. const { face, settle, snapshot } = mount()
  66. const controller = new AbortController()
  67. face.start(TAB, ROOT, controller.signal)
  68. controller.abort()
  69. expect(snapshot()).toBeUndefined()
  70. await settle({ ok: true, value: LEVEL })
  71. expect(snapshot()).toBeUndefined()
  72. })
  73. it('makes no request for a record that already ended', () => {
  74. const { face, list } = mount()
  75. const controller = new AbortController()
  76. controller.abort()
  77. face.load(TAB, ROOT, controller.signal)
  78. expect(list).not.toHaveBeenCalled()
  79. })
  80. it('lets the latest listing of a level win, whichever settles first', async () => {
  81. const { face, list, settle, settleLatest, snapshot, outstanding } = mount()
  82. const signal = new AbortController().signal
  83. const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false }
  84. face.start(TAB, ROOT, signal)
  85. // The reload gesture asks for the root again while the first listing is still out.
  86. face.load(TAB, ROOT, signal)
  87. expect(list).toHaveBeenCalledTimes(2)
  88. expect(outstanding()).toEqual([ROOT, ROOT])
  89. await settleLatest({ ok: true, value: LEVEL })
  90. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
  91. // The retired listing lands afterwards and changes nothing.
  92. await settle({ ok: true, value: older })
  93. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
  94. // A retired failure is dropped the same way.
  95. face.load(TAB, ROOT, signal)
  96. face.load(TAB, ROOT, signal)
  97. await settleLatest({ ok: true, value: LEVEL })
  98. await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) })
  99. expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
  100. })
  101. })
  102. describe('createList', () => {
  103. it('passes the session, the absolute path, and the signal through, and keeps entries and truncation', async () => {
  104. const listing: WorkspaceDirectoryListing = {
  105. path: 'src',
  106. entries: [{ name: 'a.ts', type: 'file', size: 3 }],
  107. truncated: true,
  108. }
  109. const list = vi.fn<WorkspaceFilesListRemote['workspaceFiles']['list']>()
  110. .mockResolvedValue({ ok: true, value: listing })
  111. const signal = new AbortController().signal
  112. const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/src`, signal)
  113. expect(list).toHaveBeenCalledWith(SESSION, `${ROOT}/src`, signal)
  114. expect(result).toEqual({ ok: true, value: { entries: listing.entries, truncated: true } })
  115. })
  116. it('returns a failure as the endpoint reported it', async () => {
  117. const error = new RemoteError('workspace-file/not-directory', 'file', { path: 'x', kind: 'file' })
  118. const list = vi.fn<WorkspaceFilesListRemote['workspaceFiles']['list']>()
  119. .mockResolvedValue({ ok: false, error })
  120. const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/x`, new AbortController().signal)
  121. expect(result).toEqual({ ok: false, error })
  122. })
  123. })
  124. describe('childPath', () => {
  125. it('joins with one slash whatever the parent ends in', () => {
  126. expect(childPath('/work/app', 'src')).toBe('/work/app/src')
  127. expect(childPath('/work/app/', 'src')).toBe('/work/app/src')
  128. expect(childPath('/', 'etc')).toBe('/etc')
  129. expect(childPath('C:\\work\\', 'src')).toBe('C:\\work/src')
  130. })
  131. })