diff options
Diffstat (limited to 'remote/cdp/domains/content')
-rw-r--r-- | remote/cdp/domains/content/DOM.sys.mjs | 246 | ||||
-rw-r--r-- | remote/cdp/domains/content/Emulation.sys.mjs | 50 | ||||
-rw-r--r-- | remote/cdp/domains/content/Input.sys.mjs | 57 | ||||
-rw-r--r-- | remote/cdp/domains/content/Log.sys.mjs | 86 | ||||
-rw-r--r-- | remote/cdp/domains/content/Network.sys.mjs | 18 | ||||
-rw-r--r-- | remote/cdp/domains/content/Page.sys.mjs | 451 | ||||
-rw-r--r-- | remote/cdp/domains/content/Performance.sys.mjs | 32 | ||||
-rw-r--r-- | remote/cdp/domains/content/Runtime.sys.mjs | 629 | ||||
-rw-r--r-- | remote/cdp/domains/content/Security.sys.mjs | 32 | ||||
-rw-r--r-- | remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs | 530 |
10 files changed, 2131 insertions, 0 deletions
diff --git a/remote/cdp/domains/content/DOM.sys.mjs b/remote/cdp/domains/content/DOM.sys.mjs new file mode 100644 index 0000000000..8f920756e6 --- /dev/null +++ b/remote/cdp/domains/content/DOM.sys.mjs @@ -0,0 +1,246 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class DOM extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + /** + * Describes node given its id. + * + * Does not require domain to be enabled. Does not start tracking any objects. + * + * @param {Object} options + * @param {number=} options.backendNodeId [not supported] + * Identifier of the backend node. + * @param {number=} options.depth [not supported] + * The maximum depth at which children should be retrieved, defaults to 1. + * Use -1 for the entire subtree or provide an integer larger than 0. + * @param {number=} options.nodeId [not supported] + * Identifier of the node. + * @param {string} options.objectId + * JavaScript object id of the node wrapper. + * @param {boolean=} options.pierce [not supported] + * Whether or not iframes and shadow roots should be traversed + * when returning the subtree, defaults to false. + * + * @return {DOM.Node} + * Node description. + */ + describeNode(options = {}) { + const { objectId } = options; + + // Until nodeId/backendNodeId is supported force usage of the objectId + if (!["string"].includes(typeof objectId)) { + throw new TypeError("objectId: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + if (typeof debuggerObj.nodeId == "undefined") { + throw new Error("Object id doesn't reference a Node"); + } + + const unsafeObj = debuggerObj.unsafeDereference(); + + const attributes = []; + if (unsafeObj.attributes) { + // Flatten the list of attributes for name and value + for (const attribute of unsafeObj.attributes) { + attributes.push(attribute.name, attribute.value); + } + } + + let context = this.docShell.browsingContext; + if (HTMLIFrameElement.isInstance(unsafeObj)) { + context = unsafeObj.contentWindow.docShell.browsingContext; + } + + const node = { + nodeId: debuggerObj.nodeId, + backendNodeId: debuggerObj.nodeId, + nodeType: unsafeObj.nodeType, + nodeName: unsafeObj.nodeName, + localName: unsafeObj.localName, + nodeValue: unsafeObj.nodeValue ? unsafeObj.nodeValue.toString() : "", + childNodeCount: unsafeObj.childElementCount, + attributes: attributes.length ? attributes : undefined, + frameId: context.id.toString(), + }; + + return { node }; + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } + + getContentQuads(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + if (!unsafeObject.getBoxQuads) { + throw new Error("RemoteObject is not a node"); + } + let quads = unsafeObject.getBoxQuads({ relativeTo: this.content.document }); + quads = quads.map(quad => { + return [ + quad.p1.x, + quad.p1.y, + quad.p2.x, + quad.p2.y, + quad.p3.x, + quad.p3.y, + quad.p4.x, + quad.p4.y, + ].map(Math.round); + }); + return { quads }; + } + + getBoxModel(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + const bounding = unsafeObject.getBoundingClientRect(); + const model = { + width: Math.round(bounding.width), + height: Math.round(bounding.height), + }; + for (const box of ["content", "padding", "border", "margin"]) { + const quads = unsafeObject.getBoxQuads({ + box, + relativeTo: this.content.document, + }); + + // getBoxQuads may return more than one element. In this case we have to compute the bounding box + // of all these boxes. + let bounding = { + p1: { x: Infinity, y: Infinity }, + p2: { x: -Infinity, y: Infinity }, + p3: { x: -Infinity, y: -Infinity }, + p4: { x: Infinity, y: -Infinity }, + }; + quads.forEach(quad => { + bounding = { + p1: { + x: Math.min(bounding.p1.x, quad.p1.x), + y: Math.min(bounding.p1.y, quad.p1.y), + }, + p2: { + x: Math.max(bounding.p2.x, quad.p2.x), + y: Math.min(bounding.p2.y, quad.p2.y), + }, + p3: { + x: Math.max(bounding.p3.x, quad.p3.x), + y: Math.max(bounding.p3.y, quad.p3.y), + }, + p4: { + x: Math.min(bounding.p4.x, quad.p4.x), + y: Math.max(bounding.p4.y, quad.p4.y), + }, + }; + }); + + model[box] = [ + bounding.p1.x, + bounding.p1.y, + bounding.p2.x, + bounding.p2.y, + bounding.p3.x, + bounding.p3.y, + bounding.p4.x, + bounding.p4.y, + ].map(Math.round); + } + return { + model, + }; + } + + /** + * Resolves the JavaScript node object for a given NodeId or BackendNodeId. + * + * @param {Object} options + * @param {number} options.backendNodeId [required for now] + * Backend identifier of the node to resolve. + * @param {number=} options.executionContextId + * Execution context in which to resolve the node. + * @param {number=} options.nodeId [not supported] + * Id of the node to resolve. + * @param {string=} options.objectGroup [not supported] + * Symbolic group name that can be used to release multiple objects. + * + * @return {Runtime.RemoteObject} + * JavaScript object wrapper for given node. + */ + resolveNode(options = {}) { + const { backendNodeId, executionContextId } = options; + + // Until nodeId is supported force usage of the backendNodeId + // Bug 1625417 - CDP expects the id as number + if (!["string"].includes(typeof backendNodeId)) { + throw new TypeError("backendNodeId: string value expected"); + } + if (!["undefined", "number"].includes(typeof executionContextId)) { + throw new TypeError("executionContextId: integer value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + + // Retrieve the node to resolve, and its context + const debuggerObj = Runtime._getRemoteObjectByNodeId(backendNodeId); + + if (!debuggerObj) { + throw new Error(`No node with given id found`); + } + + // If execution context isn't specified use the default one for the node + let context; + if (typeof executionContextId != "undefined") { + context = Runtime.contexts.get(executionContextId); + if (!context) { + throw new Error(`Node with given id does not belong to the document`); + } + } else { + context = Runtime._getDefaultContextForWindow(); + } + + Runtime._setRemoteObject(debuggerObj, context); + + return { + object: Runtime._serializeRemoteObject(debuggerObj, context.id), + }; + } +} diff --git a/remote/cdp/domains/content/Emulation.sys.mjs b/remote/cdp/domains/content/Emulation.sys.mjs new file mode 100644 index 0000000000..41bb0c76ea --- /dev/null +++ b/remote/cdp/domains/content/Emulation.sys.mjs @@ -0,0 +1,50 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +export class Emulation extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Waits until the viewport has reached the new dimensions. + */ + async _awaitViewportDimensions({ width, height }) { + const win = this.content; + let resized; + + // Updates for background tabs are throttled, and we also we have to make + // sure that the new browser dimensions have been received by the content + // process. As such wait for the next animation frame. + await lazy.AnimationFramePromise(win); + + const checkBrowserSize = () => { + if (win.innerWidth === width && win.innerHeight === height) { + resized(); + } + }; + + return new Promise(resolve => { + resized = resolve; + + win.addEventListener("resize", checkBrowserSize); + + // Trigger a layout flush in case none happened yet. + checkBrowserSize(); + }).finally(() => { + win.removeEventListener("resize", checkBrowserSize); + }); + } +} diff --git a/remote/cdp/domains/content/Input.sys.mjs b/remote/cdp/domains/content/Input.sys.mjs new file mode 100644 index 0000000000..e7a6ffc709 --- /dev/null +++ b/remote/cdp/domains/content/Input.sys.mjs @@ -0,0 +1,57 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Input extends ContentProcessDomain { + constructor(session) { + super(session); + + // Internal id used to track existing event handlers. + this._eventId = 0; + + // Map of event id -> event handler promise. + this._eventPromises = new Map(); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Add an event listener in the content page for the provided eventName. + * This method will return a unique handler id that can be used to wait + * for the event. + * + * Example usage from a parent process domain: + * + * const id = await this.executeInChild("_addContentEventListener", "click"); + * // do something that triggers a click in content + * await this.executeInChild("_waitForContentEvent", id); + */ + _addContentEventListener(eventName) { + const eventPromise = new Promise(resolve => { + this.chromeEventHandler.addEventListener(eventName, resolve, { + mozSystemGroup: true, + once: true, + }); + }); + this._eventId++; + this._eventPromises.set(this._eventId, eventPromise); + return this._eventId; + } + + /** + * Wait for an event listener added via `addContentEventListener` to be fired. + */ + async _waitForContentEvent(eventId) { + const eventPromise = this._eventPromises.get(eventId); + if (!eventPromise) { + throw new Error("No event promise found for id " + eventId); + } + await eventPromise; + this._eventPromises.delete(eventId); + } +} diff --git a/remote/cdp/domains/content/Log.sys.mjs b/remote/cdp/domains/content/Log.sys.mjs new file mode 100644 index 0000000000..f8c561d429 --- /dev/null +++ b/remote/cdp/domains/content/Log.sys.mjs @@ -0,0 +1,86 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const CONSOLE_MESSAGE_LEVEL_MAP = { + [Ci.nsIConsoleMessage.debug]: "verbose", + [Ci.nsIConsoleMessage.info]: "info", + [Ci.nsIConsoleMessage.warn]: "warning", + [Ci.nsIConsoleMessage.error]: "error", +}; + +export class Log extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (!this.enabled) { + this.enabled = true; + + Services.console.registerListener(this); + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + + Services.console.unregisterListener(this); + } + } + + _getLogCategory(category) { + if (category.startsWith("CORS")) { + return "network"; + } else if (category.includes("javascript")) { + return "javascript"; + } + + return "other"; + } + + // nsIObserver + + /** + * Takes all script error messages that do not have an exception attached, + * and emits a "Log.entryAdded" event. + * + * @param {nsIConsoleMessage} message + * Message originating from the nsIConsoleService. + */ + observe(message) { + if (message instanceof Ci.nsIScriptError && !message.hasException) { + let url; + if (message.sourceName !== "debugger eval code") { + url = message.sourceName; + } + + const entry = { + source: this._getLogCategory(message.category), + level: CONSOLE_MESSAGE_LEVEL_MAP[message.logLevel], + text: message.errorMessage, + timestamp: message.timeStamp, + url, + lineNumber: message.lineNumber, + }; + + this.emit("Log.entryAdded", { entry }); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/cdp/domains/content/Network.sys.mjs b/remote/cdp/domains/content/Network.sys.mjs new file mode 100644 index 0000000000..91dc44cf46 --- /dev/null +++ b/remote/cdp/domains/content/Network.sys.mjs @@ -0,0 +1,18 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Network extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _updateLoadFlags(flags) { + this.docShell.defaultLoadFlags = flags; + } +} diff --git a/remote/cdp/domains/content/Page.sys.mjs b/remote/cdp/domains/content/Page.sys.mjs new file mode 100644 index 0000000000..5f833a9102 --- /dev/null +++ b/remote/cdp/domains/content/Page.sys.mjs @@ -0,0 +1,451 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const { + LOAD_FLAGS_BYPASS_CACHE, + LOAD_FLAGS_BYPASS_PROXY, + LOAD_FLAGS_NONE, +} = Ci.nsIWebNavigation; + +export class Page extends ContentProcessDomain { + constructor(session) { + super(session); + + this.enabled = false; + this.lifecycleEnabled = false; + // script id => { source, worldName } + this.scriptsToEvaluateOnLoad = new Map(); + this.worldsToEvaluateOnLoad = new Set(); + + // This map is used to keep a reference to the loader id for + // those Page events, which do not directly rely on + // Network events. This might be a temporary solution until + // the Network observer could be queried for that. But right + // now this lives in the parent process. + this.frameIdToLoaderId = new Map(); + + this._onFrameAttached = this._onFrameAttached.bind(this); + this._onFrameDetached = this._onFrameDetached.bind(this); + this._onFrameNavigated = this._onFrameNavigated.bind(this); + this._onScriptLoaded = this._onScriptLoaded.bind(this); + + this.session.contextObserver.on("script-loaded", this._onScriptLoaded); + } + + destructor() { + this.setLifecycleEventsEnabled({ enabled: false }); + this.session.contextObserver.off("script-loaded", this._onScriptLoaded); + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.session.contextObserver.on("frame-attached", this._onFrameAttached); + this.session.contextObserver.on("frame-detached", this._onFrameDetached); + this.session.contextObserver.on( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.addEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.session.contextObserver.off("frame-attached", this._onFrameAttached); + this.session.contextObserver.off("frame-detached", this._onFrameDetached); + this.session.contextObserver.off( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.removeEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.enabled = false; + } + } + + async reload(options = {}) { + const { ignoreCache } = options; + let flags = LOAD_FLAGS_NONE; + if (ignoreCache) { + flags |= LOAD_FLAGS_BYPASS_CACHE; + flags |= LOAD_FLAGS_BYPASS_PROXY; + } + this.docShell.reload(flags); + } + + getFrameTree() { + const getFrames = context => { + const frameTree = { + frame: this._getFrameDetails({ context }), + }; + + if (context.children.length) { + const frames = []; + for (const childContext of context.children) { + frames.push(getFrames(childContext)); + } + frameTree.childFrames = frames; + } + + return frameTree; + }; + + return { + frameTree: getFrames(this.docShell.browsingContext), + }; + } + + /** + * Enqueues given script to be evaluated in every frame upon creation + * + * If `worldName` is specified, creates an execution context with the given name + * and evaluates given script in it. + * + * At this time, queued scripts do not get evaluated, hence `source` is marked as + * "unsupported". + * + * @param {Object} options + * @param {string} options.source (not supported) + * @param {string=} options.worldName + * @return {string} Page.ScriptIdentifier + */ + addScriptToEvaluateOnNewDocument(options = {}) { + const { source, worldName } = options; + if (worldName) { + this.worldsToEvaluateOnLoad.add(worldName); + } + const identifier = Services.uuid + .generateUUID() + .toString() + .slice(1, -1); + this.scriptsToEvaluateOnLoad.set(identifier, { worldName, source }); + + return { identifier }; + } + + /** + * Creates an isolated world for the given frame. + * + * Really it just creates an execution context with label "isolated". + * + * @param {Object} options + * @param {string} options.frameId + * Id of the frame in which the isolated world should be created. + * @param {string=} options.worldName + * An optional name which is reported in the Execution Context. + * @param {boolean=} options.grantUniversalAccess (not supported) + * This is a powerful option, use with caution. + * + * @return {number} Runtime.ExecutionContextId + * Execution context of the isolated world. + */ + createIsolatedWorld(options = {}) { + const { frameId, worldName } = options; + + if (typeof frameId != "string") { + throw new TypeError("frameId: string value expected"); + } + + if (!["undefined", "string"].includes(typeof worldName)) { + throw new TypeError("worldName: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const contexts = Runtime._getContextsForFrame(frameId); + if (!contexts.length) { + throw new Error("No frame for given id found"); + } + + const defaultContext = Runtime._getDefaultContextForWindow( + contexts[0].windowId + ); + const window = defaultContext.window; + + const executionContextId = Runtime._onContextCreated("context-created", { + windowId: window.windowGlobalChild.innerWindowId, + window, + isDefault: false, + contextName: worldName, + contextType: "isolated", + }); + + return { executionContextId }; + } + + /** + * Controls whether page will emit lifecycle events. + * + * @param {Object} options + * @param {boolean} options.enabled + * If true, starts emitting lifecycle events. + */ + setLifecycleEventsEnabled(options = {}) { + const { enabled } = options; + + this.lifecycleEnabled = enabled; + } + + url() { + return this.content.location.href; + } + + _onFrameAttached(name, { frameId, window }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameAttached", { + frameId: frameId.toString(), + parentFrameId: bc.parent.id.toString(), + stack: null, + }); + + // Usually both events are emitted when the "pagehide" event is received. + // But this wont happen for a new window or frame when the initial + // about:blank page has already loaded, and is being replaced with the + // final document. + if (!window.document.isInitialDocument) { + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + + const loaderId = this.frameIdToLoaderId.get(frameId); + const timestamp = Date.now() / 1000; + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + + _onFrameDetached(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameDetached", { frameId: frameId.toString() }); + } + + _onFrameNavigated(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + this.emit("Page.frameNavigated", { + frame: this._getFrameDetails({ context: bc }), + }); + } + + /** + * @param {Object=} options + * @param {number} options.windowId + * The inner window id of the window the script has been loaded for. + * @param {Window} options.window + * The window object of the document. + */ + _onScriptLoaded(name, options = {}) { + const { windowId, window } = options; + + const Runtime = this.session.domains.get("Runtime"); + for (const world of this.worldsToEvaluateOnLoad) { + Runtime._onContextCreated("context-created", { + windowId, + window, + isDefault: false, + contextName: world, + contextType: "isolated", + }); + } + // TODO evaluate each onNewDoc script in the appropriate world + } + + emitLifecycleEvent(frameId, loaderId, name, timestamp) { + if (this.lifecycleEnabled) { + this.emit("Page.lifecycleEvent", { + frameId: frameId.toString(), + loaderId, + name, + timestamp, + }); + } + } + + handleEvent({ type, target }) { + const timestamp = Date.now() / 1000; + + // Some events such as "hashchange" use the window as the target, while + // others have a document. + const win = Window.isInstance(target) ? target : target.defaultView; + const frameId = win.docShell.browsingContext.id; + const isFrame = !!win.docShell.browsingContext.parent; + const loaderId = this.frameIdToLoaderId.get(frameId); + const url = win.location.href; + + switch (type) { + case "DOMContentLoaded": + if (!isFrame) { + this.emit("Page.domContentEventFired", { timestamp }); + } + this.emitLifecycleEvent( + frameId, + loaderId, + "DOMContentLoaded", + timestamp + ); + break; + + case "hashchange": + this.emit("Page.navigatedWithinDocument", { + frameId: frameId.toString(), + url, + }); + break; + + case "pagehide": + // Maybe better to bound to "unload" once we can register for this event + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + break; + + case "load": + if (!isFrame) { + this.emit("Page.loadEventFired", { timestamp }); + } + this.emitLifecycleEvent(frameId, loaderId, "load", timestamp); + + // XXX this should most likely be sent differently + this.emit("Page.frameStoppedLoading", { frameId: frameId.toString() }); + break; + + case "readystatechange": + if (this.content.document.readyState === "loading") { + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + } + + _updateLoaderId(data) { + const { frameId, loaderId } = data; + + this.frameIdToLoaderId.set(frameId, loaderId); + } + + _contentRect() { + const docEl = this.content.document.documentElement; + + return { + x: 0, + y: 0, + width: docEl.scrollWidth, + height: docEl.scrollHeight, + }; + } + + _devicePixelRatio() { + return ( + this.content.browsingContext.overrideDPPX || this.content.devicePixelRatio + ); + } + + _getFrameDetails({ context, id }) { + const bc = context || BrowsingContext.get(id); + const frame = bc.embedderElement; + + return { + id: bc.id.toString(), + parentId: bc.parent?.id.toString(), + loaderId: this.frameIdToLoaderId.get(bc.id), + url: bc.docShell.domWindow.location.href, + name: frame?.id || frame?.name, + securityOrigin: null, + mimeType: null, + }; + } + + _getScrollbarSize() { + const scrollbarHeight = {}; + const scrollbarWidth = {}; + + this.content.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + + return { + width: scrollbarWidth.value, + height: scrollbarHeight.value, + }; + } + + _layoutViewport() { + const scrollbarSize = this._getScrollbarSize(); + + return { + pageX: this.content.pageXOffset, + pageY: this.content.pageYOffset, + clientWidth: this.content.innerWidth - scrollbarSize.width, + clientHeight: this.content.innerHeight - scrollbarSize.height, + }; + } +} diff --git a/remote/cdp/domains/content/Performance.sys.mjs b/remote/cdp/domains/content/Performance.sys.mjs new file mode 100644 index 0000000000..e5726725b5 --- /dev/null +++ b/remote/cdp/domains/content/Performance.sys.mjs @@ -0,0 +1,32 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Performance extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/Runtime.sys.mjs b/remote/cdp/domains/content/Runtime.sys.mjs new file mode 100644 index 0000000000..0f3ab16d2a --- /dev/null +++ b/remote/cdp/domains/content/Runtime.sys.mjs @@ -0,0 +1,629 @@ +/* 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", + 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", +}; + +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, + }); + }); + } + } + + 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( + `Unable to get execution context by object ID: ${objectId}` + ); + } + 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. + * + * @return {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; + } + } + if (!context) { + throw new Error( + `Unable to get the context for object with id: ${options.objectId}` + ); + } + } else { + context = this.contexts.get(options.executionContextId); + if (!context) { + throw new Error("Cannot find context with specified id"); + } + } + + 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. + * + * @return {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("Cannot find context with specified id"); + } + } 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; + } + + _buildStackTrace(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, + }; + } + + _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._buildStackTrace(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._buildStackTrace(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" + * + * @return {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} id + * The execution context id to destroy. + * @param {number} windowId + * The inner-window id of the execution context to destroy. + * @param {number} 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) { + let entry = fromConsoleAPI(message); + this._emitConsoleAPICalled(entry); + } + + // 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} message + * 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 fromConsoleAPI(message) { + // From sendConsoleAPIMessage (toolkit/modules/Console.sys.mjs) + return { + arguments: message.arguments, + innerWindowId: message.innerID, + // TODO: Fetch the stack (Bug 1679981) + stack: undefined, + timestamp: message.timeStamp, + type: CONSOLE_API_LEVEL_MAP[message.level] || message.level, + }; +} + +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, + }; +} diff --git a/remote/cdp/domains/content/Security.sys.mjs b/remote/cdp/domains/content/Security.sys.mjs new file mode 100644 index 0000000000..7d21d386b4 --- /dev/null +++ b/remote/cdp/domains/content/Security.sys.mjs @@ -0,0 +1,32 @@ +/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Security extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs new file mode 100644 index 0000000000..8e5a496770 --- /dev/null +++ b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs @@ -0,0 +1,530 @@ +/* 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/. */ + +const TYPED_ARRAY_CLASSES = [ + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", +]; + +function uuid() { + return Services.uuid + .generateUUID() + .toString() + .slice(1, -1); +} + +/** + * This class represent a debuggable context onto which we can evaluate Javascript. + * This is typically a document, but it could also be a worker, an add-on, ... or + * any kind of context involving JS scripts. + * + * @param {Debugger} dbg + * A Debugger instance that we can use to inspect the given global. + * @param {GlobalObject} debuggee + * The debuggable context's global object. This is typically the document window + * object. But it can also be any global object, like a worker global scope object. + */ +export class ExecutionContext { + constructor(dbg, debuggee, id, isDefault) { + this._debugger = dbg; + this._debuggee = this._debugger.addDebuggee(debuggee); + + // Here, we assume that debuggee is a window object and we will propably have + // to adapt that once we cover workers or contexts that aren't a document. + this.window = debuggee; + this.windowId = this.window.windowGlobalChild.innerWindowId; + this.id = id; + this.frameId = this.window.browsingContext.id.toString(); + this.isDefault = isDefault; + + // objectId => Debugger.Object + this._remoteObjects = new Map(); + } + + destructor() { + this._debugger.removeDebuggee(this._debuggee); + } + + get browsingContext() { + return this.window.browsingContext; + } + + hasRemoteObject(objectId) { + return this._remoteObjects.has(objectId); + } + + getRemoteObject(objectId) { + return this._remoteObjects.get(objectId); + } + + getRemoteObjectByNodeId(nodeId) { + for (const value of this._remoteObjects.values()) { + if (value.nodeId == nodeId) { + return value; + } + } + + return null; + } + + releaseObject(objectId) { + return this._remoteObjects.delete(objectId); + } + + /** + * Add a new debuggerObj to the object cache. + * + * Whenever an object is returned as reference, a new entry is added + * to the internal object cache. It means the same underlying object or node + * can be represented via multiple references. + */ + setRemoteObject(debuggerObj) { + const objectId = uuid(); + + // TODO: Wrap Symbol into an object, + // which would allow us to set the objectId. + if (typeof debuggerObj == "object") { + debuggerObj.objectId = objectId; + } + + // For node objects add an unique identifier. + if ( + debuggerObj instanceof Debugger.Object && + Node.isInstance(debuggerObj.unsafeDereference()) + ) { + debuggerObj.nodeId = uuid(); + // We do not differentiate between backendNodeId and nodeId (yet) + debuggerObj.backendNodeId = debuggerObj.nodeId; + } + + this._remoteObjects.set(objectId, debuggerObj); + + return objectId; + } + + /** + * Evaluate a Javascript expression. + * + * @param {String} expression + * The JS expression to evaluate against the JS context. + * @param {boolean} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {boolean} returnByValue + * Whether the result is expected to be a JSON object + * that should be sent by value. + * + * @return {Object} A multi-form object depending if the execution + * succeed or failed. If the expression failed to evaluate, + * it will return an object with an `exceptionDetails` attribute + * matching the `ExceptionDetails` CDP type. Otherwise it will + * return an object with `result` attribute whose type is + * `RemoteObject` CDP type. + */ + async evaluate(expression, awaitPromise, returnByValue) { + let rv = this._debuggee.executeInGlobal(expression); + if (!rv) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(e); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + /** + * Given a Debugger.Object reference for an Exception, return a JSON object + * describing the exception by following CDP ExceptionDetails specification. + */ + _returnError(exception) { + if ( + this._debuggee.executeInGlobalWithBindings("exception instanceof Error", { + exception, + }).return + ) { + const text = this._debuggee.executeInGlobalWithBindings( + "exception.message", + { exception } + ).return; + return { + exceptionDetails: { + text, + }, + }; + } + + // If that isn't an Error, consider the exception as a JS value + return { + exceptionDetails: { + exception: this._toRemoteObject(exception), + }, + }; + } + + async callFunctionOn( + functionDeclaration, + callArguments = [], + returnByValue = false, + awaitPromise = false, + objectId = null + ) { + // Map the given objectId to a JS reference. + let thisArg = null; + if (objectId) { + thisArg = this.getRemoteObject(objectId); + if (!thisArg) { + throw new Error(`Unable to get target object with id: ${objectId}`); + } + } + + // First evaluate the function + const fun = this._debuggee.executeInGlobal("(" + functionDeclaration + ")"); + if (!fun) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + if (fun.throw) { + return this._returnError(fun.throw); + } + + // Then map all input arguments, which are matching CDP's CallArguments type, + // into JS values + const args = callArguments.map(arg => this._fromCallArgument(arg)); + + // Finally, call the function with these arguments + const rv = fun.return.apply(thisArg, args); + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(e); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + getProperties({ objectId, ownProperties }) { + let debuggerObj = this.getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + const result = []; + const serializeObject = (debuggerObj, isOwn) => { + for (const propertyName of debuggerObj.getOwnPropertyNames()) { + const descriptor = debuggerObj.getOwnPropertyDescriptor(propertyName); + result.push({ + name: propertyName, + + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: descriptor.writable, + value: this._toRemoteObject(descriptor.value), + get: descriptor.get + ? this._toRemoteObject(descriptor.get) + : undefined, + set: descriptor.set + ? this._toRemoteObject(descriptor.set) + : undefined, + + isOwn, + }); + } + }; + + // When `ownProperties` is set to true, we only iterate over own properties. + // Otherwise, we also iterate over propreties inherited from the prototype chain. + serializeObject(debuggerObj, true); + + if (!ownProperties) { + while (true) { + debuggerObj = debuggerObj.proto; + if (!debuggerObj) { + break; + } + serializeObject(debuggerObj, false); + } + } + + return { + result, + }; + } + + /** + * Given a CDP `CallArgument`, return a JS value that represent this argument. + * Note that `CallArgument` is actually very similar to `RemoteObject` + */ + _fromCallArgument(arg) { + if (arg.objectId) { + if (!this.hasRemoteObject(arg.objectId)) { + throw new Error("Could not find object with given id"); + } + return this.getRemoteObject(arg.objectId); + } + + if (arg.unserializableValue) { + switch (arg.unserializableValue) { + case "-0": + return -0; + case "Infinity": + return Infinity; + case "-Infinity": + return -Infinity; + case "NaN": + return NaN; + default: + if (/^\d+n$/.test(arg.unserializableValue)) { + // eslint-disable-next-line no-undef + return BigInt(arg.unserializableValue.slice(0, -1)); + } + throw new Error("Couldn't parse value object in call argument"); + } + } + + return this._deserialize(arg.value); + } + + /** + * Given a JS value, create a copy of it within the debugee compartment. + */ + _deserialize(obj) { + if (typeof obj !== "object") { + return obj; + } + const result = this._debuggee.executeInGlobalWithBindings( + "JSON.parse(obj)", + { obj: JSON.stringify(obj) } + ); + if (result.throw) { + throw new Error("Unable to deserialize object"); + } + return result.return; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @return {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObject(debuggerObj) { + const result = {}; + + // First handle all non-primitive values which are going to be wrapped by the + // Debugger API into Debugger.Object instances + if (debuggerObj instanceof Debugger.Object) { + const rawObj = debuggerObj.unsafeDereference(); + + result.objectId = this.setRemoteObject(debuggerObj); + result.type = typeof rawObj; + + // Map the Debugger API `class` attribute to CDP `subtype` + const cls = debuggerObj.class; + if (debuggerObj.isProxy) { + result.subtype = "proxy"; + } else if (cls == "Array") { + result.subtype = "array"; + } else if (cls == "RegExp") { + result.subtype = "regexp"; + } else if (cls == "Date") { + result.subtype = "date"; + } else if (cls == "Map") { + result.subtype = "map"; + } else if (cls == "Set") { + result.subtype = "set"; + } else if (cls == "WeakMap") { + result.subtype = "weakmap"; + } else if (cls == "WeakSet") { + result.subtype = "weakset"; + } else if (cls == "Error") { + result.subtype = "error"; + } else if (cls == "Promise") { + result.subtype = "promise"; + } else if (TYPED_ARRAY_CLASSES.includes(cls)) { + result.subtype = "typedarray"; + } else if (Node.isInstance(rawObj)) { + result.subtype = "node"; + result.className = ChromeUtils.getClassName(rawObj); + result.description = rawObj.localName || rawObj.nodeName; + if (rawObj.id) { + result.description += `#${rawObj.id}`; + } + } + return result; + } + + // Now, handle all values that Debugger API isn't wrapping into Debugger.API. + // This is all the primitive JS types. + result.type = typeof debuggerObj; + + // Symbol and BigInt are primitive values but aren't serializable. + // CDP expects them to be considered as objects, with an objectId to later inspect + // them. + if (result.type == "symbol") { + result.description = debuggerObj.toString(); + result.objectId = this.setRemoteObject(debuggerObj); + + return result; + } + + // A few primitive type can't be serialized and CDP has special case for them + if (Object.is(debuggerObj, NaN)) { + result.unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, -0)) { + result.unserializableValue = "-0"; + } else if (Object.is(debuggerObj, Infinity)) { + result.unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + result.unserializableValue = "-Infinity"; + } else if (result.type == "bigint") { + result.unserializableValue = `${debuggerObj}n`; + } + + if (result.unserializableValue) { + result.description = result.unserializableValue; + return result; + } + + // Otherwise, we serialize the primitive values as-is via `value` attribute + result.value = debuggerObj; + + // null is special as it has a dedicated subtype + if (debuggerObj === null) { + result.subtype = "null"; + } + + return result; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @return {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObjectByValue(debuggerObj) { + const type = typeof debuggerObj; + + if (type == "undefined") { + return { type }; + } + + let unserializableValue; + if (Object.is(debuggerObj, -0)) { + unserializableValue = "-0"; + } else if (Object.is(debuggerObj, NaN)) { + unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, Infinity)) { + unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + unserializableValue = "-Infinity"; + } else if (typeof debuggerObj == "bigint") { + unserializableValue = `${debuggerObj}n`; + } + + if (unserializableValue) { + return { + type, + unserializableValue, + description: unserializableValue, + }; + } + + const value = this._serialize(debuggerObj); + return { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }; + } + + /** + * Convert a given `Debugger.Object` to an object. + * + * @param {Debugger.Object} obj + * The object to convert + * + * @return {Object} + * The converted object + */ + _serialize(debuggerObj) { + const result = this._debuggee.executeInGlobalWithBindings( + "JSON.stringify(e)", + { e: debuggerObj } + ); + if (result.throw) { + throw new Error("Object is not serializable"); + } + + return JSON.parse(result.return); + } +} |