640 lines
19 KiB
JavaScript
640 lines
19 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs";
|
|
|
|
import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
executeSoon: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs",
|
|
ExecutionContext:
|
|
"chrome://remote/content/cdp/domains/content/runtime/ExecutionContext.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => {
|
|
return Cc["@mozilla.org/consoleAPI-storage;1"].getService(
|
|
Ci.nsIConsoleAPIStorage
|
|
);
|
|
});
|
|
|
|
// Import the `Debugger` constructor in the current scope
|
|
// eslint-disable-next-line mozilla/reject-globalThis-modification
|
|
addDebuggerToGlobal(globalThis);
|
|
|
|
const CONSOLE_API_LEVEL_MAP = {
|
|
warn: "warning",
|
|
};
|
|
|
|
// Bug 1786299: Puppeteer needs specific error messages.
|
|
const ERROR_CONTEXT_NOT_FOUND = "Cannot find context with specified id";
|
|
|
|
class SetMap extends Map {
|
|
constructor() {
|
|
super();
|
|
this._count = 1;
|
|
}
|
|
// Every key in the map is associated with a Set.
|
|
// The first time `key` is used `obj.set(key, value)` maps `key` to
|
|
// to `Set(value)`. Subsequent calls add more values to the Set for `key`.
|
|
// Note that `obj.get(key)` will return undefined if there's no such key,
|
|
// as in a regular Map.
|
|
set(key, value) {
|
|
const innerSet = this.get(key);
|
|
if (innerSet) {
|
|
innerSet.add(value);
|
|
} else {
|
|
super.set(key, new Set([value]));
|
|
}
|
|
this._count++;
|
|
return this;
|
|
}
|
|
// used as ExecutionContext id
|
|
get count() {
|
|
return this._count;
|
|
}
|
|
}
|
|
|
|
export class Runtime extends ContentProcessDomain {
|
|
constructor(session) {
|
|
super(session);
|
|
this.enabled = false;
|
|
|
|
// Map of all the ExecutionContext instances:
|
|
// [id (Number) => ExecutionContext instance]
|
|
this.contexts = new Map();
|
|
// [innerWindowId (Number) => Set of ExecutionContext instances]
|
|
this.innerWindowIdToContexts = new SetMap();
|
|
|
|
this._onContextCreated = this._onContextCreated.bind(this);
|
|
this._onContextDestroyed = this._onContextDestroyed.bind(this);
|
|
|
|
// TODO Bug 1602083
|
|
this.session.contextObserver.on("context-created", this._onContextCreated);
|
|
this.session.contextObserver.on(
|
|
"context-destroyed",
|
|
this._onContextDestroyed
|
|
);
|
|
}
|
|
|
|
destructor() {
|
|
this.disable();
|
|
|
|
this.session.contextObserver.off("context-created", this._onContextCreated);
|
|
this.session.contextObserver.off(
|
|
"context-destroyed",
|
|
this._onContextDestroyed
|
|
);
|
|
|
|
super.destructor();
|
|
}
|
|
|
|
// commands
|
|
|
|
async enable() {
|
|
if (!this.enabled) {
|
|
this.enabled = true;
|
|
|
|
Services.console.registerListener(this);
|
|
this.onConsoleLogEvent = this.onConsoleLogEvent.bind(this);
|
|
lazy.ConsoleAPIStorage.addLogEventListener(
|
|
this.onConsoleLogEvent,
|
|
Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
|
|
);
|
|
|
|
// Spin the event loop in order to send the `executionContextCreated` event right
|
|
// after we replied to `enable` request.
|
|
lazy.executeSoon(() => {
|
|
this._onContextCreated("context-created", {
|
|
windowId: this.content.windowGlobalChild.innerWindowId,
|
|
window: this.content,
|
|
isDefault: true,
|
|
});
|
|
|
|
for (const message of lazy.ConsoleAPIStorage.getEvents()) {
|
|
this.onConsoleLogEvent(message);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
disable() {
|
|
if (this.enabled) {
|
|
this.enabled = false;
|
|
|
|
Services.console.unregisterListener(this);
|
|
lazy.ConsoleAPIStorage.removeLogEventListener(this.onConsoleLogEvent);
|
|
}
|
|
}
|
|
|
|
releaseObject(options = {}) {
|
|
const { objectId } = options;
|
|
|
|
let context = null;
|
|
for (const ctx of this.contexts.values()) {
|
|
if (ctx.hasRemoteObject(objectId)) {
|
|
context = ctx;
|
|
break;
|
|
}
|
|
}
|
|
if (!context) {
|
|
throw new Error(ERROR_CONTEXT_NOT_FOUND);
|
|
}
|
|
context.releaseObject(objectId);
|
|
}
|
|
|
|
/**
|
|
* Calls function with given declaration on the given object.
|
|
*
|
|
* Object group of the result is inherited from the target object.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} options.functionDeclaration
|
|
* Declaration of the function to call.
|
|
* @param {Array.<object>=} options.arguments
|
|
* Call arguments. All call arguments must belong to the same
|
|
* JavaScript world as the target object.
|
|
* @param {boolean=} options.awaitPromise
|
|
* Whether execution should `await` for resulting value
|
|
* and return once awaited promise is resolved.
|
|
* @param {number=} options.executionContextId
|
|
* Specifies execution context which global object will be used
|
|
* to call function on. Either executionContextId or objectId
|
|
* should be specified.
|
|
* @param {string=} options.objectId
|
|
* Identifier of the object to call function on.
|
|
* Either objectId or executionContextId should be specified.
|
|
* @param {boolean=} options.returnByValue
|
|
* Whether the result is expected to be a JSON object
|
|
* which should be sent by value.
|
|
*
|
|
* @returns {RemoteObject & { exeptionDetails?: ExceptionDetails }}
|
|
*/
|
|
callFunctionOn(options = {}) {
|
|
if (typeof options.functionDeclaration != "string") {
|
|
throw new TypeError("functionDeclaration: string value expected");
|
|
}
|
|
if (
|
|
typeof options.arguments != "undefined" &&
|
|
!Array.isArray(options.arguments)
|
|
) {
|
|
throw new TypeError("arguments: array value expected");
|
|
}
|
|
if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) {
|
|
throw new TypeError("awaitPromise: boolean value expected");
|
|
}
|
|
if (!["undefined", "number"].includes(typeof options.executionContextId)) {
|
|
throw new TypeError("executionContextId: number value expected");
|
|
}
|
|
if (!["undefined", "string"].includes(typeof options.objectId)) {
|
|
throw new TypeError("objectId: string value expected");
|
|
}
|
|
if (!["undefined", "boolean"].includes(typeof options.returnByValue)) {
|
|
throw new TypeError("returnByValue: boolean value expected");
|
|
}
|
|
|
|
if (
|
|
typeof options.executionContextId == "undefined" &&
|
|
typeof options.objectId == "undefined"
|
|
) {
|
|
throw new Error(
|
|
"Either objectId or executionContextId must be specified"
|
|
);
|
|
}
|
|
|
|
let context = null;
|
|
// When an `objectId` is passed, we want to execute the function of a given object
|
|
// So we first have to find its ExecutionContext
|
|
if (options.objectId) {
|
|
for (const ctx of this.contexts.values()) {
|
|
if (ctx.hasRemoteObject(options.objectId)) {
|
|
context = ctx;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
context = this.contexts.get(options.executionContextId);
|
|
}
|
|
|
|
if (!context) {
|
|
throw new Error(ERROR_CONTEXT_NOT_FOUND);
|
|
}
|
|
|
|
return context.callFunctionOn(
|
|
options.functionDeclaration,
|
|
options.arguments,
|
|
options.returnByValue,
|
|
options.awaitPromise,
|
|
options.objectId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Evaluate expression on global object.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} options.expression
|
|
* Expression to evaluate.
|
|
* @param {boolean=} options.awaitPromise
|
|
* Whether execution should `await` for resulting value
|
|
* and return once awaited promise is resolved.
|
|
* @param {number=} options.contextId
|
|
* Specifies in which execution context to perform evaluation.
|
|
* If the parameter is omitted the evaluation will be performed
|
|
* in the context of the inspected page.
|
|
* @param {boolean=} options.returnByValue
|
|
* Whether the result is expected to be a JSON object
|
|
* that should be sent by value. Defaults to false.
|
|
* @param {boolean=} options.userGesture [unsupported]
|
|
* Whether execution should be treated as initiated by user in the UI.
|
|
*
|
|
* @returns {RemoteObject & { exeptionDetails?: ExceptionDetails }}
|
|
* The evaluation result, and optionally exception details.
|
|
*/
|
|
evaluate(options = {}) {
|
|
const {
|
|
expression,
|
|
awaitPromise = false,
|
|
contextId,
|
|
returnByValue = false,
|
|
} = options;
|
|
|
|
if (typeof expression != "string") {
|
|
throw new Error("expression: string value expected");
|
|
}
|
|
if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) {
|
|
throw new TypeError("awaitPromise: boolean value expected");
|
|
}
|
|
if (typeof returnByValue != "boolean") {
|
|
throw new Error("returnByValue: boolean value expected");
|
|
}
|
|
|
|
let context;
|
|
if (typeof contextId != "undefined") {
|
|
context = this.contexts.get(contextId);
|
|
if (!context) {
|
|
throw new Error(ERROR_CONTEXT_NOT_FOUND);
|
|
}
|
|
} else {
|
|
context = this._getDefaultContextForWindow();
|
|
}
|
|
|
|
return context.evaluate(expression, awaitPromise, returnByValue);
|
|
}
|
|
|
|
getProperties(options = {}) {
|
|
const { objectId, ownProperties } = options;
|
|
|
|
for (const ctx of this.contexts.values()) {
|
|
const debuggerObj = ctx.getRemoteObject(objectId);
|
|
if (debuggerObj) {
|
|
return ctx.getProperties({ objectId, ownProperties });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Internal methods: the following methods are not part of CDP;
|
|
* note the _ prefix.
|
|
*/
|
|
|
|
get _debugger() {
|
|
if (this.__debugger) {
|
|
return this.__debugger;
|
|
}
|
|
this.__debugger = new Debugger();
|
|
return this.__debugger;
|
|
}
|
|
|
|
_buildExceptionStackTrace(stack) {
|
|
const callFrames = [];
|
|
|
|
while (
|
|
stack &&
|
|
stack.source !== "debugger eval code" &&
|
|
!stack.source.startsWith("chrome://")
|
|
) {
|
|
callFrames.push({
|
|
functionName: stack.functionDisplayName,
|
|
scriptId: stack.sourceId.toString(),
|
|
url: stack.source,
|
|
lineNumber: stack.line - 1,
|
|
columnNumber: stack.column - 1,
|
|
});
|
|
stack = stack.parent || stack.asyncParent;
|
|
}
|
|
|
|
return {
|
|
callFrames,
|
|
};
|
|
}
|
|
|
|
_buildConsoleStackTrace(stack = []) {
|
|
const callFrames = stack
|
|
.filter(frame => !lazy.isChromeFrame(frame))
|
|
.map(frame => {
|
|
return {
|
|
functionName: frame.functionName,
|
|
scriptId: frame.sourceId.toString(),
|
|
url: frame.filename,
|
|
lineNumber: frame.lineNumber - 1,
|
|
columnNumber: frame.columnNumber - 1,
|
|
};
|
|
});
|
|
|
|
return {
|
|
callFrames,
|
|
};
|
|
}
|
|
|
|
_getRemoteObject(objectId) {
|
|
for (const ctx of this.contexts.values()) {
|
|
const debuggerObj = ctx.getRemoteObject(objectId);
|
|
if (debuggerObj) {
|
|
return debuggerObj;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_serializeRemoteObject(debuggerObj, executionContextId) {
|
|
const ctx = this.contexts.get(executionContextId);
|
|
return ctx._toRemoteObject(debuggerObj);
|
|
}
|
|
|
|
_getRemoteObjectByNodeId(nodeId, executionContextId) {
|
|
let debuggerObj = null;
|
|
|
|
if (typeof executionContextId != "undefined") {
|
|
const ctx = this.contexts.get(executionContextId);
|
|
debuggerObj = ctx.getRemoteObjectByNodeId(nodeId);
|
|
} else {
|
|
for (const ctx of this.contexts.values()) {
|
|
const obj = ctx.getRemoteObjectByNodeId(nodeId);
|
|
if (obj) {
|
|
debuggerObj = obj;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return debuggerObj;
|
|
}
|
|
|
|
_setRemoteObject(debuggerObj, context) {
|
|
return context.setRemoteObject(debuggerObj);
|
|
}
|
|
|
|
_getDefaultContextForWindow(innerWindowId) {
|
|
if (!innerWindowId) {
|
|
innerWindowId = this.content.windowGlobalChild.innerWindowId;
|
|
}
|
|
const curContexts = this.innerWindowIdToContexts.get(innerWindowId);
|
|
if (curContexts) {
|
|
for (const ctx of curContexts) {
|
|
if (ctx.isDefault) {
|
|
return ctx;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_getContextsForFrame(frameId) {
|
|
const frameContexts = [];
|
|
for (const ctx of this.contexts.values()) {
|
|
if (ctx.frameId == frameId) {
|
|
frameContexts.push(ctx);
|
|
}
|
|
}
|
|
return frameContexts;
|
|
}
|
|
|
|
_emitConsoleAPICalled(payload) {
|
|
// Filter out messages that aren't coming from a valid inner window, or from
|
|
// a different browser tab. Also messages of type "time", which are not
|
|
// getting reported by Chrome.
|
|
const curBrowserId = this.session.browsingContext.browserId;
|
|
const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId);
|
|
if (
|
|
!win ||
|
|
BrowsingContext.getFromWindow(win).browserId != curBrowserId ||
|
|
payload.type === "time"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const context = this._getDefaultContextForWindow();
|
|
this.emit("Runtime.consoleAPICalled", {
|
|
args: payload.arguments.map(arg => context._toRemoteObject(arg)),
|
|
executionContextId: context?.id || 0,
|
|
timestamp: payload.timestamp,
|
|
type: payload.type,
|
|
stackTrace: this._buildConsoleStackTrace(payload.stack),
|
|
});
|
|
}
|
|
|
|
_emitExceptionThrown(payload) {
|
|
// Filter out messages that aren't coming from a valid inner window, or from
|
|
// a different browser tab. Also messages of type "time", which are not
|
|
// getting reported by Chrome.
|
|
const curBrowserId = this.session.browsingContext.browserId;
|
|
const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId);
|
|
if (!win || BrowsingContext.getFromWindow(win).browserId != curBrowserId) {
|
|
return;
|
|
}
|
|
|
|
const context = this._getDefaultContextForWindow();
|
|
this.emit("Runtime.exceptionThrown", {
|
|
timestamp: payload.timestamp,
|
|
exceptionDetails: {
|
|
// Temporary placeholder to return a number.
|
|
exceptionId: 0,
|
|
text: payload.text,
|
|
lineNumber: payload.lineNumber,
|
|
columnNumber: payload.columnNumber,
|
|
url: payload.url,
|
|
stackTrace: this._buildExceptionStackTrace(payload.stack),
|
|
executionContextId: context?.id || undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Helper method in order to instantiate the ExecutionContext for a given
|
|
* DOM Window as well as emitting the related
|
|
* `Runtime.executionContextCreated` event
|
|
*
|
|
* @param {string} name
|
|
* Event name
|
|
* @param {object=} options
|
|
* @param {number} options.windowId
|
|
* The inner window id of the newly instantiated document.
|
|
* @param {Window} options.window
|
|
* The window object of the newly instantiated document.
|
|
* @param {string=} options.contextName
|
|
* Human-readable name to describe the execution context.
|
|
* @param {boolean=} options.isDefault
|
|
* Whether the execution context is the default one.
|
|
* @param {string=} options.contextType
|
|
* "default" or "isolated"
|
|
*
|
|
* @returns {number} ID of created context
|
|
*/
|
|
_onContextCreated(name, options = {}) {
|
|
const {
|
|
windowId,
|
|
window,
|
|
contextName = "",
|
|
isDefault = true,
|
|
contextType = "default",
|
|
} = options;
|
|
|
|
if (windowId === undefined) {
|
|
throw new Error("windowId is required");
|
|
}
|
|
|
|
// allow only one default context per inner window
|
|
if (isDefault && this.innerWindowIdToContexts.has(windowId)) {
|
|
for (const ctx of this.innerWindowIdToContexts.get(windowId)) {
|
|
if (ctx.isDefault) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
const context = new lazy.ExecutionContext(
|
|
this._debugger,
|
|
window,
|
|
this.innerWindowIdToContexts.count,
|
|
isDefault
|
|
);
|
|
this.contexts.set(context.id, context);
|
|
this.innerWindowIdToContexts.set(windowId, context);
|
|
|
|
if (this.enabled) {
|
|
this.emit("Runtime.executionContextCreated", {
|
|
context: {
|
|
id: context.id,
|
|
origin: window.origin,
|
|
name: contextName,
|
|
auxData: {
|
|
isDefault,
|
|
frameId: context.frameId,
|
|
type: contextType,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
return context.id;
|
|
}
|
|
|
|
/**
|
|
* Helper method to destroy the ExecutionContext of the given id. Also emit
|
|
* the related `Runtime.executionContextDestroyed` and
|
|
* `Runtime.executionContextsCleared` events.
|
|
* ContextObserver will call this method with either `id` or `frameId` argument
|
|
* being set.
|
|
*
|
|
* @param {string} name
|
|
* Event name
|
|
* @param {object=} options
|
|
* @param {number} options.id
|
|
* The execution context id to destroy.
|
|
* @param {number} options.windowId
|
|
* The inner-window id of the execution context to destroy.
|
|
* @param {number} options.frameId
|
|
* The frame id of execution context to destroy.
|
|
* Either `id` or `frameId` or `windowId` is passed.
|
|
*/
|
|
_onContextDestroyed(name, { id, frameId, windowId }) {
|
|
let contexts;
|
|
if ([id, frameId, windowId].filter(id => !!id).length > 1) {
|
|
throw new Error("Expects only *one* of id, frameId, windowId");
|
|
}
|
|
|
|
if (id) {
|
|
contexts = [this.contexts.get(id)];
|
|
} else if (frameId) {
|
|
contexts = this._getContextsForFrame(frameId);
|
|
} else {
|
|
contexts = this.innerWindowIdToContexts.get(windowId) || [];
|
|
}
|
|
|
|
for (const ctx of contexts) {
|
|
const isFrame = !!BrowsingContext.get(ctx.frameId).parent;
|
|
|
|
ctx.destructor();
|
|
this.contexts.delete(ctx.id);
|
|
this.innerWindowIdToContexts.get(ctx.windowId).delete(ctx);
|
|
|
|
if (this.enabled) {
|
|
this.emit("Runtime.executionContextDestroyed", {
|
|
executionContextId: ctx.id,
|
|
});
|
|
}
|
|
|
|
if (this.innerWindowIdToContexts.get(ctx.windowId).size == 0) {
|
|
this.innerWindowIdToContexts.delete(ctx.windowId);
|
|
// Only emit when all the exeuction contexts were cleared for the
|
|
// current browser / target, which means it should only be emitted
|
|
// for a top-level browsing context reference.
|
|
if (this.enabled && !isFrame) {
|
|
this.emit("Runtime.executionContextsCleared");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onConsoleLogEvent(message) {
|
|
// From sendConsoleAPIMessage (toolkit/modules/Console.sys.mjs)
|
|
this._emitConsoleAPICalled({
|
|
arguments: message.arguments,
|
|
innerWindowId: message.innerID,
|
|
stack: message.stacktrace,
|
|
timestamp: message.timeStamp,
|
|
type: CONSOLE_API_LEVEL_MAP[message.level] || message.level,
|
|
});
|
|
}
|
|
|
|
// nsIObserver
|
|
|
|
/**
|
|
* Takes a console message belonging to the current window and emits a
|
|
* "exceptionThrown" event if it's a Javascript error, otherwise a
|
|
* "consoleAPICalled" event.
|
|
*
|
|
* @param {nsIConsoleMessage} subject
|
|
* Console message.
|
|
*/
|
|
observe(subject) {
|
|
if (subject instanceof Ci.nsIScriptError && subject.hasException) {
|
|
let entry = fromScriptError(subject);
|
|
this._emitExceptionThrown(entry);
|
|
}
|
|
}
|
|
|
|
// XPCOM
|
|
|
|
get QueryInterface() {
|
|
return ChromeUtils.generateQI(["nsIConsoleListener"]);
|
|
}
|
|
}
|
|
|
|
function fromScriptError(error) {
|
|
// From dom/bindings/nsIScriptError.idl
|
|
return {
|
|
innerWindowId: error.innerWindowID,
|
|
columnNumber: error.columnNumber - 1,
|
|
lineNumber: error.lineNumber - 1,
|
|
stack: error.stack,
|
|
text: error.errorMessage,
|
|
timestamp: error.timeStamp,
|
|
url: error.sourceName,
|
|
};
|
|
}
|