fetch-observer.host.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /** Host fetch observation behavior. */
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { installFetchObserver, type FetchObserver } from '../src/host/inspection/network.ts'
  4. import type { InspectorRecordInput } from '../src/shared/bridge/messages/observation.ts'
  5. import type { InspectorJsonValue } from '../src/shared/json.ts'
  6. describe('full fetch observer', () => {
  7. const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
  8. let observer: FetchObserver | undefined
  9. afterEach(async () => {
  10. await observer?.stop()
  11. observer = undefined
  12. vi.restoreAllMocks()
  13. if (originalDescriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch')
  14. else Object.defineProperty(globalThis, 'fetch', originalDescriptor)
  15. })
  16. it('captures complete URL, headers, request body, response headers, and response body', async () => {
  17. const records: InspectorRecordInput[] = []
  18. const native = vi.fn(async (request: Request) => {
  19. expect(await request.clone().text()).toBe('secret request body')
  20. return new Response('complete response body', {
  21. status: 201,
  22. statusText: 'Created',
  23. headers: { authorization: 'response secret', 'content-type': 'text/plain' },
  24. })
  25. })
  26. Object.defineProperty(globalThis, 'fetch', { value: native, writable: true, configurable: true })
  27. observer = installFetchObserver({
  28. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  29. records.push({ topic, payload, monotonicMs })
  30. },
  31. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  32. const response = await fetch('https://example.test/path?token=visible', {
  33. method: 'POST',
  34. headers: { authorization: 'Bearer visible' },
  35. body: 'secret request body',
  36. })
  37. expect(await response.text()).toBe('complete response body')
  38. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
  39. const start = payload(records, 'fetch/start')
  40. expect(start).toMatchObject({
  41. url: 'https://example.test/path?token=visible',
  42. method: 'POST',
  43. })
  44. expect(start.headers).toEqual(expect.arrayContaining([['authorization', 'Bearer visible']]))
  45. expect(decodeChunks(records, 'fetch/request-body-chunk')).toBe('secret request body')
  46. const responseRecord = payload(records, 'fetch/response')
  47. expect(responseRecord.status).toBe(201)
  48. expect(responseRecord.headers).toEqual(expect.arrayContaining([['authorization', 'response secret']]))
  49. expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('complete response body')
  50. expect(payload(records, 'fetch/request-body-end')).toMatchObject({ truncated: false })
  51. expect(payload(records, 'fetch/end')).toMatchObject({ responseBodyTruncated: false })
  52. })
  53. it('marks bodies truncated without changing the caller response', async () => {
  54. const records: InspectorRecordInput[] = []
  55. Object.defineProperty(globalThis, 'fetch', {
  56. value: vi.fn(() => Promise.resolve(new Response('response-long'))),
  57. writable: true,
  58. configurable: true,
  59. })
  60. observer = installFetchObserver({
  61. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  62. records.push({ topic, payload, monotonicMs })
  63. },
  64. }, { maxRequestBodyBytes: 4, maxResponseBodyBytes: 4, maxChunkBytes: 2 })
  65. const response = await fetch('https://example.test/', { method: 'POST', body: 'request-long' })
  66. expect(await response.text()).toBe('response-long')
  67. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
  68. expect(decodeChunks(records, 'fetch/request-body-chunk')).toBe('requ')
  69. expect(payload(records, 'fetch/request-body-end')).toMatchObject({ capturedBytes: 4, truncated: true })
  70. expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('resp')
  71. expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true })
  72. })
  73. it('finishes response capture when the caller aborts after response headers', async () => {
  74. const records: InspectorRecordInput[] = []
  75. Object.defineProperty(globalThis, 'fetch', {
  76. value: vi.fn(async (request: Request) => new Response(new ReadableStream<Uint8Array>({
  77. start(controller) {
  78. controller.enqueue(Buffer.from('first'))
  79. request.signal.addEventListener('abort', () => {
  80. controller.error(new DOMException('aborted', 'AbortError'))
  81. }, { once: true })
  82. },
  83. }))),
  84. writable: true,
  85. configurable: true,
  86. })
  87. observer = installFetchObserver({
  88. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  89. records.push({ topic, payload, monotonicMs })
  90. },
  91. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  92. const abort = new AbortController()
  93. const response = await fetch('https://example.test/cancel-body', { signal: abort.signal })
  94. abort.abort()
  95. await expect(response.text()).rejects.toThrow()
  96. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
  97. expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first')
  98. expect(payload(records, 'fetch/end')).toMatchObject({
  99. capturedBytes: 5,
  100. responseBodyTruncated: true,
  101. responseCaptureError: 'AbortError: aborted',
  102. })
  103. expect(records.some(record => record.topic === 'fetch/error')).toBe(false)
  104. })
  105. it('reports a fetch rejected before response headers as a canceled request', async () => {
  106. const records: InspectorRecordInput[] = []
  107. Object.defineProperty(globalThis, 'fetch', {
  108. value: vi.fn(async (request: Request) => await new Promise<Response>((_resolve, reject) => {
  109. request.signal.addEventListener('abort', () => {
  110. reject(new DOMException('aborted', 'AbortError'))
  111. }, { once: true })
  112. })),
  113. writable: true,
  114. configurable: true,
  115. })
  116. observer = installFetchObserver({
  117. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  118. records.push({ topic, payload, monotonicMs })
  119. },
  120. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  121. const abort = new AbortController()
  122. const pending = fetch('https://example.test/cancel-before-response', { signal: abort.signal })
  123. abort.abort()
  124. await expect(pending).rejects.toThrow()
  125. expect(payload(records, 'fetch/error')).toMatchObject({ canceled: true })
  126. expect(records.some(record => record.topic === 'fetch/response')).toBe(false)
  127. expect(records.some(record => record.topic === 'fetch/end')).toBe(false)
  128. })
  129. it('reports non-cancellation fetch failures without manufacturing a canceled flag', async () => {
  130. const records: InspectorRecordInput[] = []
  131. Object.defineProperty(globalThis, 'fetch', {
  132. value: vi.fn(() => Promise.reject(new Error('connection failed'))),
  133. writable: true,
  134. configurable: true,
  135. })
  136. observer = installFetchObserver({
  137. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  138. records.push({ topic, payload, monotonicMs })
  139. },
  140. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  141. await expect(fetch('https://example.test/failure')).rejects.toThrow('connection failed')
  142. expect(payload(records, 'fetch/error')).toMatchObject({ message: 'Error: connection failed', canceled: false })
  143. })
  144. it('records request and response clone failures without replacing the caller response', async () => {
  145. const records: InspectorRecordInput[] = []
  146. Object.defineProperty(globalThis, 'fetch', {
  147. value: vi.fn(() => Promise.resolve(new Response('response'))),
  148. writable: true,
  149. configurable: true,
  150. })
  151. const requestClone = vi.spyOn(Request.prototype, 'clone').mockImplementationOnce(() => {
  152. throw new Error('request clone failed')
  153. })
  154. const responseClone = vi.spyOn(Response.prototype, 'clone').mockImplementationOnce(() => {
  155. throw new Error('response clone failed')
  156. })
  157. observer = installFetchObserver({
  158. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  159. records.push({ topic, payload, monotonicMs })
  160. },
  161. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  162. const response = await fetch('https://example.test/clone-failure', { method: 'POST', body: 'request' })
  163. expect(await response.text()).toBe('response')
  164. expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'Error: request clone failed' })
  165. expect(payload(records, 'fetch/end')).toMatchObject({ responseCaptureError: 'Error: response clone failed' })
  166. requestClone.mockRestore()
  167. responseClone.mockRestore()
  168. })
  169. it('handles responses without bodies and keeps stop idempotent when fetch is replaced', async () => {
  170. const records: InspectorRecordInput[] = []
  171. Object.defineProperty(globalThis, 'fetch', {
  172. value: vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))),
  173. writable: true,
  174. configurable: true,
  175. })
  176. observer = installFetchObserver({
  177. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  178. records.push({ topic, payload, monotonicMs })
  179. },
  180. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  181. const replacement = vi.fn<typeof fetch>()
  182. await fetch('https://example.test/no-content')
  183. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
  184. Object.defineProperty(globalThis, 'fetch', { value: replacement, writable: true, configurable: true })
  185. const firstStop = observer.stop()
  186. expect(observer.stop()).toBe(firstStop)
  187. await firstStop
  188. expect(globalThis.fetch).toBe(replacement)
  189. })
  190. it('rejects installation without a callable global fetch', () => {
  191. Object.defineProperty(globalThis, 'fetch', { value: undefined, writable: true, configurable: true })
  192. expect(() => installFetchObserver({ publish: vi.fn() }, {
  193. maxRequestBodyBytes: 1,
  194. maxResponseBodyBytes: 1,
  195. maxChunkBytes: 1,
  196. })).toThrow('globalThis.fetch is unavailable')
  197. })
  198. it('rejects an accessor fetch property', () => {
  199. const nativeFetch = globalThis.fetch
  200. Object.defineProperty(globalThis, 'fetch', {
  201. configurable: true,
  202. get: () => nativeFetch,
  203. })
  204. expect(() => installFetchObserver({ publish: vi.fn() }, {
  205. maxRequestBodyBytes: 1,
  206. maxResponseBodyBytes: 1,
  207. maxChunkBytes: 1,
  208. })).toThrow('globalThis.fetch is an accessor')
  209. })
  210. it('contains publisher failures from asynchronous body completion', async () => {
  211. let endAttempted = false
  212. Object.defineProperty(globalThis, 'fetch', {
  213. value: vi.fn(() => Promise.resolve(new Response('response'))),
  214. writable: true,
  215. configurable: true,
  216. })
  217. observer = installFetchObserver({
  218. publish(topic: string): void {
  219. if (topic !== 'fetch/end') return
  220. endAttempted = true
  221. throw new Error('publisher closed')
  222. },
  223. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  224. await fetch('https://example.test/publisher-failure')
  225. await vi.waitFor(() => { expect(endAttempted).toBe(true) })
  226. await expect(observer.stop()).resolves.toBeUndefined()
  227. })
  228. it('cancels an active clone reader when the observer stops', async () => {
  229. const records: InspectorRecordInput[] = []
  230. let settleRead: ((value: ReadableStreamReadResult<Uint8Array>) => void) | undefined
  231. const reader = {
  232. read: vi.fn(async () => await new Promise<ReadableStreamReadResult<Uint8Array>>((resolve) => {
  233. settleRead = resolve
  234. })),
  235. cancel: vi.fn(() => {
  236. settleRead?.({ done: true, value: undefined })
  237. return Promise.reject(new Error('cancel already observed'))
  238. }),
  239. releaseLock: vi.fn(),
  240. }
  241. Object.defineProperty(globalThis, 'fetch', {
  242. value: vi.fn(() => Promise.resolve(new Response('caller response'))),
  243. writable: true,
  244. configurable: true,
  245. })
  246. vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({
  247. body: { getReader: () => reader },
  248. } as unknown as Response)
  249. observer = installFetchObserver({
  250. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  251. records.push({ topic, payload, monotonicMs })
  252. },
  253. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  254. await fetch('https://example.test/pending-body')
  255. await observer.stop()
  256. expect(reader.cancel).toHaveBeenCalled()
  257. expect(payload(records, 'fetch/end')).toMatchObject({
  258. responseCaptureError: 'inspector stopped during body capture',
  259. })
  260. })
  261. it('contains a rejected reader cancellation after reaching the body limit', async () => {
  262. const records: InspectorRecordInput[] = []
  263. const reader = {
  264. read: vi.fn()
  265. .mockResolvedValueOnce({ done: false, value: Buffer.from('oversized') })
  266. .mockResolvedValue({ done: true, value: undefined }),
  267. cancel: vi.fn(() => Promise.reject(new Error('cancel failed'))),
  268. releaseLock: vi.fn(),
  269. }
  270. Object.defineProperty(globalThis, 'fetch', {
  271. value: vi.fn(() => Promise.resolve(new Response('caller response'))),
  272. writable: true,
  273. configurable: true,
  274. })
  275. vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({
  276. body: { getReader: () => reader },
  277. } as unknown as Response)
  278. observer = installFetchObserver({
  279. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  280. records.push({ topic, payload, monotonicMs })
  281. },
  282. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1, maxChunkBytes: 1 })
  283. await fetch('https://example.test/body-limit')
  284. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
  285. expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 1, responseBodyTruncated: true })
  286. expect(reader.cancel).toHaveBeenCalledWith('inspector body capture limit reached')
  287. })
  288. it('renders non-Error rejection values without allowing hostile coercion to escape', async () => {
  289. const records: InspectorRecordInput[] = []
  290. const plainFailure: unknown = 'plain failure'
  291. const unrenderable = { toString: () => { throw new Error('cannot stringify') } }
  292. Object.defineProperty(globalThis, 'fetch', {
  293. value: vi.fn()
  294. .mockImplementationOnce(async () => { throw plainFailure })
  295. .mockImplementationOnce(async () => { throw unrenderable }),
  296. writable: true,
  297. configurable: true,
  298. })
  299. observer = installFetchObserver({
  300. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  301. records.push({ topic, payload, monotonicMs })
  302. },
  303. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  304. await expect(fetch('https://example.test/plain-failure')).rejects.toBe('plain failure')
  305. await expect(fetch('https://example.test/unrenderable-failure')).rejects.toBe(unrenderable)
  306. expect(records.filter(record => record.topic === 'fetch/error').map(record => record.payload))
  307. .toEqual(expect.arrayContaining([
  308. expect.objectContaining({ message: 'plain failure', canceled: false }),
  309. expect.objectContaining({ message: 'unrenderable fetch error', canceled: false }),
  310. ]))
  311. })
  312. it('restores an inherited fetch without leaving an own property', async () => {
  313. const prototype = Object.getPrototypeOf(globalThis) as object
  314. const inheritedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'fetch')
  315. const nativeFetch = originalDescriptor?.value as typeof fetch
  316. Reflect.deleteProperty(globalThis, 'fetch')
  317. Object.defineProperty(prototype, 'fetch', { value: nativeFetch, writable: true, configurable: true })
  318. try {
  319. observer = installFetchObserver({ publish: vi.fn() }, {
  320. maxRequestBodyBytes: 1_024,
  321. maxResponseBodyBytes: 1_024,
  322. maxChunkBytes: 4,
  323. })
  324. await observer.stop()
  325. expect(Object.hasOwn(globalThis, 'fetch')).toBe(false)
  326. } finally {
  327. if (inheritedDescriptor === undefined) Reflect.deleteProperty(prototype, 'fetch')
  328. else Object.defineProperty(prototype, 'fetch', inheritedDescriptor)
  329. }
  330. })
  331. it('reports request clone read errors and non-abort DOM failures', async () => {
  332. const records: InspectorRecordInput[] = []
  333. const requestReadFailure: unknown = 'request read failed'
  334. const reader = {
  335. read: vi.fn(async () => { throw requestReadFailure }),
  336. cancel: vi.fn(() => Promise.resolve()),
  337. releaseLock: vi.fn(),
  338. }
  339. Object.defineProperty(globalThis, 'fetch', {
  340. value: vi.fn()
  341. .mockResolvedValueOnce(new Response(null, { status: 204 }))
  342. .mockRejectedValueOnce(new DOMException('network failed', 'NetworkError')),
  343. writable: true,
  344. configurable: true,
  345. })
  346. vi.spyOn(Request.prototype, 'clone').mockReturnValueOnce({
  347. body: { getReader: () => reader },
  348. } as unknown as Request)
  349. observer = installFetchObserver({
  350. publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
  351. records.push({ topic, payload, monotonicMs })
  352. },
  353. }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
  354. await fetch('https://example.test/request-read-failure')
  355. await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/request-body-end')).toBe(true) })
  356. expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'request read failed' })
  357. await expect(fetch('https://example.test/network-failure')).rejects.toThrow('network failed')
  358. expect(records.filter(record => record.topic === 'fetch/error').at(-1)?.payload)
  359. .toMatchObject({ canceled: false })
  360. })
  361. })
  362. function payload(records: readonly InspectorRecordInput[], topic: string): Record<string, unknown> {
  363. const record = records.find(candidate => candidate.topic === topic)
  364. expect(record).toBeDefined()
  365. return record!.payload as Record<string, unknown>
  366. }
  367. function decodeChunks(records: readonly InspectorRecordInput[], topic: string): string {
  368. return Buffer.concat(records
  369. .filter(record => record.topic === topic)
  370. .map(record => Buffer.from(String((record.payload as Record<string, unknown>).data), 'base64')))
  371. .toString('utf8')
  372. }