client-apply.client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  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. type ConnectionState,
  13. } from '../src/client/index.ts'
  14. type Win = {
  15. location?: { hostname: string; search: string; origin?: string }
  16. __DSH_TRANSPORT__?: ClientTransportHooks
  17. }
  18. afterEach(() => {
  19. delete (globalThis as Win).location
  20. delete (globalThis as Win).__DSH_TRANSPORT__
  21. vi.unstubAllGlobals()
  22. vi.useRealTimers()
  23. })
  24. class BrowserNetworkProbe extends EventTarget {
  25. readonly navigator = { onLine: true }
  26. setOnline(online: boolean): void {
  27. this.navigator.onLine = online
  28. this.dispatchEvent(new Event(online ? 'online' : 'offline'))
  29. }
  30. }
  31. class GenerationProbe {
  32. private readonly active = new Set<() => void>()
  33. readonly source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
  34. let settled = false
  35. const finish = (): void => {
  36. if (settled) return
  37. settled = true
  38. signal.removeEventListener('abort', finish)
  39. this.active.delete(finish)
  40. resolve()
  41. }
  42. this.active.add(finish)
  43. signal.addEventListener('abort', finish, { once: true })
  44. ready({ home: '/h' })
  45. if (signal.aborted) finish()
  46. })
  47. end(): void {
  48. for (const finish of [...this.active]) finish()
  49. }
  50. }
  51. function installGeneration(handle: ConnectionHandle): GenerationProbe {
  52. const probe = new GenerationProbe()
  53. handle.registerGenerationSource(probe.source)
  54. return probe
  55. }
  56. async function mount(): Promise<ConnectionHandle> {
  57. const ctx = new Context()
  58. await ctx.plugin({ apply, inject: [] })
  59. const handle = ctx.get('connection') as ConnectionHandle | undefined
  60. if (handle === undefined) throw new Error('ctx.connection not provided')
  61. return handle
  62. }
  63. describe('connection client apply', () => {
  64. it('uses Host bootstrap timing when Gateway starts without overrides', async () => {
  65. vi.useFakeTimers()
  66. vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', {
  67. backoffBaseMs: 10, backoffMaxMs: 10, generationReadyTimeoutMs: 20,
  68. })
  69. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  70. const handle = await mount()
  71. const signals: AbortSignal[] = []
  72. handle.registerGenerationSource(signal => new Promise<void>((resolve) => {
  73. signals.push(signal)
  74. signal.addEventListener('abort', () => { resolve() }, { once: true })
  75. }))
  76. const loop = handle.start({})
  77. try {
  78. await vi.advanceTimersByTimeAsync(20)
  79. expect(signals[0]?.aborted).toBe(true)
  80. expect(handle.state.getSnapshot()).toBe('connecting')
  81. await vi.advanceTimersByTimeAsync(10)
  82. expect(signals).toHaveLength(2)
  83. } finally {
  84. loop.stop()
  85. await vi.advanceTimersByTimeAsync(0)
  86. warnSpy.mockRestore()
  87. }
  88. })
  89. it.each([{ generationReadyTimeoutMs: 0 }, { backoffFactor: NaN }])('rejects malformed bootstrap recovery before publishing the service: %j', (recovery) => {
  90. vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', recovery)
  91. const ctx = new Context()
  92. expect(() => { apply(ctx) }).toThrow()
  93. expect(ctx.get('connection')).toBeUndefined()
  94. })
  95. it('rejects a NaN start override without acquiring the generation source', async () => {
  96. const handle = await mount()
  97. const source = vi.fn<ConnectionGenerationSource>()
  98. const unregister = handle.registerGenerationSource(source)
  99. try {
  100. expect(() => handle.start({}, { backoffFactor: NaN })).toThrow(/backoffFactor.*finite/)
  101. expect(source).not.toHaveBeenCalled()
  102. } finally {
  103. unregister()
  104. }
  105. })
  106. it('treats a runtime without browser location as local', async () => {
  107. delete (globalThis as Win).location
  108. expect((await mount()).isLoopback).toBe(true)
  109. })
  110. it('mounts ctx.connection and identifies a loopback page', async () => {
  111. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  112. const handle = await mount()
  113. expect(handle.isLoopback).toBe(true)
  114. })
  115. it('selects the fixture RPC transport under ?fixture', async () => {
  116. ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
  117. const handle = await mount()
  118. await expect(handle.rpc.call('/api', 'settings/describe', { args: {} }))
  119. .resolves.toMatchObject({ ok: true })
  120. })
  121. it('reports non-loopback page authority through the connection handle', async () => {
  122. ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
  123. expect((await mount()).isLoopback).toBe(false)
  124. })
  125. it('requires one generation source and ignores a stale source disposer', async () => {
  126. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  127. const handle = await mount()
  128. const first = new GenerationProbe()
  129. const second = new GenerationProbe()
  130. expect(() => handle.start({})).toThrow('no generation source is registered')
  131. const unregisterFirst = handle.registerGenerationSource(first.source)
  132. expect(() => { handle.registerGenerationSource(second.source) })
  133. .toThrow('a generation source is already registered')
  134. unregisterFirst()
  135. const unregisterSecond = handle.registerGenerationSource(second.source)
  136. unregisterFirst()
  137. const loop = handle.start({})
  138. await vi.waitFor(() => {
  139. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  140. })
  141. unregisterSecond()
  142. expect(handle.generation.getSnapshot()).toBeUndefined()
  143. loop.stop()
  144. })
  145. it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => {
  146. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  147. const handle = await mount()
  148. installGeneration(handle)
  149. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  150. const generations: Array<string | undefined> = []
  151. const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') })
  152. const stopGeneration = handle.generation.subscribe(() => {
  153. generations.push(handle.generation.getSnapshot()?.host.home)
  154. })
  155. expect(handle.generation.getSnapshot()).toBeUndefined()
  156. let connected = 0
  157. const loop = handle.start({ onConnected: () => { connected++ } })
  158. expect(() => handle.start({})).toThrow(/already owned by another consumer/)
  159. await vi.waitFor(() => {
  160. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  161. })
  162. loop.stop() // teardown must not throw; the fixture streams abort quietly
  163. expect(handle.generation.getSnapshot()).toBeUndefined()
  164. expect(generations).toEqual(['/h', undefined])
  165. expect(connected).toBe(1)
  166. expect(errorSpy).toHaveBeenCalledTimes(2)
  167. stopThrowing()
  168. stopGeneration()
  169. errorSpy.mockRestore()
  170. })
  171. it('does not notify state subscribers when a pre-ready loop stops', async () => {
  172. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  173. const handle = await mount()
  174. handle.registerGenerationSource(signal => new Promise<void>((resolve) => {
  175. signal.addEventListener('abort', () => { resolve() }, { once: true })
  176. }))
  177. const listener = vi.fn()
  178. const unsubscribe = handle.state.subscribe(listener)
  179. const loop = handle.start({})
  180. loop.stop()
  181. expect(handle.state.getSnapshot()).toBeUndefined()
  182. expect(listener).not.toHaveBeenCalled()
  183. unsubscribe()
  184. })
  185. it('allows a replacement owner and ignores the previous owner handle', async () => {
  186. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  187. const handle = await mount()
  188. const generation = installGeneration(handle)
  189. const first = handle.start({})
  190. await vi.waitFor(() => {
  191. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  192. })
  193. first.stop()
  194. expect(handle.generation.getSnapshot()).toBeUndefined()
  195. const second = handle.start({})
  196. await vi.waitFor(() => {
  197. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  198. })
  199. first.stop()
  200. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  201. second.stop()
  202. generation.end()
  203. })
  204. it('lets the connection service force only its current owner to reconnect', async () => {
  205. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  206. const handle = await mount()
  207. installGeneration(handle)
  208. const requested = vi.fn()
  209. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  210. const loop = handle.start({ onReconnectRequested: requested }, {
  211. backoffBaseMs: 60_000,
  212. backoffFactor: 2,
  213. backoffMaxMs: 120_000,
  214. generationReadyTimeoutMs: 500,
  215. })
  216. try {
  217. await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(1) })
  218. handle.reconnect()
  219. await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(2) })
  220. expect(requested).toHaveBeenCalledOnce()
  221. loop.stop()
  222. handle.reconnect()
  223. expect(requested).toHaveBeenCalledOnce()
  224. } finally {
  225. loop.stop()
  226. warnSpy.mockRestore()
  227. }
  228. })
  229. it('ignores a non-browser window shim without navigator state', async () => {
  230. vi.stubGlobal('window', new EventTarget())
  231. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  232. const handle = await mount()
  233. installGeneration(handle)
  234. const loop = handle.start({})
  235. try {
  236. await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') })
  237. } finally {
  238. loop.stop()
  239. }
  240. })
  241. it('feeds browser offline and online events into the owned retry loop', async () => {
  242. vi.useFakeTimers()
  243. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  244. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  245. const browser = new BrowserNetworkProbe()
  246. vi.stubGlobal('window', browser)
  247. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  248. const handle = await mount()
  249. let calls = 0
  250. const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
  251. calls++
  252. ready({ home: '/h' })
  253. signal.addEventListener('abort', () => { resolve() }, { once: true })
  254. })
  255. handle.registerGenerationSource(source)
  256. const states: Array<ConnectionState | undefined> = []
  257. const unsubscribe = handle.state.subscribe(() => { states.push(handle.state.getSnapshot()) })
  258. const loop = handle.start({}, {
  259. backoffBaseMs: 100,
  260. backoffFactor: 2,
  261. backoffMaxMs: 1_000,
  262. generationReadyTimeoutMs: 500,
  263. })
  264. try {
  265. await vi.advanceTimersByTimeAsync(0)
  266. expect(handle.state.getSnapshot()).toBe('connected')
  267. expect(calls).toBe(1)
  268. browser.setOnline(false)
  269. expect(handle.state.getSnapshot()).toBe('disconnected')
  270. await vi.advanceTimersByTimeAsync(10_000)
  271. expect(calls).toBe(1)
  272. browser.setOnline(true)
  273. expect(handle.state.getSnapshot()).toBe('connecting')
  274. await vi.advanceTimersByTimeAsync(49)
  275. expect(calls).toBe(1)
  276. await vi.advanceTimersByTimeAsync(1)
  277. expect(calls).toBe(2)
  278. expect(handle.state.getSnapshot()).toBe('connected')
  279. expect(states).toEqual(['connected', 'disconnected', 'connecting', 'connected'])
  280. } finally {
  281. unsubscribe()
  282. loop.stop()
  283. randomSpy.mockRestore()
  284. warnSpy.mockRestore()
  285. }
  286. })
  287. it('does not announce a generation synchronously stopped by a generation subscriber', async () => {
  288. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  289. const handle = await mount()
  290. installGeneration(handle)
  291. const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
  292. let sawGeneration = false
  293. const stopGeneration = handle.generation.subscribe(() => {
  294. if (handle.generation.getSnapshot() === undefined) return
  295. sawGeneration = true
  296. owner.loop?.stop()
  297. })
  298. const connected = vi.fn()
  299. const loop = handle.start({ onConnected: connected })
  300. owner.loop = loop
  301. try {
  302. await vi.waitFor(() => { expect(sawGeneration).toBe(true) })
  303. expect(handle.generation.getSnapshot()).toBeUndefined()
  304. expect(connected).not.toHaveBeenCalled()
  305. } finally {
  306. stopGeneration()
  307. loop.stop()
  308. }
  309. })
  310. it('retracts the generation while connecting and publishes the next generation', async () => {
  311. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  312. const handle = await mount()
  313. const generation = installGeneration(handle)
  314. const generations: Array<string | undefined> = []
  315. const reconnectSnapshots: Array<string | undefined> = []
  316. const stopGeneration = handle.generation.subscribe(() => {
  317. generations.push(handle.generation.getSnapshot()?.host.home)
  318. })
  319. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  320. const loop = handle.start({
  321. onStateChange: (state) => {
  322. if (state === 'connecting') {
  323. reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home)
  324. }
  325. },
  326. }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 })
  327. try {
  328. await vi.waitFor(() => {
  329. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  330. })
  331. generation.end()
  332. await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
  333. await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) })
  334. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  335. } finally {
  336. stopGeneration()
  337. loop.stop()
  338. warnSpy.mockRestore()
  339. }
  340. })
  341. it('publishes connection state directly on the service and isolates subscribers', async () => {
  342. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  343. const handle = await mount()
  344. const generation = installGeneration(handle)
  345. const snapshots: Array<ConnectionState | undefined> = []
  346. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  347. const unsubscribe = handle.state.subscribe(() => { snapshots.push(handle.state.getSnapshot()) })
  348. const stopThrowing = handle.state.subscribe(() => { throw new Error('state subscriber failed') })
  349. expect(handle.state.getSnapshot()).toBeUndefined()
  350. const loop = handle.start({}, {
  351. backoffBaseMs: 10,
  352. backoffFactor: 2,
  353. backoffMaxMs: 80,
  354. generationReadyTimeoutMs: 500,
  355. })
  356. try {
  357. await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') })
  358. const connected = handle.state.getSnapshot()
  359. expect(handle.state.getSnapshot()).toBe(connected)
  360. generation.end()
  361. await vi.waitFor(() => {
  362. expect(snapshots).toEqual([
  363. 'connected',
  364. 'connecting',
  365. 'connected',
  366. ])
  367. })
  368. expect(errorSpy).toHaveBeenCalledWith('[connection] state listener threw:', expect.any(Error))
  369. } finally {
  370. unsubscribe()
  371. stopThrowing()
  372. loop.stop()
  373. errorSpy.mockRestore()
  374. }
  375. expect(handle.state.getSnapshot()).toBeUndefined()
  376. })
  377. it('does not announce disconnection after a generation subscriber stops the loop', async () => {
  378. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  379. const handle = await mount()
  380. const generation = installGeneration(handle)
  381. const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
  382. let stoppedOnRetraction = false
  383. const stopGeneration = handle.generation.subscribe(() => {
  384. if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return
  385. stoppedOnRetraction = true
  386. owner.loop.stop()
  387. })
  388. const states: ConnectionState[] = []
  389. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  390. const loop = handle.start({
  391. onStateChange: (state) => { states.push(state) },
  392. }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 })
  393. owner.loop = loop
  394. try {
  395. await vi.waitFor(() => {
  396. expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
  397. })
  398. generation.end()
  399. await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) })
  400. expect(handle.generation.getSnapshot()).toBeUndefined()
  401. expect(states).toEqual(['connected'])
  402. } finally {
  403. stopGeneration()
  404. loop.stop()
  405. warnSpy.mockRestore()
  406. }
  407. })
  408. it('carries RPC calls without requiring secure-context randomUUID', async () => {
  409. ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
  410. vi.stubGlobal('crypto', {
  411. getRandomValues(bytes: Uint8Array) {
  412. return bytes.fill(0)
  413. },
  414. })
  415. const handle = await mount()
  416. const original = globalThis.fetch
  417. const seen: { url: string; body: unknown }[] = []
  418. globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
  419. const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
  420. if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
  421. const body = JSON.parse(init.body) as { rpcId: string }
  422. seen.push({ url, body })
  423. return Response.json({
  424. type: 'server-response',
  425. rpcId: body.rpcId,
  426. result: { ok: true, value: { ref: 'goal-1' } },
  427. })
  428. }
  429. try {
  430. await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
  431. .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
  432. } finally {
  433. globalThis.fetch = original
  434. vi.unstubAllGlobals()
  435. }
  436. expect(seen).toHaveLength(1)
  437. expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
  438. expect(seen[0]?.body).toMatchObject({
  439. type: 'client-request',
  440. rpcId: '00000000-0000-4000-8000-000000000000',
  441. method: 'goals/create',
  442. payload: { args: { agentId: 'agent-1' } },
  443. })
  444. })
  445. it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
  446. ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
  447. const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
  448. (endpoint, payload, signal) => (async function *(): AsyncGenerator {
  449. signal.throwIfAborted()
  450. yield { endpoint, payload }
  451. })(),
  452. )
  453. ;(globalThis as Win).__DSH_TRANSPORT__ = {
  454. fetch: vi.fn<ClientTransportHooks['fetch']>(),
  455. openStream,
  456. ownsHost: true,
  457. }
  458. const handle = await mount()
  459. const abort = new AbortController()
  460. const open = handle.rpc.open
  461. if (open === undefined) throw new Error('worker-local stream carrier was not installed')
  462. const values = []
  463. for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) {
  464. values.push(value)
  465. }
  466. expect(values).toEqual([{
  467. endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
  468. }])
  469. expect(openStream).toHaveBeenCalledWith(
  470. 'session/follow',
  471. { args: { sessionId: 'session-1' } },
  472. abort.signal,
  473. )
  474. expect(handle.isLoopback).toBe(true)
  475. expect(() => open('/rpc', 'session/follow', {}, abort.signal))
  476. .toThrow('worker-local streams require the /api channel')
  477. expect(() => open('/api/path', 'session/follow', {}, abort.signal))
  478. .toThrow('invalid RPC target')
  479. })
  480. it('validates generic RPC transport failures, correlation, and targets', async () => {
  481. ;(globalThis as Win).location = {
  482. hostname: 'harness.example', search: '', origin: 'https://harness.example',
  483. }
  484. const handle = await mount()
  485. const original = globalThis.fetch
  486. const abort = new AbortController()
  487. globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
  488. try {
  489. await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
  490. .rejects.toThrow('HTTP 503')
  491. expect(globalThis.fetch).toHaveBeenCalledWith(
  492. new URL('https://harness.example/api/goals/create'),
  493. expect.objectContaining({ signal: abort.signal }),
  494. )
  495. ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
  496. globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
  497. type: 'server-response',
  498. rpcId: 'different-rpc',
  499. result: { ok: true, value: null },
  500. }))
  501. await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
  502. const fetch = vi.mocked(globalThis.fetch)
  503. expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
  504. expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
  505. const respond = (result: unknown): void => {
  506. globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => {
  507. if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body')
  508. const request = JSON.parse(init.body) as { rpcId: string }
  509. return Response.json({ type: 'server-response', rpcId: request.rpcId, result })
  510. }
  511. }
  512. for (const envelope of [
  513. null,
  514. { type: 'other', rpcId: 'rpc', result: { ok: true } },
  515. { type: 'server-response', rpcId: 1, result: { ok: true } },
  516. ]) {
  517. globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope))
  518. await expect(handle.rpc.call('/api', 'goals/create', {}))
  519. .rejects.toThrow('invalid server-response envelope')
  520. }
  521. respond(null)
  522. await expect(handle.rpc.call('/api', 'goals/create', {}))
  523. .rejects.toThrow('invalid server-response result')
  524. respond({ ok: 'yes' })
  525. await expect(handle.rpc.call('/api', 'goals/create', {}))
  526. .rejects.toThrow('invalid server-response result')
  527. respond({ ok: false, error: null })
  528. await expect(handle.rpc.call('/api', 'goals/create', {}))
  529. .rejects.toThrow('invalid server-response result')
  530. for (const error of [
  531. { code: 1, message: 'failed', details: {} },
  532. { code: 'failed', message: 1, details: {} },
  533. { code: 'failed', message: 'failed', details: [] },
  534. ]) {
  535. respond({ ok: false, error })
  536. await expect(handle.rpc.call('/api', 'goals/create', {}))
  537. .rejects.toThrow('invalid server-response failure')
  538. }
  539. respond({
  540. ok: false,
  541. error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
  542. })
  543. await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({
  544. ok: false,
  545. error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
  546. })
  547. } finally {
  548. globalThis.fetch = original
  549. }
  550. for (const [channel, endpoint] of [
  551. ['api2', 'goals/create'],
  552. ['/api/path', 'goals/create'],
  553. ['/api', ''],
  554. ['/api', '.'],
  555. ['/api', '..'],
  556. ['/api', 'goals//create'],
  557. ['/api', 'goals/create?unsafe'],
  558. ] as const) {
  559. await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
  560. }
  561. })
  562. it('carries Goal Remotes over the client-only fixture state', async () => {
  563. ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
  564. const handle = await mount()
  565. const created = await handle.rpc.call('/api', 'goals/create', {
  566. args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
  567. })
  568. expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
  569. if (!created.ok) throw new Error('fixture Goal create failed')
  570. const ref = (created.value as { ref: { id: string; revision: number } }).ref
  571. const edited = await handle.rpc.call('/api', 'goals/edit', {
  572. args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
  573. })
  574. expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
  575. const editedRef = { id: ref.id, revision: 2 }
  576. const paused = await handle.rpc.call('/api', 'goals/pause', {
  577. args: { agentId: 'fx-alpha', ref: editedRef },
  578. })
  579. expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
  580. const resumed = await handle.rpc.call('/api', 'goals/resume', {
  581. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
  582. })
  583. expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
  584. const completed = await handle.rpc.call('/api', 'goals/complete', {
  585. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
  586. })
  587. expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
  588. await expect(handle.rpc.call('/api', 'goals/clear', {
  589. args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
  590. })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
  591. await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
  592. await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
  593. .rejects.toThrow(/endpoint.*unavailable/)
  594. })
  595. })