debugger.e2e.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  2. import { fileURLToPath } from 'node:url'
  3. import WebSocket, { type RawData } from 'ws'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { isPlainObject } from '../src/shared/json.ts'
  6. interface CdpMessage {
  7. readonly id?: number
  8. readonly method?: string
  9. readonly params?: Record<string, unknown>
  10. readonly result?: Record<string, unknown>
  11. readonly error?: { message: string }
  12. }
  13. class CdpClient {
  14. private nextId = 0
  15. private readonly pending = new Map<number, (message: CdpMessage) => void>()
  16. private readonly events: CdpMessage[] = []
  17. private readonly eventWaiters = new Set<() => void>()
  18. private constructor(private readonly socket: WebSocket) {
  19. socket.on('message', (data) => {
  20. const message = JSON.parse(rawText(data)) as CdpMessage
  21. if (message.id !== undefined) this.pending.get(message.id)?.(message)
  22. else {
  23. this.events.push(message)
  24. for (const wake of [...this.eventWaiters]) wake()
  25. }
  26. })
  27. }
  28. static async connect(url: string): Promise<CdpClient> {
  29. const socket = new WebSocket(url)
  30. await new Promise<void>((resolve, reject) => {
  31. socket.once('open', () => { resolve() })
  32. socket.once('error', reject)
  33. })
  34. return new CdpClient(socket)
  35. }
  36. call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
  37. const id = ++this.nextId
  38. return new Promise((resolve, reject) => {
  39. const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000)
  40. this.pending.set(id, (message) => {
  41. clearTimeout(timer)
  42. this.pending.delete(id)
  43. resolve(message)
  44. })
  45. this.socket.send(JSON.stringify({ id, method, params }))
  46. })
  47. }
  48. waitForEvent(method: string, predicate: (event: CdpMessage) => boolean = () => true): Promise<CdpMessage> {
  49. const found = this.events.find(event => event.method === method && predicate(event))
  50. if (found !== undefined) return Promise.resolve(found)
  51. return new Promise((resolve, reject) => {
  52. const timer = setTimeout(() => {
  53. this.eventWaiters.delete(check)
  54. reject(new Error(`CDP event timed out: ${method}`))
  55. }, 5_000)
  56. const check = (): void => {
  57. const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
  58. if (event === undefined) return
  59. clearTimeout(timer)
  60. this.eventWaiters.delete(check)
  61. resolve(event)
  62. }
  63. this.eventWaiters.add(check)
  64. })
  65. }
  66. async close(): Promise<void> {
  67. if (this.socket.readyState === WebSocket.CLOSED) return
  68. const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
  69. this.socket.close()
  70. await closed
  71. }
  72. }
  73. function rawText(data: RawData): string {
  74. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  75. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  76. return Buffer.from(data).toString('utf8')
  77. }
  78. describe('Host debugger through the Inspector Worker', () => {
  79. let child: ChildProcessWithoutNullStreams | undefined
  80. let cdp: CdpClient | undefined
  81. afterEach(async () => {
  82. await cdp?.close()
  83. cdp = undefined
  84. if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
  85. child = undefined
  86. })
  87. it('evaluates a paused Host frame and resumes while the main thread is stopped', async () => {
  88. const fixture = fileURLToPath(new URL('./fixtures/debug-host.ts', import.meta.url))
  89. const tsx = import.meta.resolve('tsx/esm')
  90. child = spawn(process.execPath, ['--import', tsx, fixture], {
  91. env: { ...process.env, TSX_TSCONFIG_PATH: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) },
  92. stdio: ['pipe', 'pipe', 'pipe'],
  93. })
  94. const firstLine = await readLine(child)
  95. const endpoint = JSON.parse(firstLine) as { webSocketDebuggerUrl: string }
  96. cdp = await CdpClient.connect(endpoint.webSocketDebuggerUrl)
  97. expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
  98. expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
  99. const parsed = await cdp.waitForEvent('Debugger.scriptParsed', event =>
  100. String(event.params?.url).endsWith('/debug-host.ts'))
  101. const scriptId = parsed.params?.scriptId
  102. expect(typeof scriptId).toBe('string')
  103. const source = await cdp.call('Debugger.getScriptSource', { scriptId })
  104. expect(source.result?.scriptSource).toContain('breakpointProbe')
  105. await cdp.call('Runtime.evaluate', { expression: 'console.log("host-console-probe")' })
  106. const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', (event) => {
  107. const args = event.params?.args
  108. return Array.isArray(args) && args.some(arg => isPlainObject(arg) && arg.value === 'host-console-probe')
  109. })
  110. expect(consoleEvent.params?.type).toBe('log')
  111. const evaluated = await cdp.call('Runtime.evaluate', {
  112. expression: 'globalThis.__inspectorBreakpointProbe',
  113. })
  114. const objectId = (evaluated.result?.result as Record<string, unknown> | undefined)?.objectId
  115. expect(typeof objectId).toBe('string')
  116. expect((await cdp.call('Debugger.setBreakpointOnFunctionCall', { objectId })).error).toBeUndefined()
  117. child.stdin.write('run\n')
  118. const paused = await cdp.waitForEvent('Debugger.paused')
  119. const callFrames = paused.params?.callFrames as Array<Record<string, unknown>>
  120. const callFrameId = callFrames[0]?.callFrameId
  121. expect(typeof callFrameId).toBe('string')
  122. const scopeChain = callFrames[0]?.scopeChain as Array<Record<string, unknown>>
  123. const scopeObjectId = (scopeChain[0]?.object as Record<string, unknown> | undefined)?.objectId
  124. expect(String(scopeObjectId)).toMatch(/^runtime:/u)
  125. expect((await cdp.call('Runtime.getProperties', { objectId: scopeObjectId })).error).toBeUndefined()
  126. // This Worker-local request must complete while the Host main thread is paused.
  127. expect((await cdp.call('DSHInspector.getSources')).result?.sources).toBeDefined()
  128. const local = await cdp.call('Debugger.evaluateOnCallFrame', {
  129. callFrameId,
  130. expression: 'value',
  131. returnByValue: true,
  132. })
  133. expect(local.result?.result).toMatchObject({ type: 'number', value: 41 })
  134. const object = await cdp.call('Debugger.evaluateOnCallFrame', {
  135. callFrameId,
  136. expression: '({ pausedValue: value })',
  137. objectGroup: 'backtrace',
  138. })
  139. const pausedObjectId = (object.result?.result as Record<string, unknown> | undefined)?.objectId
  140. expect(String(pausedObjectId)).toMatch(/^runtime:/u)
  141. expect((await cdp.call('Runtime.getProperties', { objectId: pausedObjectId })).error).toBeUndefined()
  142. expect((await cdp.call('Debugger.resume')).error).toBeUndefined()
  143. const completed = await cdp.call('Runtime.evaluate', {
  144. expression: 'globalThis.__inspectorBreakpointResult',
  145. returnByValue: true,
  146. })
  147. expect(completed.result?.result).toMatchObject({ type: 'number', value: 42 })
  148. const exited = new Promise<number | null>((resolve) => { child!.once('exit', resolve) })
  149. child.stdin.write('stop\n')
  150. expect(await exited).toBe(0)
  151. child = undefined
  152. cdp = undefined
  153. }, 20_000)
  154. })
  155. function readLine(child: ChildProcessWithoutNullStreams): Promise<string> {
  156. return new Promise((resolve, reject) => {
  157. let stdout = ''
  158. let stderr = ''
  159. const onData = (chunk: Buffer): void => {
  160. stdout += chunk.toString('utf8')
  161. const newline = stdout.indexOf('\n')
  162. if (newline === -1) return
  163. cleanup()
  164. resolve(stdout.slice(0, newline))
  165. }
  166. const onError = (error: Error): void => { cleanup(); reject(error) }
  167. const onExit = (): void => {
  168. cleanup()
  169. reject(new Error(`debug Host exited before output; stderr:\n${stderr}`))
  170. }
  171. const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf8') }
  172. const cleanup = (): void => {
  173. child.stdout.off('data', onData)
  174. child.stderr.off('data', onStderr)
  175. child.off('error', onError)
  176. child.off('exit', onExit)
  177. }
  178. child.stdout.on('data', onData)
  179. child.stderr.on('data', onStderr)
  180. child.once('error', onError)
  181. child.once('exit', onExit)
  182. })
  183. }