fetch-carrier.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { ApiProxy } from '../src/api/index.ts'
  3. import type { RpcMessage, RpcRequest } from '../src/api/rpc.ts'
  4. import { toFetchHandler } from '../src/fetch/handler.ts'
  5. import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
  6. /** Minimal in-memory ApiProxy that echoes rpcIds. */
  7. function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
  8. return {
  9. subagents: {
  10. async list(request) {
  11. return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
  12. },
  13. async prompt(request, signal) {
  14. if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
  15. if (!signal.aborted) {
  16. await new Promise<void>((resolve) => {
  17. signal.addEventListener('abort', () => { resolve() }, { once: true })
  18. })
  19. }
  20. return {
  21. rpcId: request.rpcId,
  22. result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
  23. }
  24. }
  25. return {
  26. rpcId: request.rpcId,
  27. result: { ok: true, value: { messageId: 'message-1' as never } },
  28. }
  29. },
  30. async interrupt(request) {
  31. return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
  32. },
  33. },
  34. host: {
  35. async describe(request) {
  36. if (overrides.crashOn === 'host.describe') throw new Error('impl crashed')
  37. return {
  38. rpcId: request.rpcId,
  39. result: {
  40. ok: true,
  41. value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
  42. },
  43. }
  44. },
  45. async pickDirectory(request) {
  46. return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
  47. },
  48. async listDirectory(request) {
  49. return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } }
  50. },
  51. async createDirectory(request) {
  52. return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
  53. },
  54. async openPath(request) {
  55. return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
  56. },
  57. },
  58. agentPresets: {
  59. list(request: RpcRequest<{}>) {
  60. return Promise.resolve({
  61. rpcId: request.rpcId,
  62. result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
  63. })
  64. },
  65. select(request: RpcRequest<{ agentPreset: string }>) {
  66. const value = { agentPreset: request.payload.agentPreset }
  67. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
  68. },
  69. read(request: RpcRequest<{ agentPreset: string }>) {
  70. const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
  71. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
  72. },
  73. copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
  74. const value = { agentPreset: request.payload.agentPreset }
  75. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
  76. },
  77. openDocument(request: RpcRequest<{ agentPreset: string }>) {
  78. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
  79. },
  80. remove(request: RpcRequest<{ agentPreset: string }>) {
  81. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
  82. },
  83. },
  84. skills: {
  85. async list(request) {
  86. return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
  87. },
  88. },
  89. goals: {
  90. async create(request) {
  91. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  92. },
  93. async edit(request) {
  94. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  95. },
  96. async pause(request) {
  97. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  98. },
  99. async resume(request) {
  100. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  101. },
  102. async complete(request) {
  103. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  104. },
  105. async clear(request) {
  106. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
  107. },
  108. },
  109. settings: {
  110. async describe(request) {
  111. return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
  112. },
  113. async openDocument(request) {
  114. return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
  115. },
  116. async update(request) {
  117. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
  118. },
  119. async replace(request) {
  120. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
  121. },
  122. async mutate(request) {
  123. return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
  124. },
  125. },
  126. credentials: {
  127. async describe(request) {
  128. return { rpcId: request.rpcId, result: { ok: true, value: { credentials: {} } } }
  129. },
  130. async set(request) {
  131. return { rpcId: request.rpcId, result: { ok: true, value: {} } }
  132. },
  133. async unset(request) {
  134. return { rpcId: request.rpcId, result: { ok: true, value: {} } }
  135. },
  136. },
  137. llm: {
  138. async providers(request) {
  139. return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } }
  140. },
  141. async models(request) {
  142. return {
  143. rpcId: request.rpcId,
  144. result: {
  145. ok: true,
  146. value: {
  147. default: { provider: 'test', model: 'test' },
  148. routableProviders: [],
  149. groups: [],
  150. failures: [],
  151. },
  152. },
  153. }
  154. },
  155. async discoverModels(request) {
  156. return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }
  157. },
  158. },
  159. downloads: {
  160. async sessionLog() {
  161. return new Response('stub', { status: 404 })
  162. },
  163. },
  164. }
  165. }
  166. function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
  167. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  168. }
  169. describe('unary round trip (handler ⇄ client, no network)', () => {
  170. it('carries a success result and echoes the minted rpcId', async () => {
  171. const response = await client().host.describe({})
  172. expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } })
  173. expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
  174. })
  175. it('carries a business error as 200 + error result', async () => {
  176. const response = await client().settings.update({ ns: 'test', patch: {} })
  177. expect(response.result.ok).toBe(false)
  178. if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected')
  179. })
  180. it('round-trips every agent-preset method, authoring included', async () => {
  181. const c = client()
  182. // The whole domain crosses the carrier: the roster a picker reads, the
  183. // per-session switch, and the authoring calls the settings page makes.
  184. // Each has its own request schema, so a registration missing from either
  185. // half fails here rather than in the browser.
  186. expect((await c.agentPresets.list({})).result).toEqual({
  187. ok: true, value: { presets: [], authorable: false, hasDocument: false },
  188. })
  189. expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
  190. .toEqual({ ok: true, value: { agentPreset: 'minimal' } })
  191. expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
  192. ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
  193. })
  194. expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
  195. .toEqual({ ok: true, value: { agentPreset: 'mine' } })
  196. expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
  197. .toEqual({ ok: true, value: { opened: true } })
  198. expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
  199. })
  200. it('round-trips the native picker without the default unary timeout', async () => {
  201. const api = fakeApi()
  202. api.host.pickDirectory = async (request) => {
  203. await new Promise(resolve => setTimeout(resolve, 15))
  204. return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } }
  205. }
  206. const response = await client(api, 1).host.pickDirectory({})
  207. expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
  208. })
  209. it('round-trips the browse listing and creation calls through the wire form', async () => {
  210. const c = client()
  211. const listed = await c.host.listDirectory({ path: '/w' })
  212. expect(listed.result).toEqual({
  213. ok: true,
  214. value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false },
  215. })
  216. const home = await c.host.listDirectory({})
  217. expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } })
  218. const created = await c.host.createDirectory({ path: '/w', name: 'fresh' })
  219. expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } })
  220. })
  221. it('round-trips host.openPath through the wire form', async () => {
  222. const api = fakeApi()
  223. let opened: string | undefined
  224. api.host.openPath = async (request) => {
  225. opened = request.payload.path
  226. return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
  227. }
  228. const response = await client(api).host.openPath({ path: '/tmp/a.txt' })
  229. expect(opened).toBe('/tmp/a.txt')
  230. expect(response.result).toEqual({ ok: true, value: { opened: true } })
  231. })
  232. it('round-trips skill.list through the wire form', async () => {
  233. const c = client()
  234. const skills = await c.skills.list({ sessionId: 's' as never })
  235. expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
  236. })
  237. it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => {
  238. vi.useFakeTimers()
  239. const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
  240. const controller = new AbortController()
  241. setTimeout(() => {
  242. controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
  243. }, milliseconds)
  244. return controller.signal
  245. })
  246. try {
  247. const api = fakeApi()
  248. api.host.pickDirectory = async (request) => {
  249. await new Promise(resolve => setTimeout(resolve, 30_001))
  250. return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } }
  251. }
  252. const execution = client(api).host.pickDirectory({})
  253. const assertion = expect(execution).resolves.toMatchObject({
  254. result: { ok: true, value: { path: '/tmp/slow' } },
  255. })
  256. await Promise.all([
  257. vi.advanceTimersByTimeAsync(30_001),
  258. assertion,
  259. ])
  260. expect(timeoutSpy).not.toHaveBeenCalled()
  261. } finally {
  262. timeoutSpy.mockRestore()
  263. vi.useRealTimers()
  264. }
  265. })
  266. it('round-trips the subagent domain through the wire form', async () => {
  267. const c = client()
  268. expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
  269. .toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
  270. expect((await c.subagents.prompt({
  271. parentSessionId: 'parent' as never,
  272. childSessionId: 'child' as never,
  273. mode: 'continuable',
  274. content: [],
  275. })).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
  276. expect((await c.subagents.interrupt({
  277. parentSessionId: 'parent' as never,
  278. childSessionId: 'child' as never,
  279. mode: 'continuable',
  280. })).result).toEqual({ ok: true, value: { accepted: true } })
  281. })
  282. it('keeps caller and connection aborts on a deadline-exempt unary', async () => {
  283. const api = fakeApi()
  284. const started = Promise.withResolvers<AbortSignal>()
  285. api.host.pickDirectory = async (request, signal) => {
  286. started.resolve(signal)
  287. if (!signal.aborted) {
  288. await new Promise<void>((resolve) => {
  289. signal.addEventListener('abort', () => { resolve() }, { once: true })
  290. })
  291. }
  292. return {
  293. rpcId: request.rpcId,
  294. result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
  295. }
  296. }
  297. const controller = new AbortController()
  298. const execution = client(api).host.pickDirectory({}, controller.signal)
  299. const handlerSignal = await started.promise
  300. controller.abort(new Error('connection closed'))
  301. await expect(execution).rejects.toThrow('connection closed')
  302. expect(handlerSignal.aborted).toBe(true)
  303. })
  304. it('propagates the carrier Request signal into subagent.prompt', async () => {
  305. const handler = toFetchHandler(fakeApi())
  306. const controller = new AbortController()
  307. const body = JSON.stringify({
  308. type: 'client-request',
  309. rpcId: 'r-subagent-sig',
  310. method: 'subagent.prompt',
  311. payload: {
  312. parentSessionId: 'parent',
  313. childSessionId: 'child',
  314. mode: 'continuable',
  315. content: [{ type: 'text', text: 'hang' }],
  316. },
  317. })
  318. const pending = handler.fetch(new Request(
  319. 'http://x/api/subagent.prompt',
  320. { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
  321. ))
  322. controller.abort()
  323. const response = await pending
  324. const parsed = await response.json() as {
  325. rpcId: string
  326. result: { error?: { code: string } }
  327. }
  328. expect(parsed.rpcId).toBe('r-subagent-sig')
  329. expect(parsed.result.error?.code).toBe('cancelled')
  330. })
  331. it('propagates the carrier Request signal into host.pickDirectory', async () => {
  332. const api = fakeApi()
  333. api.host.pickDirectory = async (request, signal) => {
  334. if (!signal.aborted) {
  335. await new Promise<void>((resolve) => {
  336. signal.addEventListener('abort', () => { resolve() }, { once: true })
  337. })
  338. }
  339. return {
  340. rpcId: request.rpcId,
  341. result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
  342. }
  343. }
  344. const handler = toFetchHandler(api)
  345. const controller = new AbortController()
  346. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
  347. const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
  348. method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
  349. }))
  350. controller.abort()
  351. const parsed = await (await pending).json() as { result: { error?: { code: string } } }
  352. expect(parsed.result.error?.code).toBe('cancelled')
  353. })
  354. })
  355. describe('handler carrier-layer statuses', () => {
  356. const handler = toFetchHandler(fakeApi())
  357. it('404s unknown paths and non-POST non-stream methods', async () => {
  358. expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
  359. expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404)
  360. 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)
  361. })
  362. it('400s a non-JSON body', async () => {
  363. const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
  364. expect(response.status).toBe(400)
  365. })
  366. it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
  367. const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
  368. expect(response.status).toBe(200)
  369. const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
  370. expect(body.rpcId).toBe('invalid-request')
  371. expect(body.result.error?.code).toBe('bad-request')
  372. })
  373. it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
  374. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'host.describe', payload: {} })
  375. const response = await handler.fetch(new Request('http://x/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  376. const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
  377. expect(parsed.rpcId).toBe('r-9')
  378. expect(parsed.result.error?.message).toContain('does not match path')
  379. })
  380. it('rejects an invalid payload with the zod issues attached', async () => {
  381. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.openPath', payload: {} })
  382. const response = await handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  383. const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
  384. expect(parsed.result.error?.code).toBe('bad-request')
  385. expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
  386. })
  387. it('500s when the impl itself throws', async () => {
  388. const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' }))
  389. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} })
  390. const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
  391. expect(response.status).toBe(500)
  392. expect(await response.text()).toContain('impl crashed')
  393. })
  394. it('accepts (url, init) form fetch invocation', async () => {
  395. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} })
  396. const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
  397. expect(response.status).toBe(200)
  398. })
  399. })
  400. describe('client transport failures', () => {
  401. it('throws on a non-OK unary transport', async () => {
  402. const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
  403. await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503')
  404. })
  405. it('throws on an rpcId echo mismatch', async () => {
  406. const lying = new InProcessApiClient({
  407. fetch: async () => Response.json({
  408. type: 'server-response',
  409. rpcId: 'someone-else',
  410. result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } },
  411. }),
  412. })
  413. await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch')
  414. })
  415. })
  416. describe('envelope observation', () => {
  417. it('batches envelopes per microtask and isolates a throwing listener', async () => {
  418. const c = client()
  419. const batches: (readonly RpcMessage[])[] = []
  420. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  421. const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
  422. const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
  423. await c.host.describe({})
  424. await new Promise((resolve) => { setTimeout(resolve, 0) })
  425. // request and response tap in separate microtask windows (the await between
  426. // them yields), so both arrive but batch count is timing-defined
  427. expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
  428. expect(errorSpy).toHaveBeenCalled()
  429. unsubscribe()
  430. unsubscribeThrowing()
  431. errorSpy.mockRestore()
  432. })
  433. it('skips buffering entirely with no listeners and after unsubscribe', async () => {
  434. const c = client()
  435. const seen: RpcMessage[] = []
  436. const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
  437. unsubscribe()
  438. await c.host.describe({})
  439. await new Promise((resolve) => { setTimeout(resolve, 0) })
  440. expect(seen).toHaveLength(0)
  441. })
  442. it('coalesces multiple calls in one microtask window into one flush', async () => {
  443. const c = client()
  444. const batches: (readonly RpcMessage[])[] = []
  445. c.subscribeEnvelopes((batch) => { batches.push(batch) })
  446. await Promise.all([c.host.describe({}), c.skills.list({ sessionId: 's1' as never })])
  447. await new Promise((resolve) => { setTimeout(resolve, 0) })
  448. const total = batches.reduce((n, batch) => n + batch.length, 0)
  449. expect(total).toBe(4)
  450. })
  451. })
  452. describe('resolveBase', () => {
  453. it('prefers a real location.origin and falls back to the internal authority', async () => {
  454. class Probe extends AbstractApiClient {
  455. urls: string[] = []
  456. protected async doFetch(input: URL): Promise<Response> {
  457. this.urls.push(input.href)
  458. return Response.json({
  459. type: 'server-response',
  460. rpcId: this.lastMinted,
  461. result: {
  462. ok: true,
  463. value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
  464. },
  465. })
  466. }
  467. lastMinted = ''
  468. protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
  469. const id = super.mintRpcId()
  470. this.lastMinted = id
  471. return id
  472. }
  473. }
  474. const probe = new Probe()
  475. await probe.host.describe({})
  476. expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  477. const globalWithLocation = globalThis as { location?: { origin?: string } }
  478. globalWithLocation.location = { origin: 'http://host.example' }
  479. try {
  480. const probe2 = new Probe()
  481. await probe2.host.describe({})
  482. expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
  483. globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
  484. const probe3 = new Probe()
  485. await probe3.host.describe({})
  486. expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  487. } finally {
  488. delete globalWithLocation.location
  489. }
  490. })
  491. })