1
0

resources.client.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. /**
  2. * The resource lifecycle: one address opens when its first holder arrives,
  3. * stays open across holder changes, and closes when the last one leaves.
  4. * Providers are scripted feeds so every transition is driven by the spec,
  5. * never by timing.
  6. */
  7. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  10. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  11. import { protocolOf, RESOURCE_SCHEME, ResourceRegistry } from '../src/client/resources.ts'
  12. import type { ResourceOpenContext, ResourceProvider } from '../src/client/contract.ts'
  13. declare module '@deepseek-ai/dsh-client-ui-slots' {
  14. interface ResourceProtocolMap {
  15. feed: string
  16. counter: number
  17. }
  18. }
  19. const A = `${RESOURCE_SCHEME}://feed/one`
  20. /** One scripted stream: the spec pushes value frames, failure frames, or ends it. */
  21. interface Feed {
  22. readonly ctx: ResourceOpenContext
  23. push(value: string): void
  24. fail(error: RemoteFailure): void
  25. end(): void
  26. /** Whether the consumer returned the iterator (its `finally` ran). */
  27. readonly returned: boolean
  28. readonly closed: Promise<undefined>
  29. }
  30. type Step = { readonly kind: 'frame'; readonly frame: RemoteResult<string> } | { readonly kind: 'end' }
  31. function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterable<RemoteResult<string>> } {
  32. const steps: Step[] = []
  33. let wake: (() => void) | undefined
  34. let returned = false
  35. const closed = Promise.withResolvers<undefined>()
  36. const notify = (): void => { wake?.(); wake = undefined }
  37. async function* stream(): AsyncGenerator<RemoteResult<string>> {
  38. try {
  39. for (;;) {
  40. if (steps.length === 0) await new Promise<void>((resolve) => { wake = resolve })
  41. const step = steps.shift()
  42. if (step === undefined) continue
  43. if (step.kind === 'end') return
  44. yield step.frame
  45. }
  46. } finally {
  47. returned = true
  48. closed.resolve(undefined)
  49. }
  50. }
  51. const feed: Feed = {
  52. ctx,
  53. closed: closed.promise,
  54. push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() },
  55. fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() },
  56. end: () => { steps.push({ kind: 'end' }); notify() },
  57. get returned() { return returned },
  58. }
  59. return { feed, stream: stream() }
  60. }
  61. /** A `feed` provider whose every `open` is recorded and spec-driven. */
  62. function scriptedProvider() {
  63. const opens: Feed[] = []
  64. onTestFinished(async () => {
  65. for (const feed of opens) feed.end()
  66. await Promise.all(opens.map(feed => feed.closed))
  67. })
  68. const provider = {
  69. protocol: 'feed' as const,
  70. open: vi.fn((_address: string, ctx: ResourceOpenContext) => {
  71. const { feed, stream } = createFeed(ctx)
  72. opens.push(feed)
  73. return stream
  74. }),
  75. } satisfies ResourceProvider<'feed'>
  76. return { provider, opens, last: () => opens[opens.length - 1]! }
  77. }
  78. const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
  79. function bench() {
  80. const ctx = new Context()
  81. const registry = new ResourceRegistry(ctx)
  82. const scripted = scriptedProvider()
  83. const snapshot = (address = A) => registry.source(address).getSnapshot()
  84. return { ctx, registry, ...scripted, snapshot }
  85. }
  86. describe('protocolOf', () => {
  87. it('reads the dsh-resource host, lower-cased, and reports none for any other address', () => {
  88. expect(protocolOf('dsh-resource://file/session/s1/home/ys/b.txt')).toBe('file')
  89. expect(protocolOf('DSH-RESOURCE://File/session/s1/a')).toBe('file')
  90. expect(protocolOf('dsh-resource://chat/node/1')).toBe('chat')
  91. // A navigation address is not a resource.
  92. expect(protocolOf('sidebar://guide')).toBeUndefined()
  93. expect(protocolOf('file://sessions/s1/a.txt')).toBeUndefined()
  94. expect(protocolOf('dsh-resource:///no-host')).toBeUndefined()
  95. expect(protocolOf('/a/b.txt')).toBeUndefined()
  96. expect(protocolOf('')).toBeUndefined()
  97. })
  98. })
  99. describe('ResourceRegistry providers', () => {
  100. it('owns a protocol by exactly one provider, and frees it on dispose', () => {
  101. const b = bench()
  102. const dispose = b.registry.register(b.provider)
  103. expect(() => b.registry.register(scriptedProvider().provider)).toThrow('protocol "feed" already has a provider')
  104. dispose()
  105. dispose()
  106. expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow()
  107. })
  108. it('reports none for an address whose protocol has no provider, and for a navigation address', () => {
  109. const b = bench()
  110. expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined, failure: undefined })
  111. expect(b.snapshot('sidebar://guide')).toMatchObject({ status: 'none' })
  112. const unsubscribe = b.registry.source(A).subscribe(() => {})
  113. expect(b.snapshot().status).toBe('none')
  114. unsubscribe()
  115. })
  116. it('opens a held address when its provider arrives, and closes it when the provider leaves', async () => {
  117. const b = bench()
  118. const seen = vi.fn()
  119. b.registry.source(A).subscribe(seen)
  120. expect(b.snapshot().status).toBe('none')
  121. const dispose = b.registry.register(b.provider)
  122. expect(b.snapshot().status).toBe('loading')
  123. expect(b.provider.open).toHaveBeenCalledWith(A, { signal: expect.any(AbortSignal) as AbortSignal })
  124. b.last().push('v1')
  125. await vi.waitFor(() => { expect(b.snapshot()).toMatchObject({ status: 'live', value: 'v1' }) })
  126. dispose()
  127. expect(b.last().ctx.signal.aborted).toBe(true)
  128. expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined })
  129. expect(seen).toHaveBeenCalled()
  130. })
  131. it('opens the arriving protocol\'s held addresses only, leaving another protocol\'s records as they were', () => {
  132. const b = bench()
  133. const other = `${RESOURCE_SCHEME}://other/one`
  134. b.registry.source(other).subscribe(() => {})
  135. b.registry.source(A).subscribe(() => {})
  136. b.registry.register(b.provider)
  137. expect(b.snapshot().status).toBe('loading')
  138. expect(b.snapshot(other).status).toBe('none')
  139. expect(b.provider.open).toHaveBeenCalledExactlyOnceWith(A, { signal: expect.any(AbortSignal) as AbortSignal })
  140. })
  141. it('turns an idle, unheld address to loading when its provider arrives, without opening it', () => {
  142. const b = bench()
  143. expect(b.snapshot().status).toBe('none')
  144. b.registry.register(b.provider)
  145. expect(b.snapshot().status).toBe('loading')
  146. expect(b.provider.open).not.toHaveBeenCalled()
  147. })
  148. it('drops a registration when the registering fiber is disposed', async () => {
  149. const b = bench()
  150. const fiber = b.ctx.plugin({
  151. apply: (child: Context) => { child.effect(() => b.registry.register(b.provider), 'spec: feed provider') },
  152. })
  153. await fiber.await()
  154. expect(b.snapshot().status).toBe('loading')
  155. await fiber.dispose()
  156. expect(b.snapshot().status).toBe('none')
  157. expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow()
  158. })
  159. it('drops every registration when the registry\'s own fiber is disposed', async () => {
  160. const root = new Context()
  161. let registry: ResourceRegistry | undefined
  162. const fiber = root.plugin({ apply: (child: Context) => { registry = new ResourceRegistry(child) } })
  163. await fiber.await()
  164. const { provider } = scriptedProvider()
  165. registry!.register(provider)
  166. registry!.source(A).subscribe(() => {})
  167. expect(provider.open).toHaveBeenCalledTimes(1)
  168. await fiber.dispose()
  169. expect(registry!.source(A).getSnapshot().status).toBe('none')
  170. })
  171. })
  172. describe('ResourceRegistry holders', () => {
  173. it('opens on the first subscriber only, and closes after the last one leaves', async () => {
  174. const b = bench()
  175. b.registry.register(b.provider)
  176. const source = b.registry.source(A)
  177. expect(b.provider.open).not.toHaveBeenCalled()
  178. const first = source.subscribe(() => {})
  179. const second = source.subscribe(() => {})
  180. expect(b.provider.open).toHaveBeenCalledTimes(1)
  181. b.last().push('v1')
  182. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
  183. first()
  184. first()
  185. expect(b.last().ctx.signal.aborted).toBe(false)
  186. expect(source.getSnapshot().value).toBe('v1')
  187. second()
  188. expect(b.last().ctx.signal.aborted).toBe(true)
  189. expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
  190. })
  191. it('keeps one source per address and separates addresses', () => {
  192. const b = bench()
  193. expect(b.registry.source(A)).toBe(b.registry.source(A))
  194. expect(b.registry.source(A)).not.toBe(b.registry.source(`${RESOURCE_SCHEME}://feed/two`))
  195. })
  196. it('pins hold the address open until the signal aborts; an aborted signal pins nothing', () => {
  197. const b = bench()
  198. b.registry.register(b.provider)
  199. const controller = new AbortController()
  200. b.registry.pin(A, controller.signal)
  201. expect(b.provider.open).toHaveBeenCalledTimes(1)
  202. controller.abort()
  203. expect(b.last().ctx.signal.aborted).toBe(true)
  204. const aborted = new AbortController()
  205. aborted.abort()
  206. b.registry.pin(A, aborted.signal)
  207. expect(b.provider.open).toHaveBeenCalledTimes(1)
  208. })
  209. it('hands a remounting subscriber the latest value without reopening while a pin holds it', async () => {
  210. const b = bench()
  211. b.registry.register(b.provider)
  212. const pin = new AbortController()
  213. b.registry.pin(A, pin.signal)
  214. const source = b.registry.source(A)
  215. const unsubscribe = source.subscribe(() => {})
  216. b.last().push('v1')
  217. b.last().push('v2')
  218. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') })
  219. unsubscribe()
  220. b.last().push('v3')
  221. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v3') })
  222. const seen = vi.fn()
  223. source.subscribe(seen)
  224. expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v3' })
  225. expect(b.provider.open).toHaveBeenCalledTimes(1)
  226. expect(seen).not.toHaveBeenCalled()
  227. })
  228. it('reopens after the last holder left, as a fresh stream', async () => {
  229. const b = bench()
  230. b.registry.register(b.provider)
  231. const source = b.registry.source(A)
  232. const first = source.subscribe(() => {})
  233. b.last().push('v1')
  234. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
  235. first()
  236. const second = source.subscribe(() => {})
  237. expect(b.provider.open).toHaveBeenCalledTimes(2)
  238. expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
  239. b.last().push('v2')
  240. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') })
  241. second()
  242. })
  243. })
  244. describe('ResourceRegistry streams', () => {
  245. it('ignores what a released stream still yields, and returns its iterator', async () => {
  246. const b = bench()
  247. b.registry.register(b.provider)
  248. const source = b.registry.source(A)
  249. const unsubscribe = source.subscribe(() => {})
  250. const feed = b.last()
  251. unsubscribe()
  252. expect(feed.ctx.signal.aborted).toBe(true)
  253. feed.push('late')
  254. await settle()
  255. await settle()
  256. expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
  257. expect(feed.returned).toBe(true)
  258. })
  259. it('keeps the last value live when the stream ends on its own', async () => {
  260. const b = bench()
  261. b.registry.register(b.provider)
  262. const source = b.registry.source(A)
  263. source.subscribe(() => {})
  264. b.last().push('v1')
  265. b.last().end()
  266. await settle()
  267. await settle()
  268. expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v1' })
  269. })
  270. it('reports a failure frame beside the last value, and the next ok frame clears it', async () => {
  271. const b = bench()
  272. b.registry.register(b.provider)
  273. const source = b.registry.source(A)
  274. source.subscribe(() => {})
  275. b.last().push('v1')
  276. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
  277. const failure = new RemoteError('gateway/bad-request', 'refused', {})
  278. b.last().fail(failure)
  279. await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') })
  280. expect(source.getSnapshot()).toMatchObject({ value: 'v1', failure })
  281. b.last().push('v2')
  282. await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('live') })
  283. expect(source.getSnapshot()).toMatchObject({ value: 'v2', failure: undefined })
  284. })
  285. it('reports a failure frame that arrives first with no value', async () => {
  286. const b = bench()
  287. b.registry.register(b.provider)
  288. const source = b.registry.source(A)
  289. source.subscribe(() => {})
  290. b.last().fail(new RemoteError('gateway/bad-request', 'refused', {}))
  291. await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') })
  292. expect(source.getSnapshot()).toMatchObject({ value: undefined, failure: { code: 'gateway/bad-request' } })
  293. })
  294. it('drops a failure frame that follows the release that aborted the stream', async () => {
  295. const b = bench()
  296. b.registry.register(b.provider)
  297. const source = b.registry.source(A)
  298. const unsubscribe = source.subscribe(() => {})
  299. const feed = b.last()
  300. unsubscribe()
  301. feed.fail(new RemoteError('gateway/internal', 'after abort', {}))
  302. await settle()
  303. await settle()
  304. expect(source.getSnapshot()).toMatchObject({ status: 'loading', failure: undefined })
  305. })
  306. })
  307. describe('ResourceRegistry addresses', () => {
  308. it('opens different complete addresses independently and supplies only the lifetime signal', async () => {
  309. const b = bench()
  310. b.registry.register(b.provider)
  311. const other = A + '?variant=second'
  312. const first = b.registry.source(A)
  313. const second = b.registry.source(other)
  314. const releaseFirst = first.subscribe(() => {})
  315. const firstFeed = b.last()
  316. const releaseSecond = second.subscribe(() => {})
  317. const secondFeed = b.last()
  318. expect(first).not.toBe(second)
  319. expect(b.provider.open.mock.calls).toEqual([[A, firstFeed.ctx], [other, secondFeed.ctx]])
  320. expect(firstFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
  321. expect(secondFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
  322. firstFeed.push('first data')
  323. secondFeed.push('second data')
  324. await vi.waitFor(() => { expect(first.getSnapshot().value).toBe('first data') })
  325. await vi.waitFor(() => { expect(second.getSnapshot().value).toBe('second data') })
  326. releaseFirst()
  327. expect(firstFeed.ctx.signal.aborted).toBe(true)
  328. expect(secondFeed.ctx.signal.aborted).toBe(false)
  329. expect(second.getSnapshot().value).toBe('second data')
  330. releaseSecond()
  331. expect(secondFeed.ctx.signal.aborted).toBe(true)
  332. })
  333. })
  334. describe('ResourceRegistry stream generations', () => {
  335. it.each(['value', 'failure'] as const)('drops late %s frames after a released resource reopens', async (kind) => {
  336. const b = bench()
  337. b.registry.register(b.provider)
  338. const source = b.registry.source(A)
  339. const releaseFirst = source.subscribe(() => {})
  340. const oldFeed = b.last()
  341. oldFeed.push('old data')
  342. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') })
  343. releaseFirst()
  344. const releaseCurrent = source.subscribe(() => {})
  345. const currentFeed = b.last()
  346. expect(currentFeed).not.toBe(oldFeed)
  347. currentFeed.push('current data')
  348. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('current data') })
  349. const current = source.getSnapshot()
  350. if (kind === 'value') oldFeed.push('late old data')
  351. else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {}))
  352. await oldFeed.closed
  353. expect(oldFeed.returned).toBe(true)
  354. expect(source.getSnapshot()).toBe(current)
  355. releaseCurrent()
  356. })
  357. it.each(['value', 'failure'] as const)('drops old-provider %s frames after replacement', async (kind) => {
  358. const b = bench()
  359. const releaseProvider = b.registry.register(b.provider)
  360. const source = b.registry.source(A)
  361. const unsubscribe = source.subscribe(() => {})
  362. const oldFeed = b.last()
  363. oldFeed.push('old data')
  364. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') })
  365. releaseProvider()
  366. expect(oldFeed.ctx.signal.aborted).toBe(true)
  367. expect(source.getSnapshot()).toEqual({ status: 'none', value: undefined, failure: undefined })
  368. const replacement = scriptedProvider()
  369. b.registry.register(replacement.provider)
  370. expect(source.getSnapshot()).toEqual({ status: 'loading', value: undefined, failure: undefined })
  371. replacement.last().push('replacement data')
  372. await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('replacement data') })
  373. const current = source.getSnapshot()
  374. if (kind === 'value') oldFeed.push('late old data')
  375. else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {}))
  376. await oldFeed.closed
  377. expect(oldFeed.returned).toBe(true)
  378. expect(source.getSnapshot()).toBe(current)
  379. unsubscribe()
  380. })
  381. it('streams another protocol as plain numbers', async () => {
  382. const b = bench()
  383. const closed = Promise.withResolvers<undefined>()
  384. const dispose = b.registry.register({
  385. protocol: 'counter',
  386. async *open() {
  387. try { yield { ok: true as const, value: 1 } } finally { closed.resolve(undefined) }
  388. },
  389. })
  390. onTestFinished(dispose)
  391. const source = b.registry.source('dsh-resource://counter/one')
  392. const unsubscribe = source.subscribe(() => {})
  393. await closed.promise
  394. expect(source.getSnapshot()).toEqual({ status: 'live', value: 1, failure: undefined })
  395. unsubscribe()
  396. })
  397. })