assembly-test-client.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. // @vitest-environment jsdom
  2. /**
  3. * TestClient over the web profile's roster read from its bundles: production
  4. * `bootClient` over in-process modules, every Remote call answered by a
  5. * `RemoteMock` bound to that client's Connection instance, mount, HMR-style reload,
  6. * unload, and fail-loud teardown.
  7. */
  8. import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
  9. import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
  10. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  11. import type { AssemblyPlan, ClientPluginModule, TestClientOptions } from '../src/assembly/index.ts'
  12. import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
  13. /** The Gateway client and what it injects: the Typert registry and the Connection. */
  14. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  15. const MODULES = '@deepseek-ai/dsh-client-modules'
  16. const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
  17. const PARALLEL_PROBE = '@deepseek-ai/dsh-client-test-parallel-probe'
  18. /** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */
  19. const SIDEBAR_SETTINGS = 'sidebar.settings' as never
  20. const BRAND = '@deepseek-ai/dsh-client-ui-brand-official'
  21. const globals = globalThis as { EventSource?: unknown; ResizeObserver?: unknown }
  22. /** The whole roster's first boot pays the cold module transform of every plugin package. */
  23. const COLD_BOOT_TIMEOUT_MS = 60_000
  24. async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise<TestClient> {
  25. const mock = RemoteMock.create().load(remoteDefaultResponses)
  26. const client = await TestClient.start(plan, mock, options)
  27. onTestFinished(() => client.dispose())
  28. return client
  29. }
  30. describe('TestClient (jsdom)', () => {
  31. it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => {
  32. const mock = RemoteMock.create().load(remoteDefaultResponses)
  33. const client = await TestClient.start({ roster: webApp }, mock, { mount: true })
  34. expect(client.connection.state.getSnapshot()).toBe('connected')
  35. expect(mock.log.streams('$events')).toHaveLength(1)
  36. const container = client.container!
  37. expect(document.body.contains(container)).toBe(true)
  38. expect(container.childElementCount).toBeGreaterThan(0)
  39. expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
  40. expect(globals.EventSource).toBeDefined()
  41. expect(globals.ResizeObserver).toBeDefined()
  42. await client.dispose()
  43. expect(document.body.contains(container)).toBe(false)
  44. expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
  45. expect(globals.EventSource).toBeUndefined()
  46. expect(globals.ResizeObserver).toBeUndefined()
  47. await client.dispose()
  48. }, COLD_BOOT_TIMEOUT_MS)
  49. it('leaves a pre-existing global alone and removes only the shims it installed', async () => {
  50. const existing = { existing: true }
  51. vi.stubGlobal('ResizeObserver', existing)
  52. onTestFinished(() => { vi.unstubAllGlobals() })
  53. const client = await started({ roster: API_ROSTER })
  54. expect(globals.ResizeObserver).toBe(existing)
  55. expect(globals.EventSource).toBeDefined()
  56. await client.dispose()
  57. expect(globals.ResizeObserver).toBe(existing)
  58. expect(globals.EventSource).toBeUndefined()
  59. })
  60. it('boots separate client instances against their own mocks and keeps shared shims until the last dispose', async () => {
  61. const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 }))
  62. const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 }))
  63. const [a, b] = await Promise.all([
  64. TestClient.start({ roster: API_ROSTER }, mockA),
  65. TestClient.start({ roster: API_ROSTER }, mockB),
  66. ])
  67. onTestFinished(() => a.dispose())
  68. onTestFinished(() => b.dispose())
  69. const rename = async (client: TestClient): Promise<unknown> =>
  70. (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise<unknown> } } }).remote.session.rename({ sessionId: 's', title: 't' })
  71. await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
  72. await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } })
  73. expect(mockA.log.calls('session/rename')).toHaveLength(1)
  74. expect(mockB.log.calls('session/rename')).toHaveLength(1)
  75. await a.reload('@deepseek-ai/dsh-client-connection')
  76. await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') })
  77. await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
  78. expect(mockA.log.calls('session/rename')).toHaveLength(2)
  79. expect(mockB.log.calls('session/rename')).toHaveLength(1)
  80. await a.dispose()
  81. expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
  82. expect(globals.EventSource).toBeDefined()
  83. await b.dispose()
  84. expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
  85. expect(globals.EventSource).toBeUndefined()
  86. })
  87. it('boots two plugin trees concurrently instead of serializing the worker', async () => {
  88. let arrivals = 0
  89. const gate = Promise.withResolvers<undefined>()
  90. const probe: ClientPluginModule = {
  91. async apply() {
  92. arrivals += 1
  93. await gate.promise
  94. },
  95. }
  96. const roster = ClientRoster.of([
  97. { name: MODULES, inject: [], immediately: true },
  98. { name: PARALLEL_PROBE, inject: [], immediately: false },
  99. ])
  100. const first = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false })
  101. const second = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false })
  102. const starts = Promise.allSettled([first, second])
  103. let overlapFailure: unknown
  104. try {
  105. await vi.waitFor(() => { expect(arrivals).toBe(2) }, { timeout: 5_000 })
  106. } catch (error) {
  107. overlapFailure = error
  108. } finally {
  109. gate.resolve(undefined)
  110. }
  111. const results = await starts
  112. for (const result of results) {
  113. if (result.status === 'fulfilled') onTestFinished(() => result.value.dispose())
  114. }
  115. const rejected = results.find(result => result.status === 'rejected')
  116. if (rejected?.status === 'rejected') {
  117. const reason: unknown = rejected.reason
  118. throw reason
  119. }
  120. if (overlapFailure !== undefined) throw overlapFailure
  121. }, COLD_BOOT_TIMEOUT_MS)
  122. it('reloads the bootstrap modules row against its own Loader internal', async () => {
  123. const roster = ClientRoster.of([{ name: MODULES, inject: [], immediately: true }])
  124. const a = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false })
  125. onTestFinished(() => a.dispose())
  126. const b = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false })
  127. onTestFinished(() => b.dispose())
  128. const modulesA = a.ctx.modules
  129. const modulesB = b.ctx.modules
  130. expect(modulesA).not.toBe(modulesB)
  131. expect(modulesA).toBe(a.ctx.loader.internal)
  132. expect(modulesB).toBe(b.ctx.loader.internal)
  133. await a.reload(MODULES)
  134. expect(a.ctx.modules).toBe(modulesA)
  135. expect(b.ctx.modules).toBe(modulesB)
  136. })
  137. it('boots the api subset without a mount and exposes the typed Remote', async () => {
  138. const client = await started({ roster: API_ROSTER })
  139. expect(client.container).toBeUndefined()
  140. expect(client.ctx.remote.session).toBeDefined()
  141. expect(client.connection.state.getSnapshot()).toBe('connected')
  142. })
  143. it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => {
  144. const before = document.body.childElementCount
  145. await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true }))
  146. .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`')
  147. expect(document.body.childElementCount).toBe(before)
  148. })
  149. it('creates no mount element when the roster cannot be loaded', async () => {
  150. const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }])
  151. const before = document.body.childElementCount
  152. await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow()
  153. expect(document.body.childElementCount).toBe(before)
  154. })
  155. it('mounts into a caller-supplied element and leaves it in place on dispose', async () => {
  156. const host = document.createElement('main')
  157. document.body.appendChild(host)
  158. const mock = RemoteMock.create().load(remoteDefaultResponses)
  159. const client = await TestClient.start({ roster: webApp }, mock, { mount: host })
  160. expect(client.container).toBe(host)
  161. expect(host.childElementCount).toBeGreaterThan(0)
  162. await client.dispose()
  163. expect(document.body.contains(host)).toBe(true)
  164. host.remove()
  165. })
  166. it('boots with a provided row in place of the real plugin', async () => {
  167. const apply = vi.fn()
  168. const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true })
  169. expect(apply).toHaveBeenCalledOnce()
  170. expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true)
  171. })
  172. it('reload rebuilds the declaring entry; unload collapses it', async () => {
  173. const client = await started({ roster: webApp }, { mount: true })
  174. expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
  175. await client.reload(SIDEBAR)
  176. await client.flush()
  177. expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
  178. const cleanupStarted = Promise.withResolvers<undefined>()
  179. const releaseCleanup = Promise.withResolvers<undefined>()
  180. const sidebar = [...client.ctx.loader.entries()].find(entry => entry.options.name === SIDEBAR)!
  181. sidebar.fiber!.ctx.effect(() => async () => {
  182. cleanupStarted.resolve(undefined)
  183. await releaseCleanup.promise
  184. })
  185. let unloaded = false
  186. const unloading = client.unload(SIDEBAR).then(() => { unloaded = true })
  187. try {
  188. await cleanupStarted.promise
  189. await client.flush()
  190. expect(unloaded).toBe(false)
  191. } finally {
  192. releaseCleanup.resolve(undefined)
  193. await unloading
  194. }
  195. expect(unloaded).toBe(true)
  196. await client.flush()
  197. expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0)
  198. await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`)
  199. })
  200. it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => {
  201. const mock = RemoteMock.create()
  202. const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose())
  203. await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/)
  204. })
  205. it('waits through a carrier flap for the connection to become ready', async () => {
  206. let opens = 0
  207. const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => {
  208. opens += 1
  209. const first = opens === 1
  210. setTimeout(() => {
  211. if (first) stream.fail(new Error('flap'))
  212. // Branded on the Gateway side; the test mints a plain string.
  213. else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } })
  214. }, 20)
  215. })
  216. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  217. onTestFinished(() => client.dispose())
  218. expect(client.connection.state.getSnapshot()).toBe('connected')
  219. expect(mock.log.streams('$events')).toHaveLength(2)
  220. })
  221. it('reports the log when the connection never becomes ready', async () => {
  222. // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock
  223. // logs as unmatched, while $events never sends ready.
  224. const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller'])
  225. const mock = RemoteMock.create().stream('$events', openStream([]))
  226. await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 }))
  227. .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/)
  228. expect(globals.EventSource).toBeUndefined()
  229. })
  230. })