diff options
Diffstat (limited to 'remote/cdp/domains/content/Runtime.sys.mjs')
-rw-r--r-- | remote/cdp/domains/content/Runtime.sys.mjs | 643 |
1 files changed, 643 insertions, 0 deletions
diff --git a/remote/cdp/domains/content/Runtime.sys.mjs b/remote/cdp/domains/content/Runtime.sys.mjs new file mode 100644 index 0000000000..9c8092e3ef --- /dev/null +++ b/remote/cdp/domains/content/Runtime.sys.mjs @@ -0,0 +1,643 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.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 {Object<RemoteObject, 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 {Object<RemoteObject, 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, topic, data) { + 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, + }; +} |