network.host.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. /** Worker-side Network projection behavior. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { NetworkDomain, type NetworkSink } from '../src/worker/cdp/domains/network/session.ts'
  4. import { NetworkStore } from '../src/worker/inspection/network-store.ts'
  5. import { inspectorId } from '../src/shared/bridge/ids.ts'
  6. import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
  7. import type { IngestedInspectorRecord } from '../src/worker/bridge/hub.ts'
  8. import type { InspectorJsonValue } from '../src/shared/json.ts'
  9. const source: InspectorSourceDescriptor = {
  10. sourceId: inspectorId<'InspectorSourceId'>('host-network', 'sourceId'),
  11. generation: inspectorId<'InspectorSourceGeneration'>('network-generation', 'generation'),
  12. kind: 'host',
  13. label: 'Host',
  14. timeOriginMs: performance.timeOrigin,
  15. capabilities: [],
  16. }
  17. describe('Inspector Network domain', () => {
  18. it('bounds incomplete bodies and marks the retained prefix truncated', () => {
  19. const sendEvent = vi.fn()
  20. const sink: NetworkSink = { sendEvent }
  21. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 4 })
  22. const network = new NetworkDomain(store)
  23. network.enable(sink)
  24. store.append(source, requestRecords('first', 'abcdef'))
  25. const response = network.handle('Network.getResponseBody', { requestId: requestId('first') }, sink)
  26. expect(response).toEqual({
  27. body: Buffer.from('abcd').toString('base64'),
  28. base64Encoded: true,
  29. dshInspectorTruncated: true,
  30. })
  31. const dataEvent = sendEvent.mock.calls.find(call => call[0] === 'Network.dataReceived')
  32. expect(dataEvent?.[1]).toMatchObject({ dataLength: 6, encodedDataLength: 6 })
  33. expect(dataEvent?.[1]).not.toHaveProperty('data')
  34. })
  35. it('evicts completed requests before retaining a later body', () => {
  36. const sink: NetworkSink = { sendEvent: vi.fn() }
  37. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 4 })
  38. const network = new NetworkDomain(store)
  39. store.append(source, requestRecords('first', 'aaaa'))
  40. store.append(source, requestRecords('second', 'bbbb'))
  41. expect(() => network.handle('Network.getResponseBody', { requestId: requestId('first') }, sink)).toThrow(
  42. 'No resource with given identifier',
  43. )
  44. expect(network.handle('Network.getResponseBody', { requestId: requestId('second') }, sink)).toEqual({
  45. body: Buffer.from('bbbb').toString('base64'),
  46. base64Encoded: true,
  47. dshInspectorTruncated: false,
  48. })
  49. })
  50. it('streams later response chunks only to CDP sessions that opted in', () => {
  51. const firstSend = vi.fn()
  52. const secondSend = vi.fn()
  53. const first: NetworkSink = { sendEvent: firstSend }
  54. const second: NetworkSink = { sendEvent: secondSend }
  55. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  56. const network = new NetworkDomain(store)
  57. network.enable(first)
  58. network.enable(second)
  59. const records = requestRecords('stream', 'data: first\n\n')
  60. store.append(source, records.slice(0, 2))
  61. expect(network.handle('Network.streamResourceContent', { requestId: requestId('stream') }, first)).toEqual({
  62. bufferedData: '',
  63. })
  64. store.append(source, records.slice(2, 3))
  65. const firstData = firstSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')
  66. const secondData = secondSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')
  67. expect(firstData?.[1]).toMatchObject({ data: Buffer.from('data: first\n\n').toString('base64') })
  68. expect(secondData?.[1]).not.toHaveProperty('data')
  69. expect(network.handle('Network.streamResourceContent', { requestId: requestId('stream') }, second)).toEqual({
  70. bufferedData: Buffer.from('data: first\n\n').toString('base64'),
  71. })
  72. const later = Buffer.from('data: second\n\n').toString('base64')
  73. store.append(source, [{
  74. sequence: 4,
  75. monotonicMs: 4,
  76. topic: 'fetch/response-body-chunk',
  77. payload: { requestId: 'stream', data: later },
  78. }])
  79. expect(firstSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')?.[1]).toMatchObject({ data: later })
  80. expect(secondSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')?.[1]).toMatchObject({ data: later })
  81. })
  82. it('projects and replays parsed Server-Sent Events through the CDP EventSource path', () => {
  83. const liveSend = vi.fn()
  84. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  85. const network = new NetworkDomain(store)
  86. network.enable({ sendEvent: liveSend })
  87. store.append(source, eventStreamRecords('events'))
  88. expect(liveSend).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.objectContaining({
  89. type: 'EventSource',
  90. }))
  91. expect(liveSend).toHaveBeenCalledWith('Network.responseReceived', expect.objectContaining({
  92. type: 'EventSource',
  93. }))
  94. expect(liveSend.mock.calls
  95. .filter(call => call[0] === 'Network.eventSourceMessageReceived')
  96. .map(call => call[1] as unknown))
  97. .toEqual([
  98. expect.objectContaining({ eventName: 'message', eventId: '1', data: 'first' }),
  99. expect.objectContaining({ eventName: 'update', eventId: '2', data: 'second\nline' }),
  100. ])
  101. expect(liveSend.mock.calls.map(call => String(call[0]))).toEqual([
  102. 'Network.requestWillBeSent',
  103. 'Network.responseReceived',
  104. 'Network.eventSourceMessageReceived',
  105. 'Network.dataReceived',
  106. 'Network.eventSourceMessageReceived',
  107. 'Network.dataReceived',
  108. 'Network.loadingFinished',
  109. ])
  110. const replay = vi.fn()
  111. network.enable({ sendEvent: replay })
  112. expect(replay).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.objectContaining({
  113. type: 'EventSource',
  114. }))
  115. expect(replay.mock.calls
  116. .filter(call => call[0] === 'Network.eventSourceMessageReceived')
  117. .map(call => call[1] as unknown))
  118. .toEqual([
  119. expect.objectContaining({ timestamp: 0.003, eventName: 'message', eventId: '1', data: 'first' }),
  120. expect.objectContaining({ timestamp: 0.004, eventName: 'update', eventId: '2', data: 'second\nline' }),
  121. ])
  122. expect(replay.mock.calls.map(call => String(call[0]))).toEqual([
  123. 'Network.requestWillBeSent',
  124. 'Network.responseReceived',
  125. 'Network.eventSourceMessageReceived',
  126. 'Network.eventSourceMessageReceived',
  127. 'Network.loadingFinished',
  128. ])
  129. })
  130. it('bounds active request metadata and does not retain per-chunk events for replay', () => {
  131. const firstSend = vi.fn()
  132. const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1_024 })
  133. const network = new NetworkDomain(store)
  134. network.enable({ sendEvent: firstSend })
  135. store.append(source, requestRecords('active-first', 'first').slice(0, 1))
  136. store.append(source, requestRecords('active-second', 'second').slice(0, 1))
  137. expect(firstSend).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({
  138. requestId: requestId('active-first'),
  139. canceled: true,
  140. }))
  141. expect(() => network.handle(
  142. 'Network.getRequestPostData',
  143. { requestId: requestId('active-first') },
  144. { sendEvent: vi.fn() },
  145. )).toThrow('No resource with given identifier')
  146. expect(() => { store.append(source, requestRecords('active-first', 'first').slice(1)) }).not.toThrow()
  147. store.append(source, requestRecords('active-second', 'second').slice(1))
  148. const replay = vi.fn()
  149. network.enable({ sendEvent: replay })
  150. expect(replay.mock.calls.some(call => call[0] === 'Network.dataReceived')).toBe(false)
  151. expect(replay).toHaveBeenCalledTimes(3)
  152. expect(replay).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.any(Object))
  153. expect(replay).toHaveBeenNthCalledWith(2, 'Network.responseReceived', expect.any(Object))
  154. expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object))
  155. })
  156. it('finishes a response whose observer clone ended with a capture error', () => {
  157. const sendEvent = vi.fn()
  158. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  159. const network = new NetworkDomain(store)
  160. network.enable({ sendEvent })
  161. const records = requestRecords('capture-error', 'partial')
  162. store.append(source, [
  163. ...records.slice(0, 3),
  164. {
  165. sequence: 4,
  166. monotonicMs: 4,
  167. topic: 'fetch/end',
  168. payload: {
  169. requestId: 'capture-error',
  170. capturedBytes: 7,
  171. responseBodyTruncated: true,
  172. responseCaptureError: 'AbortError: aborted',
  173. },
  174. },
  175. ])
  176. expect(sendEvent).toHaveBeenCalledWith('Network.loadingFinished', expect.objectContaining({
  177. requestId: requestId('capture-error'),
  178. encodedDataLength: 7,
  179. dshInspectorTruncated: true,
  180. }))
  181. expect(sendEvent.mock.calls.some(call => call[0] === 'Network.loadingFailed')).toBe(false)
  182. expect(network.handle('Network.getResponseBody', { requestId: requestId('capture-error') }, { sendEvent: vi.fn() }))
  183. .toMatchObject({
  184. body: Buffer.from('partial').toString('base64'),
  185. dshInspectorTruncated: true,
  186. dshInspectorCaptureError: 'AbortError: aborted',
  187. })
  188. })
  189. it('marks a failure after response headers truncated with the transport error', () => {
  190. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  191. const observed: unknown[] = []
  192. const unsubscribe = store.subscribe((event) => { observed.push(event) })
  193. store.append(source, [
  194. ...requestRecords('midstream', 'partial').slice(0, 3),
  195. {
  196. sequence: 4,
  197. monotonicMs: 4,
  198. topic: 'fetch/error',
  199. payload: { requestId: 'midstream', message: 'socket reset', canceled: false },
  200. },
  201. ])
  202. expect(store.responseBody(requestId('midstream'))).toMatchObject({
  203. bytes: Buffer.from('partial'),
  204. truncated: true,
  205. captureError: 'socket reset',
  206. complete: true,
  207. })
  208. expect(observed.at(-1)).toMatchObject({ type: 'request-failed', errorText: 'socket reset', canceled: false })
  209. unsubscribe()
  210. store.dispose()
  211. })
  212. it('retains request capture metadata and isolates malformed observations', () => {
  213. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  214. const observed: unknown[] = []
  215. store.subscribe(() => { throw new Error('broken observer') })
  216. const unsubscribe = store.subscribe((event) => { observed.push(event) })
  217. const start = requestRecords('metadata', 'response')[0]!
  218. store.append(source, [
  219. { ...start, topic: 'ignored/topic' },
  220. { ...start, payload: null },
  221. start,
  222. start,
  223. { sequence: 2, monotonicMs: 2, topic: 'fetch/request-body-chunk', payload: { requestId: 'metadata', data: Buffer.from('body').toString('base64') } },
  224. { sequence: 3, monotonicMs: 3, topic: 'fetch/request-body-end', payload: { requestId: 'metadata', truncated: true, captureError: 'request capture failed' } },
  225. ])
  226. expect(store.requestBody(requestId('metadata'))).toMatchObject({
  227. bytes: Buffer.from('body'),
  228. truncated: true,
  229. captureError: 'request capture failed',
  230. complete: false,
  231. })
  232. expect(() => store.responseBody(requestId('metadata'))).toThrow('response headers have not arrived')
  233. store.append(source, [
  234. requestRecords('metadata', 'response')[1]!,
  235. requestRecords('metadata', 'response')[2]!,
  236. {
  237. sequence: 4,
  238. monotonicMs: 4,
  239. topic: 'fetch/end',
  240. payload: {
  241. requestId: 'metadata',
  242. capturedBytes: 8,
  243. responseBodyTruncated: true,
  244. responseCaptureError: 'response capture failed',
  245. },
  246. },
  247. {
  248. sequence: 5,
  249. monotonicMs: 5,
  250. topic: 'fetch/error',
  251. payload: { requestId: 'metadata', message: 'late failure', canceled: false },
  252. },
  253. ])
  254. expect(store.responseBody(requestId('metadata'))).toMatchObject({
  255. bytes: Buffer.from('response'),
  256. truncated: true,
  257. captureError: 'response capture failed',
  258. complete: true,
  259. })
  260. expect(observed).toHaveLength(4)
  261. unsubscribe()
  262. store.dispose()
  263. expect(() => store.requestBody(requestId('metadata'))).toThrow('No resource with given identifier')
  264. expect(() => store.requestBody(1)).toThrow('Network requestId must be a string')
  265. })
  266. it('closes only active requests from the selected source and supports replacement', () => {
  267. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  268. const observed: Array<{ type: string; requestId?: string }> = []
  269. store.subscribe((event) => { observed.push(event) })
  270. const clientSource: InspectorSourceDescriptor = {
  271. ...source,
  272. sourceId: inspectorId<'InspectorSourceId'>('other-network', 'sourceId'),
  273. generation: inspectorId<'InspectorSourceGeneration'>('other-generation', 'generation'),
  274. kind: 'client',
  275. }
  276. store.append(source, requestRecords('complete', 'done'))
  277. store.append(source, requestRecords('active', 'partial').slice(0, 3))
  278. store.append(clientSource, requestRecords('other', 'partial').slice(0, 3))
  279. store.close(source, 'source closed')
  280. expect(observed.filter(event => event.type === 'request-failed')).toEqual([
  281. expect.objectContaining({ requestId: requestId('active') }),
  282. ])
  283. store.close(source, 'source closed again')
  284. store.replace(clientSource, [])
  285. expect(observed.filter(event => event.type === 'request-failed')).toHaveLength(2)
  286. })
  287. it('rejects malformed fetch fields without losing later valid records', () => {
  288. const store = new NetworkStore({ maxRetainedRequests: 20, maxJournalBytes: 1_024 })
  289. const validStart = requestRecords('valid', 'ok')[0]!
  290. const malformed: IngestedInspectorRecord[] = [
  291. { ...validStart, payload: null },
  292. { ...validStart, payload: { ...validStart.payload as object, requestId: 1 } },
  293. { ...validStart, payload: { ...validStart.payload as object, wallTimeMs: Number.POSITIVE_INFINITY } },
  294. { ...validStart, payload: { ...validStart.payload as object, headers: {} } },
  295. { ...validStart, payload: { ...validStart.payload as object, headers: [[1, 'value']] } },
  296. { ...validStart, payload: { ...validStart.payload as object, hasBody: 'yes' } },
  297. ]
  298. store.append(source, [...malformed, validStart])
  299. const invalidPayloads: InspectorJsonValue[] = [
  300. { requestId: 'valid', data: '' },
  301. { requestId: 'valid', data: 'abc' },
  302. { requestId: 'valid', data: '!!!!' },
  303. { requestId: 'valid', data: 'ZE==' },
  304. ]
  305. store.append(source, invalidPayloads.map((payload, index) => ({
  306. sequence: index + 2,
  307. monotonicMs: index + 2,
  308. topic: 'fetch/request-body-chunk',
  309. payload,
  310. })))
  311. store.append(source, [
  312. { sequence: 10, monotonicMs: 10, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: 'yes' } },
  313. { sequence: 11, monotonicMs: 11, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: false, captureError: 1 } },
  314. { sequence: 12, monotonicMs: 12, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: '200', statusText: 'OK', headers: [], mimeType: 'text/plain' } },
  315. { sequence: 13, monotonicMs: 13, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: 200, statusText: 'OK', headers: [['bad']], mimeType: 'text/plain' } },
  316. requestRecords('valid', 'ok')[1]!,
  317. requestRecords('valid', 'ok')[2]!,
  318. requestRecords('valid', 'ok')[3]!,
  319. requestRecords('valid', 'ok')[3]!,
  320. ])
  321. expect(store.responseBody(requestId('valid')).bytes).toEqual(Buffer.from('ok'))
  322. const failedStart = requestRecords('failed-before-response', '')[0]!
  323. store.append(source, [failedStart, {
  324. sequence: 20,
  325. monotonicMs: 20,
  326. topic: 'fetch/error',
  327. payload: { requestId: 'failed-before-response', message: 'connection failed', canceled: false },
  328. }])
  329. })
  330. it('tracks zero-byte truncation and evicts a completed request before an active request', () => {
  331. const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1 })
  332. store.append(source, requestRecords('completed', 'a'))
  333. const active = requestRecords('active', 'bc')
  334. store.append(source, [
  335. active[0]!,
  336. {
  337. sequence: 2,
  338. monotonicMs: 2,
  339. topic: 'fetch/request-body-chunk',
  340. payload: { requestId: 'active', data: Buffer.from('x').toString('base64') },
  341. },
  342. active[1]!,
  343. active[2]!,
  344. ])
  345. expect(() => store.requestBody(requestId('completed'))).toThrow('No resource with given identifier')
  346. expect(store.responseBody(requestId('active'))).toMatchObject({
  347. bytes: Buffer.alloc(0),
  348. truncated: true,
  349. complete: false,
  350. })
  351. store.append(source, [{
  352. sequence: 4,
  353. monotonicMs: 4,
  354. topic: 'fetch/request-body-chunk',
  355. payload: { requestId: 'active', data: Buffer.from('d').toString('base64') },
  356. }])
  357. expect(store.requestBody(requestId('active'))).toMatchObject({ bytes: Buffer.from('x'), truncated: true })
  358. })
  359. it('rejects a non-list header field without dropping the active request', () => {
  360. const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
  361. const start = requestRecords('headers', 'ok')[0]!
  362. store.append(source, [{ ...start, payload: { ...start.payload as object, headers: null } }, start])
  363. expect(store.requestBody(requestId('headers')).complete).toBe(false)
  364. })
  365. })
  366. function requestRecords(localId: string, body: string): IngestedInspectorRecord[] {
  367. return [
  368. {
  369. sequence: 1,
  370. monotonicMs: 1,
  371. topic: 'fetch/start',
  372. payload: { requestId: localId, url: 'https://example.test/', method: 'GET', headers: [], hasBody: false, wallTimeMs: 1 },
  373. },
  374. {
  375. sequence: 2,
  376. monotonicMs: 2,
  377. topic: 'fetch/response',
  378. payload: { requestId: localId, url: 'https://example.test/', status: 200, statusText: 'OK', headers: [], mimeType: 'text/plain' },
  379. },
  380. {
  381. sequence: 3,
  382. monotonicMs: 3,
  383. topic: 'fetch/response-body-chunk',
  384. payload: { requestId: localId, data: Buffer.from(body).toString('base64') },
  385. },
  386. {
  387. sequence: 4,
  388. monotonicMs: 4,
  389. topic: 'fetch/end',
  390. payload: { requestId: localId, capturedBytes: body.length, responseBodyTruncated: false },
  391. },
  392. ]
  393. }
  394. function eventStreamRecords(localId: string): IngestedInspectorRecord[] {
  395. const first = 'id: 1\ndata: first\n\n'
  396. const second = 'id: 2\nevent: update\ndata: second\ndata: line\n\n'
  397. return [
  398. {
  399. sequence: 1,
  400. monotonicMs: 1,
  401. topic: 'fetch/start',
  402. payload: { requestId: localId, url: 'https://example.test/events', method: 'GET', headers: [], hasBody: false, wallTimeMs: 1 },
  403. },
  404. {
  405. sequence: 2,
  406. monotonicMs: 2,
  407. topic: 'fetch/response',
  408. payload: {
  409. requestId: localId,
  410. url: 'https://example.test/events',
  411. status: 200,
  412. statusText: 'OK',
  413. headers: [['content-type', 'text/event-stream; charset=utf-8']],
  414. mimeType: 'TEXT/EVENT-STREAM',
  415. },
  416. },
  417. {
  418. sequence: 3,
  419. monotonicMs: 3,
  420. topic: 'fetch/response-body-chunk',
  421. payload: { requestId: localId, data: Buffer.from(first).toString('base64') },
  422. },
  423. {
  424. sequence: 4,
  425. monotonicMs: 4,
  426. topic: 'fetch/response-body-chunk',
  427. payload: { requestId: localId, data: Buffer.from(second).toString('base64') },
  428. },
  429. {
  430. sequence: 5,
  431. monotonicMs: 5,
  432. topic: 'fetch/end',
  433. payload: { requestId: localId, capturedBytes: first.length + second.length, responseBodyTruncated: false },
  434. },
  435. ]
  436. }
  437. function requestId(localId: string): string {
  438. return `${source.sourceId}:${source.generation}:${localId}`
  439. }