apply.client.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // @vitest-environment jsdom
  2. /**
  3. * The plugin's wiring: `ctx.resources` is provided, the `resource` root keyed
  4. * hook reaches every slot component as `useResource`, and both leave with the
  5. * fiber so a reload of the plugin registers cleanly again.
  6. */
  7. import { afterEach, describe, expect, it, vi } from 'vitest'
  8. import { act } from '@testing-library/react'
  9. import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
  10. import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
  11. import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  12. import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts'
  13. import { apply as hostApply } from '../src/index.ts'
  14. import { ResourceRegistry } from '../src/client/resources.ts'
  15. import type { ResourceProvider } from '../src/client/contract.ts'
  16. declare module '@deepseek-ai/dsh-client-ui-slots' {
  17. interface SlotMap {
  18. 'resources.probe': { kind: 'single'; scope: 'root' }
  19. 'resources.sessionProbe': { kind: 'single'; scope: 'session' }
  20. 'resources.sessionPeer': { kind: 'single'; scope: 'session' }
  21. }
  22. interface ResourceProtocolMap {
  23. feed: string
  24. }
  25. }
  26. const A = 'dsh-resource://feed/one'
  27. let runtime: SlotTestRuntime | undefined
  28. afterEach(async () => {
  29. await runtime?.dispose()
  30. runtime = undefined
  31. })
  32. const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
  33. async function boot(): Promise<SlotTestRuntime> {
  34. const rt = await SlotTestRuntime.create()
  35. await rt.declare({
  36. 'resources.probe': { kind: 'single', scope: 'root' },
  37. 'resources.sessionProbe': { kind: 'single', scope: 'session' },
  38. 'resources.sessionPeer': { kind: 'single', scope: 'session' },
  39. })
  40. return rt
  41. }
  42. describe('client-resources apply', () => {
  43. it('keeps the host Loader entry inert', () => {
  44. expect(hostApply).not.toThrow()
  45. })
  46. it('provides ctx.resources and hands every slot component useResource over the shared source', async () => {
  47. runtime = await boot()
  48. await runtime.mount({ inject: [...inject], apply })
  49. expect(runtime.ctx.resources).toBeInstanceOf(ResourceRegistry)
  50. // A root-scope component reading one address through the standard hook.
  51. let seen: ResourceSnapshot<string> | undefined
  52. runtime.slots.register({ name: 'resources.probe' }, ({ useResource }: { useResource: UseResource }) => {
  53. seen = useResource<'feed'>(A)
  54. return null
  55. })
  56. runtime.renderSlot('resources.probe', {})
  57. expect(seen).toMatchObject({ status: 'none', value: undefined })
  58. let push: ((value: string) => void) | undefined
  59. await act(async () => {
  60. runtime!.ctx.effect(() => runtime!.ctx.resources.register<'feed'>({
  61. protocol: 'feed',
  62. open: () => ({
  63. [Symbol.asyncIterator]: () => ({
  64. next: () => new Promise<IteratorResult<RemoteResult<string>>>((resolve) => {
  65. push = (value) => { resolve({ done: false, value: { ok: true, value } }) }
  66. }),
  67. }),
  68. }),
  69. }), 'spec: feed provider')
  70. })
  71. expect(seen).toMatchObject({ status: 'loading' })
  72. // Rendering the hook is what holds the address: the provider's stream is open.
  73. expect(push).toBeDefined()
  74. await act(async () => { push!('v1'); await settle() })
  75. expect(seen).toMatchObject({ status: 'live', value: 'v1' })
  76. expect(runtime.ctx.resources.source(A).getSnapshot()).toBe(seen)
  77. })
  78. it('withdraws both on dispose, so a remount registers again without a duplicate', async () => {
  79. runtime = await boot()
  80. const handle = await runtime.mount({ inject: [...inject], apply })
  81. await handle.dispose()
  82. expect(runtime.ctx.get('resources')).toBeUndefined()
  83. // Records only whether the standard hook is present on a root-scope component's props.
  84. let hook: UseResource | undefined
  85. runtime.slots.register({ name: 'resources.probe' }, (props: { useResource?: UseResource }) => {
  86. hook = props.useResource
  87. return null
  88. })
  89. runtime.renderSlot('resources.probe', {})
  90. expect(hook).toBeUndefined()
  91. await runtime.mount({ inject: [...inject], apply })
  92. expect(hook).toBeTypeOf('function')
  93. })
  94. it('shares one address across independently bound views without reopening on rebinding', async () => {
  95. runtime = await boot()
  96. const rt = runtime
  97. const firstId = await rt.sessions.add({ id: 'first-session' })
  98. const secondId = await rt.sessions.add({ id: 'second-session' })
  99. const firstReference = rt.sessions.retain(firstId)
  100. await firstReference.ready
  101. const secondReference = rt.sessions.retain(secondId)
  102. await secondReference.ready
  103. await rt.mount({ inject: [...inject], apply })
  104. const opened = Promise.withResolvers<undefined>()
  105. const open = vi.fn<ResourceProvider<'feed'>['open']>(async function* () {
  106. try { yield { ok: true as const, value: 'shared data' } } finally { opened.resolve(undefined) }
  107. })
  108. await act(async () => {
  109. rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'feed', open }), 'spec: shared address')
  110. })
  111. const source = rt.ctx.resources.source(A)
  112. const seen: {
  113. root?: ResourceSnapshot<string>
  114. first?: ResourceSnapshot<string>
  115. second?: ResourceSnapshot<string>
  116. firstSession?: string
  117. secondSession?: string
  118. } = {}
  119. rt.slots.register({ name: 'resources.probe' }, ({ useResource }: PropsRuntime<'resources.probe'>) => {
  120. seen.root = useResource<'feed'>(A)
  121. return null
  122. })
  123. rt.slots.register({ name: 'resources.sessionProbe' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionProbe'>) => {
  124. seen.firstSession = sessionId
  125. seen.first = useResource<'feed'>(A)
  126. return null
  127. })
  128. rt.slots.register({ name: 'resources.sessionPeer' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionPeer'>) => {
  129. seen.secondSession = sessionId
  130. seen.second = useResource<'feed'>(A)
  131. return null
  132. })
  133. rt.renderSlot('resources.probe', {})
  134. const firstView = rt.renderSlot('resources.sessionProbe', {}, { session: firstReference })
  135. const secondView = rt.renderSlot('resources.sessionPeer', {}, { session: secondReference })
  136. await act(async () => { await opened.promise })
  137. const snapshot = source.getSnapshot()
  138. expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined })
  139. expect(seen.root).toBe(snapshot)
  140. expect(seen.first).toBe(snapshot)
  141. expect(seen.second).toBe(snapshot)
  142. expect([seen.firstSession, seen.secondSession]).toEqual([firstId, secondId])
  143. expect(open).toHaveBeenCalledTimes(1)
  144. expect(open.mock.calls[0]![0]).toBe(A)
  145. expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
  146. const replacement = rt.sessions.retain(secondId)
  147. await replacement.ready
  148. firstView.update({}, { session: replacement })
  149. firstReference.release()
  150. secondView.update({})
  151. expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId])
  152. expect(rt.ctx.resources.source(A)).toBe(source)
  153. expect(seen.root).toBe(snapshot)
  154. expect(seen.first).toBe(snapshot)
  155. expect(seen.second).toBe(snapshot)
  156. expect(open).toHaveBeenCalledTimes(1)
  157. expect(open.mock.calls[0]![1].signal.aborted).toBe(false)
  158. })
  159. })