| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type Protocol from 'devtools-protocol'; |
| import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; |
| |
| import type {Chrome} from '../../../extension-api/ExtensionAPI.js'; |
| import type {WasmValue} from '../src/WasmTypes.js'; |
| |
| import {makeURL, relativePathname} from './TestUtils.js'; |
| |
| interface PauseLocation { |
| rawLocation: Chrome.DevTools.RawLocation; |
| callFrame: Protocol.Debugger.CallFrame; |
| } |
| |
| type Handler<Method extends keyof ProtocolMapping.Events> = |
| (method: Method, event: ProtocolMapping.Events[Method][0]) => unknown; |
| |
| async function waitFor<ReturnT>( |
| fn: (() => ReturnT | undefined)|(() => Promise<ReturnT|undefined>), timeout = 0): Promise<ReturnT> { |
| let waitTime = 0; |
| const callback = async(resolve: (value: ReturnT) => void, reject: (reason?: unknown) => void): Promise<void> => { |
| try { |
| const result = await fn(); |
| if (result) { |
| resolve(result); |
| } else if (timeout > 0 && waitTime > timeout) { |
| reject(); |
| } else { |
| waitTime += 100; |
| setTimeout(() => callback(resolve, reject), 100); |
| } |
| } catch (e) { |
| reject(e); |
| } |
| }; |
| return await new Promise<ReturnT>((resolve, reject) => callback(resolve, reject)); |
| } |
| |
| export interface BreakLocation { |
| lineNumber: number; |
| locations: Protocol.Debugger.Location[]; |
| } |
| |
| export class Debugger { |
| private readonly socket: WebSocket; |
| private readonly targetId: string; |
| private connected: boolean; |
| private readonly queue: string[]; |
| private readonly callbacks = new Map<number, { |
| method: string, |
| resolve: (r: ProtocolMapping.Commands[keyof ProtocolMapping.Commands]['returnType']) => unknown, |
| reject: (r: unknown) => unknown, |
| }>(); |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| private readonly eventHandlers = new Map<string, Set<Handler<any>>>(); |
| private nextMessageId = 0; |
| private readonly scripts = new Map<string, Protocol.Debugger.ScriptParsedEvent>(); |
| private readonly scriptsById = new Map<string, Protocol.Debugger.ScriptParsedEvent>(); |
| private nextStopId = 0n; |
| private waitForPauseQueue: Array<{resolve: (pauseLocation: PauseLocation) => void}> = []; |
| private pauseLocation?: PauseLocation; |
| private readonly callFrameToStopId = new Map<string, bigint>(); |
| private readonly stopIdToCallFrame = new Map<bigint, string>(); |
| private readonly setBreakpoints = new Map<number, Protocol.Debugger.SetBreakpointByUrlResponse>(); |
| |
| static async create(): Promise<Debugger> { |
| const response = await fetch('/json/new', {method: 'PUT'}); |
| const {id} = await response.json(); |
| const debug = new Debugger(id); |
| debug.on('Debugger.scriptParsed', debug.scriptParsed.bind(debug)).on('Debugger.paused', debug.paused.bind(debug)); |
| await debug.send('Debugger.enable', undefined); |
| await debug.send('Page.enable', undefined); |
| return debug; |
| } |
| |
| private constructor(targetId: string) { |
| const url = `ws://localhost:9222/devtools/page/${targetId}`; |
| this.targetId = targetId; |
| this.socket = new WebSocket(url); |
| this.socket.onerror = this.onError.bind(this); |
| this.socket.onopen = this.onOpen.bind(this); |
| this.socket.onmessage = this.onMessage.bind(this); |
| this.socket.onclose = this.onClose.bind(this); |
| this.queue = []; |
| this.connected = false; |
| } |
| |
| private onError(): void { |
| console.error('Communication error'); |
| } |
| |
| private onOpen(): void { |
| this.connected = true; |
| for (const m of this.queue) { |
| this.sendRaw(m); |
| } |
| this.queue.slice(); |
| } |
| |
| private onMessage(ev: MessageEvent<string>): void { |
| const result = JSON.parse(ev.data); |
| if ('id' in result) { |
| const callback = this.callbacks.get(result.id); |
| if (!callback) { |
| throw new Error('Received response for an unknown request'); |
| } |
| if (result.error) { |
| callback.reject(result.error); |
| } else { |
| callback.resolve(result.result); |
| } |
| } else { |
| const {method, params} = result; |
| this.eventHandlers.get(method)?.forEach(handler => handler(method, params)); |
| } |
| } |
| |
| private onClose(_ev: Event): void { |
| this.connected = false; |
| this.eventHandlers.clear(); |
| for (const {method, reject} of this.callbacks.values()) { |
| reject(new Error(`'${method}' failed: Disconnected.`)); |
| } |
| } |
| |
| private sendRaw(message: string): void { |
| if (!this.connected) { |
| this.queue.push(message); |
| } else { |
| this.socket.send(message); |
| } |
| } |
| |
| private nextId(): number { |
| return this.nextMessageId++; |
| } |
| |
| on<Method extends keyof ProtocolMapping.Events>(method: Method, handler: Handler<Method>): Debugger { |
| this.eventHandlers.set(method, (this.eventHandlers.get(method) ?? new Set()).add(handler)); |
| return this; |
| } |
| |
| off<Method extends keyof ProtocolMapping.Events>(method: Method, handler?: Handler<Method>): Debugger { |
| if (handler) { |
| this.eventHandlers.get(method)?.delete(handler); |
| } else { |
| this.eventHandlers.delete(method); |
| } |
| return this; |
| } |
| |
| private send<Method extends keyof ProtocolMapping.Commands>( |
| method: Method, params: ProtocolMapping.Commands[Method]['paramsType'][0]): |
| Promise<ProtocolMapping.Commands[Method]['returnType']> { |
| const id = this.nextId(); |
| this.sendRaw(JSON.stringify({id, method, params})); |
| return new Promise<ProtocolMapping.Commands[Method]['returnType']>( |
| (resolve, reject) => this.callbacks.set(id, {method, resolve, reject})); |
| } |
| |
| async navigate(url: string): Promise<string> { |
| const frameInfo = await this.send('Page.navigate', {url}); |
| return frameInfo.frameId; |
| } |
| |
| async close(): Promise<void> { |
| await this.send('Page.close', undefined); |
| this.socket.close(); |
| } |
| |
| private scriptParsed(method: 'Debugger.scriptParsed', event: Protocol.Debugger.ScriptParsedEvent): void { |
| const {scriptId, url} = event; |
| this.scripts.set(url, event); |
| this.scriptsById.set(scriptId, event); |
| } |
| |
| private paused(method: 'Debugger.paused', event: Protocol.Debugger.PausedEvent): void { |
| this.callFrameToStopId.clear(); |
| const {callFrames: [callFrame]} = event; |
| if (!callFrame) { |
| throw new Error('Paused without callframes'); |
| } |
| const {location: {columnNumber, scriptId}} = callFrame; |
| const script = this.scriptsById.get(scriptId); |
| if (!script) { |
| throw new Error(`Paused in unknown script ${scriptId}`); |
| } |
| if (columnNumber === undefined) { |
| throw new Error('Missing code offset in paused location'); |
| } |
| |
| const rawLocation = { |
| rawModuleId: scriptId, |
| codeOffset: columnNumber - (script.codeOffset || 0), |
| inlineFrameIndex: 0, |
| }; |
| |
| this.pauseLocation = {rawLocation, callFrame}; |
| if (this.waitForPauseQueue.length > 0) { |
| const {resolve} = this.waitForPauseQueue[0]; |
| this.waitForPauseQueue = this.waitForPauseQueue.slice(1); |
| resolve(this.pauseLocation); |
| } |
| } |
| |
| stopIdForCallFrame({callFrameId}: Protocol.Debugger.CallFrame): bigint { |
| const stopId = this.callFrameToStopId.get(callFrameId); |
| if (stopId !== undefined) { |
| return stopId; |
| } |
| const newStopId = this.nextStopId++; |
| this.callFrameToStopId.set(callFrameId, newStopId); |
| this.stopIdToCallFrame.set(newStopId, callFrameId); |
| return newStopId; |
| } |
| |
| async waitForScript(url: string, timeout = 0): Promise<string> { |
| return await waitFor(() => this.scripts.get(url)?.scriptId, timeout); |
| } |
| |
| async waitForPause(timeout = 0): Promise<PauseLocation> { |
| if (this.pauseLocation) { |
| return this.pauseLocation; |
| } |
| const waitPromise = new Promise<PauseLocation>(resolve => this.waitForPauseQueue.push({resolve})); |
| if (timeout === 0) { |
| return await waitPromise; |
| } |
| const timeoutPromise = new Promise<PauseLocation>((_, r) => setTimeout(() => r(new Error('Timeout')), timeout)); |
| return await Promise.race([waitPromise, timeoutPromise]); |
| } |
| |
| async evaluateFunction<T>(expression: string): Promise<T> { |
| const {result, exceptionDetails} = |
| await this.send('Runtime.evaluate', {expression, returnByValue: true, awaitPromise: true}); |
| if (exceptionDetails) { |
| throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); |
| } |
| return result.value; |
| } |
| |
| async evaluateOnCallFrameByRef(expression: string, {callFrameId}: Protocol.Debugger.CallFrame): |
| Promise<Protocol.Runtime.RemoteObject> { |
| const {result, exceptionDetails} = |
| await this.send('Debugger.evaluateOnCallFrame', {expression, returnByValue: false, callFrameId}); |
| if (exceptionDetails) { |
| throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); |
| } |
| return result; |
| } |
| |
| async getRemoteObject({callFrameId}: Protocol.Debugger.CallFrame, object: Chrome.DevTools.ForeignObject): |
| Promise<Protocol.Runtime.RemoteObject> { |
| const expression = `${object.valueClass}s[${object.index}]`; |
| const {result, exceptionDetails} = |
| await this.send('Debugger.evaluateOnCallFrame', {expression, silent: true, generatePreview: true, callFrameId}); |
| if (exceptionDetails) { |
| throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); |
| } |
| return result; |
| } |
| |
| async toObject(objectId: string, ...keys: string[]): Promise<Record<string, unknown>> { |
| const {result, exceptionDetails} = await this.send('Runtime.getProperties', {objectId}); |
| if (exceptionDetails) { |
| throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); |
| } |
| |
| const obj: Record<string, unknown> = {}; |
| for (const {name, value} of result.filter(p => keys.length === 0 || keys.includes(p.name))) { |
| if (value) { |
| if (value.value) { |
| obj[name] = value.value; |
| } else if (value.objectId) { |
| obj[name] = this.toObject(value.objectId); |
| } |
| } |
| } |
| return obj; |
| } |
| |
| async evaluateOnCallFrame<T>( |
| expectValue: boolean, convert: (result: Protocol.Runtime.RemoteObject) => T, expression: string, |
| {callFrameId}: Protocol.Debugger.CallFrame): Promise<T> { |
| return await this.evaluateOnCallFrameId(expectValue, convert, expression, callFrameId); |
| } |
| |
| async evaluateOnCallFrameId<T>( |
| expectValue: boolean, convert: (result: Protocol.Runtime.RemoteObject) => T, expression: string, |
| callFrameId: string): Promise<T> { |
| const {result, exceptionDetails} = await this.send( |
| 'Debugger.evaluateOnCallFrame', |
| {expression, returnByValue: !expectValue, generatePreview: expectValue, callFrameId}); |
| if (exceptionDetails) { |
| throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); |
| } |
| return convert(result); |
| } |
| |
| async waitForFunction<T>(expression: string, timeout = 0): Promise<T> { |
| return await waitFor(() => this.evaluateFunction<T>(expression), timeout); |
| } |
| |
| page(script: string): WasmBackendPage { |
| return new WasmBackendPage(script, this); |
| } |
| |
| isPaused(): boolean { |
| return this.pauseLocation !== undefined; |
| } |
| |
| async resume(): Promise<void> { |
| this.pauseLocation = undefined; |
| await this.send('Debugger.resume', undefined); |
| } |
| |
| async clearBreakpoints(): Promise<void> { |
| for (const {breakpointId} of this.setBreakpoints.values()) { |
| await this.send('Debugger.removeBreakpoint', {breakpointId}); |
| } |
| this.setBreakpoints.clear(); |
| } |
| |
| async setBreakpointByRawLocation(scriptId: string, rawLocationRange: Chrome.DevTools.RawLocationRange): |
| Promise<Protocol.Debugger.SetBreakpointByUrlResponse> { |
| const script = this.scriptsById.get(scriptId); |
| if (!script) { |
| throw new Error('Unknown script id'); |
| } |
| const {codeOffset, url} = script; |
| const columnNumber = rawLocationRange.startOffset + (codeOffset || 0); |
| const prevBreakpoint = this.setBreakpoints.get(columnNumber); |
| if (prevBreakpoint) { |
| return prevBreakpoint; |
| } |
| const breakLocation = {lineNumber: 0, url, columnNumber}; |
| const breakpoint = await this.send('Debugger.setBreakpointByUrl', breakLocation); |
| if (breakpoint.locations.length === 0) { |
| throw new Error(`Failed to set breakpoint at offset ${rawLocationRange.startOffset}`); |
| } |
| this.setBreakpoints.set(columnNumber, breakpoint); |
| return breakpoint; |
| } |
| |
| async setBreakpointsOnSourceLines( |
| sourceLines: Array<string|RegExp>, sourceFileURL: URL, plugin: Chrome.DevTools.LanguageExtensionPlugin, |
| rawModuleId: string): Promise<BreakLocation[]> { |
| if (sourceFileURL.protocol !== 'file:') { |
| throw new Error('Not a file URL'); |
| } |
| |
| const {config: {basePath}} = __karma__ as {config: {basePath: string}}; |
| const contents = await fetch(`/base/${relativePathname(sourceFileURL, new URL(basePath, 'file://'))}`); |
| const testText = await contents.text(); |
| const lines = testText.split('\n'); |
| |
| const breakpoints = []; |
| for (const sourceLine of sourceLines) { |
| const sourceLineNumber = typeof sourceLine === 'string' ? lines.findIndex(l => l.includes(sourceLine)) : |
| lines.findIndex(l => l.match(sourceLine)); |
| if (sourceLineNumber < 0) { |
| throw new Error('Source line not found'); |
| } |
| |
| // Breakpoints must be set in sequence to avoid racing on updating this.setBreakpoints |
| breakpoints.push(await this.setBreakpoint(sourceLineNumber, sourceFileURL, plugin, rawModuleId)); |
| } |
| return breakpoints; |
| } |
| |
| async setBreakpointOnSourceLine( |
| sourceLine: string|RegExp, sourceFileURL: URL, plugin: Chrome.DevTools.LanguageExtensionPlugin, |
| rawModuleId: string): Promise<BreakLocation> { |
| return (await this.setBreakpointsOnSourceLines([sourceLine], sourceFileURL, plugin, rawModuleId))[0]; |
| } |
| |
| async setBreakpoint( |
| sourceLineNumber: number, sourceFileURL: URL, plugin: Chrome.DevTools.LanguageExtensionPlugin, |
| rawModuleId: string): Promise<BreakLocation> { |
| const lineNumber = await slideLine(plugin, rawModuleId, sourceFileURL.href, sourceLineNumber); |
| const rawLocationRanges = await plugin.sourceLocationToRawLocation( |
| {rawModuleId, sourceFileURL: sourceFileURL.href, lineNumber, columnNumber: -1}); |
| if (rawLocationRanges.length === 0) { |
| throw new Error('Failed to map source location'); |
| } |
| |
| const setBreakpointLocations = []; |
| for (const rawLocation of rawLocationRanges) { |
| const {locations} = await this.setBreakpointByRawLocation(rawModuleId, rawLocation); |
| if (locations.length === 0) { |
| throw new Error('Failed to set breakpoint'); |
| } |
| setBreakpointLocations.push(locations); |
| } |
| |
| const breakpoint = {lineNumber, locations: setBreakpointLocations.flat()}; |
| return breakpoint; |
| } |
| |
| private getCallFrameId(stopId: bigint): string { |
| const callFrameId = this.stopIdToCallFrame.get(stopId); |
| if (callFrameId === undefined) { |
| throw new Error(`Unknown stopid ${stopId}`); |
| } |
| return callFrameId; |
| } |
| |
| async getWasmLinearMemory(offset: number, length: number, stopId: bigint): Promise<ArrayBuffer> { |
| const data = await this.evaluateOnCallFrameId<number[]>( |
| false, result => result.value, |
| `[].slice.call(new Uint8Array(memories[0].buffer, ${Number(offset)}, ${Number(length)}))`, |
| this.getCallFrameId(stopId)); |
| return new Uint8Array(data).buffer; |
| } |
| private convertWasmValue(valueClass: 'local'|'global'|'operand', index: number): |
| (obj: Protocol.Runtime.RemoteObject) => Chrome.DevTools.WasmValue { |
| return (obj): Chrome.DevTools.WasmValue => { |
| const type = obj?.description; |
| const value: string = obj.preview?.properties?.find(o => o.name === 'value')?.value ?? ''; |
| switch (type) { |
| case 'i32': |
| case 'f32': |
| case 'f64': |
| return {type, value: Number(value)}; |
| case 'i64': |
| return {type, value: BigInt(value)}; |
| case 'v128': |
| return {type, value}; |
| default: |
| return {type: 'reftype', valueClass, index}; |
| } |
| }; |
| } |
| getWasmLocal(local: number, stopId: bigint): Promise<WasmValue> { |
| return this.evaluateOnCallFrameId<WasmValue>( |
| true, this.convertWasmValue('local', local), `locals[${Number(local)}]`, this.getCallFrameId(stopId)); |
| } |
| getWasmGlobal(global: number, stopId: bigint): Promise<WasmValue> { |
| return this.evaluateOnCallFrameId<WasmValue>( |
| true, this.convertWasmValue('global', global), `globals[${Number(global)}]`, this.getCallFrameId(stopId)); |
| } |
| getWasmOp(op: number, stopId: bigint): Promise<WasmValue> { |
| return this.evaluateOnCallFrameId<WasmValue>( |
| true, this.convertWasmValue('operand', op), `operands[${Number(op)}]`, this.getCallFrameId(stopId)); |
| } |
| } |
| |
| class WasmBackendPage { |
| private readonly script: string; |
| private readonly debug: Debugger; |
| |
| constructor(script: string, debug: Debugger) { |
| this.script = script; |
| this.debug = debug; |
| } |
| |
| async open(timeout = 0): Promise<void> { |
| await this.debug.navigate('about:blank'); |
| await this.debug.navigate(makeURL(`/build/tests/inputs/page.html?${this.script}`)); |
| await this.debug.waitForFunction('window.load && window.load()', timeout); |
| } |
| |
| async go(timeout = 0): Promise<number> { |
| await this.debug.waitForFunction('window.isReady && window.isReady()', timeout); |
| return await this.debug.evaluateFunction<number>('window.go()'); |
| } |
| } |
| |
| async function slideLine( |
| plugin: Chrome.DevTools.LanguageExtensionPlugin, rawModuleId: string, sourceUrl: string, |
| lineNumber: number): Promise<number> { |
| const lines = await plugin.getMappedLines(rawModuleId, sourceUrl) || []; |
| for (const line of lines) { |
| if (line > lineNumber) { |
| return line; |
| } |
| } |
| throw new Error('Line unmapped'); |
| } |