face.client.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. /**
  2. * The face's contract with the store: a read in flight is visible, its outcome
  3. * lands as a page or a failure, a read outlived by its tab writes nothing, a
  4. * reload starts over from the first line and retires the reads still out, and
  5. * a newer file version arriving past the first line restarts the walk. The read
  6. * runs under the session the file names, not the one the face was injected for.
  7. */
  8. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  9. import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
  10. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  11. import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
  12. import type { WorkspaceFileBytes, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
  13. import { textFace } from '../src/client/face.ts'
  14. import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts'
  15. import { hostFileOf } from '../src/client/rpc.ts'
  16. import { createTextStore } from '../src/client/store.ts'
  17. import { ABSOLUTE_PATH, FILE, PATH, SESSION, failure, page } from './fixtures.client.ts'
  18. import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
  19. const TAB_1 = 'tab-1' as TabId
  20. /** One read awaiting the spec's answer. */
  21. interface PendingRead {
  22. readonly offset: number
  23. readonly promise: Promise<RemoteResult<WorkspaceFileText>>
  24. resolve(result: RemoteResult<WorkspaceFileText>): void
  25. }
  26. function complete(version = 'v1', data = new Uint8Array([0, 1, 255])) {
  27. return {
  28. ok: true as const,
  29. value: { absolutePath: ABSOLUTE_PATH, version, offset: 0, data, eof: true, bytes: data.byteLength },
  30. }
  31. }
  32. function bytesFailure(): RemoteResult<never> {
  33. return {
  34. ok: false,
  35. error: {
  36. name: 'RemoteError',
  37. isDSHRemoteError: true,
  38. code: 'workspace-file/outside-workspace',
  39. message: 'The file is outside the caller workspace',
  40. details: { path: ABSOLUTE_PATH },
  41. },
  42. }
  43. }
  44. /** Deferred results keyed by page offset or complete-read sequence number. */
  45. function readQueue<T>() {
  46. const pending: Array<PromiseWithResolvers<RemoteResult<T>> & { key: number }> = []
  47. return {
  48. request(key: number): Promise<RemoteResult<T>> {
  49. const deferred = Promise.withResolvers<RemoteResult<T>>()
  50. pending.push({ ...deferred, key })
  51. return deferred.promise
  52. },
  53. settle: async (result: RemoteResult<T>, key?: number): Promise<void> => {
  54. const at = key === undefined ? 0 : pending.findIndex(call => call.key === key)
  55. const call = pending[at]
  56. if (call === undefined) throw new Error('no outstanding read to settle')
  57. pending.splice(at, 1)
  58. call.resolve(result)
  59. // The face registered its synchronous store-writing reaction before this await.
  60. await call.promise
  61. },
  62. outstanding: () => pending.map(call => call.key),
  63. async close(): Promise<void> {
  64. const remaining = pending.splice(0)
  65. for (const call of remaining) call.resolve(bytesFailure())
  66. await Promise.all(remaining.map(call => call.promise))
  67. },
  68. }
  69. }
  70. function bench(sessionId = 'other-session' as SessionId) {
  71. const instance = createTextStore().create()
  72. const pending: PendingRead[] = []
  73. const read = vi.fn<ReadWorkspaceFilePage>((_session, _path, offset) => {
  74. const deferred = Promise.withResolvers<RemoteResult<WorkspaceFileText>>()
  75. pending.push({ offset, ...deferred })
  76. return deferred.promise
  77. })
  78. const whole = readQueue<WorkspaceFileBytes>()
  79. let sequence = 0
  80. const bytes = vi.fn<ReadDocumentBytes>(() => whole.request(++sequence))
  81. const controller = new AbortController()
  82. onTestFinished(async () => {
  83. controller.abort()
  84. const remaining = pending.splice(0)
  85. for (const call of remaining) call.resolve(failure('workspace-file/outside-workspace', { path: PATH }))
  86. await Promise.all([...remaining.map(call => call.promise), whole.close()])
  87. })
  88. // The store's own `forget`, counted: the record's end must forget a tab exactly once.
  89. const forget = vi.fn(instance.actions.forget)
  90. // Injected for another session on purpose: the address's session must win.
  91. const face = textFace(read, bytes)(sessionId, { ...instance.actions, forget })
  92. /** Settle the oldest outstanding read, or the oldest one for `offset`. */
  93. const settle = async (result: RemoteResult<WorkspaceFileText>, offset?: number): Promise<void> => {
  94. const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset)
  95. const [call] = pending.splice(at, 1)
  96. if (call === undefined) throw new Error('no outstanding read to settle')
  97. call.resolve(result)
  98. await call.promise
  99. }
  100. return {
  101. instance, read, face, forget, settle, bytes, controller,
  102. settleAll: (result: RemoteResult<DocumentFileBytes>, key?: number) => whole.settle(result.ok
  103. ? { ok: true, value: { ...result.value, data: btoa(String.fromCharCode(...result.value.data)) } }
  104. : result, key),
  105. settleAllWire: whole.settle,
  106. outstandingAll: whole.outstanding,
  107. outstanding: () => pending.map(call => call.offset),
  108. tab: () => instance.getSnapshot().byTab[TAB_1],
  109. }
  110. }
  111. const settlements = [
  112. { outcome: 'success', order: 'retired-first' },
  113. { outcome: 'failure', order: 'retired-first' },
  114. { outcome: 'success', order: 'current-first' },
  115. { outcome: 'failure', order: 'current-first' },
  116. ] as const
  117. describe('textFace', () => {
  118. it('marks the read in flight, then keeps the page', async () => {
  119. const { read, face, settle, tab } = bench()
  120. const controller = new AbortController()
  121. face.loadPage(TAB_1, FILE, 1, controller.signal)
  122. expect(read).toHaveBeenCalledWith(SESSION, PATH, 1, controller.signal)
  123. expect(tab()?.loading).toBe(true)
  124. await settle(page(1, ['a', 'b'], false))
  125. expect(tab()).toMatchObject({ loading: false, pages: { 1: { text: 'a\nb', lines: 2 } }, eof: false })
  126. })
  127. it('records a failed read', async () => {
  128. const { face, settle, tab } = bench()
  129. face.loadPage(TAB_1, FILE, 1, new AbortController().signal)
  130. await settle(failure('workspace-file/outside-workspace', { path: PATH }))
  131. expect(tab()?.failure?.code).toBe('workspace-file/outside-workspace')
  132. expect(tab()?.loading).toBe(false)
  133. })
  134. it('forgets the tab when its record ends, once, however many reads armed it, and writes nothing afterwards', async () => {
  135. const { read, face, forget, settle, tab } = bench()
  136. const controller = new AbortController()
  137. const armed = vi.spyOn(controller.signal, 'addEventListener')
  138. onTestFinished(() => { armed.mockRestore() })
  139. face.loadPage(TAB_1, FILE, 1, controller.signal)
  140. await settle(page(1, ['a'], false))
  141. face.loadPage(TAB_1, FILE, 2, controller.signal)
  142. face.reloadPages(TAB_1, FILE, controller.signal)
  143. expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1)
  144. expect(tab()).toBeDefined()
  145. controller.abort()
  146. expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1)
  147. expect(tab()).toBeUndefined()
  148. // The reads still out settle into nothing, and no request is made for the ended record.
  149. await settle(page(1, ['A'], true))
  150. expect(tab()).toBeUndefined()
  151. face.loadPage(TAB_1, FILE, 1, controller.signal)
  152. face.reloadPages(TAB_1, FILE, controller.signal)
  153. expect(read).toHaveBeenCalledTimes(3)
  154. expect(tab()).toBeUndefined()
  155. })
  156. it('reloads from the first line, dropping the pages and keeping the view', async () => {
  157. const { instance, read, face, settle, tab } = bench()
  158. const controller = new AbortController()
  159. face.loadPage(TAB_1, FILE, 1, controller.signal)
  160. await settle(page(1, ['a'], false))
  161. instance.actions.scrolled(TAB_1, 77)
  162. face.reloadPages(TAB_1, FILE, controller.signal)
  163. expect(tab()).toMatchObject({ pages: {}, eof: false, version: undefined, loading: true, scrollTop: 77 })
  164. expect(read).toHaveBeenLastCalledWith(SESSION, PATH, 1, controller.signal)
  165. })
  166. it('drops a page that settles after a reload retired it, whichever lands first', async () => {
  167. const { face, settle, outstanding, tab } = bench()
  168. const signal = new AbortController().signal
  169. face.loadPage(TAB_1, FILE, 1, signal)
  170. await settle(page(1, ['a', 'b', 'c'], false))
  171. // Load-more is out when the reader reloads: the new first page lands first.
  172. face.loadPage(TAB_1, FILE, 4, signal)
  173. face.reloadPages(TAB_1, FILE, signal)
  174. expect(outstanding()).toEqual([4, 1])
  175. await settle(page(1, ['A'], false, 'v2'), 1)
  176. expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false })
  177. // The retired page lands afterwards and changes nothing, not even the end flag.
  178. await settle(page(4, ['d'], true), 4)
  179. expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false })
  180. })
  181. it('starts the walk over when a page of a newer version arrives past the first line', async () => {
  182. const { read, face, settle, outstanding, tab } = bench()
  183. const signal = new AbortController().signal
  184. face.loadPage(TAB_1, FILE, 1, signal)
  185. await settle(page(1, ['a', 'b', 'c'], false))
  186. face.loadPage(TAB_1, FILE, 4, signal)
  187. // The file changed between the two reads: the page is not kept beside the older ones.
  188. await settle(page(4, ['D'], true, 'v2'))
  189. expect(tab()).toMatchObject({ pages: {}, version: undefined, eof: false, loading: true })
  190. expect(read).toHaveBeenCalledTimes(3)
  191. expect(outstanding()).toEqual([1])
  192. await settle(page(1, ['A', 'B'], true, 'v2'))
  193. expect(tab()).toMatchObject({ pages: { 1: { text: 'A\nB', lines: 2 } }, version: 'v2', eof: true, loading: false })
  194. })
  195. it('keeps a first page of a newer version, since the store drops the older pages for it', async () => {
  196. const { face, settle, tab } = bench()
  197. const signal = new AbortController().signal
  198. face.loadPage(TAB_1, FILE, 1, signal)
  199. await settle(page(1, ['a'], false))
  200. // A retry of the first page after the file changed lands as the new version.
  201. face.loadPage(TAB_1, FILE, 1, signal)
  202. await settle(page(1, ['A'], true, 'v2'))
  203. expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: true })
  204. })
  205. it('loads native complete bytes with only the tab signal', async () => {
  206. const { face, read, bytes, settleAll, tab, controller } = bench()
  207. const result = complete()
  208. face.loadAll(TAB_1, FILE, controller.signal)
  209. expect(bytes).toHaveBeenCalledExactlyOnceWith(FILE, controller.signal)
  210. expect(read).not.toHaveBeenCalled()
  211. expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, pages: {}, failure: undefined })
  212. expect(tab()?.complete).toBeUndefined()
  213. await settleAll(result)
  214. expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: false, complete: result.value, version: 'v1', eof: true, pages: {} })
  215. })
  216. it('records a complete-read failure and clears it when the read is retried', async () => {
  217. const { face, settleAll, tab, controller } = bench()
  218. face.loadAll(TAB_1, FILE, controller.signal)
  219. await settleAll(bytesFailure())
  220. expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: false, failure: { code: 'workspace-file/outside-workspace' } })
  221. expect(tab()?.complete).toBeUndefined()
  222. face.loadAll(TAB_1, FILE, controller.signal)
  223. expect(tab()).toMatchObject({ loading: true, failure: undefined })
  224. await settleAll(complete())
  225. expect(tab()).toMatchObject({ loading: false, failure: undefined, complete: complete().value })
  226. })
  227. it('records malformed complete-byte wire data as a failed read', async () => {
  228. const { face, settleAllWire, tab, controller } = bench()
  229. face.loadAll(TAB_1, FILE, controller.signal)
  230. await settleAllWire({
  231. ok: true,
  232. value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: '!!!', eof: true, bytes: 3 },
  233. })
  234. expect(tab()).toMatchObject({
  235. mode: 'bytes-complete', loading: false, version: undefined,
  236. failure: { code: 'gateway/internal', message: 'document file byte response has malformed base64 data' },
  237. })
  238. expect(tab()?.complete).toBeUndefined()
  239. })
  240. it('reloads complete bytes, discarding the old result and preserving the view', async () => {
  241. const { instance, face, bytes, settleAll, tab, controller } = bench()
  242. face.loadAll(TAB_1, FILE, controller.signal)
  243. await settleAll(complete())
  244. instance.actions.selected(TAB_1, 'test/whole-file')
  245. instance.actions.scrolled(TAB_1, 77)
  246. instance.actions.toggledWrap(TAB_1)
  247. instance.actions.navigated(TAB_1, 3)
  248. face.reloadAll(TAB_1, FILE, controller.signal)
  249. expect(bytes).toHaveBeenCalledTimes(2)
  250. expect(bytes).toHaveBeenLastCalledWith(FILE, controller.signal)
  251. expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, version: undefined, eof: false, pages: {} })
  252. expect(tab()?.complete).toBeUndefined()
  253. const result = complete('v2', new Uint8Array([2, 3, 255]))
  254. await settleAll(result)
  255. expect(tab()).toMatchObject({
  256. complete: result.value, version: 'v2', loading: false, eof: true,
  257. rendererId: 'test/whole-file', scrollTop: 77, wrap: false, revision: 3,
  258. })
  259. })
  260. it('reports a failed complete reload without restoring the discarded bytes', async () => {
  261. const { face, settleAll, tab, controller } = bench()
  262. face.loadAll(TAB_1, FILE, controller.signal)
  263. await settleAll(complete())
  264. face.reloadAll(TAB_1, FILE, controller.signal)
  265. await settleAll(bytesFailure())
  266. expect(tab()).toMatchObject({
  267. mode: 'bytes-complete', loading: false, version: undefined, eof: false,
  268. failure: { code: 'workspace-file/outside-workspace' },
  269. })
  270. expect(tab()?.complete).toBeUndefined()
  271. })
  272. it.each(['loadAll', 'reloadAll'] as const)('%s does not request or create state for an ended tab', (method) => {
  273. const { face, bytes, forget, tab, controller } = bench()
  274. controller.abort()
  275. face[method](TAB_1, FILE, controller.signal)
  276. expect(bytes).not.toHaveBeenCalled()
  277. expect(forget).not.toHaveBeenCalled()
  278. expect(tab()).toBeUndefined()
  279. })
  280. it.each(['success', 'failure'] as const)('ignores complete-read %s after tab abort', async (outcome) => {
  281. const { instance, face, bytes, forget, settleAll, tab, controller } = bench()
  282. face.loadAll(TAB_1, FILE, controller.signal)
  283. face.reloadAll(TAB_1, FILE, controller.signal)
  284. controller.abort()
  285. expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1)
  286. expect(tab()).toBeUndefined()
  287. const snapshot = instance.getSnapshot()
  288. await settleAll(outcome === 'success' ? complete() : bytesFailure(), 1)
  289. await settleAll(outcome === 'success' ? complete('v2') : bytesFailure(), 2)
  290. expect(instance.getSnapshot()).toBe(snapshot)
  291. face.loadAll(TAB_1, FILE, controller.signal)
  292. face.reloadAll(TAB_1, FILE, controller.signal)
  293. expect(bytes).toHaveBeenCalledTimes(2)
  294. expect(instance.getSnapshot()).toBe(snapshot)
  295. })
  296. it.each(settlements)('ignores retired complete-read $outcome after reload ($order)', async ({ outcome, order }) => {
  297. const { instance, face, settleAll, outstandingAll, tab, controller } = bench()
  298. face.loadAll(TAB_1, FILE, controller.signal)
  299. face.reloadAll(TAB_1, FILE, controller.signal)
  300. expect(outstandingAll()).toEqual([1, 2])
  301. const current = complete('v2', new Uint8Array([2, 3, 255]))
  302. const settleRetired = async (): Promise<void> => {
  303. const snapshot = instance.getSnapshot()
  304. await settleAll(outcome === 'success' ? complete() : bytesFailure(), 1)
  305. expect(instance.getSnapshot()).toBe(snapshot)
  306. }
  307. if (order === 'retired-first') {
  308. await settleRetired()
  309. await settleAll(current, 2)
  310. } else {
  311. await settleAll(current, 2)
  312. await settleRetired()
  313. }
  314. expect(tab()).toMatchObject({ complete: current.value, version: 'v2', loading: false, failure: undefined })
  315. })
  316. describe.each(['text-pages', 'bytes-complete'] as const)('switching away from %s', (mode) => {
  317. it.each(settlements)('ignores the previous mode\'s $outcome ($order)', async ({ outcome, order }) => {
  318. const { instance, face, settle, settleAll, outstanding, outstandingAll, tab, controller } = bench()
  319. const { signal } = controller
  320. if (mode === 'text-pages') {
  321. face.loadPage(TAB_1, FILE, 1, signal)
  322. face.loadAll(TAB_1, FILE, signal)
  323. } else {
  324. face.loadAll(TAB_1, FILE, signal)
  325. face.loadPage(TAB_1, FILE, 1, signal)
  326. }
  327. expect(outstanding()).toEqual([1])
  328. expect(outstandingAll()).toEqual([1])
  329. const settleRetired = async (): Promise<void> => {
  330. const snapshot = instance.getSnapshot()
  331. if (mode === 'text-pages') await settle(outcome === 'success' ? page(1, ['old'], true) : bytesFailure())
  332. else await settleAll(outcome === 'success' ? complete() : bytesFailure())
  333. expect(instance.getSnapshot()).toBe(snapshot)
  334. }
  335. const settleCurrent = async (): Promise<void> => {
  336. if (mode === 'text-pages') await settleAll(complete('v2', new Uint8Array([2, 3, 255])))
  337. else await settle(page(1, ['current'], true, 'v2'))
  338. }
  339. if (order === 'retired-first') {
  340. await settleRetired()
  341. await settleCurrent()
  342. } else {
  343. await settleCurrent()
  344. await settleRetired()
  345. }
  346. expect(tab()).toMatchObject({ version: 'v2', loading: false, failure: undefined, eof: true })
  347. if (mode === 'text-pages') {
  348. expect(tab()).toMatchObject({ mode: 'bytes-complete', pages: {}, complete: complete('v2', new Uint8Array([2, 3, 255])).value })
  349. } else {
  350. expect(tab()).toMatchObject({ mode: 'text-pages', pages: { 1: { text: 'current', lines: 1 } } })
  351. expect(tab()?.complete).toBeUndefined()
  352. }
  353. })
  354. })
  355. it('keeps earlier generations retired after returning to complete-byte mode', async () => {
  356. const { instance, face, settle, settleAll, tab, controller } = bench()
  357. face.loadAll(TAB_1, FILE, controller.signal)
  358. face.loadPage(TAB_1, FILE, 1, controller.signal)
  359. face.loadAll(TAB_1, FILE, controller.signal)
  360. await settleAll(complete('v2', new Uint8Array([2, 3, 255])), 2)
  361. const snapshot = instance.getSnapshot()
  362. await settleAll(complete(), 1)
  363. await settle(bytesFailure())
  364. expect(instance.getSnapshot()).toBe(snapshot)
  365. expect(tab()).toMatchObject({ mode: 'bytes-complete', complete: complete('v2', new Uint8Array([2, 3, 255])).value, pages: {} })
  366. })
  367. it.each([PATH, ABSOLUTE_PATH, 'C:/w/notes.md', '//host/share/notes.md'])('uses the addressed Session for %s in both reading modes', async (path) => {
  368. const first = bench()
  369. const secondSession = 'second-caller-session' as SessionId
  370. const second = bench(secondSession)
  371. const address = sessionFileAddress(SESSION, path)
  372. const firstFile = hostFileOf(address)
  373. const secondFile = hostFileOf(address)
  374. expect(firstFile).toEqual({ sessionId: SESSION, path })
  375. expect(secondFile).toEqual(firstFile)
  376. first.face.loadPage(TAB_1, firstFile, 1, first.controller.signal)
  377. second.face.reloadPages(TAB_1, secondFile, second.controller.signal)
  378. expect(first.read).toHaveBeenCalledExactlyOnceWith(firstFile.sessionId, firstFile.path, 1, first.controller.signal)
  379. expect(second.read).toHaveBeenCalledExactlyOnceWith(secondFile.sessionId, secondFile.path, 1, second.controller.signal)
  380. await first.settle(page(1, ['first'], true))
  381. await second.settle(page(1, ['second'], true))
  382. first.face.loadAll(TAB_1, firstFile, first.controller.signal)
  383. second.face.reloadAll(TAB_1, secondFile, second.controller.signal)
  384. expect(first.bytes).toHaveBeenCalledExactlyOnceWith(firstFile, first.controller.signal)
  385. expect(second.bytes).toHaveBeenCalledExactlyOnceWith(secondFile, second.controller.signal)
  386. await first.settleAll(complete('v1'))
  387. await second.settleAll(complete('v2'))
  388. expect(first.tab()?.version).toBe('v1')
  389. expect(second.tab()?.version).toBe('v2')
  390. })
  391. })