summaryrefslogtreecommitdiffstats
path: root/devtools/server/tracer/tracer.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/tracer/tracer.jsm')
-rw-r--r--devtools/server/tracer/tracer.jsm336
1 files changed, 294 insertions, 42 deletions
diff --git a/devtools/server/tracer/tracer.jsm b/devtools/server/tracer/tracer.jsm
index 82c746bb57..955b25fe3a 100644
--- a/devtools/server/tracer/tracer.jsm
+++ b/devtools/server/tracer/tracer.jsm
@@ -25,6 +25,7 @@ const EXPORTED_SYMBOLS = [
"addTracingListener",
"removeTracingListener",
"NEXT_INTERACTION_MESSAGE",
+ "DOM_MUTATIONS",
];
const NEXT_INTERACTION_MESSAGE =
@@ -43,8 +44,23 @@ const FRAME_EXIT_REASONS = {
THROW: "throw",
};
+const DOM_MUTATIONS = {
+ // Track all DOM Node being added
+ ADD: "add",
+ // Track all attributes being modified
+ ATTRIBUTES: "attributes",
+ // Track all DOM Node being removed
+ REMOVE: "remove",
+};
+
const listeners = new Set();
+// Detecting worker is different if this file is loaded via Common JS loader (isWorker global)
+// or as a JSM (constructor name)
+const isWorker =
+ globalThis.isWorker ||
+ globalThis.constructor.name == "WorkerDebuggerGlobalScope";
+
// 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.
@@ -60,7 +76,7 @@ const customLazy = {
// (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") {
+ if (isWorker) {
return require("Debugger");
}
const { addDebuggerToGlobal } = ChromeUtils.importESModule(
@@ -113,25 +129,44 @@ const customLazy = {
* @param {Boolean} options.traceDOMEvents
* Optional setting to enable tracing all the DOM events being going through
* dom/events/EventListenerManager.cpp's `EventListenerManager`.
+ * @param {Array<string>} options.traceDOMMutations
+ * Optional setting to enable tracing all the DOM mutations.
+ * This array may contains three strings:
+ * - "add": trace all new DOM Node being added,
+ * - "attributes": trace all DOM attribute modifications,
+ * - "delete": trace all DOM Node being removed.
* @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.traceSteps
+ * Optional setting to enable tracing each frame within a function execution.
+ * (i.e. not only function call and function returns [when traceFunctionReturn is true])
* @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 {String} options.filterFrameSourceUrl
+ * Optional setting to restrict all traces to only a given source URL.
+ * This is a loose check, so any source whose URL includes the passed string will be traced.
* @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.
+ * @param {Number} options.pauseOnStep
+ * Optional setting to delay each frame execution for a given amount of time in ms.
*/
class JavaScriptTracer {
constructor(options) {
this.onEnterFrame = this.onEnterFrame.bind(this);
+ // DevTools CommonJS Workers modules don't have access to AbortController
+ if (!isWorker) {
+ this.abortController = new AbortController();
+ }
+
// 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.
@@ -152,56 +187,48 @@ class JavaScriptTracer {
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.loggingMethod = isWorker ? dump.bind(null) : dump;
}
this.traceDOMEvents = !!options.traceDOMEvents;
+
+ if (options.traceDOMMutations) {
+ if (!Array.isArray(options.traceDOMMutations)) {
+ throw new Error("'traceDOMMutations' attribute should be an array");
+ }
+ const acceptedValues = Object.values(DOM_MUTATIONS);
+ if (!options.traceDOMMutations.every(e => acceptedValues.includes(e))) {
+ throw new Error(
+ `'traceDOMMutations' only accept array of strings whose values can be: ${acceptedValues}`
+ );
+ }
+ this.traceDOMMutations = options.traceDOMMutations;
+ }
+ this.traceSteps = !!options.traceSteps;
this.traceValues = !!options.traceValues;
this.traceFunctionReturn = !!options.traceFunctionReturn;
this.maxDepth = options.maxDepth;
this.maxRecords = options.maxRecords;
this.records = 0;
+ if ("pauseOnStep" in options) {
+ if (typeof options.pauseOnStep != "number") {
+ throw new Error("'pauseOnStep' attribute should be a number");
+ }
+ this.pauseOnStep = options.pauseOnStep;
+ }
+ if ("filterFrameSourceUrl" in options) {
+ if (typeof options.filterFrameSourceUrl != "string") {
+ throw new Error("'filterFrameSourceUrl' attribute should be a string");
+ }
+ this.filterFrameSourceUrl = options.filterFrameSourceUrl;
+ }
// 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");
- }
+ if (options.traceOnNextInteraction && !isWorker) {
+ this.#waitForNextInteraction();
} else {
this.#startTracing();
}
@@ -212,6 +239,44 @@ class JavaScriptTracer {
isTracing = false;
/**
+ * In case `traceOnNextInteraction` option is used, delay the actual start of tracing until a first user interaction.
+ */
+ #waitForNextInteraction() {
+ // Use a dedicated Abort Controller as we are going to stop it as soon as we get the first user interaction,
+ // whereas other listeners would typically wait for tracer stop.
+ this.nextInteractionAbortController = new AbortController();
+
+ const listener = () => {
+ this.nextInteractionAbortController.abort();
+ // Avoid tracing if the users asked to stop tracing while we were waiting for the user interaction.
+ if (this.dbg) {
+ this.#startTracing();
+ }
+ };
+ const eventOptions = {
+ signal: this.nextInteractionAbortController.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");
+ }
+ }
+
+ /**
* Actually really start watching for executions.
*
* This may be delayed when traceOnNextInteraction options is used.
@@ -225,6 +290,10 @@ class JavaScriptTracer {
if (this.traceDOMEvents) {
this.startTracingDOMEvents();
}
+ // This feature isn't supported on Workers as they aren't interacting with the DOM Tree
+ if (this.traceDOMMutations?.length > 0 && !isWorker) {
+ this.startTracingDOMMutations();
+ }
// In any case, we consider the tracing as started
this.notifyToggle(true);
@@ -248,6 +317,97 @@ class JavaScriptTracer {
this.currentDOMEvent = null;
}
+ startTracingDOMMutations() {
+ this.tracedGlobal.document.devToolsWatchingDOMMutations = true;
+
+ const eventOptions = {
+ signal: this.abortController.signal,
+ capture: true,
+ };
+ if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) {
+ this.tracedGlobal.docShell.chromeEventHandler.addEventListener(
+ "devtoolschildinserted",
+ this.#onDOMMutation,
+ eventOptions
+ );
+ }
+ if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) {
+ this.tracedGlobal.docShell.chromeEventHandler.addEventListener(
+ "devtoolsattrmodified",
+ this.#onDOMMutation,
+ eventOptions
+ );
+ }
+ if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) {
+ this.tracedGlobal.docShell.chromeEventHandler.addEventListener(
+ "devtoolschildremoved",
+ this.#onDOMMutation,
+ eventOptions
+ );
+ }
+ }
+
+ stopTracingDOMMutations() {
+ this.tracedGlobal.document.devToolsWatchingDOMMutations = false;
+ // Note that the event listeners are all going to be unregistered via the AbortController.
+ }
+
+ /**
+ * Called for any DOM Mutation done in the traced document.
+ *
+ * @param {DOM Event} event
+ */
+ #onDOMMutation = event => {
+ // Ignore elements inserted by DevTools, like the inspector's highlighters
+ if (event.target.isNativeAnonymous) {
+ return;
+ }
+
+ let type = "";
+ switch (event.type) {
+ case "devtoolschildinserted":
+ type = DOM_MUTATIONS.ADD;
+ break;
+ case "devtoolsattrmodified":
+ type = DOM_MUTATIONS.ATTRIBUTES;
+ break;
+ case "devtoolschildremoved":
+ type = DOM_MUTATIONS.REMOVE;
+ break;
+ default:
+ throw new Error("Unexpected DOM Mutation event type: " + event.type);
+ }
+
+ let shouldLogToStdout = true;
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingDOMMutation == "function") {
+ shouldLogToStdout |= listener.onTracingDOMMutation({
+ depth: this.depth,
+ prefix: this.prefix,
+
+ type,
+ element: event.target,
+ caller: Components.stack.caller,
+ });
+ }
+ }
+ }
+
+ if (shouldLogToStdout) {
+ const padding = "—".repeat(this.depth + 1);
+ this.loggingMethod(
+ this.prefix +
+ padding +
+ `[DOM Mutation | ${type}] ` +
+ objectToString(event.target) +
+ "\n"
+ );
+ }
+ };
+
/**
* Called by DebuggerNotificationObserver interface when a DOM event start being notified
* and after it has been notified.
@@ -277,7 +437,7 @@ class JavaScriptTracer {
.makeDebuggeeValue(notification.event)
.getProperty("type").return;
}
- this.currentDOMEvent = `DOM(${type})`;
+ this.currentDOMEvent = `DOM | ${type}`;
} else {
this.currentDOMEvent = notification.type;
}
@@ -306,14 +466,22 @@ class JavaScriptTracer {
this.depth = 0;
// Cancel the traceOnNextInteraction event listeners.
- if (this.abortController) {
- this.abortController.abort();
- this.abortController = null;
+ if (this.nextInteractionAbortController) {
+ this.nextInteractionAbortController.abort();
+ this.nextInteractionAbortController = null;
}
if (this.traceDOMEvents) {
this.stopTracingDOMEvents();
}
+ if (this.traceDOMMutations?.length > 0 && !isWorker) {
+ this.stopTracingDOMMutations();
+ }
+
+ // Unregister all event listeners
+ if (this.abortController) {
+ this.abortController.abort();
+ }
this.tracedGlobal = null;
this.isTracing = false;
@@ -406,10 +574,21 @@ class JavaScriptTracer {
return;
}
try {
+ // If an optional filter is passed, ignore frames which aren't matching the filter string
+ if (
+ this.filterFrameSourceUrl &&
+ !frame.script.source.url?.includes(this.filterFrameSourceUrl)
+ ) {
+ return;
+ }
+
// 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);
+ // Save the current depth for the DOM Mutation handler
+ this.depth = depth;
+
// Ignore the frame if we reached the depth limit (if one is provided)
if (this.maxDepth && depth >= this.maxDepth) {
return;
@@ -467,6 +646,39 @@ class JavaScriptTracer {
this.logFrameEnteredToStdout(frame, depth);
}
+ if (this.traceSteps) {
+ frame.onStep = () => {
+ // Spidermonkey steps on many intermediate positions which don't make sense to the user.
+ // `isStepStart` is close to each statement start, which is meaningful to the user.
+ const { isStepStart } = frame.script.getOffsetMetadata(frame.offset);
+ if (!isStepStart) {
+ return;
+ }
+
+ shouldLogToStdout = true;
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingFrameStep == "function") {
+ shouldLogToStdout |= listener.onTracingFrameStep({
+ frame,
+ depth,
+ prefix: this.prefix,
+ });
+ }
+ }
+ }
+ if (shouldLogToStdout) {
+ this.logFrameStepToStdout(frame, depth);
+ }
+ // Optionaly pause the frame execution by letting the other event loop to run in between.
+ if (typeof this.pauseOnStep == "number") {
+ syncPause(this.pauseOnStep);
+ }
+ };
+ }
+
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)
@@ -520,6 +732,11 @@ class JavaScriptTracer {
this.logFrameExitedToStdout(frame, depth, why, rv);
}
};
+
+ // Optionaly pause the frame execution by letting the other event loop to run in between.
+ if (typeof this.pauseOnStep == "number") {
+ syncPause(this.pauseOnStep);
+ }
} catch (e) {
console.error("Exception while tracing javascript", e);
}
@@ -576,6 +793,20 @@ class JavaScriptTracer {
}
/**
+ * Display to stdout one given frame execution, which represents a step within a function execution.
+ *
+ * @param {Debugger.Frame} frame
+ * @param {Number} depth
+ */
+ logFrameStepToStdout(frame, depth) {
+ const padding = "—".repeat(depth + 1);
+
+ const message = `${padding}— ${getTerminalHyperLink(frame)}`;
+
+ 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
@@ -787,6 +1018,27 @@ function getTerminalHyperLink(frame) {
return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`;
}
+/**
+ * Helper function to synchronously pause the current frame execution
+ * for a given duration in ms.
+ *
+ * @param {Number} duration
+ */
+function syncPause(duration) {
+ let freeze = true;
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ () => {
+ freeze = false;
+ },
+ duration,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ Services.tm.spinEventLoopUntil("debugger-slow-motion", function () {
+ return !freeze;
+ });
+}
+
// This JSM may be execute as CommonJS when loaded in the worker thread
if (typeof module == "object") {
module.exports = {