summaryrefslogtreecommitdiffstats
path: root/devtools/server/tracer
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/tracer')
-rw-r--r--devtools/server/tracer/tests/browser/browser_document_tracer.js2
-rw-r--r--devtools/server/tracer/tests/browser/browser_worker_tracer.js2
-rw-r--r--devtools/server/tracer/tests/xpcshell/test_tracer.js264
-rw-r--r--devtools/server/tracer/tracer.jsm336
4 files changed, 560 insertions, 44 deletions
diff --git a/devtools/server/tracer/tests/browser/browser_document_tracer.js b/devtools/server/tracer/tests/browser/browser_document_tracer.js
index 694842fa8b..dcf2c9eb4d 100644
--- a/devtools/server/tracer/tests/browser/browser_document_tracer.js
+++ b/devtools/server/tracer/tests/browser/browser_document_tracer.js
@@ -52,7 +52,7 @@ add_task(async function testTracingWorker() {
const firstFrame = frames[0];
is(firstFrame.formatedDisplayName, "λ foo");
- is(firstFrame.currentDOMEvent, "DOM(click)");
+ is(firstFrame.currentDOMEvent, "DOM | click");
const lastFrame = frames.at(-1);
is(lastFrame.formatedDisplayName, "λ bar");
diff --git a/devtools/server/tracer/tests/browser/browser_worker_tracer.js b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
index 815da85853..f555bd06b0 100644
--- a/devtools/server/tracer/tests/browser/browser_worker_tracer.js
+++ b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
@@ -52,7 +52,7 @@ add_task(async function testTracingWorker() {
ok(lastFrame.frame);
});
-function waitForWorkerDebugger(url, dbgUrl) {
+function waitForWorkerDebugger(url) {
return new Promise(function (resolve) {
wdm.addListener({
onRegister(dbg) {
diff --git a/devtools/server/tracer/tests/xpcshell/test_tracer.js b/devtools/server/tracer/tests/xpcshell/test_tracer.js
index fe9a984aa8..0f38052ba5 100644
--- a/devtools/server/tracer/tests/xpcshell/test_tracer.js
+++ b/devtools/server/tracer/tests/xpcshell/test_tracer.js
@@ -238,3 +238,267 @@ add_task(async function testTracingFunctionReturnAndValues() {
info("Stop tracing");
stopTracing();
});
+
+add_task(async function testTracingStep() {
+ // Test the `traceStep` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ const source = `
+function foo() {
+ bar(); /* line 3 */
+ second(); /* line 4 */
+}
+function bar() {
+ let res; /* line 7 */
+ if (1 === 1) { /* line 8 */
+ res = "string"; /* line 9 */
+ } else {
+ res = "nope"
+ }
+ return res; /* line 13 */
+};
+function second() {
+ let x = 0; /* line 16 */
+ for (let i = 0; i < 2; i++) { /* line 17 */
+ x++; /* line 18 */
+ }
+ return null; /* line 20 */
+};
+foo();`;
+ Cu.evalInSandbox(source, sandbox, null, "file.js", 1);
+
+ // 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,
+ traceSteps: true,
+ loggingMethod,
+ });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 19);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo");
+ Assert.stringContains(logs[1], "file.js:3:3");
+
+ // Each "step" only prints the location and nothing more
+ Assert.stringContains(logs[2], "file.js:3:3");
+
+ Assert.stringContains(logs[3], "λ bar");
+ Assert.stringContains(logs[3], "file.js:6:16");
+
+ Assert.stringContains(logs[4], "file.js:8:7");
+
+ Assert.stringContains(logs[5], "file.js:9:5");
+
+ Assert.stringContains(logs[6], "file.js:13:3");
+
+ Assert.stringContains(logs[7], "file.js:4:3");
+
+ Assert.stringContains(logs[8], "λ second");
+ Assert.stringContains(logs[8], "file.js:15:19");
+
+ Assert.stringContains(logs[9], "file.js:16:11");
+
+ // For loop
+ Assert.stringContains(logs[10], "file.js:17:16");
+
+ Assert.stringContains(logs[11], "file.js:17:19");
+
+ Assert.stringContains(logs[12], "file.js:18:5");
+
+ Assert.stringContains(logs[13], "file.js:17:26");
+
+ Assert.stringContains(logs[14], "file.js:17:19");
+
+ Assert.stringContains(logs[15], "file.js:18:5");
+
+ Assert.stringContains(logs[16], "file.js:17:26");
+
+ Assert.stringContains(logs[17], "file.js:17:19");
+ // End of for loop
+
+ Assert.stringContains(logs[18], "file.js:20:3");
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingPauseOnStep() {
+ // Test the `pauseOnStep` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ sandbox.dump = dump;
+ const source = `var counter = 0; function incrementCounter() { let x = 0; dump("++\\n"); counter++; };`;
+ Cu.evalInSandbox(source, sandbox);
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ let loggingMethodResolve;
+ function loggingMethod(str) {
+ logs.push(str);
+ if (loggingMethodResolve) {
+ loggingMethodResolve();
+ }
+ }
+
+ info("Start tracing without pause");
+ startTracing({
+ global: sandbox,
+ loggingMethod,
+ });
+
+ info("Call some code");
+ sandbox.incrementCounter();
+
+ Assert.equal(logs.length, 2);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ incrementCounter");
+
+ info(
+ "When pauseOnStep isn't used, the traced code runs synchronously to completion"
+ );
+ Assert.equal(sandbox.counter, 1);
+
+ info("Stop tracing");
+ stopTracing();
+
+ logs.length = 0;
+ sandbox.counter = 0;
+
+ info("Start tracing with 0ms pause");
+ startTracing({
+ global: sandbox,
+ pauseOnStep: 0,
+ loggingMethod,
+ });
+
+ let onTraces = Promise.withResolvers();
+ let onResumed = Promise.withResolvers();
+ // This is used when receiving new traces in `loggingMethod()`
+ loggingMethodResolve = onTraces.resolve;
+
+ info(
+ "Run the to-be-traced code in a distinct event loop as it would be paused synchronously and would prevent further test script execution"
+ );
+ Services.tm.dispatchToMainThread(() => {
+ sandbox.incrementCounter();
+ onResumed.resolve();
+ });
+
+ info("Wait for tracer to call the listener");
+ await onTraces.promise;
+
+ Assert.equal(logs.length, 2);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ incrementCounter");
+
+ info(
+ "When pauseInStep is used, the tracer listener is called, but the traced function is paused and doesn't run synchronously to completion"
+ );
+ Assert.equal(
+ sandbox.counter,
+ 0,
+ "The increment method was called but its execution flow was blocked and couldn't increment"
+ );
+
+ info("Wait for traced code to be resumed");
+ await onResumed.promise;
+ info(
+ "If we release the event loop, we can see the traced function completion"
+ );
+ Assert.equal(sandbox.counter, 1);
+
+ info("Stop tracing");
+ stopTracing();
+
+ logs.length = 0;
+ sandbox.counter = 0;
+
+ info("Start tracing with 250ms pause");
+ startTracing({
+ global: sandbox,
+ pauseOnStep: 250,
+ loggingMethod,
+ });
+
+ onTraces = Promise.withResolvers();
+ onResumed = Promise.withResolvers();
+ // This is used when receiving new traces in `loggingMethod()`
+ loggingMethodResolve = onTraces.resolve;
+
+ info(
+ "Run the to-be-traced code in a distinct event loop as it would be paused synchronously and would prevent further test script execution"
+ );
+ const startTimestamp = Cu.now();
+ Services.tm.dispatchToMainThread(() => {
+ sandbox.incrementCounter();
+ onResumed.resolve();
+ });
+
+ info("Wait for tracer to call the listener");
+ await onTraces.promise;
+
+ Assert.equal(logs.length, 2);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ incrementCounter");
+
+ info(
+ "When pauseInStep is used, the tracer lsitener is called, but the traced function is paused and doesn't run synchronously to completion"
+ );
+ Assert.equal(sandbox.counter, 0);
+
+ info("Wait for traced code to be resumed");
+ await onResumed.promise;
+ info(
+ "If we release the event loop, we can see the traced function completion"
+ );
+ Assert.equal(sandbox.counter, 1);
+ info("The thread should have paused at least the pauseOnStep's duration");
+ Assert.greater(Cu.now() - startTimestamp, 250);
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingFilterSourceUrl() {
+ // Test the `filterFrameSourceUrl` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+
+ // Use a unique global (sandbox), but with two distinct scripts (first.js and second.js)
+ const source1 = `function foo() { bar(); }`;
+ Cu.evalInSandbox(source1, sandbox, null, "first.js", 1);
+
+ // Only code running in that second source should be traced.
+ const source2 = `function bar() { }`;
+ Cu.evalInSandbox(source2, sandbox, null, "second.js", 1);
+
+ // 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,
+ filterFrameSourceUrl: "second",
+ loggingMethod,
+ });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 2);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ bar");
+ Assert.stringContains(logs[1], "second.js:1:18");
+
+ info("Stop tracing");
+ stopTracing();
+});
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 = {