summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/utils
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/webconsole/utils
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/webconsole/utils')
-rw-r--r--devtools/client/webconsole/utils/clipboard.js57
-rw-r--r--devtools/client/webconsole/utils/context-menu.js368
-rw-r--r--devtools/client/webconsole/utils/id-generator.js15
-rw-r--r--devtools/client/webconsole/utils/l10n.js70
-rw-r--r--devtools/client/webconsole/utils/messages.js914
-rw-r--r--devtools/client/webconsole/utils/moz.build14
-rw-r--r--devtools/client/webconsole/utils/object-inspector.js158
-rw-r--r--devtools/client/webconsole/utils/prefs.js46
8 files changed, 1642 insertions, 0 deletions
diff --git a/devtools/client/webconsole/utils/clipboard.js b/devtools/client/webconsole/utils/clipboard.js
new file mode 100644
index 0000000000..72b064f323
--- /dev/null
+++ b/devtools/client/webconsole/utils/clipboard.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Get the text of the element parameter, as provided by the Selection API. We need to
+ * rely on the Selection API as it mimics exactly what the user would have if they do a
+ * selection using the mouse and copy it. `HTMLElement.textContent` and
+ * `HTMLElement.innerText` follow a different codepath than user selection + copy.
+ * They both have issues when dealing with whitespaces, and therefore can't be used to
+ * have a reliable result.
+ *
+ * As the Selection API is exposed through the Window object, if we don't have a window
+ * we fallback to `HTMLElement.textContent`.
+ *
+ * @param {HTMLElement} el: The element we want the text of.
+ * @returns {String|null} The text of the element, or null if el is falsy.
+ */
+function getElementText(el) {
+ if (!el) {
+ return null;
+ }
+ // If we can, we use the Selection API to match what the user would get if they
+ // manually select and copy the message.
+ const doc = el.ownerDocument;
+ const win = doc && doc.defaultView;
+
+ if (!win) {
+ return el.textContent;
+ }
+
+ // We store the current selected range and unselect everything.
+ const selection = win.getSelection();
+ const currentSelectedRange =
+ !selection.isCollapsed && selection.getRangeAt(0);
+ selection.removeAllRanges();
+
+ // Then creates a range from `el`, and get the text content.
+ const range = doc.createRange();
+ range.selectNode(el);
+ selection.addRange(range);
+ const text = selection.toString();
+
+ // Finally we revert the selection to what it was.
+ selection.removeRange(range);
+ if (currentSelectedRange) {
+ selection.addRange(currentSelectedRange);
+ }
+
+ return text;
+}
+
+module.exports = {
+ getElementText,
+};
diff --git a/devtools/client/webconsole/utils/context-menu.js b/devtools/client/webconsole/utils/context-menu.js
new file mode 100644
index 0000000000..7996194d56
--- /dev/null
+++ b/devtools/client/webconsole/utils/context-menu.js
@@ -0,0 +1,368 @@
+/* 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";
+
+const Menu = require("resource://devtools/client/framework/menu.js");
+const MenuItem = require("resource://devtools/client/framework/menu-item.js");
+
+const {
+ MESSAGE_SOURCE,
+} = require("resource://devtools/client/webconsole/constants.js");
+
+const clipboardHelper = require("resource://devtools/shared/platform/clipboard.js");
+const {
+ l10n,
+} = require("resource://devtools/client/webconsole/utils/messages.js");
+const actions = require("resource://devtools/client/webconsole/actions/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "saveAs",
+ "resource://devtools/shared/DevToolsUtils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "openContentLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getElementText",
+ "resource://devtools/client/webconsole/utils/clipboard.js",
+ true
+);
+
+/**
+ * Create a Menu instance for the webconsole.
+ *
+ * @param {Event} context menu event
+ * {Object} message (optional) message object containing metadata such as:
+ * - {String} source
+ * - {String} request
+ * @param {Object} options
+ * - {Actions} bound actions
+ * - {WebConsoleWrapper} wrapper instance used for accessing properties like the store
+ * and window.
+ */
+function createContextMenu(event, message, webConsoleWrapper) {
+ const { target } = event;
+ const { parentNode, toolbox, hud } = webConsoleWrapper;
+ const store = webConsoleWrapper.getStore();
+ const { dispatch } = store;
+
+ const messageEl = target.closest(".message");
+ const clipboardText = getElementText(messageEl);
+
+ const linkEl = target.closest("a[href]");
+ const url = linkEl && linkEl.href;
+
+ const messageVariable = target.closest(".objectBox");
+ // Ensure that console.group and console.groupCollapsed commands are not captured
+ const variableText =
+ messageVariable &&
+ !messageEl.classList.contains("startGroup") &&
+ !messageEl.classList.contains("startGroupCollapsed")
+ ? messageVariable.textContent
+ : null;
+
+ // Retrieve closes actor id from the DOM.
+ const actorEl =
+ target.closest("[data-link-actor-id]") ||
+ target.querySelector("[data-link-actor-id]");
+ const actor = actorEl ? actorEl.dataset.linkActorId : null;
+
+ const rootObjectInspector = target.closest(".object-inspector");
+ const rootActor = rootObjectInspector
+ ? rootObjectInspector.querySelector("[data-link-actor-id]")
+ : null;
+ // We can have object which are not displayed inside an ObjectInspector (e.g. Errors),
+ // so let's default to `actor`.
+ const rootActorId = rootActor ? rootActor.dataset.linkActorId : actor;
+
+ const elementNode =
+ target.closest(".objectBox-node") || target.closest(".objectBox-textNode");
+ const isConnectedElement =
+ elementNode && elementNode.querySelector(".open-inspector") !== null;
+
+ const win = parentNode.ownerDocument.defaultView;
+ const selection = win.getSelection();
+
+ const { source, request, messageId } = message || {};
+
+ const menu = new Menu({ id: "webconsole-menu" });
+
+ // Copy URL for a network request.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-copy-url",
+ label: l10n.getStr("webconsole.menu.copyURL.label"),
+ accesskey: l10n.getStr("webconsole.menu.copyURL.accesskey"),
+ visible: source === MESSAGE_SOURCE.NETWORK,
+ click: () => {
+ if (!request) {
+ return;
+ }
+ clipboardHelper.copyString(request.url);
+ },
+ })
+ );
+
+ if (toolbox && request) {
+ // Open Network message in the Network panel.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-open-in-network-panel",
+ label: l10n.getStr("webconsole.menu.openInNetworkPanel.label"),
+ accesskey: l10n.getStr("webconsole.menu.openInNetworkPanel.accesskey"),
+ visible: source === MESSAGE_SOURCE.NETWORK,
+ click: () => dispatch(actions.openNetworkPanel(message.messageId)),
+ })
+ );
+ // Resend Network message.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-resend-network-request",
+ label: l10n.getStr("webconsole.menu.resendNetworkRequest.label"),
+ accesskey: l10n.getStr(
+ "webconsole.menu.resendNetworkRequest.accesskey"
+ ),
+ visible: source === MESSAGE_SOURCE.NETWORK,
+ click: () => dispatch(actions.resendNetworkRequest(messageId)),
+ })
+ );
+ }
+
+ // Open URL in a new tab for a network request.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-open-url",
+ label: l10n.getStr("webconsole.menu.openURL.label"),
+ accesskey: l10n.getStr("webconsole.menu.openURL.accesskey"),
+ visible: source === MESSAGE_SOURCE.NETWORK,
+ click: () => {
+ if (!request) {
+ return;
+ }
+ openContentLink(request.url);
+ },
+ })
+ );
+
+ // Open DOM node in the Inspector panel.
+ const contentDomReferenceEl = target.closest(
+ "[data-link-content-dom-reference]"
+ );
+ if (isConnectedElement && contentDomReferenceEl) {
+ const contentDomReference = contentDomReferenceEl.getAttribute(
+ "data-link-content-dom-reference"
+ );
+
+ menu.append(
+ new MenuItem({
+ id: "console-menu-open-node",
+ label: l10n.getStr("webconsole.menu.openNodeInInspector.label"),
+ accesskey: l10n.getStr("webconsole.menu.openNodeInInspector.accesskey"),
+ disabled: false,
+ click: () =>
+ dispatch(
+ actions.openNodeInInspector(JSON.parse(contentDomReference))
+ ),
+ })
+ );
+ }
+
+ // Store as global variable.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-store",
+ label: l10n.getStr("webconsole.menu.storeAsGlobalVar.label"),
+ accesskey: l10n.getStr("webconsole.menu.storeAsGlobalVar.accesskey"),
+ disabled: !actor,
+ click: () => dispatch(actions.storeAsGlobal(actor)),
+ })
+ );
+
+ // Copy message or grip.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-copy",
+ label: l10n.getStr("webconsole.menu.copyMessage.label"),
+ accesskey: l10n.getStr("webconsole.menu.copyMessage.accesskey"),
+ // Disabled if there is no selection and no message element available to copy.
+ disabled: selection.isCollapsed && !clipboardText,
+ click: () => {
+ if (selection.isCollapsed) {
+ // If the selection is empty/collapsed, copy the text content of the
+ // message for which the context menu was opened.
+ clipboardHelper.copyString(clipboardText);
+ } else {
+ clipboardHelper.copyString(selection.toString());
+ }
+ },
+ })
+ );
+
+ // Copy message object.
+ menu.append(
+ new MenuItem({
+ id: "console-menu-copy-object",
+ label: l10n.getStr("webconsole.menu.copyObject.label"),
+ accesskey: l10n.getStr("webconsole.menu.copyObject.accesskey"),
+ // Disabled if there is no actor and no variable text associated.
+ disabled: !actor && !variableText,
+ click: () => dispatch(actions.copyMessageObject(actor, variableText)),
+ })
+ );
+
+ // Export to clipboard
+ menu.append(
+ new MenuItem({
+ id: "console-menu-export-clipboard",
+ label: l10n.getStr("webconsole.menu.copyAllMessages.label"),
+ accesskey: l10n.getStr("webconsole.menu.copyAllMessages.accesskey"),
+ disabled: false,
+ async click() {
+ const outputText = await getUnvirtualizedConsoleOutputText(
+ webConsoleWrapper
+ );
+ clipboardHelper.copyString(outputText);
+ },
+ })
+ );
+
+ // Export to file
+ menu.append(
+ new MenuItem({
+ id: "console-menu-export-file",
+ label: l10n.getStr("webconsole.menu.saveAllMessagesFile.label"),
+ accesskey: l10n.getStr("webconsole.menu.saveAllMessagesFile.accesskey"),
+ disabled: false,
+ // Note: not async, but returns a promise for the actual save.
+ click: async () => {
+ const date = new Date();
+ const suggestedName =
+ `console-export-${date.getFullYear()}-` +
+ `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` +
+ `${date.getMinutes()}-${date.getSeconds()}.txt`;
+ const outputText = await getUnvirtualizedConsoleOutputText(
+ webConsoleWrapper
+ );
+ const data = new TextEncoder().encode(outputText);
+ saveAs(window, data, suggestedName);
+ },
+ })
+ );
+
+ // Open object in sidebar.
+ const shouldOpenSidebar = store.getState().prefs.sidebarToggle;
+ if (shouldOpenSidebar) {
+ menu.append(
+ new MenuItem({
+ id: "console-menu-open-sidebar",
+ label: l10n.getStr("webconsole.menu.openInSidebar.label1"),
+ accesskey: l10n.getStr("webconsole.menu.openInSidebar.accesskey"),
+ disabled: !rootActorId,
+ click: () => dispatch(actions.openSidebar(messageId, rootActorId)),
+ })
+ );
+ }
+
+ if (url) {
+ menu.append(
+ new MenuItem({
+ id: "console-menu-open-url",
+ label: l10n.getStr("webconsole.menu.openURL.label"),
+ accesskey: l10n.getStr("webconsole.menu.openURL.accesskey"),
+ click: () =>
+ openContentLink(url, {
+ inBackground: true,
+ relatedToCurrent: true,
+ }),
+ })
+ );
+ menu.append(
+ new MenuItem({
+ id: "console-menu-copy-url",
+ label: l10n.getStr("webconsole.menu.copyURL.label"),
+ accesskey: l10n.getStr("webconsole.menu.copyURL.accesskey"),
+ click: () => clipboardHelper.copyString(url),
+ })
+ );
+ }
+
+ // Emit the "menu-open" event for testing.
+ const { screenX, screenY } = event;
+ menu.once("open", () => webConsoleWrapper.emitForTests("menu-open"));
+ menu.popup(screenX, screenY, hud.chromeWindow.document);
+
+ return menu;
+}
+
+exports.createContextMenu = createContextMenu;
+
+/**
+ * Returns the whole text content of the console output.
+ * We're creating a new ConsoleOutput using the current store, turning off virtualization
+ * so we can have access to all the messages.
+ *
+ * @param {WebConsoleWrapper} webConsoleWrapper
+ * @returns Promise<String>
+ */
+async function getUnvirtualizedConsoleOutputText(webConsoleWrapper) {
+ return new Promise(resolve => {
+ const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
+ const {
+ createElement,
+ createFactory,
+ } = require("resource://devtools/client/shared/vendor/react.js");
+ const ConsoleOutput = createFactory(
+ require("resource://devtools/client/webconsole/components/Output/ConsoleOutput.js")
+ );
+ const {
+ Provider,
+ createProvider,
+ } = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+ const { parentNode, toolbox } = webConsoleWrapper;
+ const doc = parentNode.ownerDocument;
+
+ // Create an element that won't impact the layout of the console
+ const singleUseElement = doc.createElement("section");
+ singleUseElement.classList.add("clipboard-only");
+ doc.body.append(singleUseElement);
+
+ const consoleOutput = ConsoleOutput({
+ serviceContainer: {
+ ...webConsoleWrapper.getServiceContainer(),
+ preventStacktraceInitialRenderDelay: true,
+ },
+ disableVirtualization: true,
+ });
+
+ ReactDOM.render(
+ createElement(
+ Provider,
+ {
+ store: webConsoleWrapper.getStore(),
+ },
+ toolbox
+ ? createElement(
+ createProvider(toolbox.commands.targetCommand.storeId),
+ { store: toolbox.commands.targetCommand.store },
+ consoleOutput
+ )
+ : consoleOutput
+ ),
+ singleUseElement,
+ () => {
+ resolve(getElementText(singleUseElement));
+ singleUseElement.remove();
+ ReactDOM.unmountComponentAtNode(singleUseElement);
+ }
+ );
+ });
+}
diff --git a/devtools/client/webconsole/utils/id-generator.js b/devtools/client/webconsole/utils/id-generator.js
new file mode 100644
index 0000000000..17144dd73c
--- /dev/null
+++ b/devtools/client/webconsole/utils/id-generator.js
@@ -0,0 +1,15 @@
+/* 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";
+
+exports.IdGenerator = class IdGenerator {
+ constructor() {
+ this.messageId = 1;
+ }
+
+ getNextId(packet) {
+ return packet && packet.actor ? packet.actor : "" + this.messageId++;
+ }
+};
diff --git a/devtools/client/webconsole/utils/l10n.js b/devtools/client/webconsole/utils/l10n.js
new file mode 100644
index 0000000000..5c647f84e6
--- /dev/null
+++ b/devtools/client/webconsole/utils/l10n.js
@@ -0,0 +1,70 @@
+/* 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";
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const helper = new LocalizationHelper(
+ "devtools/client/locales/webconsole.properties"
+);
+
+const l10n = {
+ /**
+ * Generates a formatted timestamp string for displaying in console messages.
+ *
+ * @param integer [milliseconds]
+ * Optional, allows you to specify the timestamp in milliseconds since
+ * the UNIX epoch.
+ * @return string
+ * The timestamp formatted for display.
+ */
+ timestampString(milliseconds) {
+ const d = new Date(milliseconds ? milliseconds : null);
+ const hours = d.getHours();
+ const minutes = d.getMinutes();
+ const seconds = d.getSeconds();
+ milliseconds = d.getMilliseconds();
+ const parameters = [hours, minutes, seconds, milliseconds];
+ return l10n.getFormatStr("timestampFormat", parameters);
+ },
+
+ /**
+ * Retrieve a localized string.
+ *
+ * @param string name
+ * The string name you want from the Web Console string bundle.
+ * @return string
+ * The localized string.
+ */
+ getStr(name) {
+ try {
+ return helper.getStr(name);
+ } catch (ex) {
+ console.error("Failed to get string: " + name);
+ throw ex;
+ }
+ },
+
+ /**
+ * Retrieve a localized string formatted with values coming from the given
+ * array.
+ *
+ * @param string name
+ * The string name you want from the Web Console string bundle.
+ * @param array array
+ * The array of values you want in the formatted string.
+ * @return string
+ * The formatted local string.
+ */
+ getFormatStr(name, array) {
+ try {
+ return helper.getFormatStr(name, ...array);
+ } catch (ex) {
+ console.error("Failed to format string: " + name);
+ throw ex;
+ }
+ },
+};
+
+module.exports = l10n;
diff --git a/devtools/client/webconsole/utils/messages.js b/devtools/client/webconsole/utils/messages.js
new file mode 100644
index 0000000000..aa3682bc37
--- /dev/null
+++ b/devtools/client/webconsole/utils/messages.js
@@ -0,0 +1,914 @@
+/* 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";
+
+const l10n = require("resource://devtools/client/webconsole/utils/l10n.js");
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+const {
+ isSupportedByConsoleTable,
+} = require("resource://devtools/shared/webconsole/messages.js");
+
+// URL Regex, common idioms:
+//
+// Lead-in (URL):
+// ( Capture because we need to know if there was a lead-in
+// character so we can include it as part of the text
+// preceding the match. We lack look-behind matching.
+// ^| The URL can start at the beginning of the string.
+// [\s(,;'"`“] Or whitespace or some punctuation that does not imply
+// a context which would preclude a URL.
+// )
+//
+// We do not need a trailing look-ahead because our regex's will terminate
+// because they run out of characters they can eat.
+
+// What we do not attempt to have the regexp do:
+// - Avoid trailing '.' and ')' characters. We let our greedy match absorb
+// these, but have a separate regex for extra characters to leave off at the
+// end.
+//
+// The Regex (apart from lead-in/lead-out):
+// ( Begin capture of the URL
+// (?: (potential detect beginnings)
+// https?:\/\/| Start with "http" or "https"
+// www\d{0,3}[.][a-z0-9.\-]{2,249}|
+// Start with "www", up to 3 numbers, then "." then
+// something that looks domain-namey. We differ from the
+// next case in that we do not constrain the top-level
+// domain as tightly and do not require a trailing path
+// indicator of "/". This is IDN root compatible.
+// [a-z0-9.\-]{2,250}[.][a-z]{2,4}\/
+// Detect a non-www domain, but requiring a trailing "/"
+// to indicate a path. This only detects IDN domains
+// with a non-IDN root. This is reasonable in cases where
+// there is no explicit http/https start us out, but
+// unreasonable where there is. Our real fix is the bug
+// to port the Thunderbird/gecko linkification logic.
+//
+// Domain names can be up to 253 characters long, and are
+// limited to a-zA-Z0-9 and '-'. The roots don't have
+// hyphens unless they are IDN roots. Root zones can be
+// found here: http://www.iana.org/domains/root/db
+// )
+// [-\w.!~*'();,/?:@&=+$#%]*
+// path onwards. We allow the set of characters that
+// encodeURI does not escape plus the result of escaping
+// (so also '%')
+// )
+// eslint-disable-next-line max-len
+const urlRegex =
+ /(^|[\s(,;'"`“])((?:https?:\/\/|www\d{0,3}[.][a-z0-9.\-]{2,249}|[a-z0-9.\-]{2,250}[.][a-z]{2,4}\/)[-\w.!~*'();,/?:@&=+$#%]*)/im;
+
+// Set of terminators that are likely to have been part of the context rather
+// than part of the URL and so should be uneaten. This is '(', ',', ';', plus
+// quotes and question end-ing punctuation and the potential permutations with
+// parentheses (english-specific).
+const uneatLastUrlCharsRegex = /(?:[),;.!?`'"]|[.!?]\)|\)[.!?])$/;
+
+const {
+ MESSAGE_SOURCE,
+ MESSAGE_TYPE,
+ MESSAGE_LEVEL,
+} = require("resource://devtools/client/webconsole/constants.js");
+const {
+ ConsoleMessage,
+ NetworkEventMessage,
+} = require("resource://devtools/client/webconsole/types.js");
+
+function prepareMessage(resource, idGenerator) {
+ if (!resource.source) {
+ resource = transformResource(resource);
+ }
+
+ resource.id = idGenerator.getNextId(resource);
+ return resource;
+}
+
+/**
+ * Transforms a resource given its type.
+ *
+ * @param {Object} resource: This can be either a simple RDP packet or an object emitted
+ * by the Resource API.
+ */
+function transformResource(resource) {
+ switch (resource.resourceType || resource.type) {
+ case ResourceCommand.TYPES.CONSOLE_MESSAGE: {
+ return transformConsoleAPICallResource(resource);
+ }
+
+ case ResourceCommand.TYPES.PLATFORM_MESSAGE: {
+ return transformPlatformMessageResource(resource);
+ }
+
+ case ResourceCommand.TYPES.ERROR_MESSAGE: {
+ return transformPageErrorResource(resource);
+ }
+
+ case ResourceCommand.TYPES.CSS_MESSAGE: {
+ return transformCSSMessageResource(resource);
+ }
+
+ case ResourceCommand.TYPES.NETWORK_EVENT: {
+ return transformNetworkEventResource(resource);
+ }
+
+ case "will-navigate": {
+ return transformNavigationMessagePacket(resource);
+ }
+
+ case "evaluationResult":
+ default: {
+ return transformEvaluationResultPacket(resource);
+ }
+ }
+}
+
+// eslint-disable-next-line complexity
+function transformConsoleAPICallResource(consoleMessageResource) {
+ const { message, targetFront } = consoleMessageResource;
+
+ let parameters = message.arguments;
+ let type = message.level;
+ let level = getLevelFromType(type);
+ let messageText = null;
+ const { timer } = message;
+
+ // Special per-type conversion.
+ switch (type) {
+ case "clear":
+ // We show a message to users when calls console.clear() is called.
+ parameters = [l10n.getStr("consoleCleared")];
+ break;
+ case "count":
+ case "countReset":
+ // Chrome RDP doesn't have a special type for count.
+ type = MESSAGE_TYPE.LOG;
+ const { counter } = message;
+
+ if (!counter) {
+ // We don't show anything if we don't have counter data.
+ type = MESSAGE_TYPE.NULL_MESSAGE;
+ } else if (counter.error) {
+ messageText = l10n.getFormatStr(counter.error, [counter.label]);
+ level = MESSAGE_LEVEL.WARN;
+ parameters = null;
+ } else {
+ const label = counter.label
+ ? counter.label
+ : l10n.getStr("noCounterLabel");
+ messageText = `${label}: ${counter.count}`;
+ parameters = null;
+ }
+ break;
+ case "timeStamp":
+ type = MESSAGE_TYPE.NULL_MESSAGE;
+ break;
+ case "time":
+ parameters = null;
+ if (timer && timer.error) {
+ messageText = l10n.getFormatStr(timer.error, [timer.name]);
+ level = MESSAGE_LEVEL.WARN;
+ } else {
+ // We don't show anything for console.time calls to match Chrome's behaviour.
+ type = MESSAGE_TYPE.NULL_MESSAGE;
+ }
+ break;
+ case "timeLog":
+ case "timeEnd":
+ if (timer && timer.error) {
+ parameters = null;
+ messageText = l10n.getFormatStr(timer.error, [timer.name]);
+ level = MESSAGE_LEVEL.WARN;
+ } else if (timer) {
+ // We show the duration to users when calls console.timeLog/timeEnd is called,
+ // if corresponding console.time() was called before.
+ const duration = Math.round(timer.duration * 100) / 100;
+ if (type === "timeEnd") {
+ messageText = l10n.getFormatStr("console.timeEnd", [
+ timer.name,
+ duration,
+ ]);
+ parameters = null;
+ } else if (type === "timeLog") {
+ const [, ...rest] = parameters;
+ parameters = [
+ l10n.getFormatStr("timeLog", [timer.name, duration]),
+ ...rest,
+ ];
+ }
+ } else {
+ // If the `timer` property does not exists, we don't output anything.
+ type = MESSAGE_TYPE.NULL_MESSAGE;
+ }
+ break;
+ case "table":
+ if (!isSupportedByConsoleTable(parameters)) {
+ // If the class of the first parameter is not supported,
+ // we handle the call as a simple console.log
+ type = "log";
+ }
+ break;
+ case "group":
+ type = MESSAGE_TYPE.START_GROUP;
+ if (parameters.length === 0) {
+ parameters = [l10n.getStr("noGroupLabel")];
+ }
+ break;
+ case "groupCollapsed":
+ type = MESSAGE_TYPE.START_GROUP_COLLAPSED;
+ if (parameters.length === 0) {
+ parameters = [l10n.getStr("noGroupLabel")];
+ }
+ break;
+ case "groupEnd":
+ type = MESSAGE_TYPE.END_GROUP;
+ parameters = null;
+ break;
+ case "dirxml":
+ // Handle console.dirxml calls as simple console.log
+ type = "log";
+ break;
+ }
+
+ const frame = message.filename
+ ? {
+ source: message.filename,
+ sourceId: message.sourceId,
+ line: message.lineNumber,
+ column: message.columnNumber,
+ }
+ : null;
+
+ if (frame && (type === "logPointError" || type === "logPoint")) {
+ frame.options = { logPoint: true };
+ }
+
+ return new ConsoleMessage({
+ targetFront,
+ source: MESSAGE_SOURCE.CONSOLE_API,
+ type,
+ level,
+ parameters,
+ messageText,
+ stacktrace: message.stacktrace ? message.stacktrace : null,
+ frame,
+ timeStamp: message.timeStamp,
+ userProvidedStyles: message.styles,
+ prefix: message.prefix,
+ private: message.private,
+ chromeContext: message.chromeContext,
+ });
+}
+
+function transformNavigationMessagePacket(packet) {
+ const { url } = packet;
+ return new ConsoleMessage({
+ source: MESSAGE_SOURCE.CONSOLE_FRONTEND,
+ type: MESSAGE_TYPE.NAVIGATION_MARKER,
+ level: MESSAGE_LEVEL.LOG,
+ messageText: l10n.getFormatStr("webconsole.navigated", [url]),
+ timeStamp: packet.timeStamp,
+ allowRepeating: false,
+ });
+}
+
+function transformPlatformMessageResource(platformMessageResource) {
+ const { message, timeStamp, targetFront } = platformMessageResource;
+ return new ConsoleMessage({
+ targetFront,
+ source: MESSAGE_SOURCE.CONSOLE_API,
+ type: MESSAGE_TYPE.LOG,
+ level: MESSAGE_LEVEL.LOG,
+ messageText: message,
+ timeStamp,
+ chromeContext: true,
+ });
+}
+
+function transformPageErrorResource(pageErrorResource, override = {}) {
+ const { pageError, targetFront } = pageErrorResource;
+ let level = MESSAGE_LEVEL.ERROR;
+ if (pageError.warning) {
+ level = MESSAGE_LEVEL.WARN;
+ } else if (pageError.info) {
+ level = MESSAGE_LEVEL.INFO;
+ }
+
+ const frame = pageError.sourceName
+ ? {
+ source: pageError.sourceName,
+ sourceId: pageError.sourceId,
+ line: pageError.lineNumber,
+ column: pageError.columnNumber,
+ }
+ : null;
+
+ return new ConsoleMessage(
+ Object.assign(
+ {
+ targetFront,
+ innerWindowID: pageError.innerWindowID,
+ source: MESSAGE_SOURCE.JAVASCRIPT,
+ type: MESSAGE_TYPE.LOG,
+ level,
+ category: pageError.category,
+ messageText: pageError.errorMessage,
+ stacktrace: pageError.stacktrace ? pageError.stacktrace : null,
+ frame,
+ errorMessageName: pageError.errorMessageName,
+ exceptionDocURL: pageError.exceptionDocURL,
+ hasException: pageError.hasException,
+ parameters: pageError.hasException ? [pageError.exception] : null,
+ timeStamp: pageError.timeStamp,
+ notes: pageError.notes,
+ private: pageError.private,
+ chromeContext: pageError.chromeContext,
+ isPromiseRejection: pageError.isPromiseRejection,
+ },
+ override
+ )
+ );
+}
+
+function transformCSSMessageResource(cssMessageResource) {
+ return transformPageErrorResource(cssMessageResource, {
+ cssSelectors: cssMessageResource.cssSelectors,
+ source: MESSAGE_SOURCE.CSS,
+ });
+}
+
+function transformNetworkEventResource(networkEventResource) {
+ return new NetworkEventMessage(networkEventResource);
+}
+
+function transformEvaluationResultPacket(packet) {
+ let {
+ exceptionMessage,
+ errorMessageName,
+ exceptionDocURL,
+ exception,
+ exceptionStack,
+ hasException,
+ frame,
+ result,
+ helperResult,
+ timestamp: timeStamp,
+ notes,
+ } = packet;
+
+ let parameter;
+
+ if (hasException) {
+ // If we have an exception, we prefix it, and we reset the exception message, as we're
+ // not going to use it.
+ parameter = exception;
+ exceptionMessage = null;
+ } else if (helperResult?.object) {
+ parameter = helperResult.object;
+ } else if (helperResult?.type === "error") {
+ try {
+ exceptionMessage = l10n.getFormatStr(
+ helperResult.message,
+ helperResult.messageArgs || []
+ );
+ } catch (ex) {
+ exceptionMessage = helperResult.message;
+ }
+ } else {
+ parameter = result;
+ }
+
+ const level =
+ typeof exceptionMessage !== "undefined" && packet.exceptionMessage !== null
+ ? MESSAGE_LEVEL.ERROR
+ : MESSAGE_LEVEL.LOG;
+
+ return new ConsoleMessage({
+ source: MESSAGE_SOURCE.JAVASCRIPT,
+ type: MESSAGE_TYPE.RESULT,
+ helperType: helperResult ? helperResult.type : null,
+ level,
+ messageText: exceptionMessage,
+ hasException,
+ parameters: [parameter],
+ errorMessageName,
+ exceptionDocURL,
+ stacktrace: exceptionStack,
+ frame,
+ timeStamp,
+ notes,
+ private: packet.private,
+ allowRepeating: false,
+ });
+}
+
+/**
+ * Return if passed messages are similar and can thus be "repeated".
+ * ⚠ This function is on a hot path, called for (almost) every message being sent by
+ * the server. This should be kept as fast as possible.
+ *
+ * @param {Message} message1
+ * @param {Message} message2
+ * @returns {Boolean}
+ */
+// eslint-disable-next-line complexity
+function areMessagesSimilar(message1, message2) {
+ if (!message1 || !message2) {
+ return false;
+ }
+
+ if (!areMessagesParametersSimilar(message1, message2)) {
+ return false;
+ }
+
+ if (!areMessagesStacktracesSimilar(message1, message2)) {
+ return false;
+ }
+
+ if (
+ !message1.allowRepeating ||
+ !message2.allowRepeating ||
+ message1.type !== message2.type ||
+ message1.level !== message2.level ||
+ message1.source !== message2.source ||
+ message1.category !== message2.category ||
+ message1.frame?.source !== message2.frame?.source ||
+ message1.frame?.line !== message2.frame?.line ||
+ message1.frame?.column !== message2.frame?.column ||
+ message1.messageText !== message2.messageText ||
+ message1.private !== message2.private ||
+ message1.errorMessageName !== message2.errorMessageName ||
+ message1.hasException !== message2.hasException ||
+ message1.isPromiseRejection !== message2.isPromiseRejection ||
+ message1.userProvidedStyles?.length !==
+ message2.userProvidedStyles?.length ||
+ `${message1.userProvidedStyles}` !== `${message2.userProvidedStyles}`
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Return if passed messages parameters are similar
+ * ⚠ This function is on a hot path, called for (almost) every message being sent by
+ * the server. This should be kept as fast as possible.
+ *
+ * @param {Message} message1
+ * @param {Message} message2
+ * @returns {Boolean}
+ */
+function areMessagesParametersSimilar(message1, message2) {
+ const message1ParamsLength = message1.parameters?.length;
+ if (message1ParamsLength !== message2.parameters?.length) {
+ return false;
+ }
+
+ if (!message1ParamsLength) {
+ return true;
+ }
+
+ for (let i = 0; i < message1ParamsLength; i++) {
+ const message1Parameter = message1.parameters[i];
+ const message2Parameter = message2.parameters[i];
+ // exceptions have a grip, but we want to consider 2 messages similar as long as
+ // they refer to the same error.
+ if (
+ message1.hasException &&
+ message2.hasException &&
+ message1Parameter._grip?.class == message2Parameter._grip?.class &&
+ message1Parameter._grip?.preview?.message ==
+ message2Parameter._grip?.preview?.message &&
+ message1Parameter._grip?.preview?.stack ==
+ message2Parameter._grip?.preview?.stack
+ ) {
+ continue;
+ }
+
+ // For object references (grips), that are not exceptions, we don't want to consider
+ // messages to be the same as we only have a preview of what they look like, and not
+ // some kind of property that would give us the state of a given instance at a given
+ // time.
+ if (message1Parameter._grip || message2Parameter._grip) {
+ return false;
+ }
+
+ if (message1Parameter.type !== message2Parameter.type) {
+ return false;
+ }
+
+ if (message1Parameter.type) {
+ if (message1Parameter.text !== message2Parameter.text) {
+ return false;
+ }
+ } else if (message1Parameter !== message2Parameter) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Return if passed messages stacktraces are similar
+ *
+ * @param {Message} message1
+ * @param {Message} message2
+ * @returns {Boolean}
+ */
+function areMessagesStacktracesSimilar(message1, message2) {
+ const message1StackLength = message1.stacktrace?.length;
+ if (message1StackLength !== message2.stacktrace?.length) {
+ return false;
+ }
+
+ if (!message1StackLength) {
+ return true;
+ }
+
+ for (let i = 0; i < message1StackLength; i++) {
+ const message1Frame = message1.stacktrace[i];
+ const message2Frame = message2.stacktrace[i];
+
+ if (message1Frame.filename !== message2Frame.filename) {
+ return false;
+ }
+
+ if (message1Frame.columnNumber !== message2Frame.columnNumber) {
+ return false;
+ }
+
+ if (message1Frame.lineNumber !== message2Frame.lineNumber) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Maps a Firefox RDP type to its corresponding level.
+ */
+function getLevelFromType(type) {
+ const levels = {
+ LEVEL_ERROR: "error",
+ LEVEL_WARNING: "warn",
+ LEVEL_INFO: "info",
+ LEVEL_LOG: "log",
+ LEVEL_DEBUG: "debug",
+ };
+
+ // A mapping from the console API log event levels to the Web Console levels.
+ const levelMap = {
+ error: levels.LEVEL_ERROR,
+ exception: levels.LEVEL_ERROR,
+ assert: levels.LEVEL_ERROR,
+ logPointError: levels.LEVEL_ERROR,
+ warn: levels.LEVEL_WARNING,
+ info: levels.LEVEL_INFO,
+ log: levels.LEVEL_LOG,
+ clear: levels.LEVEL_LOG,
+ trace: levels.LEVEL_LOG,
+ table: levels.LEVEL_LOG,
+ debug: levels.LEVEL_DEBUG,
+ dir: levels.LEVEL_LOG,
+ dirxml: levels.LEVEL_LOG,
+ group: levels.LEVEL_LOG,
+ groupCollapsed: levels.LEVEL_LOG,
+ groupEnd: levels.LEVEL_LOG,
+ time: levels.LEVEL_LOG,
+ timeEnd: levels.LEVEL_LOG,
+ count: levels.LEVEL_LOG,
+ };
+
+ return levelMap[type] || MESSAGE_TYPE.LOG;
+}
+
+function isGroupType(type) {
+ return [
+ MESSAGE_TYPE.START_GROUP,
+ MESSAGE_TYPE.START_GROUP_COLLAPSED,
+ ].includes(type);
+}
+
+function isPacketPrivate(packet) {
+ return (
+ packet.private === true ||
+ (packet.message && packet.message.private === true) ||
+ (packet.pageError && packet.pageError.private === true) ||
+ (packet.networkEvent && packet.networkEvent.private === true)
+ );
+}
+
+function createWarningGroupMessage(id, type, firstMessage) {
+ return new ConsoleMessage({
+ id,
+ allowRepeating: false,
+ level: MESSAGE_LEVEL.WARN,
+ source: MESSAGE_SOURCE.CONSOLE_FRONTEND,
+ type,
+ messageText: getWarningGroupLabel(firstMessage),
+ timeStamp: firstMessage.timeStamp,
+ innerWindowID: firstMessage.innerWindowID,
+ });
+}
+
+function createSimpleTableMessage(columns, items, timeStamp) {
+ return new ConsoleMessage({
+ allowRepeating: false,
+ level: MESSAGE_LEVEL.LOG,
+ source: MESSAGE_SOURCE.CONSOLE_FRONTEND,
+ type: MESSAGE_TYPE.SIMPLE_TABLE,
+ columns,
+ items,
+ timeStamp,
+ });
+}
+
+/**
+ * Given the a regular warning message, compute the label of the warning group the message
+ * could be in.
+ * For example, if the message text is:
+ * The resource at “http://evil.com” was blocked because content blocking is enabled
+ *
+ * it may be turned into
+ *
+ * The resource at “<URL>” was blocked because content blocking is enabled
+ *
+ * @param {ConsoleMessage} firstMessage
+ * @returns {String} The computed label
+ */
+function getWarningGroupLabel(firstMessage) {
+ if (
+ isContentBlockingMessage(firstMessage) ||
+ isStorageIsolationMessage(firstMessage) ||
+ isTrackingProtectionMessage(firstMessage)
+ ) {
+ return replaceURL(firstMessage.messageText, "<URL>");
+ }
+
+ if (isCookieSameSiteMessage(firstMessage)) {
+ if (Services.prefs.getBoolPref("network.cookie.sameSite.laxByDefault")) {
+ return l10n.getStr("webconsole.group.cookieSameSiteLaxByDefaultEnabled2");
+ }
+ return l10n.getStr("webconsole.group.cookieSameSiteLaxByDefaultDisabled2");
+ }
+
+ if (isCSPMessage(firstMessage)) {
+ return l10n.getStr("webconsole.group.csp");
+ }
+
+ return "";
+}
+
+/**
+ * Replace any URL in the provided text by the provided replacement text, or an empty
+ * string.
+ *
+ * @param {String} text
+ * @param {String} replacementText
+ * @returns {String}
+ */
+function replaceURL(text, replacementText = "") {
+ let result = "";
+ let currentIndex = 0;
+ let contentStart;
+ while (true) {
+ const url = urlRegex.exec(text);
+ // Pick the regexp with the earlier content; index will always be zero.
+ if (!url) {
+ break;
+ }
+ contentStart = url.index + url[1].length;
+ if (contentStart > 0) {
+ const nonUrlText = text.substring(0, contentStart);
+ result += nonUrlText;
+ }
+
+ // There are some final characters for a URL that are much more likely
+ // to have been part of the enclosing text rather than the end of the
+ // URL.
+ let useUrl = url[2];
+ const uneat = uneatLastUrlCharsRegex.exec(useUrl);
+ if (uneat) {
+ useUrl = useUrl.substring(0, uneat.index);
+ }
+
+ if (useUrl) {
+ result += replacementText;
+ }
+
+ currentIndex = currentIndex + contentStart;
+
+ currentIndex = currentIndex + useUrl.length;
+ text = text.substring(url.index + url[1].length + useUrl.length);
+ }
+
+ return result + text;
+}
+
+/**
+ * Get the warningGroup type in which the message could be in.
+ * @param {ConsoleMessage} message
+ * @returns {String|null} null if the message can't be part of a warningGroup.
+ */
+function getWarningGroupType(message) {
+ // We got report that this can be called with `undefined` (See Bug 1801462 and Bug 1810109).
+ // Until we manage to reproduce and find why this happens, guard on message so at least
+ // we don't crash the console.
+ if (!message) {
+ return null;
+ }
+
+ if (
+ message.level !== MESSAGE_LEVEL.WARN &&
+ // CookieSameSite messages are not warnings but infos
+ message.level !== MESSAGE_LEVEL.INFO
+ ) {
+ return null;
+ }
+
+ if (isContentBlockingMessage(message)) {
+ return MESSAGE_TYPE.CONTENT_BLOCKING_GROUP;
+ }
+
+ if (isStorageIsolationMessage(message)) {
+ return MESSAGE_TYPE.STORAGE_ISOLATION_GROUP;
+ }
+
+ if (isTrackingProtectionMessage(message)) {
+ return MESSAGE_TYPE.TRACKING_PROTECTION_GROUP;
+ }
+
+ if (isCookieSameSiteMessage(message)) {
+ return MESSAGE_TYPE.COOKIE_SAMESITE_GROUP;
+ }
+
+ if (isCSPMessage(message)) {
+ return MESSAGE_TYPE.CSP_GROUP;
+ }
+
+ return null;
+}
+
+/**
+ * Returns a computed id given a message
+ *
+ * @param {ConsoleMessage} type: the message type, from MESSAGE_TYPE.
+ * @param {Integer} innerWindowID: the message innerWindowID.
+ * @returns {String}
+ */
+function getParentWarningGroupMessageId(message) {
+ const warningGroupType = getWarningGroupType(message);
+ if (!warningGroupType) {
+ return null;
+ }
+
+ return `${warningGroupType}-${message.innerWindowID}`;
+}
+
+/**
+ * Returns true if the message is a warningGroup message (i.e. the "Header").
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isWarningGroup(message) {
+ return (
+ message.type === MESSAGE_TYPE.CONTENT_BLOCKING_GROUP ||
+ message.type === MESSAGE_TYPE.STORAGE_ISOLATION_GROUP ||
+ message.type === MESSAGE_TYPE.TRACKING_PROTECTION_GROUP ||
+ message.type === MESSAGE_TYPE.COOKIE_SAMESITE_GROUP ||
+ message.type === MESSAGE_TYPE.CORS_GROUP ||
+ message.type === MESSAGE_TYPE.CSP_GROUP
+ );
+}
+
+/**
+ * Returns true if the message is a content blocking message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isContentBlockingMessage(message) {
+ const { category } = message;
+ return (
+ category == "cookieBlockedPermission" ||
+ category == "cookieBlockedTracker" ||
+ category == "cookieBlockedAll" ||
+ category == "cookieBlockedForeign"
+ );
+}
+
+/**
+ * Returns true if the message is a storage isolation message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isStorageIsolationMessage(message) {
+ const { category } = message;
+ return category == "cookiePartitionedForeign";
+}
+
+/**
+ * Returns true if the message is a tracking protection message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isTrackingProtectionMessage(message) {
+ const { category } = message;
+ return category == "Tracking Protection";
+}
+
+/**
+ * Returns true if the message is a cookie message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isCookieSameSiteMessage(message) {
+ const { category } = message;
+ return category == "cookieSameSite";
+}
+
+/**
+ * Returns true if the message is a Content Security Policy (CSP) message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isCSPMessage(message) {
+ const { category } = message;
+ return typeof category == "string" && category.startsWith("CSP_");
+}
+
+function getDescriptorValue(descriptor) {
+ if (!descriptor) {
+ return descriptor;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(descriptor, "safeGetterValues")) {
+ return descriptor.safeGetterValues;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(descriptor, "getterValue")) {
+ return descriptor.getterValue;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(descriptor, "value")) {
+ return descriptor.value;
+ }
+ return descriptor;
+}
+
+function getNaturalOrder(messageA, messageB) {
+ const aFirst = -1;
+ const bFirst = 1;
+
+ // It can happen that messages are emitted in the same microsecond, making their
+ // timestamp similar. In such case, we rely on which message came first through
+ // the console API service, checking their id, except for expression result, which we'll
+ // always insert after because console API messages emitted from the expression need to
+ // be rendered before.
+ if (messageA.timeStamp === messageB.timeStamp) {
+ if (messageA.type === "result") {
+ return bFirst;
+ }
+
+ if (messageB.type === "result") {
+ return aFirst;
+ }
+
+ if (
+ !Number.isNaN(parseInt(messageA.id, 10)) &&
+ !Number.isNaN(parseInt(messageB.id, 10))
+ ) {
+ return parseInt(messageA.id, 10) < parseInt(messageB.id, 10)
+ ? aFirst
+ : bFirst;
+ }
+ }
+ return messageA.timeStamp < messageB.timeStamp ? aFirst : bFirst;
+}
+
+function isMessageNetworkError(message) {
+ return (
+ message.source === MESSAGE_SOURCE.NETWORK &&
+ message?.status &&
+ message?.status.toString().match(/^[4,5]\d\d$/)
+ );
+}
+
+module.exports = {
+ areMessagesSimilar,
+ createWarningGroupMessage,
+ createSimpleTableMessage,
+ getDescriptorValue,
+ getNaturalOrder,
+ getParentWarningGroupMessageId,
+ getWarningGroupType,
+ isContentBlockingMessage,
+ isGroupType,
+ isMessageNetworkError,
+ isPacketPrivate,
+ isWarningGroup,
+ l10n,
+ prepareMessage,
+};
diff --git a/devtools/client/webconsole/utils/moz.build b/devtools/client/webconsole/utils/moz.build
new file mode 100644
index 0000000000..8367b8c195
--- /dev/null
+++ b/devtools/client/webconsole/utils/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "clipboard.js",
+ "context-menu.js",
+ "id-generator.js",
+ "l10n.js",
+ "messages.js",
+ "object-inspector.js",
+ "prefs.js",
+)
diff --git a/devtools/client/webconsole/utils/object-inspector.js b/devtools/client/webconsole/utils/object-inspector.js
new file mode 100644
index 0000000000..8001828b51
--- /dev/null
+++ b/devtools/client/webconsole/utils/object-inspector.js
@@ -0,0 +1,158 @@
+/* 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";
+
+const {
+ createFactory,
+ createElement,
+} = require("resource://devtools/client/shared/vendor/react.js");
+
+loader.lazyGetter(this, "REPS", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .REPS;
+});
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+loader.lazyGetter(this, "ObjectInspector", function () {
+ const reps = require("resource://devtools/client/shared/components/reps/index.js");
+ return createFactory(reps.objectInspector.ObjectInspector);
+});
+
+loader.lazyRequireGetter(
+ this,
+ "SmartTrace",
+ "resource://devtools/client/shared/components/SmartTrace.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ "LongStringFront",
+ "resource://devtools/client/fronts/string.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "ObjectFront",
+ "resource://devtools/client/fronts/object.js",
+ true
+);
+
+/**
+ * Create and return an ObjectInspector for the given front.
+ *
+ * @param {Object} grip
+ * The object grip to create an ObjectInspector for.
+ * @param {Object} serviceContainer
+ * Object containing various utility functions
+ * @param {Object} override
+ * Object containing props that should override the default props passed to
+ * ObjectInspector.
+ * @returns {ObjectInspector}
+ * An ObjectInspector for the given grip.
+ */
+function getObjectInspector(
+ frontOrPrimitiveGrip,
+ serviceContainer,
+ override = {}
+) {
+ let onDOMNodeMouseOver;
+ let onDOMNodeMouseOut;
+ let onInspectIconClick;
+
+ if (serviceContainer) {
+ onDOMNodeMouseOver = serviceContainer.highlightDomElement
+ ? object => serviceContainer.highlightDomElement(object)
+ : null;
+ onDOMNodeMouseOut = serviceContainer.unHighlightDomElement
+ ? object => serviceContainer.unHighlightDomElement(object)
+ : null;
+ onInspectIconClick = serviceContainer.openNodeInInspector
+ ? (object, e) => {
+ // Stop the event propagation so we don't trigger ObjectInspector expand/collapse.
+ e.stopPropagation();
+ serviceContainer.openNodeInInspector(object);
+ }
+ : null;
+ }
+
+ const roots = createRoots(frontOrPrimitiveGrip, override.pathPrefix);
+
+ const objectInspectorProps = {
+ autoExpandDepth: 0,
+ mode: MODE.LONG,
+ standalone: true,
+ roots,
+ onViewSourceInDebugger: serviceContainer.onViewSourceInDebugger,
+ recordTelemetryEvent: serviceContainer.recordTelemetryEvent,
+ openLink: serviceContainer.openLink,
+ sourceMapURLService: serviceContainer.sourceMapURLService,
+ customFormat: override.customFormat !== false,
+ setExpanded: override.setExpanded,
+ initiallyExpanded: override.initiallyExpanded,
+ queueActorsForCleanup: override.queueActorsForCleanup,
+ cachedNodes: override.cachedNodes,
+ urlCropLimit: 120,
+ renderStacktrace: stacktrace => {
+ const attrs = {
+ key: "stacktrace",
+ stacktrace,
+ onViewSourceInDebugger: serviceContainer
+ ? serviceContainer.onViewSourceInDebugger ||
+ serviceContainer.onViewSource
+ : null,
+ onViewSource: serviceContainer.onViewSource,
+ onReady: override.maybeScrollToBottom,
+ sourceMapURLService: serviceContainer
+ ? serviceContainer.sourceMapURLService
+ : null,
+ };
+
+ if (serviceContainer?.preventStacktraceInitialRenderDelay) {
+ attrs.initialRenderDelay = 0;
+ }
+ return createElement(SmartTrace, attrs);
+ },
+ onDOMNodeMouseOver,
+ onDOMNodeMouseOut,
+ onInspectIconClick,
+ defaultRep: REPS.Grip,
+ createElement: serviceContainer?.createElement,
+ mayUseCustomFormatter: true,
+ ...override,
+ };
+
+ if (override.autoFocusRoot) {
+ Object.assign(objectInspectorProps, {
+ focusedItem: objectInspectorProps.roots[0],
+ });
+ }
+
+ return ObjectInspector(objectInspectorProps);
+}
+
+function createRoots(frontOrPrimitiveGrip, pathPrefix = "") {
+ const isFront =
+ frontOrPrimitiveGrip instanceof ObjectFront ||
+ frontOrPrimitiveGrip instanceof LongStringFront;
+ const grip = isFront ? frontOrPrimitiveGrip.getGrip() : frontOrPrimitiveGrip;
+
+ return [
+ {
+ path: `${pathPrefix}${
+ frontOrPrimitiveGrip
+ ? frontOrPrimitiveGrip.actorID || frontOrPrimitiveGrip.actor
+ : null
+ }`,
+ contents: { value: grip, front: isFront ? frontOrPrimitiveGrip : null },
+ },
+ ];
+}
+
+module.exports = {
+ getObjectInspector,
+};
diff --git a/devtools/client/webconsole/utils/prefs.js b/devtools/client/webconsole/utils/prefs.js
new file mode 100644
index 0000000000..9235c82069
--- /dev/null
+++ b/devtools/client/webconsole/utils/prefs.js
@@ -0,0 +1,46 @@
+/* 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";
+
+function getPreferenceName(hud, suffix) {
+ if (!suffix) {
+ console.error("Suffix shouldn't be falsy", { suffix });
+ return null;
+ }
+
+ if (!hud) {
+ console.error("hud shouldn't be falsy", { hud });
+ return null;
+ }
+
+ if (suffix.startsWith("devtools.")) {
+ // We don't have a suffix but a full pref name. Let's return it.
+ return suffix;
+ }
+
+ const component = hud.isBrowserConsole ? "browserconsole" : "webconsole";
+ return `devtools.${component}.${suffix}`;
+}
+
+function getPrefsService(hud) {
+ const getPrefName = pref => getPreferenceName(hud, pref);
+
+ return {
+ getBoolPref: (pref, deflt) =>
+ Services.prefs.getBoolPref(getPrefName(pref), deflt),
+ getIntPref: (pref, deflt) =>
+ Services.prefs.getIntPref(getPrefName(pref), deflt),
+ setBoolPref: (pref, value) =>
+ Services.prefs.setBoolPref(getPrefName(pref), value),
+ setIntPref: (pref, value) =>
+ Services.prefs.setIntPref(getPrefName(pref), value),
+ clearUserPref: pref => Services.prefs.clearUserPref(getPrefName(pref)),
+ getPrefName,
+ };
+}
+
+module.exports = {
+ getPrefsService,
+};