chrome-devtools-frontend
Version:
Chrome DevTools UI
483 lines (426 loc) • 18 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// 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');
}