provider.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * The `file` provider's frame stream: how the two address scopes resolve to a
  3. * Host call and a change-feed key, the opening stat, the write version that
  4. * carries no content, the disappearance that stats again, failures
  5. * as frames, and the life bounded by the signal.
  6. */
  7. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  8. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  9. import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
  10. import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
  11. import type { WorkspaceFileStat } from '../src/types.ts'
  12. import { describe, expect, it, onTestFinished } from 'vitest'
  13. import { ChangeFeed } from '../src/client/change-feed.ts'
  14. import { createFileResourceProvider } from '../src/client/provider.ts'
  15. import { FakeRemote, settle } from './fake-remote.client.ts'
  16. const S1 = 's1' as SessionId
  17. const S2 = 's2' as SessionId
  18. /** The relative path the address carries, and the absolute path the Host's frames spell for it under S1's root. */
  19. const REL_PATH = 'a b.txt'
  20. const HOST_PATH = '/w/a b.txt'
  21. const ADDRESS = sessionFileAddress(S1, REL_PATH)
  22. /** A file outside every workspace root, addressed absolutely. */
  23. const ABS_PATH = '/etc/hosts'
  24. const ABS_ADDRESS = sessionFileAddress(S1, ABS_PATH)
  25. const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes })
  26. const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH })
  27. function opened(address = ADDRESS) {
  28. const remote = new FakeRemote()
  29. const changes = new ChangeFeed(remote)
  30. const provider = createFileResourceProvider(remote, changes)
  31. const controller = new AbortController()
  32. const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
  33. onTestFinished(async () => {
  34. controller.abort()
  35. for (const request of remote.stats) {
  36. request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
  37. }
  38. await it.return?.()
  39. await changes.settle()
  40. })
  41. return { remote, provider, changes, controller, it }
  42. }
  43. /** Open, answer the opening stat, and hand back the bench once the first frame is out. */
  44. async function live(version = 'v0', bytes = 3) {
  45. const bench = opened()
  46. const first = bench.it.next()
  47. await settle()
  48. bench.remote.stats[0]!.resolve({ ok: true, value: stat(version, bytes) })
  49. await first
  50. return bench
  51. }
  52. describe('file provider — the address', () => {
  53. it.each([
  54. ['another scope', 'dsh-resource://file/shared/x/w/a.txt'],
  55. ['no path', 'dsh-resource://file/session/s1'],
  56. ['an absolute address with no path', 'dsh-resource://file/absolute/'],
  57. ['another resource type', 'dsh-resource://terminal/session/s1/1'],
  58. ['the retired file:// grammar', 'file://sessions/s1/w/a.txt'],
  59. ['a bare file URL', 'file:///w/a.txt'],
  60. ['another protocol', 'sidebar:guide'],
  61. ])('yields one unsupported-address failure and ends for %s, touching no Remote', async (_, address) => {
  62. const { remote, it } = opened(address)
  63. const first = await it.next()
  64. expect(first.done).toBe(false)
  65. expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unsupported-address', details: { address } } })
  66. await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
  67. expect(remote.stats).toEqual([])
  68. expect(remote.opened).toEqual([])
  69. })
  70. it('rejects an absolute address with no Session without touching the Remote', async () => {
  71. const address = absoluteFileAddress(ABS_PATH)
  72. const { remote, it } = opened(address)
  73. const first = await it.next()
  74. expect(first.done).toBe(false)
  75. expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } })
  76. await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
  77. expect(remote.stats).toEqual([])
  78. expect(remote.opened).toEqual([])
  79. })
  80. it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => {
  81. const { remote, it } = opened()
  82. const first = it.next()
  83. await settle()
  84. expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH })
  85. remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
  86. await first
  87. // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it.
  88. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
  89. await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
  90. })
  91. it('reads an absolute path through the Session in its address, with the absolute path as both Host path and follow key', async () => {
  92. const { remote, it } = opened(ABS_ADDRESS)
  93. const first = it.next()
  94. await settle()
  95. expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
  96. expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH })
  97. remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } })
  98. await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } } })
  99. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } })
  100. await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
  101. })
  102. it('opens one change stream per session named by the addresses', async () => {
  103. const remote = new FakeRemote()
  104. const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
  105. const signal = new AbortController().signal
  106. void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
  107. void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
  108. await settle()
  109. expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2])
  110. expect(remote.stats.map(pending => pending.sessionId)).toEqual([S1, S2])
  111. })
  112. })
  113. describe('file provider — the opening stat', () => {
  114. it('stats the decoded relative path in the session the address names and yields its metadata', async () => {
  115. const { remote, it, controller } = opened()
  116. const first = it.next()
  117. await settle()
  118. expect(remote.stats).toHaveLength(1)
  119. expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
  120. remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
  121. await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3 } } })
  122. })
  123. it('omits bytes when the backend reports none', async () => {
  124. const { remote, it } = opened()
  125. const first = it.next()
  126. await settle()
  127. remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } })
  128. await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } } })
  129. })
  130. it('yields the Host failure as a frame and keeps following the address', async () => {
  131. const { remote, it } = opened()
  132. const first = it.next()
  133. await settle()
  134. const error = notFound()
  135. remote.stats[0]!.resolve({ ok: false, error })
  136. await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
  137. const source = remote.opened[0]!.source
  138. // Still gone: no stat, no frame; the pull stays open for what comes next.
  139. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
  140. const pending = it.next()
  141. await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
  142. expect(remote.stats).toHaveLength(1)
  143. // The agent creates the file: the write stats again and the value goes live.
  144. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
  145. await settle()
  146. remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) })
  147. await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5 } } })
  148. })
  149. it('ends without a frame when aborted during the stat', async () => {
  150. const { remote, it, controller } = opened()
  151. const first = it.next()
  152. await settle()
  153. controller.abort()
  154. remote.stats[0]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
  155. await expect(first).resolves.toEqual({ done: true, value: undefined })
  156. })
  157. it('shares one change stream between two files of a session', async () => {
  158. const remote = new FakeRemote()
  159. const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
  160. const signal = new AbortController().signal
  161. void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
  162. void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next()
  163. await settle()
  164. expect(remote.opened).toHaveLength(1)
  165. expect(remote.stats).toHaveLength(2)
  166. })
  167. })
  168. describe('file provider — Host writes', () => {
  169. it('reports a write with its version and keeps the byte count', async () => {
  170. const { remote, it } = await live()
  171. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
  172. await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3 } } })
  173. expect(remote.stats).toHaveLength(1)
  174. })
  175. it('ignores a frame carrying the version it already holds', async () => {
  176. const { remote, it } = await live('v0')
  177. const source = remote.opened[0]!.source
  178. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
  179. // The pull outlives the silent tick: the frame that finally answers it is v1.
  180. const pending = it.next()
  181. await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
  182. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
  183. await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
  184. })
  185. it('does not lose a write reported during the opening stat', async () => {
  186. const { remote, it } = opened()
  187. const first = it.next()
  188. await settle()
  189. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
  190. await settle()
  191. remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
  192. await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
  193. await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
  194. })
  195. })
  196. describe('file provider — a reported disappearance', () => {
  197. it('ends quietly when aborted during the stat', async () => {
  198. const { remote, it, controller, changes } = await live()
  199. const next = it.next()
  200. await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
  201. const request = await remote.waitForStat(1)
  202. controller.abort()
  203. request.resolve({ ok: true, value: stat('late', 9) })
  204. await expect(next).resolves.toEqual({ done: true, value: undefined })
  205. await changes.settle()
  206. expect(remote.disposed).toEqual(['workspace file changes of s1'])
  207. })
  208. it('stats again and, when the file is still there, yields its fresh metadata', async () => {
  209. const { remote, it } = await live('v0', 3)
  210. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
  211. const next = it.next()
  212. await settle()
  213. expect(remote.stats).toHaveLength(2)
  214. remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) })
  215. await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9 } } })
  216. })
  217. it('yields the not-found frame and keeps following, so a later write stats again and brings the file back', async () => {
  218. const { remote, it } = await live('v0', 3)
  219. const source = remote.opened[0]!.source
  220. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
  221. const next = it.next()
  222. await settle()
  223. const error = notFound()
  224. remote.stats[1]!.resolve({ ok: false, error })
  225. await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
  226. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v3' } })
  227. const back = it.next()
  228. await settle()
  229. remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) })
  230. await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8 } } })
  231. })
  232. })
  233. describe('file provider — the end', () => {
  234. it('ends when its signal aborts and releases the session stream', async () => {
  235. const { remote, it, controller } = await live()
  236. controller.abort()
  237. await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
  238. await settle()
  239. expect(remote.disposed).toEqual(['workspace file changes of s1'])
  240. })
  241. it('ends when the Host closes the session stream', async () => {
  242. const { remote, it } = await live()
  243. remote.opened[0]!.source.end()
  244. await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
  245. })
  246. })