/* 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/. */ "use strict"; /* global XPCNativeWrapper */ const { ActorClassWithSpec, Actor } = require("devtools/shared/protocol"); const { webconsoleSpec } = require("devtools/shared/specs/webconsole"); const Services = require("Services"); const { Cc, Ci, Cu } = require("chrome"); const { DevToolsServer } = require("devtools/server/devtools-server"); const { ThreadActor } = require("devtools/server/actors/thread"); const { ObjectActor } = require("devtools/server/actors/object"); const { LongStringActor } = require("devtools/server/actors/string"); const { createValueGrip, isArray, stringIsLong, } = require("devtools/server/actors/object/utils"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const ErrorDocs = require("devtools/server/actors/errordocs"); loader.lazyRequireGetter( this, "evalWithDebugger", "devtools/server/actors/webconsole/eval-with-debugger", true ); loader.lazyRequireGetter( this, "NetworkMonitorActor", "devtools/server/actors/network-monitor/network-monitor", true ); loader.lazyRequireGetter( this, "ConsoleFileActivityListener", "devtools/server/actors/webconsole/listeners/console-file-activity", true ); loader.lazyRequireGetter( this, "StackTraceCollector", "devtools/server/actors/network-monitor/stack-trace-collector", true ); loader.lazyRequireGetter( this, "JSPropertyProvider", "devtools/shared/webconsole/js-property-provider", true ); loader.lazyRequireGetter( this, "NetUtil", "resource://gre/modules/NetUtil.jsm", true ); loader.lazyRequireGetter( this, ["isCommand", "validCommands"], "devtools/server/actors/webconsole/commands", true ); loader.lazyRequireGetter( this, "createMessageManagerMocks", "devtools/server/actors/webconsole/message-manager-mock", true ); loader.lazyRequireGetter( this, ["addWebConsoleCommands", "CONSOLE_WORKER_IDS", "WebConsoleUtils"], "devtools/server/actors/webconsole/utils", true ); loader.lazyRequireGetter( this, "EnvironmentActor", "devtools/server/actors/environment", true ); loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); loader.lazyRequireGetter( this, "MESSAGE_CATEGORY", "devtools/shared/constants", true ); loader.lazyRequireGetter( this, "stringToCauseType", "devtools/server/actors/network-monitor/network-observer", true ); // Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py loader.lazyRequireGetter( this, "RESERVED_JS_KEYWORDS", "devtools/shared/webconsole/reserved-js-words" ); // Overwrite implemented listeners for workers so that we don't attempt // to load an unsupported module. if (isWorker) { loader.lazyRequireGetter( this, ["ConsoleAPIListener", "ConsoleServiceListener"], "devtools/server/actors/webconsole/worker-listeners", true ); } else { loader.lazyRequireGetter( this, "ConsoleAPIListener", "devtools/server/actors/webconsole/listeners/console-api", true ); loader.lazyRequireGetter( this, "ConsoleServiceListener", "devtools/server/actors/webconsole/listeners/console-service", true ); loader.lazyRequireGetter( this, "ConsoleReflowListener", "devtools/server/actors/webconsole/listeners/console-reflow", true ); loader.lazyRequireGetter( this, "ContentProcessListener", "devtools/server/actors/webconsole/listeners/content-process", true ); loader.lazyRequireGetter( this, "DocumentEventsListener", "devtools/server/actors/webconsole/listeners/document-events", true ); } loader.lazyRequireGetter( this, "ObjectUtils", "devtools/server/actors/object/utils" ); function isObject(value) { return Object(value) === value; } /** * The WebConsoleActor implements capabilities needed for the Web Console * feature. * * @constructor * @param object connection * The connection to the client, DevToolsServerConnection. * @param object [parentActor] * Optional, the parent actor. */ const WebConsoleActor = ActorClassWithSpec(webconsoleSpec, { initialize: function(connection, parentActor) { Actor.prototype.initialize.call(this, connection); this.conn = connection; this.parentActor = parentActor; this._prefs = {}; this.dbg = this.parentActor.dbg; this._gripDepth = 0; this._evalCounter = 0; this._listeners = new Set(); this._lastConsoleInputEvaluation = undefined; this.objectGrip = this.objectGrip.bind(this); this._onWillNavigate = this._onWillNavigate.bind(this); this._onChangedToplevelDocument = this._onChangedToplevelDocument.bind( this ); this.onConsoleServiceMessage = this.onConsoleServiceMessage.bind(this); this.onConsoleAPICall = this.onConsoleAPICall.bind(this); this.onDocumentEvent = this.onDocumentEvent.bind(this); EventEmitter.on( this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument ); this._onObserverNotification = this._onObserverNotification.bind(this); if (this.parentActor.isRootActor) { Services.obs.addObserver( this._onObserverNotification, "last-pb-context-exited" ); } this.traits = { // Supports retrieving blocked urls blockedUrls: true, }; }, /** * Debugger instance. * * @see jsdebugger.jsm */ dbg: null, /** * This is used by the ObjectActor to keep track of the depth of grip() calls. * @private * @type number */ _gripDepth: null, /** * Web Console-related preferences. * @private * @type object */ _prefs: null, /** * Holds a set of all currently registered listeners. * * @private * @type Set */ _listeners: null, /** * The devtools server connection instance. * @type object */ conn: null, /** * List of supported features by the console actor. * @type object */ traits: null, /** * The global we work with (this can be a Window, a Worker global or even a Sandbox * for processes and addons). * * @type nsIDOMWindow, WorkerGlobalScope or Sandbox */ get global() { if (this.parentActor.isRootActor) { return this._getWindowForBrowserConsole(); } return this.parentActor.window || this.parentActor.workerGlobal; }, /** * Get a window to use for the browser console. * * @private * @return nsIDOMWindow * The window to use, or null if no window could be found. */ _getWindowForBrowserConsole: function() { // Check if our last used chrome window is still live. let window = this._lastChromeWindow && this._lastChromeWindow.get(); // If not, look for a new one. if (!window || window.closed) { window = this.parentActor.window; if (!window) { // Try to find the Browser Console window to use instead. window = Services.wm.getMostRecentWindow("devtools:webconsole"); // We prefer the normal chrome window over the console window, // so we'll look for those windows in order to replace our reference. const onChromeWindowOpened = () => { // We'll look for this window when someone next requests window() Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened"); this._lastChromeWindow = null; }; Services.obs.addObserver(onChromeWindowOpened, "domwindowopened"); } this._handleNewWindow(window); } return window; }, /** * Store a newly found window on the actor to be used in the future. * * @private * @param nsIDOMWindow window * The window to store on the actor (can be null). */ _handleNewWindow: function(window) { if (window) { if (this._hadChromeWindow) { Services.console.logStringMessage("Webconsole context has changed"); } this._lastChromeWindow = Cu.getWeakReference(window); this._hadChromeWindow = true; } else { this._lastChromeWindow = null; } }, /** * Whether we've been using a window before. * * @private * @type boolean */ _hadChromeWindow: false, /** * A weak reference to the last chrome window we used to work with. * * @private * @type nsIWeakReference */ _lastChromeWindow: null, // The evalGlobal is used at the scope for JS evaluation. _evalGlobal: null, get evalGlobal() { return this._evalGlobal || this.global; }, set evalGlobal(global) { this._evalGlobal = global; if (!this._progressListenerActive) { EventEmitter.on(this.parentActor, "will-navigate", this._onWillNavigate); this._progressListenerActive = true; } }, /** * Flag used to track if we are listening for events from the progress * listener of the target actor. We use the progress listener to clear * this.evalGlobal on page navigation. * * @private * @type boolean */ _progressListenerActive: false, /** * The ConsoleServiceListener instance. * @type object */ consoleServiceListener: null, /** * The ConsoleAPIListener instance. */ consoleAPIListener: null, /** * The ConsoleFileActivityListener instance. */ consoleFileActivityListener: null, /** * The ConsoleReflowListener instance. */ consoleReflowListener: null, /** * The Web Console Commands names cache. * @private * @type array */ _webConsoleCommandsCache: null, grip: function() { return { actor: this.actorID }; }, hasNativeConsoleAPI: function(window) { if (isWorker || !(window instanceof Ci.nsIDOMWindow)) { // We can only use XPCNativeWrapper on non-worker nsIDOMWindow. return true; } let isNative = false; try { // We are very explicitly examining the "console" property of // the non-Xrayed object here. const console = window.wrappedJSObject.console; // In xpcshell tests, console ends up being undefined and XPCNativeWrapper // crashes in debug builds. if (console) { isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE; } } catch (ex) { // ignored } return isNative; }, _findProtoChain: ThreadActor.prototype._findProtoChain, _removeFromProtoChain: ThreadActor.prototype._removeFromProtoChain, /** * Destroy the current WebConsoleActor instance. */ destroy() { this.stopListeners(); Actor.prototype.destroy.call(this); EventEmitter.off( this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument ); if (this.parentActor.isRootActor) { Services.obs.removeObserver( this._onObserverNotification, "last-pb-context-exited" ); } this._webConsoleCommandsCache = null; this._lastConsoleInputEvaluation = null; this._evalGlobal = null; this.dbg = null; this.conn = null; }, /** * Create and return an environment actor that corresponds to the provided * Debugger.Environment. This is a straightforward clone of the ThreadActor's * method except that it stores the environment actor in the web console * actor's pool. * * @param Debugger.Environment environment * The lexical environment we want to extract. * @return The EnvironmentActor for |environment| or |undefined| for host * functions or functions scoped to a non-debuggee global. */ createEnvironmentActor: function(environment) { if (!environment) { return undefined; } if (environment.actor) { return environment.actor; } const actor = new EnvironmentActor(environment, this); this.manage(actor); environment.actor = actor; return actor; }, /** * Create a grip for the given value. * * @param mixed value * @return object */ createValueGrip: function(value) { return createValueGrip(value, this, this.objectGrip); }, /** * Make a debuggee value for the given value. * * @param mixed value * The value you want to get a debuggee value for. * @param boolean useObjectGlobal * If |true| the object global is determined and added as a debuggee, * otherwise |this.global| is used when makeDebuggeeValue() is invoked. * @return object * Debuggee value for |value|. */ makeDebuggeeValue: function(value, useObjectGlobal) { if (useObjectGlobal && isObject(value)) { try { const global = Cu.getGlobalForObject(value); const dbgGlobal = this.dbg.makeGlobalObjectReference(global); return dbgGlobal.makeDebuggeeValue(value); } catch (ex) { // The above can throw an exception if value is not an actual object // or 'Object in compartment marked as invisible to Debugger' } } const dbgGlobal = this.dbg.makeGlobalObjectReference(this.global); return dbgGlobal.makeDebuggeeValue(value); }, /** * Create a grip for the given object. * * @param object object * The object you want. * @param object pool * A Pool where the new actor instance is added. * @param object * The object grip. */ objectGrip: function(object, pool) { const actor = new ObjectActor( object, { thread: this.parentActor.threadActor, getGripDepth: () => this._gripDepth, incrementGripDepth: () => this._gripDepth++, decrementGripDepth: () => this._gripDepth--, createValueGrip: v => this.createValueGrip(v), createEnvironmentActor: env => this.createEnvironmentActor(env), }, this.conn ); pool.manage(actor); return actor.form(); }, /** * Create a grip for the given string. * * @param string string * The string you want to create the grip for. * @param object pool * A Pool where the new actor instance is added. * @return object * A LongStringActor object that wraps the given string. */ longStringGrip: function(string, pool) { const actor = new LongStringActor(this.conn, string); pool.manage(actor); return actor.form(); }, /** * Create a long string grip if needed for the given string. * * @private * @param string string * The string you want to create a long string grip for. * @return string|object * A string is returned if |string| is not a long string. * A LongStringActor grip is returned if |string| is a long string. */ _createStringGrip: function(string) { if (string && stringIsLong(string)) { return this.longStringGrip(string, this); } return string; }, /** * Returns the latest web console input evaluation. * This is undefined if no evaluations have been completed. * * @return object */ getLastConsoleInputEvaluation: function() { return this._lastConsoleInputEvaluation; }, /** * Preprocess a debugger object (e.g. return the `boundTargetFunction` * debugger object if the given debugger object is a bound function). * * This method is called by both the `inspect` binding implemented * for the webconsole and the one implemented for the devtools API * `browser.devtools.inspectedWindow.eval`. */ preprocessDebuggerObject(dbgObj) { // Returns the bound target function on a bound function. if (dbgObj?.isBoundFunction && dbgObj?.boundTargetFunction) { return dbgObj.boundTargetFunction; } return dbgObj; }, /** * This helper is used by the WebExtensionInspectedWindowActor to * inspect an object in the developer toolbox. * * NOTE: shared parts related to preprocess the debugger object (between * this function and the `inspect` webconsole command defined in * "devtools/server/actor/webconsole/utils.js") should be added to * the webconsole actors' `preprocessDebuggerObject` method. */ inspectObject(dbgObj, inspectFromAnnotation) { dbgObj = this.preprocessDebuggerObject(dbgObj); this.emit("inspectObject", { objectActor: this.createValueGrip(dbgObj), inspectFromAnnotation, }); }, // Request handlers for known packet types. /** * Handler for the "startListeners" request. * * @param array listeners * An array of events to start sent by the Web Console client. * @return object * The response object which holds the startedListeners array. */ // eslint-disable-next-line complexity startListeners: async function(listeners) { const startedListeners = []; const global = !this.parentActor.isRootActor ? this.global : null; for (const event of listeners) { switch (event) { case "PageError": // Workers don't support this message type yet if (isWorker) { break; } if (!this.consoleServiceListener) { this.consoleServiceListener = new ConsoleServiceListener( global, this.onConsoleServiceMessage ); this.consoleServiceListener.init(); } startedListeners.push(event); break; case "ConsoleAPI": if (!this.consoleAPIListener) { // Create the consoleAPIListener // (and apply the filtering options defined in the parent actor). this.consoleAPIListener = new ConsoleAPIListener( global, this.onConsoleAPICall, this.parentActor.consoleAPIListenerOptions ); this.consoleAPIListener.init(); } startedListeners.push(event); break; case "NetworkActivity": // Workers don't support this message type if (isWorker) { break; } if (!this.netmonitors) { // Instanciate fake message managers used for service worker's netmonitor // when running in the content process, and for netmonitor running in the // same process when running in the parent process. // `createMessageManagerMocks` returns a couple of connected messages // managers that pass messages to each other to simulate the process // boundary. We will use the first one for the webconsole-actor and the // second one will be used by the netmonitor-actor. const [mmMockParent, mmMockChild] = createMessageManagerMocks(); // Maintain the list of message manager we should message to/listen from // to support the netmonitor instances, also records actorID of each // NetworkMonitorActor. // Array of `{ messageManager, parentProcess }`. // Where `parentProcess` is true for the netmonitor actor instanciated in the // parent process. this.netmonitors = []; // Check if the actor is running in a content process const isInContentProcess = Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT && this.parentActor.messageManager; if (isInContentProcess) { // Start a network monitor in the parent process to listen to // most requests that happen in parent. This one will communicate through // `messageManager`. await this.conn.spawnActorInParentProcess(this.actorID, { module: "devtools/server/actors/network-monitor/network-monitor", constructor: "NetworkMonitorActor", args: [{ browserId: this.parentActor.browserId }, this.actorID], }); this.netmonitors.push({ messageManager: this.parentActor.messageManager, parentProcess: true, }); } // When the console actor runs in the parent process, Netmonitor can be ran // in the process and communicate through `messageManagerMock`. // And while it runs in the content process, we also spawn one in the content // to listen to requests that happen in the content process (for instance // service workers requests) new NetworkMonitorActor( this.conn, { window: global }, this.actorID, mmMockParent ); this.netmonitors.push({ messageManager: mmMockChild, parentProcess: !isInContentProcess, }); // Create a StackTraceCollector that's going to be shared both by // the NetworkMonitorActor running in the same process for service worker // requests, as well with the NetworkMonitorActor running in the parent // process. It will communicate via message manager for this one. this.stackTraceCollector = new StackTraceCollector( { window: global }, this.netmonitors ); this.stackTraceCollector.init(); } startedListeners.push(event); break; case "FileActivity": // Workers don't support this message type if (isWorker) { break; } if (this.global instanceof Ci.nsIDOMWindow) { if (!this.consoleFileActivityListener) { this.consoleFileActivityListener = new ConsoleFileActivityListener( this.global, this ); } this.consoleFileActivityListener.startMonitor(); startedListeners.push(event); } break; case "ReflowActivity": // Workers don't support this message type if (isWorker) { break; } if (!this.consoleReflowListener) { this.consoleReflowListener = new ConsoleReflowListener( this.global, this ); } startedListeners.push(event); break; case "ContentProcessMessages": // Workers don't support this message type if (isWorker) { break; } if (!this.contentProcessListener) { this.contentProcessListener = new ContentProcessListener( this.onConsoleAPICall ); } startedListeners.push(event); break; case "DocumentEvents": // Workers don't support this message type if (isWorker) { break; } if (!this.documentEventsListener) { this.documentEventsListener = new DocumentEventsListener( this.parentActor ); this.documentEventsListener.on("*", this.onDocumentEvent); this.documentEventsListener.listen(); } startedListeners.push(event); break; } } // Update the live list of running listeners startedListeners.forEach(this._listeners.add, this._listeners); return { startedListeners: startedListeners, nativeConsoleAPI: this.hasNativeConsoleAPI(this.global), traits: this.traits, }; }, /** * Handler for the "stopListeners" request. * * @param array listeners * An array of events to stop sent by the Web Console client. * @return object * The response packet to send to the client: holds the * stoppedListeners array. */ stopListeners: function(listeners) { const stoppedListeners = []; // If no specific listeners are requested to be detached, we stop all // listeners. const eventsToDetach = listeners || [ "PageError", "ConsoleAPI", "NetworkActivity", "FileActivity", "ReflowActivity", "ContentProcessMessages", "DocumentEvents", ]; for (const event of eventsToDetach) { switch (event) { case "PageError": if (this.consoleServiceListener) { this.consoleServiceListener.destroy(); this.consoleServiceListener = null; } stoppedListeners.push(event); break; case "ConsoleAPI": if (this.consoleAPIListener) { this.consoleAPIListener.destroy(); this.consoleAPIListener = null; } stoppedListeners.push(event); break; case "NetworkActivity": if (this.netmonitors) { for (const { messageManager } of this.netmonitors) { messageManager.sendAsyncMessage("debug:destroy-network-monitor", { actorID: this.actorID, }); } this.netmonitors = null; } if (this.stackTraceCollector) { this.stackTraceCollector.destroy(); this.stackTraceCollector = null; } stoppedListeners.push(event); break; case "FileActivity": if (this.consoleFileActivityListener) { this.consoleFileActivityListener.stopMonitor(); this.consoleFileActivityListener = null; } stoppedListeners.push(event); break; case "ReflowActivity": if (this.consoleReflowListener) { this.consoleReflowListener.destroy(); this.consoleReflowListener = null; } stoppedListeners.push(event); break; case "ContentProcessMessages": if (this.contentProcessListener) { this.contentProcessListener.destroy(); this.contentProcessListener = null; } stoppedListeners.push(event); break; case "DocumentEvents": if (this.documentEventsListener) { this.documentEventsListener.destroy(); this.documentEventsListener = null; } stoppedListeners.push(event); break; } } // Update the live list of running listeners stoppedListeners.forEach(this._listeners.delete, this._listeners); return { stoppedListeners: stoppedListeners }; }, /** * Handler for the "getCachedMessages" request. This method sends the cached * error messages and the window.console API calls to the client. * * @param array messageTypes * An array of message types sent by the Web Console client. * @return object * The response packet to send to the client: it holds the cached * messages array. */ getCachedMessages: function(messageTypes) { if (!messageTypes) { return { error: "missingParameter", message: "The messageTypes parameter is missing.", }; } const messages = []; const consoleServiceCachedMessages = messageTypes.includes("PageError") || messageTypes.includes("LogMessage") ? this.consoleServiceListener?.getCachedMessages( !this.parentActor.isRootActor ) : null; for (const type of messageTypes) { switch (type) { case "ConsoleAPI": { if (!this.consoleAPIListener) { break; } // this.global might not be a window (can be a worker global or a Sandbox), // and in such case performance isn't defined const winStartTime = this.global?.performance?.timing ?.navigationStart; const cache = this.consoleAPIListener.getCachedMessages( !this.parentActor.isRootActor ); cache.forEach(cachedMessage => { // Filter out messages that came from a ServiceWorker but happened // before the page was requested. if ( cachedMessage.innerID === "ServiceWorker" && winStartTime > cachedMessage.timeStamp ) { return; } messages.push({ message: this.prepareConsoleMessageForRemote(cachedMessage), type: "consoleAPICall", }); }); break; } case "PageError": { if (!consoleServiceCachedMessages) { break; } for (const cachedMessage of consoleServiceCachedMessages) { if (!(cachedMessage instanceof Ci.nsIScriptError)) { continue; } messages.push({ pageError: this.preparePageErrorForRemote(cachedMessage), type: "pageError", }); } break; } case "LogMessage": { if (!consoleServiceCachedMessages) { break; } for (const cachedMessage of consoleServiceCachedMessages) { if (cachedMessage instanceof Ci.nsIScriptError) { continue; } messages.push({ message: this._createStringGrip(cachedMessage.message), timeStamp: cachedMessage.timeStamp, type: "logMessage", }); } break; } } } return { messages: messages, }; }, /** * Handler for the "evaluateJSAsync" request. This method evaluates a given * JavaScript string with an associated `resultID`. * * The result will be returned later as an unsolicited `evaluationResult`, * that can be associated back to this request via the `resultID` field. * * @param object request * The JSON request object received from the Web Console client. * @return object * The response packet to send to with the unique id in the * `resultID` field. */ evaluateJSAsync: async function(request) { const startTime = Date.now(); // Use Date instead of UUID as this code is used by workers, which // don't have access to the UUID XPCOM component. // Also use a counter in order to prevent mixing up response when calling // evaluateJSAsync during the same millisecond. const resultID = startTime + "-" + this._evalCounter++; // Execute the evaluation in the next event loop in order to immediately // reply with the resultID. DevToolsUtils.executeSoon(async () => { try { // Execute the script that may pause. let response = await this.evaluateJS(request); // Wait for any potential returned Promise. response = await this._maybeWaitForResponseResult(response); // Set the timestamp only now, so any messages logged in the expression will come // before the result. Add an extra millisecond so the result has a different timestamp // than the console message it might have emitted (unlike the evaluation result, // console api messages are throttled before being handled by the webconsole client, // which might cause some ordering issue). response.timestamp = Date.now() + 1; // Finally, emit an unsolicited evaluationResult packet with the evaluation result. this.emit("evaluationResult", { type: "evaluationResult", resultID, startTime, ...response, }); return; } catch (e) { const message = `Encountered error while waiting for Helper Result: ${e}\n${e.stack}`; DevToolsUtils.reportException("evaluateJSAsync", Error(message)); } }); return { resultID }; }, /** * In order to have asynchronous commands (e.g. screenshot, top-level await, …) , * we have to be able to handle promises. This method handles waiting for the promise, * and then returns the result. * * @private * @param object response * The response packet to send to with the unique id in the * `resultID` field, and potentially a promise in the `helperResult` or in the * `awaitResult` field. * * @return object * The updated response object. */ _maybeWaitForResponseResult: async function(response) { if (!response) { return response; } const thenable = obj => obj && typeof obj.then === "function"; const waitForHelperResult = response.helperResult && thenable(response.helperResult); const waitForAwaitResult = response.awaitResult && thenable(response.awaitResult); if (!waitForAwaitResult && !waitForHelperResult) { return response; } // Wait for asynchronous command completion before sending back the response if (waitForHelperResult) { response.helperResult = await response.helperResult; } else if (waitForAwaitResult) { let result; try { result = await response.awaitResult; // `createValueGrip` expect a debuggee value, while here we have the raw object. // We need to call `makeDebuggeeValue` on it to make it work. const dbgResult = this.makeDebuggeeValue(result); response.result = this.createValueGrip(dbgResult); } catch (e) { // The promise was rejected. We let the engine handle this as it will report a // `uncaught exception` error. response.topLevelAwaitRejected = true; } // Remove the promise from the response object. delete response.awaitResult; } return response; }, /** * Handler for the "evaluateJS" request. This method evaluates the given * JavaScript string and sends back the result. * * @param object request * The JSON request object received from the Web Console client. * @return object * The evaluation response packet. */ evaluateJS: function(request) { const input = request.text; const evalOptions = { frameActor: request.frameActor, url: request.url, innerWindowID: request.innerWindowID, selectedNodeActor: request.selectedNodeActor, selectedObjectActor: request.selectedObjectActor, eager: request.eager, bindings: request.bindings, lineNumber: request.lineNumber, }; const { mapped } = request; // Set a flag on the thread actor which indicates an evaluation is being // done for the client. This can affect how debugger handlers behave. this.parentActor.threadActor.insideClientEvaluation = evalOptions; const evalInfo = evalWithDebugger(input, evalOptions, this); this.parentActor.threadActor.insideClientEvaluation = null; return new Promise((resolve, reject) => { // Queue up a task to run in the next tick so any microtask created by the evaluated // expression has the time to be run. // e.g. in : // ``` // const promiseThenCb = result => "result: " + result; // new Promise(res => res("hello")).then(promiseThenCb) // ``` // we want`promiseThenCb` to have run before handling the result. DevToolsUtils.executeSoon(() => { try { const result = this.prepareEvaluationResult( evalInfo, input, request.eager, mapped ); resolve(result); } catch (err) { reject(err); } }); }); }, // eslint-disable-next-line complexity prepareEvaluationResult: function(evalInfo, input, eager, mapped) { const evalResult = evalInfo.result; const helperResult = evalInfo.helperResult; let result, errorDocURL, errorMessage, errorNotes = null, errorGrip = null, frame = null, awaitResult, errorMessageName, exceptionStack; if (evalResult) { if ("return" in evalResult) { result = evalResult.return; if ( mapped?.await && result && result.class === "Promise" && typeof result.unsafeDereference === "function" ) { awaitResult = result.unsafeDereference(); } } else if ("yield" in evalResult) { result = evalResult.yield; } else if ("throw" in evalResult) { const error = evalResult.throw; errorGrip = this.createValueGrip(error); exceptionStack = this.prepareStackForRemote(evalResult.stack); if (exceptionStack) { // Set the frame based on the topmost stack frame for the exception. const { filename: source, sourceId, lineNumber: line, columnNumber: column, } = exceptionStack[0]; frame = { source, sourceId, line, column }; exceptionStack = WebConsoleUtils.removeFramesAboveDebuggerEval( exceptionStack ); } errorMessage = String(error); if (typeof error === "object" && error !== null) { try { errorMessage = DevToolsUtils.callPropertyOnObject( error, "toString" ); } catch (e) { // If the debuggee is not allowed to access the "toString" property // of the error object, calling this property from the debuggee's // compartment will fail. The debugger should show the error object // as it is seen by the debuggee, so this behavior is correct. // // Unfortunately, we have at least one test that assumes calling the // "toString" property of an error object will succeed if the // debugger is allowed to access it, regardless of whether the // debuggee is allowed to access it or not. // // To accomodate these tests, if calling the "toString" property // from the debuggee compartment fails, we rewrap the error object // in the debugger's compartment, and then call the "toString" // property from there. if (typeof error.unsafeDereference === "function") { const rawError = error.unsafeDereference(); errorMessage = rawError ? rawError.toString() : ""; } } } // It is possible that we won't have permission to unwrap an // object and retrieve its errorMessageName. try { errorDocURL = ErrorDocs.GetURL(error); errorMessageName = error.errorMessageName; } catch (ex) { // ignored } try { const line = error.errorLineNumber; const column = error.errorColumnNumber; if (typeof line === "number" && typeof column === "number") { // Set frame only if we have line/column numbers. frame = { source: "debugger eval code", line, column, }; } } catch (ex) { // ignored } try { const notes = error.errorNotes; if (notes?.length) { errorNotes = []; for (const note of notes) { errorNotes.push({ messageBody: this._createStringGrip(note.message), frame: { source: note.fileName, line: note.lineNumber, column: note.columnNumber, }, }); } } } catch (ex) { // ignored } } } // If a value is encountered that the devtools server doesn't support yet, // the console should remain functional. let resultGrip; if (!awaitResult) { try { const objectActor = this.parentActor.threadActor.getThreadLifetimeObject( result ); if (objectActor) { resultGrip = this.parentActor.threadActor.createValueGrip(result); } else { resultGrip = this.createValueGrip(result); } } catch (e) { errorMessage = e; } } // Don't update _lastConsoleInputEvaluation in eager evaluation, as it would interfere // with the $_ command. if (!eager) { if (!awaitResult) { this._lastConsoleInputEvaluation = result; } else { // If we evaluated a top-level await expression, we want to assign its result to the // _lastConsoleInputEvaluation only when the promise resolves, and only if it // resolves. If the promise rejects, we don't re-assign _lastConsoleInputEvaluation, // it will keep its previous value. const p = awaitResult.then(res => { this._lastConsoleInputEvaluation = this.makeDebuggeeValue(res); }); // If the top level await was already rejected (e.g. `await Promise.reject("bleh")`), // catch the resulting promise of awaitResult.then. // If we don't do that, the new Promise will also be rejected, and since it's // unhandled, it will generate an error. // We don't want to do that for pending promise (e.g. `await new Promise((res, rej) => setTimeout(rej,250))`), // as the the Promise rejection will be considered as handled, and the "Uncaught (in promise)" // message wouldn't be emitted. const { state } = ObjectUtils.getPromiseState(evalResult.return); if (state === "rejected") { p.catch(() => {}); } } } return { input: input, result: resultGrip, awaitResult, exception: errorGrip, exceptionMessage: this._createStringGrip(errorMessage), exceptionDocURL: errorDocURL, exceptionStack, hasException: errorGrip !== null, errorMessageName, frame, helperResult: helperResult, notes: errorNotes, }; }, /** * The Autocomplete request handler. * * @param string text * The request message - what input to autocomplete. * @param number cursor * The cursor position at the moment of starting autocomplete. * @param string frameActor * The frameactor id of the current paused frame. * @param string selectedNodeActor * The actor id of the currently selected node. * @param array authorizedEvaluations * Array of the properties access which can be executed by the engine. * @return object * The response message - matched properties. */ autocomplete: function( text, cursor, frameActorId, selectedNodeActor, authorizedEvaluations, expressionVars = [] ) { let dbgObject = null; let environment = null; let matches = []; let matchProp; let isElementAccess; const reqText = text.substr(0, cursor); if (isCommand(reqText)) { const commandsCache = this._getWebConsoleCommandsCache(); matchProp = reqText; matches = validCommands .filter( c => `:${c}`.startsWith(reqText) && commandsCache.find(n => `:${n}`.startsWith(reqText)) ) .map(c => `:${c}`); } else { // This is the case of the paused debugger if (frameActorId) { const frameActor = this.conn.getActor(frameActorId); try { // Need to try/catch since accessing frame.environment // can throw "Debugger.Frame is not live" const frame = frameActor.frame; environment = frame.environment; } catch (e) { DevToolsUtils.reportException( "autocomplete", Error("The frame actor was not found: " + frameActorId) ); } } else { dbgObject = this.dbg.addDebuggee(this.evalGlobal); } const result = JSPropertyProvider({ dbgObject, environment, inputValue: text, cursor, webconsoleActor: this, selectedNodeActor, authorizedEvaluations, expressionVars, }); if (result === null) { return { matches: null, }; } if (result && result.isUnsafeGetter === true) { return { isUnsafeGetter: true, getterPath: result.getterPath, }; } matches = result.matches || new Set(); matchProp = result.matchProp || ""; isElementAccess = result.isElementAccess; // We consider '$' as alphanumeric because it is used in the names of some // helper functions; we also consider whitespace as alphanum since it should not // be seen as break in the evaled string. const lastNonAlphaIsDot = /[.][a-zA-Z0-9$\s]*$/.test(reqText); // We only return commands and keywords when we are not dealing with a property or // element access. if (matchProp && !lastNonAlphaIsDot && !isElementAccess) { this._getWebConsoleCommandsCache().forEach(n => { // filter out `screenshot` command as it is inaccessible without the `:` prefix if (n !== "screenshot" && n.startsWith(result.matchProp)) { matches.add(n); } }); for (const keyword of RESERVED_JS_KEYWORDS) { if (keyword.startsWith(result.matchProp)) { matches.add(keyword); } } } // Sort the results in order to display lowercased item first (e.g. we want to // display `document` then `Document` as we loosely match the user input if the // first letter was lowercase). const firstMeaningfulCharIndex = isElementAccess ? 1 : 0; matches = Array.from(matches).sort((a, b) => { const aFirstMeaningfulChar = a[firstMeaningfulCharIndex]; const bFirstMeaningfulChar = b[firstMeaningfulCharIndex]; const lA = aFirstMeaningfulChar.toLocaleLowerCase() === aFirstMeaningfulChar; const lB = bFirstMeaningfulChar.toLocaleLowerCase() === bFirstMeaningfulChar; if (lA === lB) { if (a === matchProp) { return -1; } if (b === matchProp) { return 1; } return a.localeCompare(b); } return lA ? -1 : 1; }); } return { matches, matchProp, isElementAccess: isElementAccess === true, }; }, /** * The "clearMessagesCache" request handler. */ clearMessagesCache: function() { if (isWorker) { // At the moment there is no mechanism available to clear the Console API cache for // a given worker target (See https://bugzilla.mozilla.org/show_bug.cgi?id=1674336). // Worker messages from the console service (e.g. error) are emitted from the main // thread, so this cache will be cleared when the associated document target cache // is cleared. return; } const windowId = !this.parentActor.isRootActor ? WebConsoleUtils.getInnerWindowId(this.global) : null; const ConsoleAPIStorage = Cc[ "@mozilla.org/consoleAPI-storage;1" ].getService(Ci.nsIConsoleAPIStorage); ConsoleAPIStorage.clearEvents(windowId); CONSOLE_WORKER_IDS.forEach(id => { ConsoleAPIStorage.clearEvents(id); }); if (this.parentActor.isRootActor || !this.global) { // If were dealing with the root actor (e.g. the browser console), we want // to remove all cached messages, not only the ones specific to a window. Services.console.reset(); } else { WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id => Services.console.resetWindow(id) ); } }, /** * The "getPreferences" request handler. * * @param array preferences * The preferences that need to be retrieved. * @return object * The response message - a { key: value } object map. */ getPreferences: function(preferences) { const prefs = Object.create(null); for (const key of preferences) { prefs[key] = this._prefs[key]; } return { preferences: prefs }; }, /** * The "setPreferences" request handler. * * @param object preferences * The preferences that need to be updated. */ setPreferences: function(preferences) { for (const key in preferences) { this._prefs[key] = preferences[key]; if (this.netmonitors) { if (key == "NetworkMonitor.saveRequestAndResponseBodies") { for (const { messageManager } of this.netmonitors) { messageManager.sendAsyncMessage("debug:netmonitor-preference", { saveRequestAndResponseBodies: this._prefs[key], }); } } else if (key == "NetworkMonitor.throttleData") { for (const { messageManager } of this.netmonitors) { messageManager.sendAsyncMessage("debug:netmonitor-preference", { throttleData: this._prefs[key], }); } } } } return { updated: Object.keys(preferences) }; }, // End of request handlers. /** * Create an object with the API we expose to the Web Console during * JavaScript evaluation. * This object inherits properties and methods from the Web Console actor. * * @private * @param object debuggerGlobal * A Debugger.Object that wraps a content global. This is used for the * Web Console Commands. * @return object * The same object as |this|, but with an added |sandbox| property. * The sandbox holds methods and properties that can be used as * bindings during JS evaluation. */ _getWebConsoleCommands: function(debuggerGlobal) { const helpers = { window: this.evalGlobal, makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal), createValueGrip: this.createValueGrip.bind(this), preprocessDebuggerObject: this.preprocessDebuggerObject.bind(this), sandbox: Object.create(null), helperResult: null, consoleActor: this, }; addWebConsoleCommands(helpers); const evalGlobal = this.evalGlobal; function maybeExport(obj, name) { if (typeof obj[name] != "function") { return; } // By default, chrome-implemented functions that are exposed to content // refuse to accept arguments that are cross-origin for the caller. This // is generally the safe thing, but causes problems for certain console // helpers like cd(), where we users sometimes want to pass a cross-origin // window. To circumvent this restriction, we use exportFunction along // with a special option designed for this purpose. See bug 1051224. obj[name] = Cu.exportFunction(obj[name], evalGlobal, { allowCrossOriginArguments: true, }); } for (const name in helpers.sandbox) { const desc = Object.getOwnPropertyDescriptor(helpers.sandbox, name); // Workers don't have access to Cu so won't be able to exportFunction. if (!isWorker) { maybeExport(desc, "get"); maybeExport(desc, "set"); maybeExport(desc, "value"); } if (desc.value) { // Make sure the helpers can be used during eval. desc.value = debuggerGlobal.makeDebuggeeValue(desc.value); } Object.defineProperty(helpers.sandbox, name, desc); } return helpers; }, _getWebConsoleCommandsCache: function() { if (!this._webConsoleCommandsCache) { const helpers = { sandbox: Object.create(null), }; addWebConsoleCommands(helpers); this._webConsoleCommandsCache = Object.getOwnPropertyNames( helpers.sandbox ); } return this._webConsoleCommandsCache; }, // Event handlers for various listeners. /** * Handler for messages received from the ConsoleServiceListener. This method * sends the nsIConsoleMessage to the remote Web Console client. * * @param nsIConsoleMessage message * The message we need to send to the client. */ onConsoleServiceMessage: function(message) { if (message instanceof Ci.nsIScriptError) { this.emit("pageError", { pageError: this.preparePageErrorForRemote(message), }); } else { this.emit("logMessage", { message: this._createStringGrip(message.message), timeStamp: message.timeStamp, }); } }, getActorIdForInternalSourceId(id) { const actor = this.parentActor.sourcesManager.getSourceActorByInternalSourceId( id ); return actor ? actor.actorID : null; }, /** * Prepare a SavedFrame stack to be sent to the client. * * @param SavedFrame errorStack * Stack for an error we need to send to the client. * @return object * The object you can send to the remote client. */ prepareStackForRemote(errorStack) { // Convert stack objects to the JSON attributes expected by client code // Bug 1348885: If the global from which this error came from has been // nuked, stack is going to be a dead wrapper. if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) { return null; } const stack = []; let s = errorStack; while (s) { stack.push({ filename: s.source, sourceId: this.getActorIdForInternalSourceId(s.sourceId), lineNumber: s.line, columnNumber: s.column, functionName: s.functionDisplayName, asyncCause: s.asyncCause ? s.asyncCause : undefined, }); s = s.parent || s.asyncParent; } return stack; }, /** * Prepare an nsIScriptError to be sent to the client. * * @param nsIScriptError pageError * The page error we need to send to the client. * @return object * The object you can send to the remote client. */ preparePageErrorForRemote: function(pageError) { const stack = this.prepareStackForRemote(pageError.stack); let lineText = pageError.sourceLine; if ( lineText && lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH ) { lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); } let notesArray = null; const notes = pageError.notes; if (notes?.length) { notesArray = []; for (let i = 0, len = notes.length; i < len; i++) { const note = notes.queryElementAt(i, Ci.nsIScriptErrorNote); notesArray.push({ messageBody: this._createStringGrip(note.errorMessage), frame: { source: note.sourceName, sourceId: this.getActorIdForInternalSourceId(note.sourceId), line: note.lineNumber, column: note.columnNumber, }, }); } } // If there is no location information in the error but we have a stack, // fill in the location with the first frame on the stack. let { sourceName, sourceId, lineNumber, columnNumber } = pageError; if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) { sourceName = stack[0].filename; sourceId = stack[0].sourceId; lineNumber = stack[0].lineNumber; columnNumber = stack[0].columnNumber; } const isCSSMessage = pageError.category === MESSAGE_CATEGORY.CSS_PARSER; const result = { errorMessage: this._createStringGrip(pageError.errorMessage), errorMessageName: isCSSMessage ? undefined : pageError.errorMessageName, exceptionDocURL: ErrorDocs.GetURL(pageError), sourceName, sourceId: this.getActorIdForInternalSourceId(sourceId), lineText, lineNumber, columnNumber, category: pageError.category, innerWindowID: pageError.innerWindowID, timeStamp: pageError.timeStamp, warning: !!(pageError.flags & pageError.warningFlag), error: !(pageError.flags & (pageError.warningFlag | pageError.infoFlag)), info: !!(pageError.flags & pageError.infoFlag), private: pageError.isFromPrivateWindow, stacktrace: stack, notes: notesArray, chromeContext: pageError.isFromChromeContext, isPromiseRejection: isCSSMessage ? undefined : pageError.isPromiseRejection, isForwardedFromContentProcess: pageError.isForwardedFromContentProcess, cssSelectors: isCSSMessage ? pageError.cssSelectors : undefined, }; // If the pageError does have an exception object, we want to return the grip for it, // but only if we do manage to get the grip, as we're checking the property on the // client to render things differently. if (pageError.hasException) { try { const obj = this.makeDebuggeeValue(pageError.exception, true); if (obj?.class !== "DeadObject") { result.exception = this.createValueGrip(obj); result.hasException = true; } } catch (e) {} } return result; }, /** * Handler for window.console API calls received from the ConsoleAPIListener. * This method sends the object to the remote Web Console client. * * @see ConsoleAPIListener * @param object message * The console API call we need to send to the remote client. */ onConsoleAPICall: function(message) { this.emit("consoleAPICall", { message: this.prepareConsoleMessageForRemote(message), }); }, /** * Handler for the DocumentEventsListener. * * @see DocumentEventsListener * @param {String} name * The document event name that either of followings. * - dom-loading * - dom-interactive * - dom-complete * @param {Number} time * The time that the event is fired. */ onDocumentEvent: function(name, time) { this.emit("documentEvent", { name, time, }); }, /** * Send a new HTTP request from the target's window. * * @param object request * The details of the HTTP request. */ async sendHTTPRequest(request) { const { url, method, headers, body, cause } = request; // Set the loadingNode and loadGroup to the target document - otherwise the // request won't show up in the opened netmonitor. const doc = this.global.document; const channel = NetUtil.newChannel({ uri: NetUtil.newURI(url), loadingNode: doc, securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, contentPolicyType: stringToCauseType(cause.type) || Ci.nsIContentPolicy.TYPE_OTHER, }); channel.QueryInterface(Ci.nsIHttpChannel); channel.loadGroup = doc.documentLoadGroup; channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING | Ci.nsIRequest.LOAD_ANONYMOUS; channel.requestMethod = method; if (headers) { for (const { name, value } of headers) { if (name.toLowerCase() == "referer") { // The referer header and referrerInfo object should always match. So // if we want to set the header from privileged context, we should set // referrerInfo. The referrer header will get set internally. channel.setNewReferrerInfo( value, Ci.nsIReferrerInfo.UNSAFE_URL, true ); } else { channel.setRequestHeader(name, value, false); } } } if (body) { channel.QueryInterface(Ci.nsIUploadChannel2); const bodyStream = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); bodyStream.setData(body, body.length); channel.explicitSetUploadStream(bodyStream, null, -1, method, false); } NetUtil.asyncFetch(channel, () => {}); if (!this.netmonitors) { return null; } const { channelId } = channel; // Only query the NetworkMonitorActor running in the parent process, where the // request will be done. There always is one listener running in the parent process, // see startListeners. const netmonitor = this.netmonitors.filter( ({ parentProcess }) => parentProcess )[0]; const { messageManager } = netmonitor; return new Promise(resolve => { const onMessage = ({ data }) => { if (data.channelId == channelId) { messageManager.removeMessageListener( "debug:get-network-event-actor:response", onMessage ); resolve({ eventActor: data.actor, }); } }; messageManager.addMessageListener( "debug:get-network-event-actor:response", onMessage ); messageManager.sendAsyncMessage("debug:get-network-event-actor:request", { channelId, }); }); }, /** * Send a message to all the netmonitor message managers, and resolve when * all of them replied with the expected responseName message. * * @param {String} messageName * Name of the message to send via the netmonitor message managers. * @param {String} responseName * Name of the message that should be received when the message has * been processed by the netmonitor instance. * @param {Object} args * argument object passed with the initial message. */ async _sendMessageToNetmonitors(messageName, responseName, args) { if (!this.netmonitors) { return null; } const results = await Promise.all( this.netmonitors.map(({ messageManager }) => { const onResponseReceived = new Promise(resolve => { messageManager.addMessageListener(responseName, function onResponse( response ) { messageManager.removeMessageListener(responseName, onResponse); resolve(response); }); }); messageManager.sendAsyncMessage(messageName, args); return onResponseReceived; }) ); return results; }, /** * Block a request based on certain filtering options. * * Currently, an exact URL match is the only supported filter type. * In the future, there may be other types of filters, such as domain. * For now, ignore anything other than URL. * * @param object filter * An object containing a `url` key with a URL to block. */ async blockRequest(filter) { await this._sendMessageToNetmonitors( "debug:block-request", "debug:block-request:response", { filter } ); return {}; }, /** * Unblock a request based on certain filtering options. * * Currently, an exact URL match is the only supported filter type. * In the future, there may be other types of filters, such as domain. * For now, ignore anything other than URL. * * @param object filter * An object containing a `url` key with a URL to unblock. */ async unblockRequest(filter) { await this._sendMessageToNetmonitors( "debug:unblock-request", "debug:unblock-request:response", { filter } ); return {}; }, /* * Gets the list of blocked request urls as per the backend */ async getBlockedUrls() { const responses = (await this._sendMessageToNetmonitors( "debug:get-blocked-urls", "debug:get-blocked-urls:response" )) || []; if (!responses || responses.length == 0) { return []; } return Array.from( new Set( responses .filter(response => response.data) .map(response => response.data) ) ); }, /** * Sets the list of blocked request URLs as provided by the netmonitor frontend * * This match will be a (String).includes match, not an exact URL match * * @param object filter * An object containing a `url` key with a URL to unblock. */ async setBlockedUrls(urls) { await this._sendMessageToNetmonitors( "debug:set-blocked-urls", "debug:set-blocked-urls:response", { urls } ); return {}; }, /** * Handler for file activity. This method sends the file request information * to the remote Web Console client. * * @see ConsoleFileActivityListener * @param string fileURI * The requested file URI. */ onFileActivity: function(fileURI) { this.emit("fileActivity", { uri: fileURI, }); }, // End of event handlers for various listeners. /** * Prepare a message from the console API to be sent to the remote Web Console * instance. * * @param object message * The original message received from console-api-log-event. * @param boolean aUseObjectGlobal * If |true| the object global is determined and added as a debuggee, * otherwise |this.global| is used when makeDebuggeeValue() is invoked. * @return object * The object that can be sent to the remote client. */ prepareConsoleMessageForRemote: function(message, useObjectGlobal = true) { const result = WebConsoleUtils.cloneObject(message); result.workerType = WebConsoleUtils.getWorkerType(result) || "none"; result.sourceId = this.getActorIdForInternalSourceId(result.sourceId); delete result.wrappedJSObject; delete result.ID; delete result.innerID; delete result.consoleID; if (result.stacktrace) { result.stacktrace = result.stacktrace.map(frame => { return { ...frame, sourceId: this.getActorIdForInternalSourceId(frame.sourceId), }; }); } result.arguments = (message.arguments || []).map(obj => { const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal); return this.createValueGrip(dbgObj); }); result.styles = (message.styles || []).map(string => { return this.createValueGrip(string); }); if (result.level === "table") { const tableItems = this._getConsoleTableMessageItems(result); if (tableItems) { result.arguments[0].ownProperties = tableItems; result.arguments[0].preview = null; } // Only return the 2 first params. result.arguments = result.arguments.slice(0, 2); } result.category = message.category || "webdev"; result.innerWindowID = message.innerID; return result; }, /** * Return the properties needed to display the appropriate table for a given * console.table call. * This function does a little more than creating an ObjectActor for the first * parameter of the message. When layout out the console table in the output, we want * to be able to look into sub-properties so the table can have a different layout ( * for arrays of arrays, objects with objects properties, arrays of objects, …). * So here we need to retrieve the properties of the first parameter, and also all the * sub-properties we might need. * * @param {Object} result: The console.table message. * @returns {Object} An object containing the properties of the first argument of the * console.table call. */ _getConsoleTableMessageItems: function(result) { if ( !result || !Array.isArray(result.arguments) || result.arguments.length == 0 ) { return null; } const [tableItemGrip] = result.arguments; const dataType = tableItemGrip.class; const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType); const ignoreNonIndexedProperties = isArray(tableItemGrip); const tableItemActor = this.getActorByID(tableItemGrip.actor); if (!tableItemActor) { return null; } // Retrieve the properties (or entries for Set/Map) of the console table first arg. const iterator = needEntries ? tableItemActor.enumEntries() : tableItemActor.enumProperties({ ignoreNonIndexedProperties, }); const { ownProperties } = iterator.all(); // The iterator returns a descriptor for each property, wherein the value could be // in one of those sub-property. const descriptorKeys = ["safeGetterValues", "getterValue", "value"]; Object.values(ownProperties).forEach(desc => { if (typeof desc !== "undefined") { descriptorKeys.forEach(key => { if (desc && desc.hasOwnProperty(key)) { const grip = desc[key]; // We need to load sub-properties as well to render the table in a nice way. const actor = grip && this.getActorByID(grip.actor); if (actor) { const res = actor .enumProperties({ ignoreNonIndexedProperties: isArray(grip), }) .all(); if (res?.ownProperties) { desc[key].ownProperties = res.ownProperties; } } } }); } }); return ownProperties; }, /** * Notification observer for the "last-pb-context-exited" topic. * * @private * @param object subject * Notification subject - in this case it is the inner window ID that * was destroyed. * @param string topic * Notification topic. */ _onObserverNotification: function(subject, topic) { if (topic === "last-pb-context-exited") { this.emit("lastPrivateContextExited"); } }, /** * The "will-navigate" progress listener. This is used to clear the current * eval scope. */ _onWillNavigate: function({ window, isTopLevel }) { if (isTopLevel) { this._evalGlobal = null; EventEmitter.off(this.parentActor, "will-navigate", this._onWillNavigate); this._progressListenerActive = false; } }, /** * This listener is called when we switch to another frame, * mostly to unregister previous listeners and start listening on the new document. */ _onChangedToplevelDocument: function() { // Convert the Set to an Array const listeners = [...this._listeners]; // Unregister existing listener on the previous document // (pass a copy of the array as it will shift from it) this.stopListeners(listeners.slice()); // This method is called after this.global is changed, // so we register new listener on this new global this.startListeners(listeners); // Also reset the cached top level chrome window being targeted this._lastChromeWindow = null; }, }); exports.WebConsoleActor = WebConsoleActor;