client-apply.client.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. /**
  2. * Connection plugin browser-half apply: ctx.connection handle mounting, mode
  3. * selection off the page URL, and single-consumer connection-loop ownership.
  4. */
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import {
  8. apply,
  9. type ClientTransportHooks,
  10. type ConnectionGenerationSource,
  11. type ConnectionHandle,
  12. } from '../src/client/index.ts'
  13. import { FixtureApiClient } from '../src/client/fixture.ts'
  14. import { WebApiClient } from '../src/client/web-api-client.ts'
  15. type Win = {
  16. location?: { hostname: string; search: string; origin?: string }
  17. __DSH_TRANSPORT__?: ClientTransportHooks
  18. }
  19. afterEach(() => {
  20. delete (globalThis as Win).location
  21. delete (globalThis as Win).__DSH_TRANSPORT__
  22. })
  23. class GenerationProbe {
  24. private readonly active = new Set<() => void>()
  25. readonly source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
  26. let settled = false
  27. const finish = (): void => {
  28. if (settled) return
  29. settled = true
  30. signal.removeEventListener('abort', finish)
  31. this.active.delete(finish)
  32. resolve()
  33. }
  34. this.active.add(finish)
  35. signal.addEventListener('abort', finish, { once: true })
  36. ready()
  37. if (signal.aborted) finish()
  38. })
  39. end(): void {
  40. for (const finish of [...this.active]) finish()
  41. }
  42. }
  43. function installGeneration(handle: ConnectionHandle): GenerationProbe {
  44. const probe = new GenerationProbe()
  45. handle.registerGenerationSource(probe.source)
  46. return probe
  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('requires one generation source and ignores a stale source disposer', async () => {
  75. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  76. const handle = await mount()
  77. const first = new GenerationProbe()
  78. const second = new GenerationProbe()
  79. expect(() => handle.start({})).toThrow('no generation source is registered')
  80. const unregisterFirst = handle.registerGenerationSource(first.source)
  81. expect(() => { handle.registerGenerationSource(second.source) })
  82. .toThrow('a generation source is already registered')
  83. unregisterFirst()
  84. const unregisterSecond = handle.registerGenerationSource(second.source)
  85. unregisterFirst()
  86. const loop = handle.start({})
  87. await vi.waitFor(() => {
  88. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  89. })
  90. unregisterSecond()
  91. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  92. loop.stop()
  93. })
  94. it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => {
  95. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  96. const handle = await mount()
  97. installGeneration(handle)
  98. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  99. const descriptions: Array<boolean | undefined> = []
  100. const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
  101. const stopDescription = handle.hostDescription.subscribe(() => {
  102. descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
  103. })
  104. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  105. // config omitted: the `config ?? {}` default arm is part of the surface.
  106. let connected = 0
  107. const loop = handle.start({ onConnected: () => { connected++ } })
  108. expect(() => handle.start({})).toThrow(/already owned by another consumer/)
  109. await vi.waitFor(() => {
  110. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  111. })
  112. loop.stop() // teardown must not throw; the fixture streams abort quietly
  113. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  114. expect(descriptions).toEqual([true, undefined])
  115. expect(connected).toBe(1)
  116. expect(errorSpy).toHaveBeenCalledTimes(2)
  117. stopThrowing()
  118. stopDescription()
  119. errorSpy.mockRestore()
  120. })
  121. it('allows a replacement owner and ignores the previous owner handle', async () => {
  122. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  123. const handle = await mount()
  124. const generation = installGeneration(handle)
  125. const first = handle.start({})
  126. await vi.waitFor(() => {
  127. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  128. })
  129. first.stop()
  130. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  131. const second = handle.start({})
  132. await vi.waitFor(() => {
  133. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  134. })
  135. first.stop()
  136. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  137. second.stop()
  138. generation.end()
  139. })
  140. it('does not announce a generation synchronously stopped by a description subscriber', async () => {
  141. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  142. const handle = await mount()
  143. installGeneration(handle)
  144. const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
  145. let sawDescription = false
  146. const stopDescription = handle.hostDescription.subscribe(() => {
  147. if (handle.hostDescription.getSnapshot() === undefined) return
  148. sawDescription = true
  149. owner.loop?.stop()
  150. })
  151. const connected = vi.fn()
  152. const loop = handle.start({ onConnected: connected })
  153. owner.loop = loop
  154. try {
  155. await vi.waitFor(() => { expect(sawDescription).toBe(true) })
  156. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  157. expect(connected).not.toHaveBeenCalled()
  158. } finally {
  159. stopDescription()
  160. loop.stop()
  161. }
  162. })
  163. it('retracts the host description while reconnecting and republishes the next generation', async () => {
  164. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  165. const handle = await mount()
  166. const generation = installGeneration(handle)
  167. const descriptions: Array<boolean | undefined> = []
  168. const reconnectSnapshots: Array<boolean | undefined> = []
  169. const stopDescription = handle.hostDescription.subscribe(() => {
  170. descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
  171. })
  172. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  173. const loop = handle.start({
  174. onStateChange: (state) => {
  175. if (state === 'reconnecting') {
  176. reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath)
  177. }
  178. },
  179. }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 })
  180. try {
  181. await vi.waitFor(() => {
  182. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  183. })
  184. generation.end()
  185. await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
  186. await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) })
  187. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  188. } finally {
  189. stopDescription()
  190. loop.stop()
  191. warnSpy.mockRestore()
  192. }
  193. })
  194. it('does not announce reconnecting after a description subscriber stops the loop', async () => {
  195. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  196. const handle = await mount()
  197. const generation = installGeneration(handle)
  198. const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
  199. let stoppedOnRetraction = false
  200. const stopDescription = handle.hostDescription.subscribe(() => {
  201. if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return
  202. stoppedOnRetraction = true
  203. owner.loop.stop()
  204. })
  205. const states: string[] = []
  206. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  207. const loop = handle.start({
  208. onStateChange: (state) => { states.push(state) },
  209. }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 })
  210. owner.loop = loop
  211. try {
  212. await vi.waitFor(() => {
  213. expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
  214. })
  215. generation.end()
  216. await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) })
  217. expect(handle.hostDescription.getSnapshot()).toBeUndefined()
  218. expect(states).toEqual(['connected'])
  219. } finally {
  220. stopDescription()
  221. loop.stop()
  222. warnSpy.mockRestore()
  223. }
  224. })
  225. it('WebApiClient keeps unary calls on globalThis.fetch', async () => {
  226. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  227. const handle = await mount()
  228. const original = globalThis.fetch
  229. const seen: string[] = []
  230. globalThis.fetch = (input: URL | RequestInfo) => {
  231. seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
  232. return Promise.resolve(new Response('{}', { status: 200 }))
  233. }
  234. try {
  235. // Schema rejection is fine — the transport hop is the assertion.
  236. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
  237. } finally {
  238. globalThis.fetch = original
  239. }
  240. expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
  241. })
  242. it('carries RPC calls without requiring secure-context randomUUID', async () => {
  243. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  244. vi.stubGlobal('crypto', {
  245. getRandomValues(bytes: Uint8Array) {
  246. return bytes.fill(0)
  247. },
  248. })
  249. const handle = await mount()
  250. const original = globalThis.fetch
  251. const seen: { url: string; body: unknown }[] = []
  252. globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
  253. const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
  254. if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
  255. const body = JSON.parse(init.body) as { rpcId: string }
  256. seen.push({ url, body })
  257. return Response.json({
  258. type: 'server-response',
  259. rpcId: body.rpcId,
  260. result: { ok: true, value: { ref: 'goal-1' } },
  261. })
  262. }
  263. try {
  264. await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
  265. .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
  266. } finally {
  267. globalThis.fetch = original
  268. vi.unstubAllGlobals()
  269. }
  270. expect(seen).toHaveLength(1)
  271. expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
  272. expect(seen[0]?.body).toMatchObject({
  273. type: 'client-request',
  274. rpcId: '00000000-0000-4000-8000-000000000000',
  275. method: 'goals/create',
  276. payload: { args: { agentId: 'agent-1' } },
  277. })
  278. })
  279. it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
  280. ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
  281. const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
  282. (endpoint, payload, signal) => (async function *(): AsyncGenerator {
  283. signal.throwIfAborted()
  284. yield { endpoint, payload }
  285. })(),
  286. )
  287. ;(globalThis as Win).__DSH_TRANSPORT__ = {
  288. createApiClient: () => new FixtureApiClient(),
  289. fetch: vi.fn<ClientTransportHooks['fetch']>(),
  290. openStream,
  291. ownsHost: true,
  292. }
  293. const handle = await mount()
  294. const abort = new AbortController()
  295. const open = handle.rpc.open
  296. if (open === undefined) throw new Error('worker-local stream carrier was not installed')
  297. const values = []
  298. for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) {
  299. values.push(value)
  300. }
  301. expect(values).toEqual([{
  302. endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
  303. }])
  304. expect(openStream).toHaveBeenCalledWith(
  305. 'session/follow',
  306. { args: { sessionId: 'session-1' } },
  307. abort.signal,
  308. )
  309. expect(handle.isLoopback).toBe(true)
  310. expect(() => open('/rpc', 'session/follow', {}, abort.signal))
  311. .toThrow('worker-local streams require the /api channel')
  312. expect(() => open('/api/path', 'session/follow', {}, abort.signal))
  313. .toThrow('invalid RPC target')
  314. })
  315. it('validates generic RPC transport failures, correlation, and targets', async () => {
  316. ;(globalThis as Win).location = {
  317. hostname: 'harness.example', search: '', origin: 'https://harness.example',
  318. }
  319. const handle = await mount()
  320. const original = globalThis.fetch
  321. const abort = new AbortController()
  322. globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
  323. try {
  324. await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
  325. .rejects.toThrow('HTTP 503')
  326. expect(globalThis.fetch).toHaveBeenCalledWith(
  327. new URL('https://harness.example/api/goals/create'),
  328. expect.objectContaining({ signal: abort.signal }),
  329. )
  330. ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
  331. globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
  332. type: 'server-response',
  333. rpcId: 'different-rpc',
  334. result: { ok: true, value: null },
  335. }))
  336. await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
  337. const fetch = vi.mocked(globalThis.fetch)
  338. expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
  339. expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
  340. const respond = (result: unknown): void => {
  341. globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => {
  342. if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body')
  343. const request = JSON.parse(init.body) as { rpcId: string }
  344. return Response.json({ type: 'server-response', rpcId: request.rpcId, result })
  345. }
  346. }
  347. for (const envelope of [
  348. null,
  349. { type: 'other', rpcId: 'rpc', result: { ok: true } },
  350. { type: 'server-response', rpcId: 1, result: { ok: true } },
  351. ]) {
  352. globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope))
  353. await expect(handle.rpc.call('/api', 'goals/create', {}))
  354. .rejects.toThrow('invalid server-response envelope')
  355. }
  356. respond(null)
  357. await expect(handle.rpc.call('/api', 'goals/create', {}))
  358. .rejects.toThrow('invalid server-response result')
  359. respond({ ok: 'yes' })
  360. await expect(handle.rpc.call('/api', 'goals/create', {}))
  361. .rejects.toThrow('invalid server-response result')
  362. respond({ ok: false, error: null })
  363. await expect(handle.rpc.call('/api', 'goals/create', {}))
  364. .rejects.toThrow('invalid server-response result')
  365. for (const error of [
  366. { code: 1, message: 'failed', details: {} },
  367. { code: 'failed', message: 1, details: {} },
  368. { code: 'failed', message: 'failed', details: [] },
  369. ]) {
  370. respond({ ok: false, error })
  371. await expect(handle.rpc.call('/api', 'goals/create', {}))
  372. .rejects.toThrow('invalid server-response failure')
  373. }
  374. respond({
  375. ok: false,
  376. error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
  377. })
  378. await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({
  379. ok: false,
  380. error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
  381. })
  382. } finally {
  383. globalThis.fetch = original
  384. }
  385. for (const [channel, endpoint] of [
  386. ['api2', 'goals/create'],
  387. ['/api/path', 'goals/create'],
  388. ['/api', ''],
  389. ['/api', '.'],
  390. ['/api', '..'],
  391. ['/api', 'goals//create'],
  392. ['/api', 'goals/create?unsafe'],
  393. ] as const) {
  394. await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
  395. }
  396. })
  397. it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
  398. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  399. const handle = await mount()
  400. const created = await handle.rpc.call('/api', 'goals/create', {
  401. args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
  402. })
  403. expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
  404. if (!created.ok) throw new Error('fixture Goal create failed')
  405. const ref = (created.value as { ref: { id: string; revision: number } }).ref
  406. const edited = await handle.rpc.call('/api', 'goals/edit', {
  407. args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
  408. })
  409. expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
  410. const editedRef = { id: ref.id, revision: 2 }
  411. const paused = await handle.rpc.call('/api', 'goals/pause', {
  412. args: { agentId: 'fx-alpha', ref: editedRef },
  413. })
  414. expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
  415. const resumed = await handle.rpc.call('/api', 'goals/resume', {
  416. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
  417. })
  418. expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
  419. const completed = await handle.rpc.call('/api', 'goals/complete', {
  420. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
  421. })
  422. expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
  423. await expect(handle.rpc.call('/api', 'goals/clear', {
  424. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
  425. })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
  426. await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
  427. await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
  428. .rejects.toThrow(/endpoint.*unavailable/)
  429. })
  430. })