summaryrefslogtreecommitdiffstats
path: root/remote/cdp/domains/content/Runtime.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'remote/cdp/domains/content/Runtime.sys.mjs')
-rw-r--r--remote/cdp/domains/content/Runtime.sys.mjs643
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,
+ };
+}