assembly-test-client-node.client.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */
  2. import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client'
  3. import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  4. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  5. import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
  6. /** The Gateway client and what it injects: the Typert registry and the Connection. */
  7. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  8. const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry'])
  9. const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks }
  10. describe('TestClient (node environment)', () => {
  11. it('boots the api roster, connects, and flushes without a DOM', async () => {
  12. expect(typeof document).toBe('undefined')
  13. const mock = RemoteMock.create().load(remoteDefaultResponses)
  14. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  15. onTestFinished(() => client.dispose())
  16. expect(client.connection.state.getSnapshot()).toBe('connected')
  17. await client.flush()
  18. }, 60_000)
  19. it('closes the client even when the plugin tree fails to dispose, and still rethrows', async () => {
  20. const mock = RemoteMock.create().load(remoteDefaultResponses)
  21. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  22. const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
  23. const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
  24. await dispose()
  25. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures.
  26. return Promise.reject('teardown boom')
  27. })
  28. onTestFinished(async () => {
  29. failure.mockRestore()
  30. try { await dispose() } finally { await client.dispose() }
  31. })
  32. await expect(client.dispose()).rejects.toThrow('teardown boom')
  33. expect(globals.__DSH_TRANSPORT__).toBeUndefined()
  34. expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
  35. await client.dispose() // idempotent after a failed teardown
  36. })
  37. it('rethrows a row that fails to apply and lets the next client boot', async () => {
  38. const failing = { apply(): void { throw new Error('apply boom') } }
  39. await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create()))
  40. .rejects.toThrow(/apply boom|typert-registry/)
  41. const mock = RemoteMock.create().load(remoteDefaultResponses)
  42. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  43. onTestFinished(() => client.dispose())
  44. expect(client.connection.state.getSnapshot()).toBe('connected')
  45. })
  46. it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => {
  47. const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
  48. await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create()))
  49. .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote.<ns> services are the tier\'s proxies')
  50. })
  51. it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => {
  52. const mock = RemoteMock.create().load(remoteDefaultResponses)
  53. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  54. const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
  55. const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
  56. await dispose()
  57. throw new Error('teardown boom')
  58. })
  59. onTestFinished(async () => {
  60. failure.mockRestore()
  61. try { await dispose() } finally { await client.dispose() }
  62. })
  63. await mock.dispatch('nowhere/call', []).catch(() => undefined)
  64. await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/)
  65. expect(globals.__DSH_TRANSPORT__).toBeUndefined()
  66. expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
  67. })
  68. it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => {
  69. const mock = RemoteMock.create().load(remoteDefaultResponses)
  70. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  71. const first = client.dispose()
  72. const second = client.dispose()
  73. await second
  74. expect(globals.__DSH_TRANSPORT__).toBeUndefined()
  75. await first
  76. })
  77. it('refuses to mount without a DOM', async () => {
  78. await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true }))
  79. .rejects.toThrow('mount requires a DOM')
  80. })
  81. it('fails loud when the roster cannot provide a connection', async () => {
  82. await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create()))
  83. .rejects.toThrow('provides no `connection` service')
  84. })
  85. it('skips readiness on request and leaves a pre-existing page transport untouched', async () => {
  86. const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) }
  87. globals.__DSH_TRANSPORT__ = previous
  88. onTestFinished(() => { delete globals.__DSH_TRANSPORT__ })
  89. const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false })
  90. expect(globals.__DSH_TRANSPORT__).toBe(previous)
  91. expect(client.ctx.get('typert')).toBeDefined()
  92. expect(() => client.connection).toThrow('provides no `connection` service')
  93. await client.dispose()
  94. expect(globals.__DSH_TRANSPORT__).toBe(previous)
  95. })
  96. })