client-apply.client.spec.ts 26 KB

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