client-browser.e2e.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { existsSync } from 'node:fs'
  2. import { readFile } from 'node:fs/promises'
  3. import { createServer, type Server } from 'node:http'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { chromium, type Browser, type Page } from 'playwright'
  7. import WebSocket, { type RawData } from 'ws'
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
  10. const packageDirectory = fileURLToPath(new URL('..', import.meta.url))
  11. const clientBundlePath = join(packageDirectory, 'lib/client.js')
  12. const clientSourceMapPath = join(packageDirectory, 'lib/client.js.map')
  13. const built = existsSync(clientBundlePath) && existsSync(clientSourceMapPath)
  14. interface CdpMessage {
  15. readonly id?: number
  16. readonly method?: string
  17. readonly params?: Record<string, unknown>
  18. readonly result?: Record<string, unknown>
  19. readonly error?: { message: string }
  20. }
  21. class BrowserTestCdpClient {
  22. private nextId = 0
  23. private readonly pending = new Map<number, (message: CdpMessage) => void>()
  24. private readonly events: CdpMessage[] = []
  25. private readonly waiters = new Set<() => void>()
  26. private constructor(private readonly socket: WebSocket) {
  27. socket.on('message', (data) => {
  28. const message = JSON.parse(rawText(data)) as CdpMessage
  29. if (message.id !== undefined) {
  30. this.pending.get(message.id)?.(message)
  31. return
  32. }
  33. this.events.push(message)
  34. for (const waiter of [...this.waiters]) waiter()
  35. })
  36. }
  37. static async connect(url: string): Promise<BrowserTestCdpClient> {
  38. const socket = new WebSocket(url)
  39. await new Promise<void>((resolve, reject) => {
  40. socket.once('open', () => { resolve() })
  41. socket.once('error', reject)
  42. })
  43. return new BrowserTestCdpClient(socket)
  44. }
  45. call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
  46. const id = ++this.nextId
  47. return new Promise((resolve, reject) => {
  48. const timer = setTimeout(() => {
  49. this.pending.delete(id)
  50. reject(new Error(`CDP call timed out: ${method}`))
  51. }, 5_000)
  52. this.pending.set(id, (message) => {
  53. clearTimeout(timer)
  54. this.pending.delete(id)
  55. resolve(message)
  56. })
  57. this.socket.send(JSON.stringify({ id, method, params }))
  58. })
  59. }
  60. waitForEvent(method: string, predicate: (message: CdpMessage) => boolean): Promise<CdpMessage> {
  61. const existing = this.events.find(event => event.method === method && predicate(event))
  62. if (existing !== undefined) return Promise.resolve(existing)
  63. return new Promise((resolve, reject) => {
  64. const timer = setTimeout(() => {
  65. this.waiters.delete(check)
  66. reject(new Error(`CDP event timed out: ${method}`))
  67. }, 5_000)
  68. const check = (): void => {
  69. const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
  70. if (event === undefined) return
  71. clearTimeout(timer)
  72. this.waiters.delete(check)
  73. resolve(event)
  74. }
  75. this.waiters.add(check)
  76. })
  77. }
  78. async close(): Promise<void> {
  79. if (this.socket.readyState === WebSocket.CLOSED) return
  80. const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
  81. this.socket.close()
  82. await closed
  83. }
  84. }
  85. describe.skipIf(!built)('Inspector built Client in Chromium', () => {
  86. let inspector: InspectorHandle | undefined
  87. let server: Server | undefined
  88. let browser: Browser | undefined
  89. let page: Page | undefined
  90. let cdp: BrowserTestCdpClient | undefined
  91. afterEach(async () => {
  92. await page?.evaluate(() => {
  93. const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => void } | undefined
  94. state?.dispose?.()
  95. }).catch(() => {})
  96. await cdp?.close()
  97. await browser?.close()
  98. await inspector?.close()
  99. if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
  100. page = undefined
  101. cdp = undefined
  102. browser = undefined
  103. inspector = undefined
  104. server = undefined
  105. })
  106. it('forwards Console values and exposes the built bundle as read-only source', async () => {
  107. inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
  108. const bundle = await readFile(clientBundlePath)
  109. const sourceMap = await readFile(clientSourceMapPath)
  110. server = createServer((request, response) => {
  111. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  112. if (url.pathname === '/client.js') {
  113. response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
  114. response.end(bundle)
  115. return
  116. }
  117. if (url.pathname === '/client.js.map') {
  118. response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
  119. response.end(sourceMap)
  120. return
  121. }
  122. response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  123. response.end(browserFixture(inspector!.endpoint.client))
  124. })
  125. await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
  126. const port = (server.address() as import('node:net').AddressInfo).port
  127. browser = await chromium.launch()
  128. page = await browser.newPage()
  129. await page.goto(`http://127.0.0.1:${String(port)}/`)
  130. await page.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
  131. cdp = await BrowserTestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
  132. await cdp.call('Runtime.enable')
  133. const contextEvent = await cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
  134. const context = event.params?.context as Record<string, unknown> | undefined
  135. return String(context?.name).startsWith('Client —')
  136. })
  137. const context = contextEvent.params?.context as Record<string, unknown>
  138. const contextId = context.id
  139. const uniqueContextId = context.uniqueId
  140. const sourceId = asRecord(context.auxData).sourceId
  141. expect(contextId).toBeTypeOf('number')
  142. expect(uniqueContextId).toBeTypeOf('string')
  143. expect(sourceId).toBeTypeOf('string')
  144. const evaluated = await cdp.call('Runtime.evaluate', {
  145. expression: 'globalThis.__inspectorConsoleEvaluation = { answer: 6 * 7 }',
  146. objectGroup: 'console',
  147. includeCommandLineAPI: true,
  148. silent: false,
  149. returnByValue: false,
  150. generatePreview: true,
  151. userGesture: true,
  152. awaitPromise: false,
  153. replMode: true,
  154. allowUnsafeEvalBlockedByCSP: false,
  155. uniqueContextId,
  156. })
  157. expect(evaluated.error).toBeUndefined()
  158. expect(asRecord(evaluated.result?.result).objectId).toMatch(/^runtime:/u)
  159. expect(await page.evaluate(() => Reflect.get(globalThis, '__inspectorConsoleEvaluation') as unknown)).toEqual({
  160. answer: 42,
  161. })
  162. await page.evaluate(() => {
  163. const value = { browser: true, nested: { ready: true } }
  164. Reflect.set(globalThis, '__inspectorBrowserValue', value)
  165. console.log(value, 'browser-client-console')
  166. setTimeout(() => { throw new Error('browser-client-exception') }, 0)
  167. })
  168. const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', event =>
  169. event.params?.executionContextId === contextId && hasArgument(event, 'browser-client-console'))
  170. const args = consoleEvent.params?.args
  171. if (!Array.isArray(args)) throw new Error('Client Console event has no arguments')
  172. expect((consoleEvent.params?.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
  173. .toBeGreaterThan(0)
  174. const objectId = asRecord(args[0]).objectId
  175. expect(String(objectId)).toMatch(/^runtime:/u)
  176. const properties = await cdp.call('Runtime.getProperties', { objectId, ownProperties: true })
  177. expect(propertyValue(properties, 'browser')).toBe(true)
  178. const exception = await cdp.waitForEvent('Runtime.exceptionThrown', (event) => {
  179. const details = event.params?.exceptionDetails as Record<string, unknown> | undefined
  180. return details !== undefined
  181. && details.executionContextId === contextId
  182. && String((details.exception as Record<string, unknown> | undefined)?.description).includes('browser-client-exception')
  183. })
  184. const exceptionDetails = exception.params?.exceptionDetails as Record<string, unknown>
  185. expect((exceptionDetails.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
  186. .toBeGreaterThan(0)
  187. const enabled = await cdp.call('Debugger.enable')
  188. expect(enabled.error).toBeUndefined()
  189. expect(enabled.result?.debuggerId).toBeTypeOf('string')
  190. const script = await cdp.waitForEvent('Debugger.scriptParsed', event =>
  191. String(event.params?.url).includes('/client.js?rev=browser-test'))
  192. expect(script.params).toMatchObject({ executionContextId: contextId, buildId: '' })
  193. const scriptId = script.params?.scriptId
  194. const content = await cdp.call('Debugger.getScriptSource', { scriptId })
  195. expect(String(content.result?.scriptSource)).toContain('ClientInspectorSource')
  196. expect((await cdp.call('Debugger.setBreakpointByUrl', {
  197. url: script.params?.url,
  198. lineNumber: 0,
  199. })).error?.message).toContain('Client native debugging is unavailable')
  200. const duplicateContext = cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
  201. const candidate = event.params?.context as Record<string, unknown> | undefined
  202. return String(candidate?.name).startsWith('Client —') && candidate?.id !== contextId
  203. })
  204. const popup = await Promise.all([
  205. page.waitForEvent('popup'),
  206. page.evaluate(() => {
  207. if (window.open(location.href, '_blank') === null) throw new Error('duplicate tab was blocked')
  208. }),
  209. ]).then(([opened]) => opened)
  210. await popup.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
  211. const duplicate = (await duplicateContext).params?.context as Record<string, unknown>
  212. expect(asRecord(duplicate.auxData).sourceId).not.toBe(sourceId)
  213. await cdp.call('Debugger.disable')
  214. await popup.evaluate(async () => {
  215. const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => Promise<void> } | undefined
  216. await state?.dispose?.()
  217. })
  218. await popup.close()
  219. }, 20_000)
  220. })
  221. function browserFixture(bootstrap: InspectorHandle['endpoint']['client']): string {
  222. const boot = {
  223. rev: 'browser-test',
  224. entries: [{
  225. id: '@deepseek-ai/dsh-experimental-inspector',
  226. url: '/client.js?rev=browser-test',
  227. rev: 'browser-test',
  228. }],
  229. }
  230. return `<!doctype html>
  231. <title>Inspector Browser Client</title>
  232. <script>
  233. globalThis.__DSH_INSPECTOR__ = ${JSON.stringify(bootstrap)};
  234. globalThis.__DSH_BOOT__ = ${JSON.stringify(boot)};
  235. globalThis.__ModuleLoader__ = { load(registration) { globalThis.__INSPECTOR_REGISTRATION__ = registration; } };
  236. </script>
  237. <script src="/client.js?rev=browser-test"></script>
  238. <script type="module">
  239. const registration = globalThis.__INSPECTOR_REGISTRATION__;
  240. const disposers = [];
  241. const root = {
  242. __inspectorContext: true,
  243. registry: new Map(),
  244. events: { _hooks: {} },
  245. async effect(callback) { const dispose = await callback(); disposers.push(dispose); return dispose; },
  246. on() { return () => {}; },
  247. provide(name, value) { this[name] = value; return () => { delete this[name]; }; },
  248. };
  249. root.root = root;
  250. const cordis = { Context: { is(value) { return value?.__inspectorContext === true; } } };
  251. const plugin = registration.factory(specifier => {
  252. if (specifier === '@deepseek-ai/cordis') return cordis;
  253. throw new Error('Unexpected Client bundle dependency ' + specifier);
  254. });
  255. await plugin.apply(root);
  256. globalThis.__INSPECTOR_BROWSER_TEST__ = {
  257. async dispose() { for (const dispose of disposers.reverse()) await dispose(); },
  258. };
  259. </script>`
  260. }
  261. function hasArgument(event: CdpMessage, value: unknown): boolean {
  262. const args = event.params?.args
  263. return Array.isArray(args) && args.some(argument => asRecord(argument).value === value)
  264. }
  265. function propertyValue(response: CdpMessage, name: string): unknown {
  266. const result = response.result?.result
  267. if (!Array.isArray(result)) throw new Error('Runtime.getProperties returned no property list')
  268. const property = result.map(asRecord).find(candidate => candidate.name === name)
  269. return asRecord(property?.value).value
  270. }
  271. function asRecord(value: unknown): Readonly<Record<string, unknown>> {
  272. if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
  273. return value as Readonly<Record<string, unknown>>
  274. }
  275. function rawText(data: RawData): string {
  276. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  277. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  278. return Buffer.from(data).toString('utf8')
  279. }