summaryrefslogtreecommitdiffstats
path: root/devtools/server/tracer
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/tracer')
-rw-r--r--devtools/server/tracer/moz.build14
-rw-r--r--devtools/server/tracer/tests/browser/Worker.tracer.js10
-rw-r--r--devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js36
-rw-r--r--devtools/server/tracer/tests/browser/browser.toml11
-rw-r--r--devtools/server/tracer/tests/browser/browser_document_tracer.js68
-rw-r--r--devtools/server/tracer/tests/browser/browser_worker_tracer.js68
-rw-r--r--devtools/server/tracer/tests/xpcshell/test_tracer.js240
-rw-r--r--devtools/server/tracer/tests/xpcshell/xpcshell.toml6
-rw-r--r--devtools/server/tracer/tracer.jsm798
9 files changed, 1251 insertions, 0 deletions
diff --git a/devtools/server/tracer/moz.build b/devtools/server/tracer/moz.build
new file mode 100644
index 0000000000..26f7665018
--- /dev/null
+++ b/devtools/server/tracer/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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("tracer.jsm")
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "General")
diff --git a/devtools/server/tracer/tests/browser/Worker.tracer.js b/devtools/server/tracer/tests/browser/Worker.tracer.js
new file mode 100644
index 0000000000..60db4545a6
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/Worker.tracer.js
@@ -0,0 +1,10 @@
+"use strict";
+
+/* eslint-disable no-unused-vars */
+
+function bar() {}
+function foo() {
+ bar();
+}
+
+postMessage("evaled");
diff --git a/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js
new file mode 100644
index 0000000000..bd6e646b3b
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js
@@ -0,0 +1,36 @@
+"use strict";
+
+/* global global, loadSubScript */
+
+try {
+ // For some reason WorkerDebuggerGlobalScope.global doesn't expose JS variables
+ // and we can't call via global.foo(). Instead we have to go throught the Debugger API.
+ const dbg = new Debugger(global);
+ const [debuggee] = dbg.getDebuggees();
+
+ /* global startTracing, stopTracing, addTracingListener, removeTracingListener */
+ loadSubScript("resource://devtools/server/tracer/tracer.jsm");
+ const frames = [];
+ const listener = {
+ onTracingFrame(args) {
+ frames.push(args);
+
+ // Return true, to also log the trace to stdout
+ return true;
+ },
+ };
+ addTracingListener(listener);
+ startTracing({ global, prefix: "testWorkerPrefix" });
+
+ debuggee.executeInGlobal("foo()");
+
+ stopTracing();
+ removeTracingListener(listener);
+
+ // Send the frames to the main thread to do the assertions there.
+ postMessage(JSON.stringify(frames));
+} catch (e) {
+ dump(
+ "Exception while running debugger test script: " + e + "\n" + e.stack + "\n"
+ );
+}
diff --git a/devtools/server/tracer/tests/browser/browser.toml b/devtools/server/tracer/tests/browser/browser.toml
new file mode 100644
index 0000000000..61423b42b9
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser.toml
@@ -0,0 +1,11 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+
+["browser_document_tracer.js"]
+
+["browser_worker_tracer.js"]
+support-files = [
+ "Worker.tracer.js",
+ "WorkerDebugger.tracer.js",
+]
diff --git a/devtools/server/tracer/tests/browser/browser_document_tracer.js b/devtools/server/tracer/tests/browser/browser_document_tracer.js
new file mode 100644
index 0000000000..694842fa8b
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser_document_tracer.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const JS_CODE = `
+window.onclick = function foo() {
+ setTimeout(function bar() {
+ dump("click and timed out\n");
+ });
+};
+`;
+const TEST_URL =
+ "data:text/html,<!DOCTYPE html><html><script>" + JS_CODE + " </script>";
+
+add_task(async function testTracingWorker() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const {
+ addTracingListener,
+ removeTracingListener,
+ startTracing,
+ stopTracing,
+ } = ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm");
+
+ // We have to fake opening DevTools otherwise DebuggerNotificationObserver wouldn't work
+ // and the tracer wouldn't be able to trace the DOM events.
+ ChromeUtils.notifyDevToolsOpened();
+
+ const frames = [];
+ const listener = {
+ onTracingFrame(frameInfo) {
+ frames.push(frameInfo);
+ },
+ };
+ info("Register a tracing listener");
+ addTracingListener(listener);
+
+ info("Start tracing the iframe");
+ startTracing({ global: content, traceDOMEvents: true });
+
+ info("Dispatch a click event on the iframe");
+ EventUtils.synthesizeMouseAtCenter(
+ content.document.documentElement,
+ {},
+ content
+ );
+
+ info("Wait for the traces generated by this click");
+ await ContentTaskUtils.waitForCondition(() => frames.length == 2);
+
+ const firstFrame = frames[0];
+ is(firstFrame.formatedDisplayName, "λ foo");
+ is(firstFrame.currentDOMEvent, "DOM(click)");
+
+ const lastFrame = frames.at(-1);
+ is(lastFrame.formatedDisplayName, "λ bar");
+ is(lastFrame.currentDOMEvent, "setTimeoutCallback");
+
+ stopTracing();
+ removeTracingListener(listener);
+
+ ChromeUtils.notifyDevToolsClosed();
+ });
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/devtools/server/tracer/tests/browser/browser_worker_tracer.js b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
new file mode 100644
index 0000000000..815da85853
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].getService(
+ Ci.nsIWorkerDebuggerManager
+);
+
+const BASE_URL =
+ "chrome://mochitests/content/browser/devtools/server/tracer/tests/browser/";
+const WORKER_URL = BASE_URL + "Worker.tracer.js";
+const WORKER_DEBUGGER_URL = BASE_URL + "WorkerDebugger.tracer.js";
+
+add_task(async function testTracingWorker() {
+ const onDbg = waitForWorkerDebugger(WORKER_URL);
+
+ info("Instantiate a regular worker");
+ const worker = new Worker(WORKER_URL);
+ info("Wait for worker to reply back");
+ await new Promise(r => (worker.onmessage = r));
+ info("Wait for WorkerDebugger to be instantiated");
+ const dbg = await onDbg;
+
+ const onDebuggerScriptSentFrames = new Promise(resolve => {
+ const listener = {
+ onMessage(msg) {
+ dbg.removeListener(listener);
+ resolve(JSON.parse(msg));
+ },
+ };
+ dbg.addListener(listener);
+ });
+ info("Evaluate a Worker Debugger test script");
+ dbg.initialize(WORKER_DEBUGGER_URL);
+
+ info("Wait for frames to be notified by the debugger script");
+ const frames = await onDebuggerScriptSentFrames;
+
+ is(frames.length, 3);
+ // There is a third frame which relates to the usage of Debugger.Object.executeInGlobal
+ // which we ignore as that's a test side effect.
+ const lastFrame = frames.at(-1);
+ const beforeLastFrame = frames.at(-2);
+ is(beforeLastFrame.depth, 1);
+ is(beforeLastFrame.formatedDisplayName, "λ foo");
+ is(beforeLastFrame.prefix, "testWorkerPrefix: ");
+ ok(beforeLastFrame.frame);
+ is(lastFrame.depth, 2);
+ is(lastFrame.formatedDisplayName, "λ bar");
+ is(lastFrame.prefix, "testWorkerPrefix: ");
+ ok(lastFrame.frame);
+});
+
+function waitForWorkerDebugger(url, dbgUrl) {
+ return new Promise(function (resolve) {
+ wdm.addListener({
+ onRegister(dbg) {
+ if (dbg.url !== url) {
+ return;
+ }
+ ok(true, "Debugger with url " + url + " should be registered.");
+ wdm.removeListener(this);
+ resolve(dbg);
+ },
+ });
+ });
+}
diff --git a/devtools/server/tracer/tests/xpcshell/test_tracer.js b/devtools/server/tracer/tests/xpcshell/test_tracer.js
new file mode 100644
index 0000000000..fe9a984aa8
--- /dev/null
+++ b/devtools/server/tracer/tests/xpcshell/test_tracer.js
@@ -0,0 +1,240 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { addTracingListener, removeTracingListener, startTracing, stopTracing } =
+ ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm");
+
+add_task(async function () {
+ // Because this test uses evalInSandbox, we need to tweak the following prefs
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+ });
+});
+
+add_task(async function testTracingContentGlobal() {
+ const toggles = [];
+ const frames = [];
+ const listener = {
+ onTracingToggled(state) {
+ toggles.push(state);
+ },
+ onTracingFrame(frameInfo) {
+ frames.push(frameInfo);
+ },
+ };
+
+ info("Register a tracing listener");
+ addTracingListener(listener);
+
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox("function bar() {}; function foo() {bar()};", sandbox);
+
+ info("Start tracing");
+ startTracing({ global: sandbox, prefix: "testContentPrefix" });
+ Assert.equal(toggles.length, 1);
+ Assert.equal(toggles[0], true);
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(frames.length, 2);
+ const lastFrame = frames.pop();
+ const beforeLastFrame = frames.pop();
+ Assert.equal(beforeLastFrame.depth, 0);
+ Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
+ Assert.equal(beforeLastFrame.prefix, "testContentPrefix: ");
+ Assert.ok(beforeLastFrame.frame);
+ Assert.equal(lastFrame.depth, 1);
+ Assert.equal(lastFrame.formatedDisplayName, "λ bar");
+ Assert.equal(lastFrame.prefix, "testContentPrefix: ");
+ Assert.ok(lastFrame.frame);
+
+ info("Stop tracing");
+ stopTracing();
+ Assert.equal(toggles.length, 2);
+ Assert.equal(toggles[1], false);
+
+ info("Recall code after stop, no more traces are logged");
+ sandbox.foo();
+ Assert.equal(frames.length, 0);
+
+ info("Start tracing again, and recall code");
+ startTracing({ global: sandbox, prefix: "testContentPrefix" });
+ sandbox.foo();
+ info("New traces are logged");
+ Assert.equal(frames.length, 2);
+
+ info("Unregister the listener and recall code");
+ removeTracingListener(listener);
+ sandbox.foo();
+ info("No more traces are logged");
+ Assert.equal(frames.length, 2);
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingJSMGlobal() {
+ // We have to register the listener code in a sandbox, i.e. in a distinct global
+ // so that we aren't creating traces when tracer calls it. (and cause infinite loops)
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const listenerSandbox = Cu.Sandbox(systemPrincipal);
+ Cu.evalInSandbox(
+ "new " +
+ function () {
+ globalThis.toggles = [];
+ globalThis.frames = [];
+ globalThis.listener = {
+ onTracingToggled(state) {
+ globalThis.toggles.push(state);
+ },
+ onTracingFrame(frameInfo) {
+ globalThis.frames.push(frameInfo);
+ },
+ };
+ },
+ listenerSandbox
+ );
+
+ info("Register a tracing listener");
+ addTracingListener(listenerSandbox.listener);
+
+ info("Start tracing");
+ startTracing({ global: null, prefix: "testPrefix" });
+ Assert.equal(listenerSandbox.toggles.length, 1);
+ Assert.equal(listenerSandbox.toggles[0], true);
+
+ info("Call some code");
+ function bar() {}
+ function foo() {
+ bar();
+ }
+ foo();
+
+ // Note that the tracer will record the two Assert.equal and the info calls.
+ // So only assert the last two frames.
+ const lastFrame = listenerSandbox.frames.at(-1);
+ const beforeLastFrame = listenerSandbox.frames.at(-2);
+ Assert.equal(beforeLastFrame.depth, 7);
+ Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
+ Assert.equal(beforeLastFrame.prefix, "testPrefix: ");
+ Assert.ok(beforeLastFrame.frame);
+ Assert.equal(lastFrame.depth, 8);
+ Assert.equal(lastFrame.formatedDisplayName, "λ bar");
+ Assert.equal(lastFrame.prefix, "testPrefix: ");
+ Assert.ok(lastFrame.frame);
+
+ info("Stop tracing");
+ stopTracing();
+ Assert.equal(listenerSandbox.toggles.length, 2);
+ Assert.equal(listenerSandbox.toggles[1], false);
+
+ removeTracingListener(listenerSandbox.listener);
+});
+
+add_task(async function testTracingValues() {
+ // Test the `traceValues` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(-0, 1, ["array"], { attribute: 3 }, "4", BigInt(5), Symbol("6"), Infinity, undefined, null, false, NaN, function foo() {}, function () {}, class MyClass {}); }; function bar(a, b, c) {}`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({ global: sandbox, traceValues: true, loggingMethod });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 3);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo()");
+ Assert.stringContains(
+ logs[2],
+ `λ bar(-0, 1, Array(1), [object Object], "4", BigInt(5), Symbol(6), Infinity, undefined, null, false, NaN, function foo(), function anonymous(), class MyClass)`
+ );
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingFunctionReturn() {
+ // Test the `traceFunctionReturn` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(); return 0 } function bar() { return "string" }; foo();`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({ global: sandbox, traceFunctionReturn: true, loggingMethod });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 5);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo");
+ Assert.stringContains(logs[2], "λ bar");
+ Assert.stringContains(logs[3], `λ bar return`);
+ Assert.stringContains(logs[4], "λ foo return");
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingFunctionReturnAndValues() {
+ // Test the `traceFunctionReturn` and `traceValues` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(); second(); } function bar() { return "string" }; function second() { return null; }; foo();`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({
+ global: sandbox,
+ traceFunctionReturn: true,
+ traceValues: true,
+ loggingMethod,
+ });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 7);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo()");
+ Assert.stringContains(logs[2], "λ bar()");
+ Assert.stringContains(logs[3], `λ bar return "string"`);
+ Assert.stringContains(logs[4], "λ second()");
+ Assert.stringContains(logs[5], `λ second return null`);
+ Assert.stringContains(logs[6], "λ foo return undefined");
+
+ info("Stop tracing");
+ stopTracing();
+});
diff --git a/devtools/server/tracer/tests/xpcshell/xpcshell.toml b/devtools/server/tracer/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..015fd2286c
--- /dev/null
+++ b/devtools/server/tracer/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+tags = "devtools"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+
+["test_tracer.js"]
diff --git a/devtools/server/tracer/tracer.jsm b/devtools/server/tracer/tracer.jsm
new file mode 100644
index 0000000000..82c746bb57
--- /dev/null
+++ b/devtools/server/tracer/tracer.jsm
@@ -0,0 +1,798 @@
+/* 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/. */
+
+/**
+ * This module implements the JavaScript tracer.
+ *
+ * It is being used by:
+ * - any code that want to manually toggle the tracer, typically when debugging code,
+ * - the tracer actor to start and stop tracing from DevTools UI,
+ * - the tracing state resource watcher in order to notify DevTools UI about the tracing state.
+ *
+ * It will default logging the tracers to the terminal/stdout.
+ * But if DevTools are opened, it may delegate the logging to the tracer actor.
+ * It will typically log the traces to the Web Console.
+ *
+ * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "startTracing",
+ "stopTracing",
+ "addTracingListener",
+ "removeTracingListener",
+ "NEXT_INTERACTION_MESSAGE",
+];
+
+const NEXT_INTERACTION_MESSAGE =
+ "Waiting for next user interaction before tracing (next mousedown or keydown event)";
+
+const FRAME_EXIT_REASONS = {
+ // The function has been early terminated by the Debugger API
+ TERMINATED: "terminated",
+ // The function simply ends by returning a value
+ RETURN: "return",
+ // The function yields a new value
+ YIELD: "yield",
+ // The function await on a promise
+ AWAIT: "await",
+ // The function throws an exception
+ THROW: "throw",
+};
+
+const listeners = new Set();
+
+// This module can be loaded from the worker thread, where we can't use ChromeUtils.
+// So implement custom lazy getters (without XPCOMUtils ESM) from here.
+// Worker codepath in DevTools will pass a custom Debugger instance.
+const customLazy = {
+ get Debugger() {
+ // When this code runs in the worker thread, loaded via `loadSubScript`
+ // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js),
+ // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class.
+ if (globalThis.Debugger) {
+ return globalThis.Debugger;
+ }
+ // When this code runs in the worker thread, loaded via `require`
+ // (ex: from tracer actor module),
+ // this module no longer has WorkerDebuggerGlobalScope as global,
+ // but has to use require() to pull Debugger.
+ if (typeof isWorker == "boolean") {
+ return require("Debugger");
+ }
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ // Avoid polluting all Modules global scope by using a Sandox as global.
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const debuggerSandbox = Cu.Sandbox(systemPrincipal);
+ addDebuggerToGlobal(debuggerSandbox);
+ delete customLazy.Debugger;
+ customLazy.Debugger = debuggerSandbox.Debugger;
+ return customLazy.Debugger;
+ },
+
+ get DistinctCompartmentDebugger() {
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const debuggerSandbox = Cu.Sandbox(systemPrincipal, {
+ // As we may debug the JSM/ESM shared global, we should be using a Debugger
+ // from another system global.
+ freshCompartment: true,
+ });
+ addDebuggerToGlobal(debuggerSandbox);
+ delete customLazy.DistinctCompartmentDebugger;
+ customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger;
+ return customLazy.DistinctCompartmentDebugger;
+ },
+};
+
+/**
+ * Start tracing against a given JS global.
+ * Only code run from that global will be logged.
+ *
+ * @param {Object} options
+ * Object with configurations:
+ * @param {Object} options.global
+ * The tracer only log traces related to the code executed within this global.
+ * When omitted, it will default to the options object's global.
+ * @param {String} options.prefix
+ * Optional string logged as a prefix to all traces.
+ * @param {Debugger} options.dbg
+ * Optional spidermonkey's Debugger instance.
+ * This allows devtools to pass a custom instance and ease worker support
+ * where we can't load jsdebugger.sys.mjs.
+ * @param {Boolean} options.loggingMethod
+ * Optional setting to use something else than `dump()` to log traces to stdout.
+ * This is mostly used by tests.
+ * @param {Boolean} options.traceDOMEvents
+ * Optional setting to enable tracing all the DOM events being going through
+ * dom/events/EventListenerManager.cpp's `EventListenerManager`.
+ * @param {Boolean} options.traceValues
+ * Optional setting to enable tracing all function call values as well,
+ * as returned values (when we do log returned frames).
+ * @param {Boolean} options.traceOnNextInteraction
+ * Optional setting to enable when the tracing should only start when the
+ * use starts interacting with the page. i.e. on next keydown or mousedown.
+ * @param {Boolean} options.traceFunctionReturn
+ * Optional setting to enable when the tracing should notify about frame exit.
+ * i.e. when a function call returns or throws.
+ * @param {Number} options.maxDepth
+ * Optional setting to ignore frames when depth is greater than the passed number.
+ * @param {Number} options.maxRecords
+ * Optional setting to stop the tracer after having recorded at least
+ * the passed number of top level frames.
+ */
+class JavaScriptTracer {
+ constructor(options) {
+ this.onEnterFrame = this.onEnterFrame.bind(this);
+
+ // By default, we would trace only JavaScript related to caller's global.
+ // As there is no way to compute the caller's global default to the global of the
+ // mandatory options argument.
+ this.tracedGlobal = options.global || Cu.getGlobalForObject(options);
+
+ // Instantiate a brand new Debugger API so that we can trace independently
+ // of all other DevTools operations. i.e. we can pause while tracing without any interference.
+ this.dbg = this.makeDebugger();
+
+ this.prefix = options.prefix ? `${options.prefix}: ` : "";
+
+ // List of all async frame which are poped per Spidermonkey API
+ // but are actually waiting for async operation.
+ // We should later enter them again when the async task they are being waiting for is completed.
+ this.pendingAwaitFrames = new Set();
+
+ this.loggingMethod = options.loggingMethod;
+ if (!this.loggingMethod) {
+ // On workers, `dump` can't be called with JavaScript on another object,
+ // so bind it.
+ // Detecting worker is different if this file is loaded via Common JS loader (isWorker)
+ // or as a JSM (constructor name)
+ this.loggingMethod =
+ typeof isWorker == "boolean" ||
+ globalThis.constructor.name == "WorkerDebuggerGlobalScope"
+ ? dump.bind(null)
+ : dump;
+ }
+
+ this.traceDOMEvents = !!options.traceDOMEvents;
+ this.traceValues = !!options.traceValues;
+ this.traceFunctionReturn = !!options.traceFunctionReturn;
+ this.maxDepth = options.maxDepth;
+ this.maxRecords = options.maxRecords;
+ this.records = 0;
+
+ // An increment used to identify function calls and their returned/exit frames
+ this.frameId = 0;
+
+ // This feature isn't supported on Workers as they aren't involving user events
+ if (options.traceOnNextInteraction && typeof isWorker !== "boolean") {
+ this.abortController = new AbortController();
+ const listener = () => {
+ this.abortController.abort();
+ // Avoid tracing if the users asked to stop tracing.
+ if (this.dbg) {
+ this.#startTracing();
+ }
+ };
+ const eventOptions = {
+ signal: this.abortController.signal,
+ capture: true,
+ };
+ // Register the event listener on the Chrome Event Handler in order to receive the event first.
+ // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
+ const eventHandler =
+ this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
+ eventHandler.addEventListener("mousedown", listener, eventOptions);
+ eventHandler.addEventListener("keydown", listener, eventOptions);
+
+ // Significate to the user that the tracer is registered, but not tracing just yet.
+ let shouldLogToStdout = listeners.size == 0;
+ for (const l of listeners) {
+ if (typeof l.onTracingPending == "function") {
+ shouldLogToStdout |= l.onTracingPending();
+ }
+ }
+ if (shouldLogToStdout) {
+ this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n");
+ }
+ } else {
+ this.#startTracing();
+ }
+ }
+
+ // Is actively tracing?
+ // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used.
+ isTracing = false;
+
+ /**
+ * Actually really start watching for executions.
+ *
+ * This may be delayed when traceOnNextInteraction options is used.
+ * Otherwise we start tracing as soon as the class instantiates.
+ */
+ #startTracing() {
+ this.isTracing = true;
+
+ this.dbg.onEnterFrame = this.onEnterFrame;
+
+ if (this.traceDOMEvents) {
+ this.startTracingDOMEvents();
+ }
+
+ // In any case, we consider the tracing as started
+ this.notifyToggle(true);
+ }
+
+ startTracingDOMEvents() {
+ this.debuggerNotificationObserver = new DebuggerNotificationObserver();
+ this.eventListener = this.eventListener.bind(this);
+ this.debuggerNotificationObserver.addListener(this.eventListener);
+ this.debuggerNotificationObserver.connect(this.tracedGlobal);
+
+ this.currentDOMEvent = null;
+ }
+
+ stopTracingDOMEvents() {
+ if (this.debuggerNotificationObserver) {
+ this.debuggerNotificationObserver.removeListener(this.eventListener);
+ this.debuggerNotificationObserver.disconnect(this.tracedGlobal);
+ this.debuggerNotificationObserver = null;
+ }
+ this.currentDOMEvent = null;
+ }
+
+ /**
+ * Called by DebuggerNotificationObserver interface when a DOM event start being notified
+ * and after it has been notified.
+ *
+ * @param {DebuggerNotification} notification
+ * Info about the DOM event. See the related idl file.
+ */
+ eventListener(notification) {
+ // For each event we get two notifications.
+ // One just before firing the listeners and another one just after.
+ //
+ // Update `this.currentDOMEvent` to be refering to the event name
+ // while the DOM event is being notified. It will be null the rest of the time.
+ //
+ // We don't need to maintain a stack of events as that's only consumed by onEnterFrame
+ // which only cares about the very lastest event being currently trigerring some code.
+ if (notification.phase == "pre") {
+ // We get notified about "real" DOM event, but also when some particular callbacks are called like setTimeout.
+ if (notification.type == "domEvent") {
+ let { type } = notification.event;
+ if (!type) {
+ // In the Worker thread, `notification.event` is an opaque wrapper.
+ // In other threads it is a Xray wrapper.
+ // Because of this difference, we have to fallback to use the Debugger.Object API.
+ type = this.dbg
+ .makeGlobalObjectReference(notification.global)
+ .makeDebuggeeValue(notification.event)
+ .getProperty("type").return;
+ }
+ this.currentDOMEvent = `DOM(${type})`;
+ } else {
+ this.currentDOMEvent = notification.type;
+ }
+ } else {
+ this.currentDOMEvent = null;
+ }
+ }
+
+ /**
+ * Stop observing execution.
+ *
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ */
+ stopTracing(reason = "") {
+ // Note that this may be called before `#startTracing()`, but still want to completely shut it down.
+ if (!this.dbg) {
+ return;
+ }
+
+ this.dbg.onEnterFrame = undefined;
+ this.dbg.removeAllDebuggees();
+ this.dbg.onNewGlobalObject = undefined;
+ this.dbg = null;
+
+ this.depth = 0;
+
+ // Cancel the traceOnNextInteraction event listeners.
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+
+ if (this.traceDOMEvents) {
+ this.stopTracingDOMEvents();
+ }
+
+ this.tracedGlobal = null;
+ this.isTracing = false;
+
+ this.notifyToggle(false, reason);
+ }
+
+ /**
+ * Instantiate a Debugger API instance dedicated to each Tracer instance.
+ * It will notably be different from the instance used in DevTools.
+ * This allows to implement tracing independently of DevTools.
+ */
+ makeDebugger() {
+ // When this code runs in the worker thread, Cu isn't available
+ // and we don't have system principal anyway in this context.
+ const { isSystemPrincipal } =
+ typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {};
+
+ // When debugging the system modules, we have to use a special instance
+ // of Debugger loaded in a distinct system global.
+ const dbg = isSystemPrincipal
+ ? new customLazy.DistinctCompartmentDebugger()
+ : new customLazy.Debugger();
+
+ // For now, we only trace calls for one particular global at a time.
+ // See the constructor for its definition.
+ dbg.addDebuggee(this.tracedGlobal);
+
+ return dbg;
+ }
+
+ /**
+ * Notify DevTools and/or the user via stdout that tracing
+ * has been enabled or disabled.
+ *
+ * @param {Boolean} state
+ * True if we just started tracing, false when it just stopped.
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ */
+ notifyToggle(state, reason) {
+ let shouldLogToStdout = listeners.size == 0;
+ for (const listener of listeners) {
+ if (typeof listener.onTracingToggled == "function") {
+ shouldLogToStdout |= listener.onTracingToggled(state, reason);
+ }
+ }
+ if (shouldLogToStdout) {
+ if (state) {
+ this.loggingMethod(this.prefix + "Start tracing JavaScript\n");
+ } else {
+ if (reason) {
+ reason = ` (reason: ${reason})`;
+ }
+ this.loggingMethod(
+ this.prefix + "Stop tracing JavaScript" + reason + "\n"
+ );
+ }
+ }
+ }
+
+ /**
+ * Notify DevTools and/or the user via stdout that tracing
+ * stopped because of an infinite loop.
+ */
+ notifyInfiniteLoop() {
+ let shouldLogToStdout = listeners.size == 0;
+ for (const listener of listeners) {
+ if (typeof listener.onTracingInfiniteLoop == "function") {
+ shouldLogToStdout |= listener.onTracingInfiniteLoop();
+ }
+ }
+ if (shouldLogToStdout) {
+ this.loggingMethod(
+ this.prefix +
+ "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!\n"
+ );
+ }
+ }
+
+ /**
+ * Called by the Debugger API (this.dbg) when a new frame is executed.
+ *
+ * @param {Debugger.Frame} frame
+ * A descriptor object for the JavaScript frame.
+ */
+ onEnterFrame(frame) {
+ // Safe check, just in case we keep being notified, but the tracer has been stopped
+ if (!this.dbg) {
+ return;
+ }
+ try {
+ // Because of async frame which are popped and entered again on completion of the awaited async task,
+ // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop).
+ const depth = getFrameDepth(frame);
+
+ // Ignore the frame if we reached the depth limit (if one is provided)
+ if (this.maxDepth && depth >= this.maxDepth) {
+ return;
+ }
+
+ // When we encounter a frame which was previously popped because of pending on an async task,
+ // ignore it and only log the following ones.
+ if (this.pendingAwaitFrames.has(frame)) {
+ this.pendingAwaitFrames.delete(frame);
+ return;
+ }
+
+ // Auto-stop the tracer if we reached the number of max recorded top level frames
+ if (depth === 0 && this.maxRecords) {
+ if (this.records >= this.maxRecords) {
+ this.stopTracing("max-records");
+ return;
+ }
+ this.records++;
+ }
+
+ // Consider depth > 100 as an infinite recursive loop and stop the tracer.
+ if (depth == 100) {
+ this.notifyInfiniteLoop();
+ this.stopTracing("infinite-loop");
+ return;
+ }
+
+ const frameId = this.frameId++;
+ let shouldLogToStdout = true;
+
+ // If there is at least one DevTools debugging this process,
+ // delegate logging to DevTools actors.
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ const formatedDisplayName = formatDisplayName(frame);
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingFrame == "function") {
+ shouldLogToStdout |= listener.onTracingFrame({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix: this.prefix,
+ currentDOMEvent: this.currentDOMEvent,
+ });
+ }
+ }
+ }
+
+ // DevTools may delegate the work to log to stdout,
+ // but if DevTools are closed, stdout is the only way to log the traces.
+ if (shouldLogToStdout) {
+ this.logFrameEnteredToStdout(frame, depth);
+ }
+
+ frame.onPop = completion => {
+ // Special case async frames. We are exiting the current frame because of waiting for an async task.
+ // (this is typically a `await foo()` from an async function)
+ // This frame should later be "entered" again.
+ if (completion?.await) {
+ this.pendingAwaitFrames.add(frame);
+ return;
+ }
+
+ if (!this.traceFunctionReturn) {
+ return;
+ }
+
+ let why = "";
+ let rv = undefined;
+ if (!completion) {
+ why = FRAME_EXIT_REASONS.TERMINATED;
+ } else if ("return" in completion) {
+ why = FRAME_EXIT_REASONS.RETURN;
+ rv = completion.return;
+ } else if ("yield" in completion) {
+ why = FRAME_EXIT_REASONS.YIELD;
+ rv = completion.yield;
+ } else if ("await" in completion) {
+ why = FRAME_EXIT_REASONS.AWAIT;
+ } else {
+ why = FRAME_EXIT_REASONS.THROW;
+ rv = completion.throw;
+ }
+
+ shouldLogToStdout = true;
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ const formatedDisplayName = formatDisplayName(frame);
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingFrameExit == "function") {
+ shouldLogToStdout |= listener.onTracingFrameExit({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix: this.prefix,
+ why,
+ rv,
+ });
+ }
+ }
+ }
+ if (shouldLogToStdout) {
+ this.logFrameExitedToStdout(frame, depth, why, rv);
+ }
+ };
+ } catch (e) {
+ console.error("Exception while tracing javascript", e);
+ }
+ }
+
+ /**
+ * Display to stdout one given frame execution, which represents a function call.
+ *
+ * @param {Debugger.Frame} frame
+ * @param {Number} depth
+ */
+ logFrameEnteredToStdout(frame, depth) {
+ const padding = "—".repeat(depth + 1);
+
+ // If we are tracing DOM events and we are in middle of an event,
+ // and are logging the topmost frame,
+ // then log a preliminary dedicated line to mention that event type.
+ if (this.currentDOMEvent && depth == 0) {
+ this.loggingMethod(this.prefix + padding + this.currentDOMEvent + "\n");
+ }
+
+ let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink(
+ frame
+ )} - ${formatDisplayName(frame)}`;
+
+ // Log arguments, but only when this feature is enabled as it introduces
+ // some significant performance and visual overhead.
+ // Also prevent trying to log function call arguments if we aren't logging a frame
+ // with arguments (e.g. Debugger evaluation frames, when executing from the console)
+ if (this.traceValues && frame.arguments) {
+ message += "(";
+ for (let i = 0, l = frame.arguments.length; i < l; i++) {
+ const arg = frame.arguments[i];
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (arg?.unsafeDereference) {
+ // Special case classes as they can't be easily differentiated in pure JavaScript
+ if (arg.isClassConstructor) {
+ message += "class " + arg.name;
+ } else {
+ message += objectToString(arg.unsafeDereference());
+ }
+ } else {
+ message += primitiveToString(arg);
+ }
+
+ if (i < l - 1) {
+ message += ", ";
+ }
+ }
+ message += ")";
+ }
+
+ this.loggingMethod(this.prefix + message + "\n");
+ }
+
+ /**
+ * Display to stdout the exit of a given frame execution, which represents a function return.
+ *
+ * @param {Debugger.Frame} frame
+ * @param {String} why
+ * @param {Number} depth
+ */
+ logFrameExitedToStdout(frame, depth, why, rv) {
+ const padding = "—".repeat(depth + 1);
+
+ let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink(
+ frame
+ )} - ${formatDisplayName(frame)} ${why}`;
+
+ // Log returned values, but only when this feature is enabled as it introduces
+ // some significant performance and visual overhead.
+ if (this.traceValues) {
+ message += " ";
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (rv?.unsafeDereference) {
+ // Special case classes as they can't be easily differentiated in pure JavaScript
+ if (rv.isClassConstructor) {
+ message += "class " + rv.name;
+ } else {
+ message += objectToString(rv.unsafeDereference());
+ }
+ } else {
+ message += primitiveToString(rv);
+ }
+ }
+
+ this.loggingMethod(this.prefix + message + "\n");
+ }
+}
+
+/**
+ * Return a string description for any arbitrary JS value.
+ * Used when logging to stdout.
+ *
+ * @param {Object} obj
+ * Any JavaScript object to describe.
+ * @return String
+ * User meaningful descriptor for the object.
+ */
+function objectToString(obj) {
+ if (Element.isInstance(obj)) {
+ let message = `<${obj.tagName}`;
+ if (obj.id) {
+ message += ` #${obj.id}`;
+ }
+ if (obj.className) {
+ message += ` .${obj.className}`;
+ }
+ message += ">";
+ return message;
+ } else if (Array.isArray(obj)) {
+ return `Array(${obj.length})`;
+ } else if (Event.isInstance(obj)) {
+ return `Event(${obj.type}) target=${objectToString(obj.target)}`;
+ } else if (typeof obj === "function") {
+ return `function ${obj.name || "anonymous"}()`;
+ }
+ return obj;
+}
+
+function primitiveToString(value) {
+ const type = typeof value;
+ if (type === "string") {
+ // Use stringify to escape special characters and display in enclosing quotes.
+ return JSON.stringify(value);
+ } else if (value === 0 && 1 / value === -Infinity) {
+ // -0 is very special and need special threatment.
+ return "-0";
+ } else if (type === "bigint") {
+ return `BigInt(${value})`;
+ } else if (value && typeof value.toString === "function") {
+ // Use toString as it allows to stringify Symbols. Converting them to string throws.
+ return value.toString();
+ }
+
+ // For all other types/cases, rely on native convertion to string
+ return value;
+}
+
+/**
+ * Try to describe the current frame we are tracing
+ *
+ * This will typically log the name of the method being called.
+ *
+ * @param {Debugger.Frame} frame
+ * The frame which is currently being executed.
+ */
+function formatDisplayName(frame) {
+ if (frame.type === "call") {
+ const callee = frame.callee;
+ // Anonymous function will have undefined name and displayName.
+ return "λ " + (callee.name || callee.displayName || "anonymous");
+ }
+
+ return `(${frame.type})`;
+}
+
+let activeTracer = null;
+
+/**
+ * Start tracing JavaScript.
+ * i.e. log the name of any function being called in JS and its location in source code.
+ *
+ * @params {Object} options (mandatory)
+ * See JavaScriptTracer.startTracing jsdoc.
+ */
+function startTracing(options) {
+ if (!options) {
+ throw new Error("startTracing excepts an options object as first argument");
+ }
+ if (!activeTracer) {
+ activeTracer = new JavaScriptTracer(options);
+ } else {
+ console.warn(
+ "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time."
+ );
+ }
+}
+
+/**
+ * Stop tracing JavaScript.
+ */
+function stopTracing() {
+ if (activeTracer) {
+ activeTracer.stopTracing();
+ activeTracer = null;
+ } else {
+ console.warn("Can't stop JavaScript Tracing as we were not tracing.");
+ }
+}
+
+/**
+ * Listen for tracing updates.
+ *
+ * The listener object may expose the following methods:
+ * - onTracingToggled(state)
+ * Where state is a boolean to indicate if tracing has just been enabled of disabled.
+ * It may be immediatelly called if a tracer is already active.
+ *
+ * - onTracingInfiniteLoop()
+ * Called when the tracer stopped because of an infinite loop.
+ *
+ * - onTracingFrame({ frame, depth, formatedDisplayName, prefix })
+ * Called each time we enter a new JS frame.
+ * - frame is a Debugger.Frame object
+ * - depth is a number and represents the depth of the frame in the call stack
+ * - formatedDisplayName is a string and is a human readable name for the current frame
+ * - prefix is a string to display as a prefix of any logged frame
+ *
+ * @param {Object} listener
+ */
+function addTracingListener(listener) {
+ listeners.add(listener);
+
+ if (
+ activeTracer?.isTracing &&
+ typeof listener.onTracingToggled == "function"
+ ) {
+ listener.onTracingToggled(true);
+ }
+}
+
+/**
+ * Unregister a listener previous registered via addTracingListener
+ */
+function removeTracingListener(listener) {
+ listeners.delete(listener);
+}
+
+function getFrameDepth(frame) {
+ if (typeof frame.depth !== "number") {
+ let depth = 0;
+ let f = frame;
+ while ((f = f.older)) {
+ depth++;
+ }
+ frame.depth = depth;
+ }
+
+ return frame.depth;
+}
+
+/**
+ * Generate a magic string that will be rendered in smart terminals as a URL
+ * for the given Frame object. This URL is special as it includes a line and column.
+ * This URL can be clicked and Firefox will automatically open the source matching
+ * the frame's URL in the currently opened Debugger.
+ * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`.
+ *
+ * @param {Debugger.Frame} frame
+ * The frame being traced.
+ * @return {String}
+ * The URL's magic string.
+ */
+function getTerminalHyperLink(frame) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+
+ // Use a special URL, including line and column numbers which Firefox
+ // interprets as to be opened in the already opened DevTool's debugger
+ const href = `${script.source.url}:${lineNumber}:${columnNumber}`;
+
+ // Use special characters in order to print working hyperlinks right from the terminal
+ // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
+ return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`;
+}
+
+// This JSM may be execute as CommonJS when loaded in the worker thread
+if (typeof module == "object") {
+ module.exports = {
+ startTracing,
+ stopTracing,
+ addTracingListener,
+ removeTracingListener,
+ };
+}