changes.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /** The `changes` stream: driven by `fs/observed`, filtered by the workspace root, ended by its signal. */
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { join } from 'node:path'
  4. import type { Context } from '@deepseek-ai/cordis'
  5. import type { FsObservation } from '@deepseek-ai/dsh-fs'
  6. import { FsVersion } from '@deepseek-ai/dsh-fs'
  7. import { WorkspaceFiles } from '../src/index.ts'
  8. import type { WorkspaceFileWatchFrame } from '../src/types.ts'
  9. import { agent, openWorkspace, type Harness } from './harness.ts'
  10. let harness: Harness
  11. const closeStreams: Array<() => Promise<unknown>> = []
  12. beforeEach(async () => {
  13. harness = await openWorkspace('dsh-workspace-files-changes-')
  14. })
  15. afterEach(async () => {
  16. try {
  17. for (const close of closeStreams.splice(0)) await close()
  18. await harness.dispose()
  19. } finally {
  20. vi.restoreAllMocks()
  21. }
  22. })
  23. /** Emit one observation for `path` the way a tool does after touching it. */
  24. async function observe(path: string, observation: FsObservation): Promise<string> {
  25. const target = await harness.ctx.fs.resolve(path)
  26. harness.ctx.emit('fs/observed', target, observation, undefined)
  27. return harness.ctx.fs.processPath(target)
  28. }
  29. const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
  30. /** Own one generation through abort and iterator completion, including failed assertions. */
  31. function open(
  32. service: WorkspaceFiles,
  33. controller = new AbortController(),
  34. ): { next(): Promise<IteratorResult<WorkspaceFileWatchFrame>>; controller: AbortController } {
  35. const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
  36. closeStreams.push(async () => {
  37. controller.abort()
  38. await iterator.return?.()
  39. })
  40. return { next: () => iterator.next(), controller }
  41. }
  42. describe('workspaceFiles.changes — frames', () => {
  43. it('acknowledges the resolved root before draining observations queued during root resolution', async () => {
  44. const service = harness.endpoint()
  45. const fs = harness.ctx.fs
  46. const original = fs.resolve.bind(fs)
  47. const root = await original(harness.workspace)
  48. const entered = Promise.withResolvers<AbortSignal | undefined>()
  49. const release = Promise.withResolvers<undefined>()
  50. vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
  51. if (path !== harness.workspace) return original(path, opts)
  52. entered.resolve(opts?.signal)
  53. await release.promise
  54. opts?.signal?.throwIfAborted()
  55. return root
  56. })
  57. const stream = open(service)
  58. const first = stream.next()
  59. let acknowledged = false
  60. void first.then(() => { acknowledged = true })
  61. try {
  62. expect(await entered.promise).toBe(stream.controller.signal)
  63. const a = await observe(join(harness.workspace, 'early-a.txt'), present('a1'))
  64. await observe(join(harness.outside, 'secret.txt'), present('outside'))
  65. const b = await observe(join(harness.workspace, 'early-b.txt'), { kind: 'absent' })
  66. expect(acknowledged).toBe(false)
  67. release.resolve(undefined)
  68. await expect(first).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  69. await expect(stream.next()).resolves.toEqual({
  70. done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } },
  71. })
  72. await expect(stream.next()).resolves.toEqual({
  73. done: false, value: { kind: 'change', change: { absolutePath: b, absent: true } },
  74. })
  75. } finally {
  76. release.resolve(undefined)
  77. }
  78. })
  79. it('reports a present observation inside the workspace as its absolute path and version', async () => {
  80. const stream = open(harness.endpoint())
  81. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  82. const pending = stream.next()
  83. const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
  84. expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
  85. expect(absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'a.txt'))))
  86. })
  87. it('reports an absent observation as absent', async () => {
  88. const stream = open(harness.endpoint())
  89. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  90. const pending = stream.next()
  91. const absolutePath = await observe(join(harness.workspace, 'gone.txt'), { kind: 'absent' })
  92. expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, absent: true } } })
  93. })
  94. it('drops observations outside the workspace root', async () => {
  95. const stream = open(harness.endpoint())
  96. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  97. const pending = stream.next()
  98. await observe(join(harness.outside, 'secret.txt'), present('v1'))
  99. const absolutePath = await observe(join(harness.workspace, 'seen.txt'), present('v2'))
  100. expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v2' } } })
  101. })
  102. it('queues observations made faster than they are pulled, in emission order', async () => {
  103. const stream = open(harness.endpoint())
  104. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  105. const first = stream.next()
  106. const a = await observe(join(harness.workspace, 'a.txt'), present('a1'))
  107. const b = await observe(join(harness.workspace, 'b.txt'), present('b1'))
  108. expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } } })
  109. expect(await stream.next()).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: b, version: 'b1' } } })
  110. })
  111. it('serves every open generation independently', async () => {
  112. const service = harness.endpoint()
  113. const one = open(service)
  114. const two = open(service)
  115. await expect(one.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  116. await expect(two.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  117. const firstOfOne = one.next()
  118. const firstOfTwo = two.next()
  119. const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
  120. expect(await firstOfOne).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
  121. expect(await firstOfTwo).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
  122. })
  123. })
  124. describe('workspaceFiles.changes — ending', () => {
  125. it('does not acknowledge a subscription disposed while its workspace root is resolving', async () => {
  126. const fs = harness.ctx.fs
  127. const original = fs.resolve.bind(fs)
  128. const root = await original(harness.workspace)
  129. const entered = Promise.withResolvers<undefined>()
  130. const release = Promise.withResolvers<undefined>()
  131. vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
  132. if (path !== harness.workspace) return original(path, opts)
  133. entered.resolve(undefined)
  134. await release.promise
  135. return root
  136. })
  137. let service: WorkspaceFiles | undefined
  138. const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
  139. service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
  140. }, { inject: ['fs', 'sandboxPolicy'] }))
  141. try {
  142. if (service === undefined) throw new Error('plugin body did not run')
  143. const stream = open(service)
  144. const first = stream.next()
  145. await entered.promise
  146. await fiber.dispose()
  147. release.resolve(undefined)
  148. await expect(first).resolves.toEqual({ done: true, value: undefined })
  149. } finally {
  150. release.resolve(undefined)
  151. await fiber.dispose()
  152. }
  153. })
  154. it('refuses an already-aborted signal', async () => {
  155. const controller = new AbortController()
  156. controller.abort()
  157. const stream = open(harness.endpoint(), controller)
  158. await expect(stream.next()).rejects.toThrow()
  159. })
  160. it('ends when its signal aborts while idle', async () => {
  161. const stream = open(harness.endpoint())
  162. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  163. const first = stream.next()
  164. // A delivered frame proves the generation is past setup and waiting idle.
  165. await observe(join(harness.workspace, 'a.txt'), present('v1'))
  166. expect((await first).done).toBe(false)
  167. const pending = stream.next()
  168. stream.controller.abort()
  169. expect(await pending).toEqual({ done: true, value: undefined })
  170. })
  171. it('drops queued observations when cancelled after ready but before the next pull', async () => {
  172. const stream = open(harness.endpoint())
  173. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  174. await observe(join(harness.workspace, 'queued-before-abort.txt'), present('v1'))
  175. stream.controller.abort()
  176. await expect(stream.next()).resolves.toEqual({ done: true, value: undefined })
  177. })
  178. it('ends when its signal aborts during setup, without delivering anything', async () => {
  179. const stream = open(harness.endpoint())
  180. const pending = stream.next()
  181. stream.controller.abort()
  182. await observe(join(harness.workspace, 'a.txt'), present('v1'))
  183. expect(await pending).toEqual({ done: true, value: undefined })
  184. })
  185. it('ends when its signal aborts while the root resolves, resolving under that signal', async () => {
  186. const fs = harness.ctx.fs
  187. const original = fs.resolve.bind(fs)
  188. let release: () => void = () => {}
  189. const gate = new Promise<void>((resolve) => { release = resolve })
  190. // A backend that checks the signal after its round-trip, as a remote one does.
  191. const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
  192. await gate
  193. opts?.signal?.throwIfAborted()
  194. return original(path, opts)
  195. })
  196. const stream = open(harness.endpoint())
  197. const pending = stream.next()
  198. stream.controller.abort()
  199. release()
  200. expect(await pending).toEqual({ done: true, value: undefined })
  201. expect(spy).toHaveBeenCalledWith(expect.any(String), { signal: stream.controller.signal })
  202. spy.mockRestore()
  203. })
  204. it('ends when its signal aborts between the root resolving and the first pull', async () => {
  205. const fs = harness.ctx.fs
  206. const original = fs.resolve.bind(fs)
  207. const controller = new AbortController()
  208. // The abort lands after the backend answered and before the drain installs its listener.
  209. const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
  210. const target = await original(path, opts)
  211. controller.abort()
  212. return target
  213. })
  214. const stream = open(harness.endpoint(), controller)
  215. expect(await stream.next()).toEqual({ done: true, value: undefined })
  216. spy.mockRestore()
  217. })
  218. it('surfaces a root that fails to resolve', async () => {
  219. const spy = vi.spyOn(harness.ctx.fs, 'resolve').mockRejectedValue(new Error('no such root'))
  220. const stream = open(harness.endpoint())
  221. await expect(stream.next()).rejects.toThrow('no such root')
  222. spy.mockRestore()
  223. })
  224. it('stops delivering to a generation the consumer returned from', async () => {
  225. const service = harness.endpoint()
  226. const controller = new AbortController()
  227. const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
  228. closeStreams.push(async () => {
  229. controller.abort()
  230. await iterator.return?.()
  231. })
  232. await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  233. const first = iterator.next()
  234. const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
  235. expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
  236. expect(await iterator.return?.(undefined)).toEqual({ done: true, value: undefined })
  237. // A later observation reaches no follower: the set is empty again, so the
  238. // second generation opened here is the only one that sees it.
  239. const stream = open(service)
  240. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  241. const pending = stream.next()
  242. const later = await observe(join(harness.workspace, 'b.txt'), present('v2'))
  243. expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: later, version: 'v2' } } })
  244. })
  245. it('ends every open generation when the owning fiber is disposed', async () => {
  246. let service: WorkspaceFiles | undefined
  247. const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
  248. service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
  249. }, { inject: ['fs', 'sandboxPolicy'] }))
  250. if (service === undefined) throw new Error('plugin body did not run')
  251. const stream = open(service)
  252. await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  253. const first = stream.next()
  254. await observe(join(harness.workspace, 'a.txt'), present('v0'))
  255. expect((await first).done).toBe(false)
  256. const pending = stream.next()
  257. await fiber.dispose()
  258. expect(await pending).toEqual({ done: true, value: undefined })
  259. // The listener left with the fiber: a fresh generation on a live service
  260. // proves the root context still observes while the disposed one is silent.
  261. const live = open(harness.endpoint())
  262. await expect(live.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
  263. const next = live.next()
  264. const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
  265. expect(await next).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
  266. })
  267. })