Source code

Revision control

Copy as Markdown

Other Tools

/* This Smurce 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";
loader.lazyRequireGetter(
this,
["getCommandAndArgs"],
true
);
loader.lazyGetter(this, "l10n", () => {
return new Localization(
[
"devtools/shared/webconsole-commands.ftl",
"devtools/server/actors/webconsole/commands/experimental-commands.ftl",
],
true
);
});
const lazy = {};
ChromeUtils.defineESModuleGetters(
lazy,
{
},
{ global: "contextual" }
);
const USAGE_STRING_MAPPING = {
block: "webconsole-commands-usage-block",
trace: "webconsole-commands-usage-trace3",
unblock: "webconsole-commands-usage-unblock",
};
/**
* WebConsole commands manager.
*
* Defines a set of functions / variables ("commands") that are available from
* the Web Console but not from the web page.
*
*/
const WebConsoleCommandsManager = {
// Flag used by eager evaluation in order to allow the execution of commands
// which are side effect free and disallow all the others.
SIDE_EFFECT_FREE: Symbol("SIDE_EFFECT_FREE"),
// Map of command name to command function or property descriptor (see register method)
_registeredCommands: new Map(),
// Map of command name to optional array of accepted argument names
_validArguments: new Map(),
// Set of command names that are side effect free
_sideEffectFreeCommands: new Set(),
/**
* Register a new command.
*
* @param {Object} options
* @param {string} options.name
* The command name (exemple: "$", "screenshot",...))
* @param {Boolean} isSideEffectFree
* Tells if the command is free of any side effect to know
* if it can run in eager console evaluation.
* @param {function|object} options.command
* The command to register.
* It can be:
* - a function for the command like "$()" or ":screenshot"
* which triggers some code.
* - a property descriptor for getters like "$0",
* which only returns a value.
* @param {Array<string>} options.validArguments
* Optional list of valid arguments.
* If passed, we will assert that passed arguments are all valid on execution.
*
* The command function or the command getter are passed a:
* - "owner" object as their first parameter (see the example below).
* See _createOwnerObject for definition.
* - "args" object with all parameters when this is ran as a ":my-command" command.
* See getCommandAndArgs for definition.
*
* Note that if you want to support `--help` argument, you need to provide a usage string in:
* devtools/shared/locales/en-US/webconsole-commands.properties
*
* @example
*
* WebConsoleCommandsManager.register("$", function (owner, selector)
* {
* return owner.window.document.querySelector(selector);
* },
* ["my-argument"]);
*
* WebConsoleCommandsManager.register("$0", {
* get: function(owner) {
* return owner.makeDebuggeeValue(owner.selectedNode);
* }
* });
*/
register({ name, isSideEffectFree, command, validArguments }) {
if (
typeof command != "function" &&
!(typeof command == "object" && typeof command.get == "function")
) {
throw new Error(
"Invalid web console command. It can only be a function, or an object with a function as 'get' attribute"
);
}
if (typeof isSideEffectFree !== "boolean") {
throw new Error(
"Invalid web console command. 'isSideEffectFree' attribute should be set and be a boolean"
);
}
this._registeredCommands.set(name, command);
if (validArguments) {
this._validArguments.set(name, validArguments);
}
if (isSideEffectFree) {
this._sideEffectFreeCommands.add(name);
}
},
/**
* Return the name of all registered commands.
*
* @return {array} List of all command names.
*/
getAllCommandNames() {
return [...this._registeredCommands.keys()];
},
/**
* There is two types of "commands" here.
*
* - Functions or variables exposed in the scope of the evaluated string from the WebConsole input.
* Example: $(), $0, copy(), clear(),...
* - "True commands", which can also be ran from the WebConsole input with ":" prefix.
* Example: this list of commands.
* Note that some "true commands" are not exposed as function (see getColonOnlyCommandNames).
*
* The following list distinguish these "true commands" from the first category.
* It especially avoid any JavaScript evaluation when the frontend tries to execute
* a string starting with ':' character.
*/
getAllColonCommandNames() {
return ["block", "help", "history", "screenshot", "unblock", "trace"];
},
/**
* Some commands are not exposed in the scope of the evaluated string,
* and can only be used via `:command-name`.
*/
getColonOnlyCommandNames() {
return ["screenshot", "trace"];
},
/**
* Map of all command objects keyed by command name.
* Commands object are the objects passed to register() method.
*
* @return {Map<string -> command>}
*/
getAllCommands() {
return this._registeredCommands;
},
/**
* Is the command name possibly overriding a symbol which
* already exists in the paused frame or the global into which
* we are about to execute into?
*/
_isCommandNameAlreadyInScope(name, frame, dbgGlobal) {
if (frame && frame.environment) {
return !!frame.environment.find(name);
}
// Fallback on global scope when Debugger.Frame doesn't come along an
// Environment, or is not a frame.
try {
// This can throw in Browser Toolbox tests
const globalEnv = dbgGlobal.asEnvironment();
if (globalEnv) {
return !!dbgGlobal.asEnvironment().find(name);
}
} catch {}
return !!dbgGlobal.getOwnPropertyDescriptor(name);
},
_createOwnerObject(
consoleActor,
debuggerGlobal,
evalInput,
selectedNodeActorID
) {
const owner = {
window: consoleActor.evalGlobal,
makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal),
createValueGrip: consoleActor.createValueGrip.bind(consoleActor),
preprocessDebuggerObject:
consoleActor.preprocessDebuggerObject.bind(consoleActor),
helperResult: null,
consoleActor,
evalInput,
};
if (selectedNodeActorID) {
const actor = consoleActor.conn.getActor(selectedNodeActorID);
if (actor) {
owner.selectedNode = actor.rawNode;
}
}
return owner;
},
_getCommandsForCurrentEnvironment() {
// Not supporting extra commands in workers yet. This should be possible to
// add one by one as long as they don't require jsm/mjs, Cu, etc.
return isWorker ? new Map() : this.getAllCommands();
},
/**
* 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.
*
* @param object consoleActor
* The related web console actor evaluating some code.
* @param object debuggerGlobal
* A Debugger.Object that wraps a content global. This is used for the
* Web Console Commands.
* @param object frame (optional)
* The frame where the string was evaluated.
* @param string evalInput
* String to evaluate.
* @param string selectedNodeActorID
* The Node actor ID of the currently selected DOM Element, if any is selected.
* @param bool preferConsoleCommandsOverLocalSymbols
* If true, define all bindings even if there's conflicting existing
* symbols. This is for the case evaluating non-user code in frame
* environment.
*
* @return object
* Object with two properties:
* - 'bindings', the object with all commands set as attribute on this object.
* - 'getHelperResult', a live getter returning the additional data the last command
* which executed want to convey to the frontend.
* (The return value of commands isn't returned to the client but it only
* returned to the code ran from console evaluation)
*/
getWebConsoleCommands(
consoleActor,
debuggerGlobal,
frame,
evalInput,
selectedNodeActorID,
preferConsoleCommandsOverLocalSymbols
) {
const bindings = Object.create(null);
const owner = this._createOwnerObject(
consoleActor,
debuggerGlobal,
evalInput,
selectedNodeActorID
);
const evalGlobal = consoleActor.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,
});
}
const commands = this._getCommandsForCurrentEnvironment();
const colonOnlyCommandNames = this.getColonOnlyCommandNames();
for (const [name, command] of commands) {
// When we run user code in frame, we want to avoid overriding existing
// symbols with commands.
//
// When we run user code in global scope, all bindings are automatically
// shadowed, except for "help" function which is checked by getEvalInput.
//
// When preferConsoleCommandsOverLocalSymbols is true, ignore symbols in
// the current scope and always use commands ones.
if (
!preferConsoleCommandsOverLocalSymbols &&
(frame || name === "help") &&
this._isCommandNameAlreadyInScope(name, frame, debuggerGlobal)
) {
continue;
}
// Also ignore commands which can only be run with the `:` prefix.
if (colonOnlyCommandNames.includes(name)) {
continue;
}
const descriptor = {
// We force the enumerability and the configurability (so the
// WebConsoleActor can reconfigure the property).
enumerable: true,
configurable: true,
};
if (typeof command === "function") {
// Function commands
descriptor.value = command.bind(undefined, owner);
maybeExport(descriptor, "value");
// Unfortunately evalWithBindings will access all bindings values,
// which would trigger a debuggee native call because bindings's property
// is using Cu.exportFunction.
// Put a magic symbol attribute on them in order to carefully accept
// all bindings as being side effect safe by default.
if (this._sideEffectFreeCommands.has(name)) {
descriptor.value.isSideEffectFree = this.SIDE_EFFECT_FREE;
}
// Make sure the helpers can be used during eval.
descriptor.value = debuggerGlobal.makeDebuggeeValue(descriptor.value);
} else if (typeof command?.get === "function") {
// Getter commands
descriptor.get = command.get.bind(undefined, owner);
maybeExport(descriptor, "get");
// See comment in previous block.
if (this._sideEffectFreeCommands.has(name)) {
descriptor.get.isSideEffectFree = this.SIDE_EFFECT_FREE;
}
}
Object.defineProperty(bindings, name, descriptor);
}
return {
// Use a method as commands will update owner.helperResult later
getHelperResult() {
return owner.helperResult;
},
bindings,
};
},
/**
* Create a function for given ':command'-style command.
*
* @param object consoleActor
* The related web console actor evaluating some code.
* @param object debuggerGlobal
* A Debugger.Object that wraps a content global. This is used for the
* Web Console Commands.
* @param string selectedNodeActorID
* The Node actor ID of the currently selected DOM Element, if any is selected.
* @param string evalInput
* String to evaluate.
*
* @return object
* Object with two properties:
* - 'commandFunc', a function corresponds to the 'commandName'
* - 'getHelperResult', a live getter returning the data the command
* which executed want to convey to the frontend.
*/
executeCommand(consoleActor, debuggerGlobal, selectedNodeActorID, evalInput) {
const { command, args } = getCommandAndArgs(evalInput);
const commands = this._getCommandsForCurrentEnvironment();
if (!commands.has(command)) {
throw new Error(`Unsupported command '${command}'`);
}
if (args.help || args.usage) {
const l10nKey = USAGE_STRING_MAPPING[command];
if (l10nKey) {
const message = l10n.formatValueSync(l10nKey);
if (message && message !== l10nKey) {
return {
result: null,
helperResult: {
type: "usage",
message,
},
};
}
}
}
const validArguments = this._validArguments.get(command);
if (validArguments) {
for (const key of Object.keys(args)) {
if (!validArguments.includes(key)) {
throw new Error(
`:${command} command doesn't support '${key}' argument.`
);
}
}
}
const owner = this._createOwnerObject(
consoleActor,
debuggerGlobal,
evalInput,
selectedNodeActorID
);
const commandFunction = commands.get(command);
// This is where we run the command passed to register method
const result = commandFunction(owner, args);
return {
result,
// commandFunction may mutate owner.helperResult which is used
// to convey additional data to the frontend.
helperResult: owner.helperResult,
};
},
};
exports.WebConsoleCommandsManager = WebConsoleCommandsManager;
/*
* Built-in commands.
*
* A list of helper functions used by Firebug can be found here:
*/
/**
* Find the first node matching a CSS selector.
*
* @param string selector
* A string that is passed to window.document.querySelector
* @param [optional] Node element
* An optional Node to replace window.document
* @return Node or null
* The result of calling document.querySelector(selector).
*/
WebConsoleCommandsManager.register({
name: "$",
isSideEffectFree: true,
command(owner, selector, element) {
try {
if (
element &&
element.querySelector &&
(element.nodeType == Node.ELEMENT_NODE ||
element.nodeType == Node.DOCUMENT_NODE ||
element.nodeType == Node.DOCUMENT_FRAGMENT_NODE)
) {
return element.querySelector(selector);
}
return owner.window.document.querySelector(selector);
} catch (err) {
// Throw an error like `err` but that belongs to `owner.window`.
throw new owner.window.DOMException(err.message, err.name);
}
},
});
/**
* Find the nodes matching a CSS selector.
*
* @param string selector
* A string that is passed to window.document.querySelectorAll.
* @param [optional] Node element
* An optional Node to replace window.document
* @return array of Node
* The result of calling document.querySelector(selector) in an array.
*/
WebConsoleCommandsManager.register({
name: "$$",
isSideEffectFree: true,
command(owner, selector, element) {
let scope = owner.window.document;
try {
if (
element &&
element.querySelectorAll &&
(element.nodeType == Node.ELEMENT_NODE ||
element.nodeType == Node.DOCUMENT_NODE ||
element.nodeType == Node.DOCUMENT_FRAGMENT_NODE)
) {
scope = element;
}
const nodes = scope.querySelectorAll(selector);
const result = new owner.window.Array();
// Calling owner.window.Array.from() doesn't work without accessing the
// wrappedJSObject, so just loop through the results instead.
for (let i = 0; i < nodes.length; i++) {
result.push(nodes[i]);
}
return result;
} catch (err) {
// Throw an error like `err` but that belongs to `owner.window`.
throw new owner.window.DOMException(err.message, err.name);
}
},
});
/**
* Returns the result of the last console input evaluation
*
* @return object|undefined
* Returns last console evaluation or undefined
*/
WebConsoleCommandsManager.register({
name: "$_",
isSideEffectFree: true,
command: {
get(owner) {
return owner.consoleActor.getLastConsoleInputEvaluation();
},
},
});
/**
* Runs an xPath query and returns all matched nodes.
*
* @param string xPath
* xPath search query to execute.
* @param [optional] Node context
* Context to run the xPath query on. Uses window.document if not set.
* @param [optional] string|number resultType
Specify the result type. Default value XPathResult.ANY_TYPE
* @return array of Node
*/
WebConsoleCommandsManager.register({
name: "$x",
isSideEffectFree: true,
command(
owner,
xPath,
context,
resultType = owner.window.XPathResult.ANY_TYPE
) {
const nodes = new owner.window.Array();
// Not waiving Xrays, since we want the original Document.evaluate function,
// instead of anything that's been redefined.
const doc = owner.window.document;
context = context || doc;
switch (resultType) {
case "number":
resultType = owner.window.XPathResult.NUMBER_TYPE;
break;
case "string":
resultType = owner.window.XPathResult.STRING_TYPE;
break;
case "bool":
resultType = owner.window.XPathResult.BOOLEAN_TYPE;
break;
case "node":
resultType = owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE;
break;
case "nodes":
resultType = owner.window.XPathResult.UNORDERED_NODE_ITERATOR_TYPE;
break;
}
const results = doc.evaluate(xPath, context, null, resultType, null);
if (results.resultType === owner.window.XPathResult.NUMBER_TYPE) {
return results.numberValue;
}
if (results.resultType === owner.window.XPathResult.STRING_TYPE) {
return results.stringValue;
}
if (results.resultType === owner.window.XPathResult.BOOLEAN_TYPE) {
return results.booleanValue;
}
if (
results.resultType === owner.window.XPathResult.ANY_UNORDERED_NODE_TYPE ||
results.resultType === owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE
) {
return results.singleNodeValue;
}
if (
results.resultType ===
owner.window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE ||
results.resultType === owner.window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
) {
for (let i = 0; i < results.snapshotLength; i++) {
nodes.push(results.snapshotItem(i));
}
return nodes;
}
let node;
while ((node = results.iterateNext())) {
nodes.push(node);
}
return nodes;
},
});
/**
* Returns the currently selected object in the highlighter.
*
* @return Object representing the current selection in the
* Inspector, or null if no selection exists.
*/
WebConsoleCommandsManager.register({
name: "$0",
isSideEffectFree: true,
command: {
get(owner) {
return owner.makeDebuggeeValue(owner.selectedNode);
},
},
});
/**
* Clears the output of the WebConsole.
*/
WebConsoleCommandsManager.register({
name: "clear",
isSideEffectFree: false,
command(owner) {
owner.helperResult = {
type: "clearOutput",
};
},
});
/**
* Clears the input history of the WebConsole.
*/
WebConsoleCommandsManager.register({
name: "clearHistory",
isSideEffectFree: false,
command(owner) {
owner.helperResult = {
type: "clearHistory",
};
},
});
/**
* Returns the result of Object.keys(object).
*
* @param object object
* Object to return the property names from.
* @return array of strings
*/
WebConsoleCommandsManager.register({
name: "keys",
isSideEffectFree: true,
command(owner, object) {
// Need to waive Xrays so we can iterate functions and accessor properties
return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window);
},
});
/**
* Returns the values of all properties on object.
*
* @param object object
* Object to display the values from.
* @return array of string
*/
WebConsoleCommandsManager.register({
name: "values",
isSideEffectFree: true,
command(owner, object) {
const values = [];
// Need to waive Xrays so we can iterate functions and accessor properties
const waived = Cu.waiveXrays(object);
const names = Object.getOwnPropertyNames(waived);
for (const name of names) {
values.push(waived[name]);
}
return Cu.cloneInto(values, owner.window);
},
});
/**
* Opens a help window in MDN.
*/
WebConsoleCommandsManager.register({
name: "help",
isSideEffectFree: false,
command(owner) {
owner.helperResult = { type: "help" };
},
});
/**
* Inspects the passed object. This is done by opening the PropertyPanel.
*
* @param object object
* Object to inspect.
*/
WebConsoleCommandsManager.register({
name: "inspect",
isSideEffectFree: false,
command(owner, object, forceExpandInConsole = false) {
const dbgObj = owner.preprocessDebuggerObject(
owner.makeDebuggeeValue(object)
);
const grip = owner.createValueGrip(dbgObj);
owner.helperResult = {
type: "inspectObject",
input: owner.evalInput,
object: grip,
forceExpandInConsole,
};
},
});
/**
* Copy the String representation of a value to the clipboard.
*
* @param any value
* A value you want to copy as a string.
* @return void
*/
WebConsoleCommandsManager.register({
name: "copy",
isSideEffectFree: false,
command(owner, value) {
let payload;
try {
if (Element.isInstance(value)) {
payload = value.outerHTML;
} else if (typeof value == "string") {
payload = value;
} else {
payload = JSON.stringify(value, null, " ");
}
} catch (ex) {
owner.helperResult = {
type: "error",
message: "webconsole.error.commands.copyError",
messageArgs: [ex.toString()],
};
return;
}
owner.helperResult = {
type: "copyValueToClipboard",
value: payload,
};
},
});
/**
* Take a screenshot of a page.
*
* @param object args
* The arguments to be passed to the screenshot
* @return void
*/
WebConsoleCommandsManager.register({
name: "screenshot",
isSideEffectFree: false,
command(owner, args = {}) {
owner.helperResult = {
type: "screenshotOutput",
args,
};
},
});
/**
* Shows a history of commands and expressions previously executed within the command line.
*
* @param object args
* The arguments to be passed to the history
* @return void
*/
WebConsoleCommandsManager.register({
name: "history",
isSideEffectFree: false,
command(owner, args = {}) {
owner.helperResult = {
type: "historyOutput",
args,
};
},
});
/**
* Block specific resource from loading
*
* @param object args
* an object with key "url", i.e. a filter
*
* @return void
*/
WebConsoleCommandsManager.register({
name: "block",
isSideEffectFree: false,
command(owner, args = {}) {
// Note that this command is implemented in the frontend, from actions's input.js
// We only forward the command arguments back to the client.
if (!args.url) {
owner.helperResult = {
type: "error",
message: "webconsole.messages.commands.blockArgMissing",
};
return;
}
owner.helperResult = {
type: "blockURL",
args,
};
},
validArguments: ["url"],
});
/*
* Unblock a blocked a resource
*
* @param object filter
* an object with key "url", i.e. a filter
*
* @return void
*/
WebConsoleCommandsManager.register({
name: "unblock",
isSideEffectFree: false,
command(owner, args = {}) {
// Note that this command is implemented in the frontend, from actions's input.js
// We only forward the command arguments back to the client.
if (!args.url) {
owner.helperResult = {
type: "error",
message: "webconsole.messages.commands.blockArgMissing",
};
return;
}
owner.helperResult = {
type: "unblockURL",
args,
};
},
validArguments: ["url"],
});
/*
* Toggle JavaScript tracing
*
* @param object args
* An object with various configuration only valid when starting the tracing.
*
* @return void
*/
WebConsoleCommandsManager.register({
name: "trace",
isSideEffectFree: false,
command(owner, args) {
if (isWorker) {
throw new Error(":trace command isn't supported in workers");
}
// Disable :trace command on worker until this feature is enabled by default
if (
!Services.prefs.getBoolPref(
"devtools.debugger.features.javascript-tracing",
false
)
) {
throw new Error(
":trace requires 'devtools.debugger.features.javascript-tracing' preference to be true"
);
}
const tracerActor =
owner.consoleActor.parentActor.getTargetScopedActor("tracer");
const logMethod = args.logMethod || "console";
let traceDOMMutations = null;
if ("dom-mutations" in args) {
// When no value is passed, track all types of mutations
if (args["dom-mutations"] === true) {
traceDOMMutations = ["add", "attributes", "remove"];
} else if (typeof args["dom-mutations"] == "string") {
// Otherwise consider the value as coma seperated list and remove any white space.
traceDOMMutations = args["dom-mutations"].split(",").map(e => e.trim());
const acceptedValues = Object.values(lazy.JSTracer.DOM_MUTATIONS);
if (!traceDOMMutations.every(e => acceptedValues.includes(e))) {
throw new Error(
`:trace --dom-mutations only accept a list of strings whose values can be: ${acceptedValues}`
);
}
} else {
throw new Error(
":trace --dom-mutations accept only no arguments, or a list mutation type strings (add,attributes,remove)"
);
}
}
// Note that toggleTracing does some sanity checks and will throw meaningful error
// when the arguments are wrong.
const enabled = tracerActor.toggleTracing({
logMethod,
prefix: args.prefix || null,
traceFunctionReturn: !!args.returns,
traceValues: !!args.values,
traceOnNextInteraction: args["on-next-interaction"] || null,
traceDOMMutations,
maxDepth: args["max-depth"] || null,
maxRecords: args["max-records"] || null,
});
owner.helperResult = {
type: "traceOutput",
enabled,
logMethod,
};
},
validArguments: [
"logMethod",
"max-depth",
"max-records",
"on-next-interaction",
"dom-mutations",
"prefix",
"returns",
"values",
],
});