/* 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 . */ const { TRACER_FIELDS_INDEXES, } = require("resource://devtools/server/actors/tracer.js"); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs", }); export const NO_SEARCH_VALUE = Symbol("no-search-value"); function initialState(previousState = { searchValueOrGrip: NO_SEARCH_VALUE }) { return { // These fields are mutable as they are large arrays and UI will rerender based on their size // The three next array are always of the same size. // List of all trace resources, as defined by the server codebase (See the TracerActor) mutableTraces: [], // Array of arrays. This is of the same size as mutableTraces. // Store the indexes within mutableTraces of each children matching the same index in mutableTraces. mutableChildren: [], // Indexes of parents within mutableTraces. mutableParents: [], // Frames are also a trace resources, but they are stored in a dedicated array. mutableFrames: [], // List of indexes within mutableTraces of top level trace, without any parent. mutableTopTraces: [], // Similar to mutableTopTraces except that filter out unwanted DOM Events. mutableFilteredTopTraces: [], // List of all trace resources indexes within mutableTraces which are about dom mutations mutableMutationTraces: [], // List of all traces matching the current search string // (this isn't only top traces) mutableMatchingTraces: [], // If the user started searching for some value, it may be an invalid expression // and the error related to this will be stored as a string in this attribute. searchExceptionMessage: null, // If a valid search has been requested, the actual value for this search is stored in this attribute. // It can be either a primitive data type, or an object actor form (aka grip) searchValueOrGrip: previousState.searchValueOrGrip, // List of all event names which triggered some JavaScript code in the current tracer record. mutableEventNames: new Set(), // List of all possible DOM Events (similar to DOM Event panel) // This is initialized once on debugger startup. // This is a Map of category objects consumed by EventListeners React component, // keyed by DOM Event name (string) communicated by the Tracer. // DOM Event name can look like this: // - global.click (click fired on window object) // - node.mousemove (mousemove fired on a DOM Element) // - xhr.error (error on an XMLHttpRequest object) // - worker.error (error from a worker) // - setTimeout (setTimeout function being called) // - setTimeoutCallback (setTimeout callback being fired) domEventInfoByTracerName: previousState.domEventInfoByTracerName || new Map(), // List of DOM Events "categories" currently available in the current traces // Categories are objects consumed by the EventListener React component. domEventCategories: [], // List of DOM Events which should be show and be in `mutableFilteredTopTraces` activeDomEvents: [], // List of DOM Events names to be highlighted in the left timeline highlightedDomEvents: [], // Index of the currently selected trace within `mutableTraces`. selectedTraceIndex: null, // Updated alongside `selectedTraceIndex`, refer to the location of the selected trace. selectedTraceLocation: null, // Object like the one generated by `generateInlinePreview`, but for the currently selected trace previews: null, // Runtime versions to help show warning when there is a mismatch between frontend and backend versions localPlatformVersion: null, remotePlatformVersion: null, // Is it currently recording trace *and* is collecting values traceValues: false, }; } // eslint-disable-next-line complexity function update(state = initialState(), action) { switch (action.type) { case "SET_TRACE_SEARCH_EXCEPTION": { return { ...state, searchExceptionMessage: action.errorMessage, searchValueOrGrip: NO_SEARCH_VALUE, mutableMatchingTraces: [], }; } case "SET_TRACE_SEARCH_STRING": { const { searchValueOrGrip } = action; if (searchValueOrGrip === NO_SEARCH_VALUE) { return { ...state, searchValueOrGrip, searchExceptionMessage: null, mutableMatchingTraces: [], }; } const mutableMatchingTraces = []; for (const trace of state.mutableTraces) { const type = trace[TRACER_FIELDS_INDEXES.TYPE]; if (type != "enter") { continue; } if (isTraceMatchingSearch(trace, searchValueOrGrip)) { mutableMatchingTraces.push(trace); } } return { ...state, searchValueOrGrip, mutableMatchingTraces, searchExceptionMessage: null, }; } case "TRACING_TOGGLED": { if (action.enabled) { state = initialState(state); if (action.traceValues) { state.traceValues = true; } else { state.searchValueOrGrip = NO_SEARCH_VALUE; } return state; } return state; } case "TRACING_CLEAR": { return initialState(state); } case "ADD_TRACES": { addTraces(state, action.traces); return { ...state }; } case "SELECT_TRACE": { const { traceIndex, location } = action; if ( traceIndex < 0 || traceIndex >= state.mutableTraces.length || traceIndex == state.selectedTraceIndex ) { return state; } const trace = state.mutableTraces[traceIndex]; return { ...state, selectedTraceIndex: traceIndex, selectedTraceLocation: location, // Also compute the inline preview data when we select a trace // and we have the values recording enabled. previews: generatePreviewsForTrace(state, trace), }; } case "SELECT_FRAME": case "PAUSED": { if (!state.previews && state.selectedTraceIndex == null) { return state; } // Reset the selected trace and previews when we pause/step/select a frame in the scope panel, // so that it is no longer highlighted, nor do we show inline variables. return { ...state, selectedTraceIndex: null, selectedTraceLocation: null, previews: null, }; } case "SET_SELECTED_LOCATION": { // Traces are reference to the generated location only, so ignore any original source being selected // and wait for SET_GENERATED_SELECTED_LOCATION instead. if (action.location.source.isOriginal) { return state; } // Ignore if the currently selected trace matches the new location. if ( state.selectedTrace && locationMatchTrace(action.location, state.selectedTrace) ) { return state; } // Lookup for a trace matching the newly selected location for (const trace of state.mutableTraces) { if (locationMatchTrace(action.location, trace)) { return { ...state, selectedTrace: trace, }; } } return { ...state, selectedTrace: null, }; } case "SET_GENERATED_SELECTED_LOCATION": { // When selecting an original location, we have to wait for the newly selected original location // to be mapped to a generated location so that we can find a matching trace. // Ignore if the currently selected trace matches the new location. if ( state.selectedTrace && locationMatchTrace(action.generatedLocation, state.selectedTrace) ) { return state; } // Lookup for a trace matching the newly selected location for (const trace of state.mutableTraces) { if (locationMatchTrace(action.generatedLocation, trace)) { return { ...state, selectedTrace: trace, }; } } return { ...state, selectedTrace: null, }; } case "CLEAR_SELECTED_LOCATION": { return { ...state, selectedTrace: null, }; } case "SET_RUNTIME_VERSIONS": { return { ...state, localPlatformVersion: action.localPlatformVersion, remotePlatformVersion: action.remotePlatformVersion, }; } case "RECEIVE_EVENT_LISTENER_TYPES": { const domEventInfoByTracerName = new Map(); for (const category of action.categories) { for (const event of category.events) { const value = { id: event.id, category, name: event.name }; if (event.type == "event") { for (const targetType of event.targetTypes) { domEventInfoByTracerName.set( `${targetType}.${event.eventType}`, value ); } } else { domEventInfoByTracerName.set(event.notificationType, value); } } } return { ...state, domEventInfoByTracerName }; } case "UPDATE_EVENT_LISTENERS": { // This action is also used for the DOM Event breakpoints panel if (action.panelKey != "tracer") { return state; } const { mutableTraces, mutableTopTraces } = state; // If all the DOM events are shown, return the unfiltered list as-is. if (action.active.length == state.mutableEventNames.size) { return { ...state, mutableFilteredTopTraces: mutableTopTraces, activeDomEvents: action.active, }; } // Update `mutableFilteredTopTraces` by re-filtering all top traces from `mutableTopTraces` // and considering the new list of DOM event names const mutableFilteredTopTraces = []; for (const traceIndex of mutableTopTraces) { const trace = mutableTraces[traceIndex]; const type = trace[TRACER_FIELDS_INDEXES.TYPE]; if (type == "event") { const eventName = trace[TRACER_FIELDS_INDEXES.EVENT_NAME]; // Map JS Tracer event name into an Event Breakpoint's ID, as `action.active` is an array of such IDs. // (from "node.click" to "event.mouse.click") const id = state.domEventInfoByTracerName.get(eventName)?.id || `event.unclassified.${eventName}`; if (action.active.includes(id)) { mutableFilteredTopTraces.push(traceIndex); } } } return { ...state, mutableFilteredTopTraces, activeDomEvents: action.active, }; } case "HIGHLIGHT_EVENT_LISTENERS": { // This action is also used for the DOM Event breakpoints panel if (action.panelKey != "tracer") { return state; } // Map ids (event.mouse.click) to event names (node.click) const eventNames = []; for (const [ eventName, { id }, ] of state.domEventInfoByTracerName.entries()) { if (action.eventIds.includes(id)) { eventNames.push(eventName); } } return { ...state, highlightedDomEvents: eventNames, }; } case "SET_SELECTED_LOCACTION_TRACES": { return { ...state, selectedLocationTraces: action.selectedLocationTraces, }; } } return state; } function addTraces(state, traces) { const { mutableTraces, mutableMutationTraces, mutableFrames, mutableTopTraces, mutableFilteredTopTraces, mutableChildren, mutableParents, mutableMatchingTraces, searchValueOrGrip, } = state; function matchParent(traceIndex, depth) { // The very last element is the one matching traceIndex, // so pick the one added just before. // We consider that traces are reported by the server in the execution order. let idx = mutableTraces.length - 2; while (idx != null) { const trace = mutableTraces[idx]; if (!trace) { break; } const currentDepth = trace[TRACER_FIELDS_INDEXES.DEPTH]; if (currentDepth < depth) { mutableChildren[idx].push(traceIndex); mutableParents.push(idx); return; } idx = mutableParents[idx]; } // If no parent was found, flag it as top level trace mutableTopTraces.push(traceIndex); mutableFilteredTopTraces.push(traceIndex); mutableParents.push(null); } for (const traceResource of traces) { // For now, only consider traces from the top level target/thread if (!traceResource.targetFront.isTopLevel) { continue; } const type = traceResource[TRACER_FIELDS_INDEXES.TYPE]; switch (type) { case "frame": { // Store the object used by SmartTraces mutableFrames.push({ functionDisplayName: traceResource[TRACER_FIELDS_INDEXES.FRAME_NAME], source: traceResource[TRACER_FIELDS_INDEXES.FRAME_URL], sourceId: traceResource[TRACER_FIELDS_INDEXES.FRAME_SOURCEID], line: traceResource[TRACER_FIELDS_INDEXES.FRAME_LINE], column: traceResource[TRACER_FIELDS_INDEXES.FRAME_COLUMN], }); break; } case "enter": { const traceIndex = mutableTraces.length; mutableTraces.push(traceResource); mutableChildren.push([]); const depth = traceResource[TRACER_FIELDS_INDEXES.DEPTH]; matchParent(traceIndex, depth); if ( searchValueOrGrip != NO_SEARCH_VALUE && isTraceMatchingSearch(traceResource, searchValueOrGrip) ) { mutableMatchingTraces.push(traceResource); } break; } case "exit": { // The sidebar doesn't use this information yet break; } case "dom-mutation": { const traceIndex = mutableTraces.length; mutableTraces.push(traceResource); mutableChildren.push([]); mutableMutationTraces.push(traceIndex); const depth = traceResource[TRACER_FIELDS_INDEXES.DEPTH]; matchParent(traceIndex, depth); break; } case "event": { const traceIndex = mutableTraces.length; mutableTraces.push(traceResource); mutableChildren.push([]); mutableParents.push(null); mutableTopTraces.push(traceIndex); const eventName = traceResource[TRACER_FIELDS_INDEXES.EVENT_NAME]; registerDOMEvent(state, eventName); // Map JS Tracer event name into an Event Breakpoint's ID, as `action.active` is an array of such IDs. // (from "node.click" to "event.mouse.click") const id = state.domEventInfoByTracerName.get(eventName)?.id || `event.unclassified.${eventName}`; // Only register in the filtered list, if this event type isn't filtered out. // (do this after `registerDOMEvent`, as that will populate `activeDomEvents` array. if (state.activeDomEvents.includes(id)) { mutableFilteredTopTraces.push(traceIndex); } break; } } } } // EventListener's category for all events that are not breakable, and not returned by the thread actor, and not in `domEventInfoByTracerName`. const UNCLASSIFIED_CATEGORY = { id: "unclassified", name: "Unclassified" }; /** * Register this possibly new event type in data set used to display EventListener React component. * * @param {object} state * @param {string} eventName */ function registerDOMEvent(state, eventName) { if (state.mutableEventNames.has(eventName)) { return; } state.mutableEventNames.add(eventName); // `domEventInfoByTracerName` is defined by the server and only register the events // for which we can set breakpoints for. // Fallback to a "unclassified" category for all these missing event types. const { category, id, name } = state.domEventInfoByTracerName.get( eventName ) || { category: UNCLASSIFIED_CATEGORY, id: `event.unclassified.${eventName}`, name: eventName, }; // By default, when we get a new event type, it is made visible if (!state.activeDomEvents.includes(id)) { state.activeDomEvents.push(id); } let newCategory = state.domEventCategories.find( cat => cat.name == category.name ); if (!newCategory) { // Create a new category with an empty event list newCategory = { id: category.id, name: category.name, events: [] }; state.domEventCategories = [...state.domEventCategories]; addSortedCategoryOrEvent(state.domEventCategories, newCategory); } if (!newCategory.events.some(e => e.name == name)) { // Register this new event in the category's event list addSortedCategoryOrEvent(newCategory.events, { id, name }); // Clone the root object to force a re-render of EventListeners React component // Cloning newCategory(.events) wouldn't be enough as that's not returned by a mapStateToProps. state.domEventCategories = [...state.domEventCategories]; } } function addSortedCategoryOrEvent(array, newElement) { const index = lazy.BinarySearch.insertionIndexOf( function (a, b) { // Both category and event are using `name` as display label return a.name.localeCompare(b.name); }, array, newElement ); array.splice(index, 0, newElement); } function locationMatchTrace(location, trace) { return ( trace.sourceId == location.sourceActor.id && trace.lineNumber == location.line && trace.columnNumber == location.column ); } /** * Reports if a given trace matches the current searched argument value. * * @param {object} trace * The trace object communicated by the backend. * @param {any primitive|ObjectActor's form} searchValueOrGrip * Either a primitive value (string, number, boolean, …) to match directly, * or, an object actor form where we could match the actor ID. */ function isTraceMatchingSearch(trace, searchValueOrGrip) { const argumentValues = trace[TRACER_FIELDS_INDEXES.ENTER_ARGS]; if (!argumentValues) { return false; } if (searchValueOrGrip) { const { actor } = searchValueOrGrip; if (actor) { return argumentValues.some(v => v.actor === searchValueOrGrip.actor); } } // `null` and `undefined` aren't serialized as-is and have a special grip object if (searchValueOrGrip === null) { return argumentValues.some(v => v?.type == "null"); } else if (searchValueOrGrip === undefined) { return argumentValues.some(v => v?.type == "undefined"); } return argumentValues.some(v => v === searchValueOrGrip); } /** * Generate the previews object consumed by InlinePreviews React component. * * @param {object} state * @param {object} trace * Trace reducer object. * @return {object} * Previews consumed by InlinePreviews. */ function generatePreviewsForTrace(state, trace) { let previews = state.previews; const argumentValues = trace[TRACER_FIELDS_INDEXES.ENTER_ARGS]; const argumentNames = trace[TRACER_FIELDS_INDEXES.ENTER_ARG_NAMES]; if (argumentNames && argumentValues) { const frameIndex = trace[TRACER_FIELDS_INDEXES.FRAME_INDEX]; const frame = state.mutableFrames[frameIndex]; // CM6 are 1-based const line = frame.line; const column = frame.column; const preview = []; for (let i = 0; i < argumentNames.length; i++) { const name = argumentNames[i]; // Values are either primitives, or an Object Front const objectGrip = argumentValues[i]?.getGrip ? argumentValues[i]?.getGrip() : argumentValues[i]; preview.push({ // All the argument will be show at the exact same spot. // Ideally it would be nice to show them next to each argument, // but the tracer currently expose the location of the first instruction // in the function body. line, column, // This attribute helps distinguish pause from trace previews type: "trace", name, value: objectGrip, }); } // This is the shape of data expected by InlinePreviews component previews = { [line]: preview, }; } return previews; } export default update;