integration.host.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. /** Host-driven integration over an isolated Client fixture. */
  2. import { createServer, type Server } from 'node:http'
  3. import { createContext, runInContext } from 'node:vm'
  4. import WebSocket, { type RawData } from 'ws'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
  7. import { InspectorClientFixture } from './fixtures/client-source.host.ts'
  8. interface CdpMessage {
  9. readonly id?: number
  10. readonly method?: string
  11. readonly params?: Record<string, unknown>
  12. readonly result?: Record<string, unknown>
  13. readonly error?: { message: string }
  14. }
  15. class TestCdpClient {
  16. private nextId = 0
  17. private readonly pending = new Map<number, (message: CdpMessage) => void>()
  18. readonly events: CdpMessage[] = []
  19. private constructor(private readonly socket: WebSocket) {
  20. socket.on('message', (data) => {
  21. const message = JSON.parse(rawText(data)) as CdpMessage
  22. if (message.id !== undefined) this.pending.get(message.id)?.(message)
  23. else this.events.push(message)
  24. })
  25. }
  26. static async connect(url: string): Promise<TestCdpClient> {
  27. const socket = new WebSocket(url)
  28. await new Promise<void>((resolve, reject) => {
  29. socket.once('open', () => { resolve() })
  30. socket.once('error', reject)
  31. })
  32. return new TestCdpClient(socket)
  33. }
  34. call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
  35. const id = ++this.nextId
  36. return new Promise((resolve, reject) => {
  37. const timer = setTimeout(() => {
  38. this.pending.delete(id)
  39. reject(new Error(`CDP call timed out: ${method}`))
  40. }, 5_000)
  41. this.pending.set(id, (message) => {
  42. clearTimeout(timer)
  43. this.pending.delete(id)
  44. resolve(message)
  45. })
  46. this.socket.send(JSON.stringify({ id, method, params }))
  47. })
  48. }
  49. async close(): Promise<void> {
  50. if (this.socket.readyState === WebSocket.CLOSED) return
  51. const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
  52. this.socket.close()
  53. await closed
  54. }
  55. }
  56. describe('experimental Inspector real Worker', () => {
  57. let inspector: InspectorHandle | undefined
  58. let cdp: TestCdpClient | undefined
  59. let secondCdp: TestCdpClient | undefined
  60. let client: InspectorClientFixture | undefined
  61. let server: Server | undefined
  62. afterEach(async () => {
  63. await client?.close()
  64. client = undefined
  65. await cdp?.close()
  66. cdp = undefined
  67. await secondCdp?.close()
  68. secondCdp = undefined
  69. await inspector?.close()
  70. inspector = undefined
  71. if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
  72. server = undefined
  73. })
  74. it('switches between Host and Client contexts and routes Client RemoteObjects', async () => {
  75. inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
  76. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  77. inspector.source.publish('host/probe', { value: 1 })
  78. client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Test Client' })
  79. await client.publish('client/probe', { value: 2 })
  80. await vi.waitFor(async () => {
  81. const response = await cdp!.call('DSHInspector.getSources')
  82. const sources = response.result?.sources as Array<{ kind: string; topics: Record<string, number> }>
  83. expect(sources.find(source => source.kind === 'host')?.topics).toEqual({ 'host/probe': 1 })
  84. expect(sources.find(source => source.kind === 'client')?.topics).toMatchObject({ 'client/probe': 1 })
  85. })
  86. ;(globalThis as Record<string, unknown>).__inspectorHostProbe = 73
  87. expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
  88. let clientContextId: number | undefined
  89. let clientUniqueContextId: string | undefined
  90. await vi.waitFor(() => {
  91. expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true)
  92. const clientContext = cdp!.events
  93. .filter(event => event.method === 'Runtime.executionContextCreated')
  94. .map(event => event.params?.context as Record<string, unknown> | undefined)
  95. .find(context => String(context?.name).startsWith('Client —'))
  96. expect(clientContext).toBeDefined()
  97. clientContextId = clientContext?.id as number
  98. clientUniqueContextId = clientContext?.uniqueId as string
  99. })
  100. if (clientContextId === undefined || clientUniqueContextId === undefined) {
  101. throw new Error('Client execution context was not announced')
  102. }
  103. const hostEvaluated = await cdp.call('Runtime.evaluate', {
  104. expression: 'globalThis.__inspectorHostProbe',
  105. returnByValue: true,
  106. })
  107. expect(hostEvaluated.result?.result).toMatchObject({ type: 'number', value: 73 })
  108. await client.setGlobal('__inspectorClientProbe', { value: 17, nested: { ready: true } })
  109. const clientEvaluated = await cdp.call('Runtime.evaluate', {
  110. expression: 'globalThis.__inspectorClientProbe',
  111. contextId: clientContextId,
  112. objectGroup: 'console',
  113. generatePreview: true,
  114. })
  115. const clientObject = clientEvaluated.result?.result as Record<string, unknown>
  116. expect(clientObject).toMatchObject({ type: 'object', className: 'Object' })
  117. expect(String(clientObject.objectId)).toMatch(/^runtime:/u)
  118. const properties = await cdp.call('Runtime.getProperties', {
  119. objectId: clientObject.objectId,
  120. ownProperties: true,
  121. })
  122. const propertyRows = recordArray(properties.result?.result)
  123. const valueProperty = propertyRows.find(property => property.name === 'value')
  124. const nestedProperty = propertyRows.find(property => property.name === 'nested')
  125. expect(asRecord(valueProperty?.value)).toMatchObject({ type: 'number', value: 17 })
  126. expect(asRecord(nestedProperty?.value).type).toBe('object')
  127. const called = await cdp.call('Runtime.callFunctionOn', {
  128. objectId: clientObject.objectId,
  129. functionDeclaration: 'function (increment) { return this.value + increment }',
  130. arguments: [{ value: 5 }],
  131. returnByValue: true,
  132. })
  133. expect(called.result?.result).toMatchObject({ type: 'number', value: 22 })
  134. const hostObject = await cdp.call('Runtime.evaluate', { expression: '({ realm: "host" })' })
  135. const hostObjectId = asRecord(hostObject.result?.result).objectId
  136. expect((await cdp.call('Runtime.callFunctionOn', {
  137. executionContextId: clientContextId,
  138. functionDeclaration: 'function (value) { return value }',
  139. arguments: [{ objectId: hostObjectId }],
  140. })).error?.message).toContain('between realms')
  141. expect((await cdp.call('Runtime.callFunctionOn', {
  142. objectId: hostObjectId,
  143. functionDeclaration: 'function (value) { return value }',
  144. arguments: [{ objectId: clientObject.objectId }],
  145. })).error?.message).toContain('between realms')
  146. expect((await cdp.call('Runtime.queryObjects', {
  147. prototypeObjectId: clientObject.objectId,
  148. })).error?.message).toContain('Client realm has no native CDP transport')
  149. const awaited = await cdp.call('Runtime.evaluate', {
  150. expression: 'Promise.resolve({ realm: "client" })',
  151. contextId: clientContextId,
  152. awaitPromise: true,
  153. returnByValue: true,
  154. })
  155. expect(awaited.result?.result).toMatchObject({ type: 'object', value: { realm: 'client' } })
  156. const uniquelyRouted = await cdp.call('Runtime.evaluate', {
  157. expression: '6 * 7',
  158. uniqueContextId: clientUniqueContextId,
  159. returnByValue: true,
  160. })
  161. expect(uniquelyRouted.result?.result).toMatchObject({ type: 'number', value: 42 })
  162. expect((await cdp.call('Runtime.releaseObject', { objectId: clientObject.objectId })).error).toBeUndefined()
  163. expect((await cdp.call('Runtime.getProperties', { objectId: clientObject.objectId })).error).toBeDefined()
  164. const thrown = await cdp.call('Runtime.evaluate', {
  165. expression: 'throw new Error("client failure")',
  166. contextId: clientContextId,
  167. })
  168. expect(asRecord(thrown.result?.exceptionDetails)).toMatchObject({
  169. text: 'Uncaught',
  170. executionContextId: clientContextId,
  171. })
  172. const pendingEvaluation = cdp.call('Runtime.evaluate', {
  173. expression: 'new Promise(() => {})',
  174. contextId: clientContextId,
  175. awaitPromise: true,
  176. })
  177. await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
  178. await client.close()
  179. client = undefined
  180. expect((await pendingEvaluation).error).toBeDefined()
  181. await vi.waitFor(() => {
  182. expect(cdp!.events.some(event =>
  183. event.method === 'Runtime.executionContextDestroyed'
  184. && event.params?.executionContextId === clientContextId)).toBe(true)
  185. })
  186. })
  187. it('isolates Client object ids and object groups by DevTools connection', async () => {
  188. inspector = await startInspector({ port: 0, captureFetch: false })
  189. client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Shared Client' })
  190. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  191. secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  192. await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
  193. const firstContext = await clientContext(cdp)
  194. const secondContext = await clientContext(secondCdp)
  195. const first = await cdp.call('Runtime.evaluate', {
  196. expression: '({ owner: "first" })',
  197. contextId: firstContext,
  198. objectGroup: 'console',
  199. })
  200. const second = await secondCdp.call('Runtime.evaluate', {
  201. expression: '({ owner: "second" })',
  202. contextId: secondContext,
  203. objectGroup: 'console',
  204. })
  205. const firstObjectId = asRecord(first.result?.result).objectId
  206. const secondObjectId = asRecord(second.result?.result).objectId
  207. expect(firstObjectId).not.toBe(secondObjectId)
  208. expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
  209. await cdp.close()
  210. cdp = undefined
  211. const secondProperties = await secondCdp.call('Runtime.getProperties', {
  212. objectId: secondObjectId,
  213. ownProperties: true,
  214. })
  215. const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
  216. expect(asRecord(owner?.value).value).toBe('second')
  217. expect((await secondCdp.call('Runtime.releaseObjectGroup', { objectGroup: 'console' })).error).toBeUndefined()
  218. expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeDefined()
  219. const beforeDisable = await secondCdp.call('Runtime.evaluate', {
  220. expression: '({ retained: true })',
  221. contextId: secondContext,
  222. })
  223. const disabledObjectId = asRecord(beforeDisable.result?.result).objectId
  224. expect((await secondCdp.call('Runtime.disable')).error).toBeUndefined()
  225. expect((await secondCdp.call('Runtime.enable')).error).toBeUndefined()
  226. expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined()
  227. })
  228. it('cancels Client Runtime work when the Worker deadline expires', async () => {
  229. inspector = await startInspector({ port: 0, captureFetch: false, clientRuntimeTimeoutMs: 20 })
  230. client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Timeout Client' })
  231. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  232. await cdp.call('Runtime.enable')
  233. const contextId = await clientContext(cdp)
  234. const timedOut = await cdp.call('Runtime.evaluate', {
  235. expression: 'new Promise(() => {})',
  236. contextId,
  237. awaitPromise: true,
  238. })
  239. expect(timedOut.error?.message).toContain('timed out after 20ms')
  240. expect((await cdp.call('Runtime.evaluate', {
  241. expression: '42',
  242. contextId,
  243. returnByValue: true,
  244. })).result?.result).toMatchObject({ type: 'number', value: 42 })
  245. })
  246. it('preserves native Host execution-context selectors', async () => {
  247. inspector = await startInspector({ port: 0, captureFetch: false })
  248. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  249. await cdp.call('Runtime.enable')
  250. const context = createContext({}, { name: 'Inspector VM Context' })
  251. runInContext('globalThis.vmMarker = "selected-vm"; let vmLexicalMarker = 1', context)
  252. let contextId: number | undefined
  253. let uniqueContextId: string | undefined
  254. await vi.waitFor(() => {
  255. const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Inspector VM Context')
  256. contextId = created?.id as number | undefined
  257. uniqueContextId = created?.uniqueId as string | undefined
  258. expect(contextId).toBeTypeOf('number')
  259. expect(uniqueContextId).toBeTypeOf('string')
  260. })
  261. const evaluated = await cdp.call('Runtime.evaluate', {
  262. expression: 'globalThis.vmMarker',
  263. contextId,
  264. returnByValue: true,
  265. })
  266. expect(evaluated.result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
  267. expect((await cdp.call('Runtime.evaluate', {
  268. expression: 'globalThis.vmMarker',
  269. uniqueContextId,
  270. returnByValue: true,
  271. })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
  272. expect((await cdp.call('Runtime.callFunctionOn', {
  273. executionContextId: contextId,
  274. functionDeclaration: 'function () { return globalThis.vmMarker }',
  275. returnByValue: true,
  276. })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
  277. expect((await cdp.call('Runtime.globalLexicalScopeNames', { executionContextId: contextId })).result?.names)
  278. .toContain('vmLexicalMarker')
  279. })
  280. it('uses the same Runtime value model for Host and Client realms', async () => {
  281. inspector = await startInspector({ port: 0, captureFetch: false })
  282. client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' })
  283. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  284. await cdp.call('Runtime.enable')
  285. const clientContextId = await clientContext(cdp)
  286. for (const [name, contextId] of [['Host', undefined], ['Client', clientContextId]] as const) {
  287. const select = contextId === undefined ? {} : { contextId }
  288. const nan = await cdp.call('Runtime.evaluate', { expression: 'NaN', ...select })
  289. expect(nan.result?.result, name).toMatchObject({ type: 'number', unserializableValue: 'NaN' })
  290. const array = await cdp.call('Runtime.evaluate', {
  291. expression: '[1, 2]',
  292. objectGroup: `compat-${name}`,
  293. ...select,
  294. })
  295. const arrayObject = asRecord(array.result?.result)
  296. expect(arrayObject, name).toMatchObject({ type: 'object', subtype: 'array', className: 'Array' })
  297. const properties = await cdp.call('Runtime.getProperties', {
  298. objectId: arrayObject.objectId,
  299. ownProperties: true,
  300. })
  301. const first = recordArray(properties.result?.result).find(property => property.name === '0')
  302. expect(first, name).toMatchObject({ configurable: true, enumerable: true, writable: true })
  303. expect(asRecord(first?.value), name).toMatchObject({ type: 'number', value: 1 })
  304. const thrown = await cdp.call('Runtime.evaluate', {
  305. expression: 'throw new TypeError("realm-compatibility")',
  306. ...select,
  307. })
  308. expect(thrown.result?.result, name).toMatchObject({ type: 'object', subtype: 'error' })
  309. expect(thrown.result?.exceptionDetails, name).toMatchObject({ text: 'Uncaught' })
  310. expect((await cdp.call('Runtime.releaseObjectGroup', { objectGroup: `compat-${name}` })).error).toBeUndefined()
  311. expect((await cdp.call('Runtime.getProperties', { objectId: arrayObject.objectId })).error).toBeDefined()
  312. }
  313. expect((await cdp.call('Runtime.evaluate', {
  314. expression: '1 + 1',
  315. throwOnSideEffect: true,
  316. })).result?.result).toMatchObject({ type: 'number', value: 2 })
  317. expect((await cdp.call('Runtime.evaluate', {
  318. expression: '1 + 1',
  319. contextId: clientContextId,
  320. throwOnSideEffect: true,
  321. })).error?.message).toContain('does not support throwOnSideEffect')
  322. expect((await cdp.call('Runtime.compileScript', {
  323. expression: '1 + 1',
  324. sourceURL: 'client-eval.js',
  325. persistScript: true,
  326. executionContextId: clientContextId,
  327. })).error?.message).toContain('Client realm has no native CDP transport')
  328. })
  329. it('forwards Client Console objects through isolated realm sessions', async () => {
  330. inspector = await startInspector({ port: 0, captureFetch: false })
  331. client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Console Client' })
  332. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  333. secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  334. await vi.waitFor(async () => {
  335. const response = await cdp!.call('DSHInspector.getSources')
  336. expect(recordArray(response.result?.sources).some(source => source.kind === 'client')).toBe(true)
  337. })
  338. // The MessagePort can deliver log requests before ingest receives Console subscriptions.
  339. await client.setIngestPaused(true)
  340. await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
  341. const firstContext = await clientContext(cdp)
  342. const secondContext = await clientContext(secondCdp)
  343. const value = { owner: 'client-console' }
  344. const marker = 'client-console-event'
  345. const logged = (async () => {
  346. // Both subscriptions precede this request on the same ingest WebSocket.
  347. // A Client response, unlike Runtime.enable, acknowledges their delivery.
  348. expect((await cdp.call('Runtime.evaluate', {
  349. contextId: firstContext,
  350. expression: 'void 0',
  351. })).error).toBeUndefined()
  352. await client.log(value, marker)
  353. })()
  354. await Promise.all([logged, client.setIngestPaused(false)])
  355. let firstEvent: CdpMessage | undefined
  356. let secondEvent: CdpMessage | undefined
  357. await vi.waitFor(() => {
  358. firstEvent = consoleEvent(cdp!, firstContext, marker)
  359. secondEvent = consoleEvent(secondCdp!, secondContext, marker)
  360. expect(firstEvent).toBeDefined()
  361. expect(secondEvent).toBeDefined()
  362. })
  363. const firstObjectId = asRecord(recordArray(firstEvent!.params?.args)[0]).objectId
  364. const secondObjectId = asRecord(recordArray(secondEvent!.params?.args)[0]).objectId
  365. expect(firstObjectId).toBeTypeOf('string')
  366. expect(secondObjectId).toBeTypeOf('string')
  367. expect(firstObjectId).not.toBe(secondObjectId)
  368. expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
  369. const secondProperties = await secondCdp.call('Runtime.getProperties', {
  370. objectId: secondObjectId,
  371. ownProperties: true,
  372. })
  373. const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
  374. expect(asRecord(owner?.value).value).toBe('client-console')
  375. expect((await cdp.call('Runtime.discardConsoleEntries')).error).toBeUndefined()
  376. expect((await cdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
  377. expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeUndefined()
  378. await client.setIngestPaused(true)
  379. await client.close()
  380. client = undefined
  381. await vi.waitFor(() => {
  382. for (const [connection, contextId] of [[cdp!, firstContext], [secondCdp!, secondContext]] as const) {
  383. expect(connection.events.some(event => event.method === 'Runtime.executionContextDestroyed'
  384. && event.params?.executionContextId === contextId)).toBe(true)
  385. }
  386. })
  387. })
  388. it('projects a chunked Client bundle as read-only Debugger source', async () => {
  389. const sourceText = `const clientSourceMarker = 42\n/*${'x'.repeat(150_000)}*/\n`
  390. const sourceMap = JSON.stringify({ version: 3, sources: ['client/index.ts'], mappings: 'AAAA' })
  391. const sourceUrl = 'http://client.test/plugins/inspector/client.js?rev=test'
  392. const sourceMapUrl = 'http://client.test/plugins/inspector/client.js.map?rev=test'
  393. inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
  394. client = await InspectorClientFixture.start(inspector.endpoint.client, {
  395. label: 'Source Client',
  396. sourceCatalog: { sourceText, sourceMap, sourceUrl, sourceMapUrl },
  397. })
  398. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  399. await cdp.call('Runtime.enable')
  400. const contextId = await clientContext(cdp)
  401. expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
  402. let script: CdpMessage | undefined
  403. await vi.waitFor(() => {
  404. script = cdp!.events.find(event => event.method === 'Debugger.scriptParsed'
  405. && event.params?.url === sourceUrl)
  406. expect(script).toBeDefined()
  407. })
  408. expect(script?.params).toMatchObject({
  409. executionContextId: contextId,
  410. sourceMapURL: sourceMapUrl,
  411. hash: 'test',
  412. isModule: false,
  413. length: sourceText.length,
  414. })
  415. const scriptId = script?.params?.scriptId
  416. expect(scriptId).toBeTypeOf('string')
  417. await expect(cdp.call('Debugger.getScriptSource', { scriptId })).resolves.toMatchObject({
  418. result: { scriptSource: sourceText },
  419. })
  420. await expect(cdp.call('Debugger.searchInContent', {
  421. scriptId,
  422. query: 'clientSourceMarker',
  423. caseSensitive: true,
  424. })).resolves.toMatchObject({
  425. result: { result: [{ lineNumber: 0, lineContent: 'const clientSourceMarker = 42' }] },
  426. })
  427. expect((await cdp.call('Debugger.setBreakpointByUrl', { url: sourceUrl, lineNumber: 0 })).error?.message)
  428. .toContain('Client native debugging is unavailable')
  429. expect((await cdp.call('Debugger.setBreakpointByUrl', {
  430. urlRegex: 'client\\.js',
  431. lineNumber: 0,
  432. })).error?.message).toContain('Client native debugging is unavailable')
  433. expect((await cdp.call('Debugger.setBreakpointByUrl', {
  434. scriptHash: 'test',
  435. lineNumber: 0,
  436. })).error?.message).toContain('Client native debugging is unavailable')
  437. expect((await cdp.call('Debugger.evaluateOnCallFrame', {
  438. callFrameId: 'client:unsupported-frame',
  439. expression: '1',
  440. })).error?.message).toContain('Client native debugging is unavailable')
  441. }, 15_000)
  442. it('projects full Host fetch data through the Network domain', async () => {
  443. server = createServer((request, response) => {
  444. let body = ''
  445. request.setEncoding('utf8')
  446. request.on('data', (chunk: string) => { body += chunk })
  447. request.on('end', () => {
  448. response.writeHead(201, { authorization: 'response-secret', 'content-type': 'application/json' })
  449. response.end(JSON.stringify({ body }))
  450. })
  451. })
  452. await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
  453. const port = (server.address() as import('node:net').AddressInfo).port
  454. inspector = await startInspector({ port: 0 })
  455. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  456. await cdp.call('Network.enable')
  457. const response = await fetch(`http://127.0.0.1:${String(port)}/capture?secret=query`, {
  458. method: 'POST',
  459. headers: { authorization: 'Bearer request-secret' },
  460. body: 'request-body',
  461. })
  462. expect(await response.json()).toEqual({ body: 'request-body' })
  463. let started: CdpMessage | undefined
  464. await vi.waitFor(() => {
  465. started = cdp!.events.find(event =>
  466. event.method === 'Network.requestWillBeSent'
  467. && String((event.params?.request as Record<string, unknown> | undefined)?.url).includes('/capture'))
  468. expect(started).toBeDefined()
  469. expect(cdp!.events.some(event =>
  470. event.method === 'Network.loadingFinished'
  471. && event.params?.requestId === started!.params?.requestId)).toBe(true)
  472. })
  473. const request = started!.params?.request as Record<string, unknown>
  474. expect(request.url).toBe(`http://127.0.0.1:${String(port)}/capture?secret=query`)
  475. expect(request.headers).toMatchObject({ authorization: 'Bearer request-secret' })
  476. const requestId = started!.params?.requestId
  477. const post = await cdp.call('Network.getRequestPostData', { requestId })
  478. expect(post.result?.postData).toBe('request-body')
  479. const body = await cdp.call('Network.getResponseBody', { requestId })
  480. expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe('{"body":"request-body"}')
  481. })
  482. it('streams later Host fetch response chunks to an opted-in CDP connection', async () => {
  483. const continueResponse = Promise.withResolvers<true>()
  484. const firstChunk = 'data: first\n\n'
  485. const laterChunk = 'event: update\nid: 2\ndata: second\ndata: line\n\n'
  486. server = createServer((_request, response) => {
  487. response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
  488. response.write(firstChunk)
  489. void continueResponse.promise.then(() => { response.end(laterChunk) })
  490. })
  491. await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
  492. const port = (server.address() as import('node:net').AddressInfo).port
  493. inspector = await startInspector({ port: 0 })
  494. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  495. await cdp.call('Network.enable')
  496. try {
  497. const response = await fetch(`http://127.0.0.1:${String(port)}/events`)
  498. let requestId: string | undefined
  499. await vi.waitFor(() => {
  500. const received = cdp!.events.find(event =>
  501. event.method === 'Network.responseReceived'
  502. && (event.params?.response as Record<string, unknown> | undefined)?.mimeType === 'text/event-stream')
  503. requestId = received?.params?.requestId as string | undefined
  504. expect(requestId).toBeTypeOf('string')
  505. expect(received?.params).toMatchObject({
  506. type: 'EventSource',
  507. response: { encodedDataLength: -1 },
  508. })
  509. expect(cdp!.events.find(event =>
  510. event.method === 'Network.requestWillBeSent'
  511. && event.params?.requestId === requestId)?.params?.type).toBe('EventSource')
  512. expect(cdp!.events.find(event =>
  513. event.method === 'Network.eventSourceMessageReceived'
  514. && event.params?.requestId === requestId)?.params).toMatchObject({
  515. eventName: 'message',
  516. eventId: '1',
  517. data: 'first',
  518. })
  519. expect(cdp!.events.some(event =>
  520. event.method === 'Network.dataReceived'
  521. && event.params?.requestId === requestId)).toBe(true)
  522. })
  523. if (requestId === undefined) throw new Error('SSE request was not observed')
  524. const streaming = await cdp.call('Network.streamResourceContent', { requestId })
  525. expect(Buffer.from(String(streaming.result?.bufferedData), 'base64').toString('utf8')).toBe(firstChunk)
  526. const laterEventOffset = cdp.events.length
  527. continueResponse.resolve(true)
  528. expect(await response.text()).toBe(firstChunk + laterChunk)
  529. await vi.waitFor(() => {
  530. expect(cdp!.events.some(event =>
  531. event.method === 'Network.loadingFinished'
  532. && event.params?.requestId === requestId)).toBe(true)
  533. const streamed = cdp!.events.slice(laterEventOffset)
  534. .filter(event => event.method === 'Network.dataReceived'
  535. && event.params?.requestId === requestId
  536. && typeof event.params?.data === 'string')
  537. .map(event => Buffer.from(String(event.params!.data), 'base64'))
  538. expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk)
  539. expect(cdp!.events.slice(laterEventOffset).find(event =>
  540. event.method === 'Network.eventSourceMessageReceived'
  541. && event.params?.requestId === requestId)?.params).toMatchObject({
  542. eventName: 'update',
  543. eventId: '2',
  544. data: 'second\nline',
  545. })
  546. })
  547. const body = await cdp.call('Network.getResponseBody', { requestId })
  548. expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(firstChunk + laterChunk)
  549. } finally {
  550. continueResponse.resolve(true)
  551. }
  552. })
  553. it('keeps captured EventSource data readable when the caller aborts after response headers', async () => {
  554. const eventStream = 'data: first\n\ndata: [DONE]\n\n'
  555. server = createServer((_request, response) => {
  556. response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
  557. response.write(eventStream)
  558. })
  559. await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
  560. const port = (server.address() as import('node:net').AddressInfo).port
  561. inspector = await startInspector({ port: 0 })
  562. cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  563. await cdp.call('Network.enable')
  564. const abort = new AbortController()
  565. const response = await fetch(`http://127.0.0.1:${String(port)}/aborted-events`, { signal: abort.signal })
  566. const reader = response.body?.getReader()
  567. if (reader === undefined) throw new Error('SSE response did not expose a body')
  568. expect(Buffer.from((await reader.read()).value ?? []).toString('utf8')).toBe(eventStream)
  569. let requestId: string | undefined
  570. await vi.waitFor(() => {
  571. const received = cdp!.events.find(event =>
  572. event.method === 'Network.responseReceived'
  573. && String((event.params?.response as Record<string, unknown> | undefined)?.url).includes('/aborted-events'))
  574. requestId = received?.params?.requestId as string | undefined
  575. expect(requestId).toBeTypeOf('string')
  576. expect(cdp!.events.filter(event =>
  577. event.method === 'Network.eventSourceMessageReceived'
  578. && event.params?.requestId === requestId).map(event => event.params?.data)).toEqual(['first', '[DONE]'])
  579. })
  580. abort.abort()
  581. await vi.waitFor(() => {
  582. expect(cdp!.events.some(event =>
  583. event.method === 'Network.loadingFinished'
  584. && event.params?.requestId === requestId)).toBe(true)
  585. })
  586. expect(cdp.events.some(event =>
  587. event.method === 'Network.loadingFailed'
  588. && event.params?.requestId === requestId)).toBe(false)
  589. const body = await cdp.call('Network.getResponseBody', { requestId })
  590. expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(eventStream)
  591. expect(body.result?.dshInspectorTruncated).toBe(true)
  592. expect(String(body.result?.dshInspectorCaptureError)).toContain('AbortError')
  593. })
  594. })
  595. async function clientContext(client: TestCdpClient): Promise<number> {
  596. let contextId: number | undefined
  597. await vi.waitFor(() => {
  598. const context = runtimeContexts(client).find(candidate => String(candidate.name).startsWith('Client —'))
  599. expect(context).toBeDefined()
  600. contextId = context?.id as number
  601. })
  602. if (contextId === undefined) throw new Error('Client execution context was not announced')
  603. return contextId
  604. }
  605. function runtimeContexts(client: TestCdpClient): Readonly<Record<string, unknown>>[] {
  606. return client.events
  607. .filter(event => event.method === 'Runtime.executionContextCreated')
  608. .map(event => asRecord(event.params?.context))
  609. }
  610. function consoleEvent(client: TestCdpClient, contextId: number, marker: string): CdpMessage | undefined {
  611. return client.events.find((event) => {
  612. if (event.method !== 'Runtime.consoleAPICalled' || event.params?.executionContextId !== contextId) return false
  613. const args = event.params.args
  614. return Array.isArray(args) && args.some(argument => asRecord(argument).value === marker)
  615. })
  616. }
  617. function recordArray(value: unknown): Readonly<Record<string, unknown>>[] {
  618. if (!Array.isArray(value)) throw new Error('expected an array of records')
  619. return value.map(asRecord)
  620. }
  621. function asRecord(value: unknown): Readonly<Record<string, unknown>> {
  622. if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
  623. return value as Readonly<Record<string, unknown>>
  624. }
  625. function rawText(data: RawData): string {
  626. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  627. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  628. return Buffer.from(data).toString('utf8')
  629. }