client-apply.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * Connection plugin browser-half apply: ctx.connection handle mounting, mode
  3. * selection off the page URL, and the single-consumer stream-loop ownership.
  4. */
  5. import { Context } from 'cordis'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { apply, type ConnectionHandle } from '../src/client/index.ts'
  8. import type { RpcMessage } from '../src/client/api.ts'
  9. import { RpcId } from '../src/client/api.ts'
  10. import { FixtureApiClient } from '../src/client/fixture.ts'
  11. import { WebApiClient } from '../src/client/web-api-client.ts'
  12. type Win = { location?: { hostname: string; search: string; origin?: string } }
  13. type WebSocketGlobal = { WebSocket?: typeof WebSocket }
  14. const originalWebSocket = globalThis.WebSocket
  15. const sockets: FakeWebSocket[] = []
  16. class FakeWebSocket extends EventTarget {
  17. static readonly CONNECTING = 0
  18. static readonly OPEN = 1
  19. static readonly CLOSING = 2
  20. static readonly CLOSED = 3
  21. readonly url: string
  22. readyState = FakeWebSocket.CONNECTING
  23. constructor(url: string | URL) {
  24. super()
  25. this.url = String(url)
  26. sockets.push(this)
  27. queueMicrotask(() => {
  28. if (this.readyState !== FakeWebSocket.CONNECTING) return
  29. this.readyState = FakeWebSocket.OPEN
  30. this.dispatchEvent(new Event('open'))
  31. })
  32. }
  33. close(): void {
  34. if (this.readyState === FakeWebSocket.CLOSED) return
  35. this.readyState = FakeWebSocket.CLOSED
  36. this.dispatchEvent(new Event('close'))
  37. }
  38. receive(data: unknown): void {
  39. this.dispatchEvent(new MessageEvent('message', { data }))
  40. }
  41. }
  42. afterEach(() => {
  43. delete (globalThis as Win).location
  44. sockets.length = 0
  45. if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
  46. else globalThis.WebSocket = originalWebSocket
  47. })
  48. async function mount(): Promise<ConnectionHandle> {
  49. const ctx = new Context()
  50. await ctx.plugin({ apply, inject: [] })
  51. const handle = ctx.get('connection') as ConnectionHandle | undefined
  52. if (handle === undefined) throw new Error('ctx.connection not provided')
  53. return handle
  54. }
  55. describe('connection client apply', () => {
  56. it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
  57. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  58. const handle = await mount()
  59. expect(handle.api).toBeInstanceOf(WebApiClient)
  60. expect(handle.isLoopback).toBe(true)
  61. })
  62. it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
  63. ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
  64. expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
  65. delete (globalThis as Win).location
  66. const handle = await mount()
  67. expect(handle.api).toBeInstanceOf(WebApiClient)
  68. expect(handle.isLoopback).toBe(true)
  69. })
  70. it('reports non-loopback page authority through the connection handle', async () => {
  71. ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
  72. expect((await mount()).isLoopback).toBe(false)
  73. })
  74. it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
  75. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  76. const handle = await mount()
  77. // config omitted: the `config ?? {}` default arm is part of the surface.
  78. const loop = handle.start({})
  79. expect(() => handle.start({})).toThrow(/already owned by another consumer/)
  80. loop.stop() // teardown must not throw; the fixture streams abort quietly
  81. })
  82. it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
  83. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  84. const handle = await mount()
  85. const original = globalThis.fetch
  86. const seen: string[] = []
  87. globalThis.fetch = (input: URL | RequestInfo) => {
  88. seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
  89. return Promise.resolve(new Response('{}', { status: 200 }))
  90. }
  91. try {
  92. // Schema rejection is fine — the transport hop is the assertion.
  93. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
  94. await handle.api.respond({
  95. type: 'client-response',
  96. rpcId: RpcId('response-over-http'),
  97. result: { ok: true, value: {} },
  98. }).catch(() => undefined)
  99. } finally {
  100. globalThis.fetch = original
  101. }
  102. expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
  103. expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
  104. })
  105. it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
  106. ;(globalThis as Win).location = {
  107. hostname: 'localhost', search: '', origin: 'http://localhost:3080',
  108. }
  109. ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
  110. const fetch = vi.spyOn(globalThis, 'fetch')
  111. const client = (await mount()).api as WebApiClient
  112. const envelopes: RpcMessage[][] = []
  113. client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
  114. const opened: string[] = []
  115. const muxAbort = new AbortController()
  116. const hostAbort = new AbortController()
  117. const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
  118. const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
  119. const muxFrame = mux.next()
  120. const hostFrame = host.next()
  121. await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
  122. expect(sockets.map(socket => socket.url)).toEqual([
  123. 'ws://localhost:3080/api/events.mux',
  124. 'ws://localhost:3080/api/events.host',
  125. ])
  126. await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
  127. const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
  128. sockets[0]!.receive(new Uint8Array([1, 2, 3]))
  129. sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
  130. sockets[0]!.receive(JSON.stringify({
  131. type: 'server-request',
  132. rpcId: 'mux-browser',
  133. method: 'session/subscribed',
  134. payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
  135. }))
  136. sockets[1]!.receive(JSON.stringify({
  137. type: 'server-request',
  138. rpcId: 'host-browser',
  139. method: 'host/commands-changed',
  140. payload: { type: 'host/commands-changed' },
  141. }))
  142. expect(await muxFrame).toMatchObject({
  143. value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
  144. })
  145. expect(await hostFrame).toMatchObject({
  146. value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
  147. })
  148. expect(errors).toHaveBeenCalledTimes(2)
  149. await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
  150. expect(fetch).not.toHaveBeenCalled()
  151. const muxEnd = mux.next()
  152. const hostEnd = host.next()
  153. muxAbort.abort()
  154. hostAbort.abort()
  155. await expect(muxEnd).resolves.toMatchObject({ done: true })
  156. await expect(hostEnd).resolves.toMatchObject({ done: true })
  157. expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
  158. errors.mockRestore()
  159. fetch.mockRestore()
  160. })
  161. it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
  162. ;(globalThis as Win).location = {
  163. hostname: 'harness.example', search: '', origin: 'https://harness.example',
  164. }
  165. ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
  166. const client = (await mount()).api
  167. const abort = new AbortController()
  168. const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
  169. const pending = iterator.next()
  170. await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
  171. abort.abort()
  172. await expect(pending).resolves.toMatchObject({ done: true })
  173. })
  174. it('closes a WebSocket immediately when its signal was already aborted', async () => {
  175. ;(globalThis as Win).location = {
  176. hostname: 'localhost', search: '', origin: 'http://localhost:3080',
  177. }
  178. ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
  179. const client = (await mount()).api
  180. const abort = new AbortController()
  181. abort.abort()
  182. const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
  183. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  184. expect(sockets).toHaveLength(1)
  185. expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
  186. })
  187. })