diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/webconsole.js | 1736 |
1 files changed, 1736 insertions, 0 deletions
diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js new file mode 100644 index 0000000000..14401b3fbe --- /dev/null +++ b/devtools/server/actors/webconsole.js @@ -0,0 +1,1736 @@ +/* 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/. */ + +/* global clearConsoleEvents */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { + webconsoleSpec, +} = require("resource://devtools/shared/specs/webconsole.js"); + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { ThreadActor } = require("resource://devtools/server/actors/thread.js"); +const { ObjectActor } = require("resource://devtools/server/actors/object.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); +const { + createValueGrip, + isArray, + stringIsLong, +} = require("resource://devtools/server/actors/object/utils.js"); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const ErrorDocs = require("resource://devtools/server/actors/errordocs.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +loader.lazyRequireGetter( + this, + "evalWithDebugger", + "resource://devtools/server/actors/webconsole/eval-with-debugger.js", + true +); +loader.lazyRequireGetter( + this, + "ConsoleFileActivityListener", + "resource://devtools/server/actors/webconsole/listeners/console-file-activity.js", + true +); +loader.lazyRequireGetter( + this, + "jsPropertyProvider", + "resource://devtools/shared/webconsole/js-property-provider.js", + true +); +loader.lazyRequireGetter( + this, + ["isCommand"], + "resource://devtools/server/actors/webconsole/commands/parser.js", + true +); +loader.lazyRequireGetter( + this, + ["CONSOLE_WORKER_IDS", "WebConsoleUtils"], + "resource://devtools/server/actors/webconsole/utils.js", + true +); +loader.lazyRequireGetter( + this, + ["WebConsoleCommandsManager"], + "resource://devtools/server/actors/webconsole/commands/manager.js", + true +); +loader.lazyRequireGetter( + this, + "EnvironmentActor", + "resource://devtools/server/actors/environment.js", + true +); +loader.lazyRequireGetter( + this, + "EventEmitter", + "resource://devtools/shared/event-emitter.js" +); +loader.lazyRequireGetter( + this, + "MESSAGE_CATEGORY", + "resource://devtools/shared/constants.js", + true +); + +// Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py +loader.lazyRequireGetter( + this, + "RESERVED_JS_KEYWORDS", + "resource://devtools/shared/webconsole/reserved-js-words.js" +); + +// Overwrite implemented listeners for workers so that we don't attempt +// to load an unsupported module. +if (isWorker) { + loader.lazyRequireGetter( + this, + ["ConsoleAPIListener", "ConsoleServiceListener"], + "resource://devtools/server/actors/webconsole/worker-listeners.js", + true + ); +} else { + loader.lazyRequireGetter( + this, + "ConsoleAPIListener", + "resource://devtools/server/actors/webconsole/listeners/console-api.js", + true + ); + loader.lazyRequireGetter( + this, + "ConsoleServiceListener", + "resource://devtools/server/actors/webconsole/listeners/console-service.js", + true + ); + loader.lazyRequireGetter( + this, + "ConsoleReflowListener", + "resource://devtools/server/actors/webconsole/listeners/console-reflow.js", + true + ); + loader.lazyRequireGetter( + this, + "DocumentEventsListener", + "resource://devtools/server/actors/webconsole/listeners/document-events.js", + true + ); +} +loader.lazyRequireGetter( + this, + "ObjectUtils", + "resource://devtools/server/actors/object/utils.js" +); + +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. + */ +class WebConsoleActor extends Actor { + constructor(connection, parentActor) { + super(connection, webconsoleSpec); + + this.parentActor = parentActor; + + 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 + ); + } + + /** + * Debugger instance. + * + * @see jsdebugger.sys.mjs + */ + dbg = null; + + /** + * This is used by the ObjectActor to keep track of the depth of grip() calls. + * @private + * @type number + */ + _gripDepth = null; + + /** + * Holds a set of all currently registered listeners. + * + * @private + * @type Set + */ + _listeners = 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. + * + * (note that is is also used for browser toolbox and webextension + * i.e. all targets flagged with isRootActor=true) + * + * @private + * @return nsIDOMWindow + * The window to use, or null if no window could be found. + */ + _getWindowForBrowserConsole() { + // Check if our last used chrome window is still live. + let window = this._lastChromeWindow && this._lastChromeWindow.get(); + // If not, look for a new one. + // In case of WebExtension reload of the background page, the last + // chrome window might be a dead wrapper, from which we can't check for window.closed. + if (!window || Cu.isDeadWrapper(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(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; + + grip() { + return { actor: this.actorID }; + } + + _findProtoChain = ThreadActor.prototype._findProtoChain; + _removeFromProtoChain = ThreadActor.prototype._removeFromProtoChain; + + /** + * Destroy the current WebConsoleActor instance. + */ + destroy() { + this.stopListeners(); + super.destroy(); + + EventEmitter.off( + this.parentActor, + "changed-toplevel-document", + this._onChangedToplevelDocument + ); + + this._lastConsoleInputEvaluation = null; + this._evalGlobal = null; + this.dbg = 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(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(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(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(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(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(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() { + 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 + async startListeners(listeners) { + const startedListeners = []; + const global = !this.parentActor.isRootActor ? this.global : null; + const isTargetActorContentProcess = + this.parentActor.targetType === Targets.TYPES.PROCESS; + + 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, + { + matchExactWindow: this.parentActor.ignoreSubFrames, + } + ); + 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, + { + matchExactWindow: this.parentActor.ignoreSubFrames, + ...(this.parentActor.consoleAPIListenerOptions || {}), + } + ); + this.consoleAPIListener.init(); + } + startedListeners.push(event); + break; + case "NetworkActivity": + // Workers don't support this message type + if (isWorker) { + break; + } + // Bug 1807650 removed this in favor of the new Watcher/Resources APIs + const errorMessage = + "NetworkActivity is no longer supported. " + + "Instead use Watcher actor's watchResources and listen to NETWORK_EVENT resource"; + dump(errorMessage + "\n"); + throw new Error(errorMessage); + 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 "DocumentEvents": + // Workers don't support this message type + if (isWorker || isTargetActorContentProcess) { + break; + } + if (!this.documentEventsListener) { + this.documentEventsListener = new DocumentEventsListener( + this.parentActor + ); + + this.documentEventsListener.on("dom-loading", data => + this.onDocumentEvent("dom-loading", data) + ); + this.documentEventsListener.on("dom-interactive", data => + this.onDocumentEvent("dom-interactive", data) + ); + this.documentEventsListener.on("dom-complete", data => + this.onDocumentEvent("dom-complete", data) + ); + + this.documentEventsListener.listen(); + } + startedListeners.push(event); + break; + } + } + + // Update the live list of running listeners + startedListeners.forEach(this._listeners.add, this._listeners); + + return { + startedListeners, + }; + } + + /** + * 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(listeners) { + const stoppedListeners = []; + + // If no specific listeners are requested to be detached, we stop all + // listeners. + const eventsToDetach = listeners || [ + "PageError", + "ConsoleAPI", + "FileActivity", + "ReflowActivity", + "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 "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 "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 }; + } + + /** + * 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(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.microSecondTimeStamp / 1000, + type: "logMessage", + }); + } + break; + } + } + } + + return { + 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. + */ + async evaluateJSAsync(request) { + const startTime = ChromeUtils.dateNow(); + // Use a timestamp instead of a 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 + // at the exact same time. + const resultID = startTime + "-" + this._evalCounter++; + + // Execute the evaluation in the next event loop in order to immediately + // reply with the resultID. + // + // The console input should be evaluated with micro task level != 0, + // so that microtask checkpoint isn't performed while evaluating it. + DevToolsUtils.executeSoonWithMicroTask(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 (e.g. console.log) + // can be appended before the result message (unlike the evaluation result, other + // console resources are throttled before being handled by the webconsole client, + // which might cause some ordering issue). + // Use ChromeUtils.dateNow() as it gives us a higher precision than Date.now(). + response.timestamp = ChromeUtils.dateNow(); + // Finally, emit an unsolicited evaluationResult packet with the evaluation result. + this.emit("evaluationResult", { + type: "evaluationResult", + resultID, + startTime, + ...response, + }); + } 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 support async evaluations (e.g. 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. + */ + async _maybeWaitForResponseResult(response) { + if (!response?.awaitResult) { + return response; + } + + 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(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, + // This flag is set to true in most cases as we consider most evaluations as internal and: + // * prevent any breakpoint from being triggerred when evaluating the JS input + // * prevent spawning Debugger.Source for the evaluated JS and showing it in Debugger UI + // This is only set to false when evaluating the console input. + disableBreaks: !!request.disableBreaks, + // Optional flag, to be set to true when Console Commands should override local symbols with + // the same name. Like if the page defines `$`, the evaluated string will use the `$` implemented + // by the console command instead of the page's function. + preferConsoleCommandsOverLocalSymbols: + !!request.preferConsoleCommandsOverLocalSymbols, + }; + + const { mapped } = request; + + // Set a flag on the thread actor which indicates an evaluation is being + // done for the client. This is used to disable all types of breakpoints for all sources + // via `disabledBreaks`. When this flag is used, `reportExceptionsWhenBreaksAreDisabled` + // allows to still pause on exceptions. + this.parentActor.threadActor.insideClientEvaluation = evalOptions; + + let evalInfo; + try { + evalInfo = evalWithDebugger(input, evalOptions, this); + } finally { + 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(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, + result: resultGrip, + awaitResult, + exception: errorGrip, + exceptionMessage: this._createStringGrip(errorMessage), + exceptionDocURL: errorDocURL, + exceptionStack, + hasException: errorGrip !== null, + errorMessageName, + frame, + 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( + 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)) { + matchProp = reqText; + matches = WebConsoleCommandsManager.getAllColonCommandNames() + .filter(c => `:${c}`.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, + frameActorId, + 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) { + const colonOnlyCommands = + WebConsoleCommandsManager.getColonOnlyCommandNames(); + for (const name of WebConsoleCommandsManager.getAllCommandNames()) { + // Filter out commands like `screenshot` as it is inaccessible without the `:` prefix + if ( + !colonOnlyCommands.includes(name) && + name.startsWith(result.matchProp) + ) { + matches.add(name); + } + } + + 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 "clearMessagesCacheAsync" request handler. + */ + clearMessagesCacheAsync() { + if (isWorker) { + // Defined on WorkerScope + clearConsoleEvents(); + 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 if (this.parentActor.ignoreSubFrames) { + Services.console.resetWindow(windowId); + } else { + WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id => + Services.console.resetWindow(id) + ); + } + } + + // End of request handlers. + + // 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(message) { + if (message instanceof Ci.nsIScriptError) { + this.emit("pageError", { + pageError: this.preparePageErrorForRemote(message), + }); + } else { + this.emit("logMessage", { + message: this._createStringGrip(message.message), + timeStamp: message.microSecondTimeStamp / 1000, + }); + } + } + + 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(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.microSecondTimeStamp / 1000, + 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. + * @param object extraProperties + * an object whose properties will be folded in the packet that is emitted. + */ + onConsoleAPICall(message, extraProperties = {}) { + this.emit("consoleAPICall", { + message: this.prepareConsoleMessageForRemote(message), + ...extraProperties, + }); + } + + /** + * 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. + * @param {Boolean} hasNativeConsoleAPI + * Tells if the window.console object is native or overwritten by script in the page. + * Only passed when `name` is "dom-complete" (see devtools/server/actors/webconsole/listeners/document-events.js). + */ + onDocumentEvent(name, { time, hasNativeConsoleAPI }) { + this.emit("documentEvent", { + name, + time, + hasNativeConsoleAPI, + }); + } + + /** + * 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(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 the console storage listener. + * @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(message, useObjectGlobal = true) { + const result = { + arguments: message.arguments + ? message.arguments.map(obj => { + const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal); + return this.createValueGrip(dbgObj); + }) + : [], + chromeContext: message.chromeContext, + columnNumber: message.columnNumber, + filename: message.filename, + level: message.level, + lineNumber: message.lineNumber, + // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property + timeStamp: message.microSecondTimeStamp + ? message.microSecondTimeStamp / 1000 + : message.timeStamp, + sourceId: this.getActorIdForInternalSourceId(message.sourceId), + category: message.category || "webdev", + innerWindowID: message.innerID, + }; + + // It only make sense to include the following properties in the message when they have + // a meaningful value. Otherwise we simply don't include them so we save cycles in JSActor communication. + if (message.counter) { + result.counter = message.counter; + } + if (message.private) { + result.private = message.private; + } + if (message.prefix) { + result.prefix = message.prefix; + } + + if (message.stacktrace) { + result.stacktrace = message.stacktrace.map(frame => { + return { + ...frame, + sourceId: this.getActorIdForInternalSourceId(frame.sourceId), + }; + }); + } + + if (message.styles && message.styles.length) { + result.styles = message.styles.map(string => { + return this.createValueGrip(string); + }); + } + + if (message.timer) { + result.timer = message.timer; + } + + if (message.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); + } + + 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(result) { + if ( + !result || + !Array.isArray(result.arguments) || + !result.arguments.length + ) { + 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; + } + + /** + * The "will-navigate" progress listener. This is used to clear the current + * eval scope. + */ + _onWillNavigate({ 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() { + // 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; |