1116 lines
38 KiB
JavaScript
1116 lines
38 KiB
JavaScript
/* 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.
|
|
*/
|
|
|
|
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 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)
|
|
// eslint-disable-next-line no-shadow
|
|
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.
|
|
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 (isWorker) {
|
|
// require is defined for workers.
|
|
// eslint-disable-next-line no-undef
|
|
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",
|
|
{ global: "contextual" }
|
|
);
|
|
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 {Boolean} options.traceAllGlobals
|
|
* When set to true, this will trace all the globals running in the current thread.
|
|
* @param {String} options.prefix
|
|
* Optional string logged as a prefix to all traces.
|
|
* @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 {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();
|
|
}
|
|
|
|
if (options.traceAllGlobals) {
|
|
this.traceAllGlobals = true;
|
|
if (options.traceOnNextInteraction) {
|
|
throw new Error(
|
|
"Tracing all globals and waiting for next user interaction are not yet compatible"
|
|
);
|
|
}
|
|
if (this.traceDOMEvents) {
|
|
throw new Error(
|
|
"Tracing all globals and DOM Events are not yet compatible"
|
|
);
|
|
}
|
|
if (options.global) {
|
|
throw new Error(
|
|
"'global' option should be omitted when using 'traceAllGlobals'"
|
|
);
|
|
}
|
|
} else {
|
|
// 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.
|
|
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 && !isWorker) {
|
|
this.#waitForNextInteraction();
|
|
} 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;
|
|
|
|
/**
|
|
* 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.
|
|
* Otherwise we start tracing as soon as the class instantiates.
|
|
*/
|
|
#startTracing() {
|
|
this.isTracing = true;
|
|
|
|
this.dbg.onEnterFrame = this.onEnterFrame;
|
|
|
|
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);
|
|
}
|
|
|
|
startTracingDOMEvents() {
|
|
this.debuggerNotificationObserver = new DebuggerNotificationObserver();
|
|
this.eventListener = this.eventListener.bind(this);
|
|
this.debuggerNotificationObserver.addListener(this.eventListener);
|
|
this.debuggerNotificationObserver.connect(this.tracedGlobal);
|
|
|
|
// When we are tracing a document, also ensure connecting to all its children iframe globals.
|
|
// If we don't, Debugger API would fire onEnterFrame for their JavaScript code,
|
|
// but DOM Events wouldn't be notified by DebuggerNotificationObserver.
|
|
if (!isWorker && this.tracedGlobal instanceof Ci.nsIDOMWindow) {
|
|
const { browserId } = this.tracedGlobal.browsingContext;
|
|
// Keep track of any future global
|
|
this.dbg.onNewGlobalObject = g => {
|
|
try {
|
|
const win = g.unsafeDereference();
|
|
// only process globals relating to documents, and which are within the debugged tab
|
|
if (
|
|
win instanceof Ci.nsIDOMWindow &&
|
|
win.browsingContext.browserId == browserId
|
|
) {
|
|
this.dbg.addDebuggee(g);
|
|
this.debuggerNotificationObserver.connect(win);
|
|
}
|
|
} catch (e) {}
|
|
};
|
|
// Register all, already existing children
|
|
for (const browsingContext of this.tracedGlobal.browsingContext.getAllBrowsingContextsInSubtree()) {
|
|
try {
|
|
// Only consider children which run in the same process, and exposes their window object
|
|
if (browsingContext.window) {
|
|
this.dbg.addDebuggee(browsingContext.window);
|
|
this.debuggerNotificationObserver.connect(browsingContext.window);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
this.currentDOMEvent = null;
|
|
}
|
|
|
|
stopTracingDOMEvents() {
|
|
if (this.debuggerNotificationObserver) {
|
|
this.debuggerNotificationObserver.removeListener(this.eventListener);
|
|
this.debuggerNotificationObserver.disconnect(this.tracedGlobal);
|
|
this.debuggerNotificationObserver = null;
|
|
}
|
|
this.currentDOMEvent = null;
|
|
}
|
|
|
|
startTracingDOMMutations() {
|
|
this.tracedGlobal.document.devToolsWatchingDOMMutations = true;
|
|
|
|
const eventOptions = {
|
|
signal: this.abortController.signal,
|
|
capture: true,
|
|
};
|
|
// 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;
|
|
if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) {
|
|
eventHandler.addEventListener(
|
|
"devtoolschildinserted",
|
|
this.#onDOMMutation,
|
|
eventOptions
|
|
);
|
|
}
|
|
if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) {
|
|
eventHandler.addEventListener(
|
|
"devtoolsattrmodified",
|
|
this.#onDOMMutation,
|
|
eventOptions
|
|
);
|
|
}
|
|
if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) {
|
|
eventHandler.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;
|
|
|
|
// The depth is the depth of the parent frame, consider the dom mutation as nested to it
|
|
const depth = this.depth + 1;
|
|
|
|
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,
|
|
prefix: this.prefix,
|
|
|
|
type,
|
|
element: event.target,
|
|
caller: Components.stack.caller,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldLogToStdout) {
|
|
const padding = "—".repeat(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.
|
|
*
|
|
* @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 when type is "domEvent",
|
|
// but also when some other DOM APIs are involved.
|
|
// notification's type will be "setTimeout" when the setTimeout method is called,
|
|
// or "setTimeoutCallback" when the callback passed to setTimeout is called.
|
|
// This also work against setInterval/clearTimeout/clearInterval and requestAnimationFrame.
|
|
if (notification.type == "domEvent") {
|
|
// `targetType` can help distinguish same-name DOM events fired against XHR, window or workers.
|
|
const { targetType } = notification;
|
|
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 = `${targetType}.${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.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;
|
|
|
|
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() {
|
|
if (this.traceAllGlobals) {
|
|
const dbg = new customLazy.DistinctCompartmentDebugger();
|
|
dbg.addAllGlobalsAsDebuggees();
|
|
|
|
// addAllGlobalAsAdebuggees will also add the global for this module...
|
|
// which we have to prevent tracing!
|
|
// eslint-disable-next-line mozilla/reject-globalThis-modification
|
|
dbg.removeDebuggee(globalThis);
|
|
|
|
// Add any future global being created later
|
|
dbg.onNewGlobalObject = g => dbg.addDebuggee(g);
|
|
return dbg;
|
|
}
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// 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;
|
|
}
|
|
|
|
// 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++;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
// Bail out early if any listener stopped tracing as the Frame object
|
|
// will be no longer usable by any other code.
|
|
if (!this.isTracing) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
if (this.traceSteps) {
|
|
// Collect the location notified via onTracingFrame to also avoid redundancy between similar location
|
|
// between onEnterFrame and onStep notifications.
|
|
let { lineNumber: lastLine, columnNumber: lastColumn } =
|
|
frame.script.getOffsetMetadata(frame.offset);
|
|
|
|
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, lineNumber, columnNumber } =
|
|
frame.script.getOffsetMetadata(frame.offset);
|
|
if (!isStepStart) {
|
|
return;
|
|
}
|
|
// onStep may be called on many instructions related to the same line and colunm.
|
|
// Avoid notifying duplicated steps if we stepped on the exact same location.
|
|
if (lastLine == lineNumber && lastColumn == columnNumber) {
|
|
return;
|
|
}
|
|
lastLine = lineNumber;
|
|
lastColumn = columnNumber;
|
|
|
|
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 => {
|
|
this.depth--;
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 + "DOM | " + 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 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
|
|
* @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 primitiveToString(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 String(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.
|
|
*
|
|
* - 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)) {
|
|
if (f.depth) {
|
|
depth = depth + f.depth + 1;
|
|
break;
|
|
}
|
|
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\\`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
});
|
|
}
|
|
|
|
export const JSTracer = {
|
|
startTracing,
|
|
stopTracing,
|
|
addTracingListener,
|
|
removeTracingListener,
|
|
NEXT_INTERACTION_MESSAGE,
|
|
DOM_MUTATIONS,
|
|
objectToString,
|
|
};
|