fetch-carrier.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
  3. import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
  4. import { RpcId } from '../src/api/rpc.ts'
  5. import { toFetchHandler } from '../src/fetch/handler.ts'
  6. import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
  7. /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */
  8. function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy {
  9. const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }]
  10. const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }]
  11. async function * stream<F>(frames: F[], signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
  12. for (const payload of frames) {
  13. if (signal.aborted) return
  14. yield { rpcId: RpcId(`frame-${String(frames.indexOf(payload))}`), payload }
  15. }
  16. }
  17. return {
  18. sessions: {
  19. async list(request) {
  20. if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
  21. return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
  22. },
  23. async create(request) {
  24. return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
  25. },
  26. async history(request) {
  27. if (request.payload.sessionId === ('with-todos' as never)) {
  28. return {
  29. rpcId: request.rpcId,
  30. result: { ok: true, value: { events: [], hasMore: false, todos: [{ content: 'current', status: 'in_progress' as const }] } },
  31. }
  32. }
  33. return {
  34. rpcId: request.rpcId,
  35. result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
  36. }
  37. },
  38. async models(request) {
  39. return {
  40. rpcId: request.rpcId,
  41. result: {
  42. ok: true,
  43. value: {
  44. current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
  45. groups: [],
  46. failures: [],
  47. },
  48. },
  49. }
  50. },
  51. async selectModel(request) {
  52. return {
  53. rpcId: request.rpcId,
  54. result: {
  55. ok: true,
  56. value: {
  57. selected: {
  58. provider: request.payload.provider,
  59. model: request.payload.model,
  60. ...request.payload.reasoningEffort === undefined
  61. ? {}
  62. : { reasoningEffort: request.payload.reasoningEffort },
  63. },
  64. },
  65. },
  66. }
  67. },
  68. async prompt(request) {
  69. return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
  70. },
  71. async cancel(request) {
  72. return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
  73. },
  74. },
  75. host: {
  76. async describe(request) {
  77. return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
  78. },
  79. async pickDirectory(request) {
  80. return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
  81. },
  82. },
  83. workspace: {
  84. async list(request) {
  85. return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
  86. },
  87. async create(request) {
  88. return {
  89. rpcId: request.rpcId,
  90. result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
  91. }
  92. },
  93. async rename(request) {
  94. return {
  95. rpcId: request.rpcId,
  96. result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
  97. }
  98. },
  99. async delete(request) {
  100. return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
  101. },
  102. async insertSessionBefore(request) {
  103. return {
  104. rpcId: request.rpcId,
  105. result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
  106. }
  107. },
  108. },
  109. commands: {
  110. async list(request) {
  111. return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } }
  112. },
  113. async execute(request, signal) {
  114. if (request.payload.line === '/hang') {
  115. // Cooperative hang: settles only through the carrier signal (sticky
  116. // abort checked first — listeners never fire retroactively).
  117. if (!signal.aborted) {
  118. await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
  119. }
  120. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
  121. }
  122. if (request.payload.line.startsWith('/plan')) {
  123. return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, result: { kind: 'success' as const, text: 'plan set' } } } }
  124. }
  125. return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
  126. },
  127. },
  128. skills: {
  129. async list(request) {
  130. return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
  131. },
  132. },
  133. events: {
  134. mux: (_request, signal) => stream(muxFrames, signal),
  135. host: (_request, signal) => stream(hostFrames, signal),
  136. },
  137. async respond(message: ClientResponse): Promise<RpcReceipt> {
  138. return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
  139. },
  140. }
  141. }
  142. function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
  143. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  144. }
  145. async function collect<F>(stream: AsyncIterable<RpcRequest<F>>): Promise<RpcRequest<F>[]> {
  146. const out: RpcRequest<F>[] = []
  147. for await (const envelope of stream) out.push(envelope)
  148. return out
  149. }
  150. describe('unary round trip (handler ⇄ client, no network)', () => {
  151. it('carries a success result and echoes the minted rpcId', async () => {
  152. const response = await client().sessions.list({})
  153. expect(response.result).toEqual({ ok: true, value: { items: [] } })
  154. expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
  155. })
  156. it('carries the tail-page todos projection through the wire schema (Zod must not strip it)', async () => {
  157. const response = await client().sessions.history({ sessionId: 'with-todos' as never })
  158. expect(response.result.ok).toBe(true)
  159. if (response.result.ok) expect(response.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }])
  160. })
  161. it('carries a business error as 200 + error result', async () => {
  162. const response = await client().sessions.history({ sessionId: 'missing' as never })
  163. expect(response.result.ok).toBe(false)
  164. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  165. })
  166. it('covers create/prompt/cancel/describe passthrough', async () => {
  167. const c = client()
  168. expect((await c.sessions.create({})).result.ok).toBe(true)
  169. expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
  170. const selected = await c.sessions.selectModel({
  171. sessionId: 's' as never,
  172. provider: 'deepseek',
  173. model: 'deepseek-v4-flash',
  174. reasoningEffort: 'max',
  175. })
  176. expect(selected.result).toMatchObject({
  177. ok: true,
  178. value: {
  179. selected: {
  180. provider: 'deepseek',
  181. model: 'deepseek-v4-flash',
  182. reasoningEffort: 'max',
  183. },
  184. },
  185. })
  186. expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
  187. expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
  188. expect((await c.host.describe({})).result.ok).toBe(true)
  189. })
  190. it('round-trips the native picker without the default unary timeout', async () => {
  191. const api = fakeApi()
  192. api.host.pickDirectory = async (request) => {
  193. await new Promise(resolve => setTimeout(resolve, 15))
  194. return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } }
  195. }
  196. const response = await client(api, 1).host.pickDirectory({})
  197. expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
  198. })
  199. it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
  200. const c = client()
  201. const list = await c.commands.list({ sessionId: 's' as never })
  202. expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
  203. const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
  204. expect(hit.result).toEqual({ ok: true, value: { matched: true, result: { kind: 'success', text: 'plan set' } } })
  205. const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
  206. expect(miss.result).toEqual({ ok: true, value: { matched: false } })
  207. const skills = await c.skills.list({ sessionId: 's' as never })
  208. expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
  209. })
  210. it('propagates the carrier Request signal into command.execute', async () => {
  211. const handler = toFetchHandler(fakeApi())
  212. const controller = new AbortController()
  213. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
  214. // The fake's /hang settles only when the invoke-level signal aborts: a
  215. // completed response with the cancelled error proves req.signal reached it.
  216. const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
  217. controller.abort()
  218. const response = await pending
  219. const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
  220. expect(parsed.rpcId).toBe('r-sig')
  221. expect(parsed.result.error?.code).toBe('cancelled')
  222. })
  223. it('propagates the carrier Request signal into host.pickDirectory', async () => {
  224. const api = fakeApi()
  225. api.host.pickDirectory = async (request, signal) => {
  226. if (!signal.aborted) {
  227. await new Promise<void>((resolve) => {
  228. signal.addEventListener('abort', () => { resolve() }, { once: true })
  229. })
  230. }
  231. return {
  232. rpcId: request.rpcId,
  233. result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
  234. }
  235. }
  236. const handler = toFetchHandler(api)
  237. const controller = new AbortController()
  238. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
  239. const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
  240. method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
  241. }))
  242. controller.abort()
  243. const parsed = await (await pending).json() as { result: { error?: { code: string } } }
  244. expect(parsed.result.error?.code).toBe('cancelled')
  245. })
  246. })
  247. describe('handler carrier-layer statuses', () => {
  248. const handler = toFetchHandler(fakeApi())
  249. it('404s unknown paths and non-POST non-stream methods', async () => {
  250. expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
  251. expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
  252. expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
  253. })
  254. it('400s a non-JSON body', async () => {
  255. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
  256. expect(response.status).toBe(400)
  257. })
  258. it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
  259. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
  260. expect(response.status).toBe(200)
  261. const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
  262. expect(body.rpcId).toBe('invalid-request')
  263. expect(body.result.error?.code).toBe('bad-request')
  264. })
  265. it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
  266. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
  267. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  268. const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
  269. expect(parsed.rpcId).toBe('r-9')
  270. expect(parsed.result.error?.message).toContain('does not match path')
  271. })
  272. it('rejects an invalid payload with the zod issues attached', async () => {
  273. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
  274. const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  275. const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
  276. expect(parsed.result.error?.code).toBe('bad-request')
  277. expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
  278. })
  279. it('500s when the impl itself throws', async () => {
  280. const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
  281. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
  282. const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  283. expect(response.status).toBe(500)
  284. expect(await response.text()).toContain('impl crashed')
  285. })
  286. it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
  287. const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
  288. const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json()
  289. expect(goodReceipt).toEqual({ accepted: true })
  290. const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
  291. const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json()
  292. expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
  293. })
  294. it('accepts (url, init) form fetch invocation', async () => {
  295. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
  296. const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
  297. expect(response.status).toBe(200)
  298. })
  299. })
  300. describe('SSE streams through the carrier', () => {
  301. it('yields mux frames as ServerRequest narrow forms and completes', async () => {
  302. const ac = new AbortController()
  303. const frames = await collect(client().events.mux({}, ac.signal))
  304. expect(frames).toHaveLength(1)
  305. expect(frames[0]?.payload).toMatchObject({ type: 'session/subscribed' })
  306. expect(frames[0]?.rpcId).toBe('frame-0')
  307. })
  308. it('yields host frames', async () => {
  309. const ac = new AbortController()
  310. const frames = await collect(client().events.host({}, ac.signal))
  311. expect(frames[0]?.payload).toMatchObject({ type: 'host/session-removed' })
  312. })
  313. it('drops frames after the consumer aborts mid-stream', async () => {
  314. const many = Array.from({ length: 50 }, (_, i): MuxFrame => ({ type: 'session/subscribed', sessionId: `s${String(i)}` as never, lastSeq: i }))
  315. const ac = new AbortController()
  316. const received: RpcRequest<MuxFrame>[] = []
  317. for await (const envelope of client(fakeApi({ muxFrames: many })).events.mux({}, ac.signal)) {
  318. received.push(envelope)
  319. if (received.length === 2) break // generator return → reader.cancel path
  320. }
  321. expect(received).toHaveLength(2)
  322. })
  323. it('swallows a reader.cancel rejection on early exit', async () => {
  324. const encoder = new TextEncoder()
  325. const body = new ReadableStream<Uint8Array>({
  326. start(controller) {
  327. const frame = { type: 'server-request', rpcId: 'f0', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's', lastSeq: -1 } }
  328. controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`))
  329. // stream intentionally left open: the consumer breaks first
  330. },
  331. cancel() {
  332. throw new Error('cancel refused')
  333. },
  334. })
  335. const c = new InProcessApiClient({ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }) })
  336. const received: RpcRequest<MuxFrame>[] = []
  337. for await (const envelope of c.events.mux({}, new AbortController().signal)) {
  338. received.push(envelope)
  339. break
  340. }
  341. expect(received).toHaveLength(1)
  342. })
  343. it('surfaces a mid-stream impl failure as one stream/error frame, then the stream ends', async () => {
  344. const api = fakeApi()
  345. api.events.mux = (_request, _signal) => (async function * (): AsyncGenerator<RpcRequest<MuxFrame>> {
  346. yield { rpcId: RpcId('f0'), payload: { type: 'session/subscribed', sessionId: 's' as never, lastSeq: -1 } }
  347. throw new Error('stream source died')
  348. })()
  349. const frames = await collect(client(api).events.mux({}, new AbortController().signal))
  350. expect(frames).toHaveLength(2)
  351. expect(frames[1]?.payload).toMatchObject({ type: 'stream/error', error: { code: 'internal' } })
  352. })
  353. })
  354. describe('client respond and transport failures', () => {
  355. it('passes a client-response through and parses the receipt', async () => {
  356. const receipt = await client().respond({ type: 'client-response', rpcId: RpcId('known'), result: { ok: true, value: null } })
  357. expect(receipt).toEqual({ accepted: true })
  358. const late = await client().respond({ type: 'client-response', rpcId: RpcId('late'), result: { ok: true, value: null } })
  359. expect(late).toEqual({ accepted: false, reason: 'not-pending' })
  360. })
  361. it('throws on non-OK unary and respond and stream transport', async () => {
  362. const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
  363. await expect(broken.sessions.list({})).rejects.toThrow('transport failure for /api/session.list: HTTP 503')
  364. await expect(broken.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } }))
  365. .rejects.toThrow('transport failure for /api/respond')
  366. await expect(collect(broken.events.mux({}, new AbortController().signal))).rejects.toThrow('transport failure for /api/events.mux')
  367. })
  368. it('throws on an rpcId echo mismatch', async () => {
  369. const lying = new InProcessApiClient({
  370. fetch: async () => Response.json({ type: 'server-response', rpcId: 'someone-else', result: { ok: true, value: { items: [] } } }),
  371. })
  372. await expect(lying.sessions.list({})).rejects.toThrow('rpcId mismatch')
  373. })
  374. })
  375. describe('envelope observation', () => {
  376. it('batches envelopes per microtask and isolates a throwing listener', async () => {
  377. const c = client()
  378. const batches: (readonly RpcMessage[])[] = []
  379. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  380. const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
  381. const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
  382. await c.sessions.list({})
  383. await new Promise((resolve) => { setTimeout(resolve, 0) })
  384. // request and response tap in separate microtask windows (the await between
  385. // them yields), so both arrive but batch count is timing-defined
  386. expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
  387. expect(errorSpy).toHaveBeenCalled()
  388. unsubscribe()
  389. unsubscribeThrowing()
  390. errorSpy.mockRestore()
  391. })
  392. it('skips buffering entirely with no listeners and after unsubscribe', async () => {
  393. const c = client()
  394. const seen: RpcMessage[] = []
  395. const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
  396. unsubscribe()
  397. await c.sessions.list({})
  398. await new Promise((resolve) => { setTimeout(resolve, 0) })
  399. expect(seen).toHaveLength(0)
  400. })
  401. it('coalesces multiple calls in one microtask window into one flush', async () => {
  402. const c = client()
  403. const batches: (readonly RpcMessage[])[] = []
  404. c.subscribeEnvelopes((batch) => { batches.push(batch) })
  405. await Promise.all([c.sessions.list({}), c.host.describe({})])
  406. await new Promise((resolve) => { setTimeout(resolve, 0) })
  407. const total = batches.reduce((n, batch) => n + batch.length, 0)
  408. expect(total).toBe(4)
  409. })
  410. })
  411. describe('resolveBase', () => {
  412. it('prefers a real location.origin and falls back to the internal authority', async () => {
  413. class Probe extends AbstractApiClient {
  414. urls: string[] = []
  415. protected async doFetch(input: URL): Promise<Response> {
  416. this.urls.push(input.href)
  417. return Response.json({ type: 'server-response', rpcId: this.lastMinted, result: { ok: true, value: { items: [] } } })
  418. }
  419. lastMinted = ''
  420. protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
  421. const id = super.mintRpcId()
  422. this.lastMinted = id
  423. return id
  424. }
  425. }
  426. const probe = new Probe()
  427. await probe.sessions.list({})
  428. expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  429. const globalWithLocation = globalThis as { location?: { origin?: string } }
  430. globalWithLocation.location = { origin: 'http://host.example' }
  431. try {
  432. const probe2 = new Probe()
  433. await probe2.sessions.list({})
  434. expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
  435. globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
  436. const probe3 = new Probe()
  437. await probe3.sessions.list({})
  438. expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  439. } finally {
  440. delete globalWithLocation.location
  441. }
  442. })
  443. })