/* 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/. */ /* eslint-disable no-console */ import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs"; // Exclude frames from the test harness. const hiddenSourceURLs = [ "chrome://mochikit/content/browser-test.js", "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js", ]; const TYPED_ARRAY_CLASSES = [ "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", "Int8Array", "Int16Array", "Int32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array", ]; /** * Copied from the similar helper at devtools/server/actors/object/utils.js */ function isArray(object) { return TYPED_ARRAY_CLASSES.includes(object.class) || object.class === "Array"; } // Avoid serializing any object more than once by using IDs and refering to them const USE_REFERENCES = false; // Avoid serializing more than n-th nested object attributes const MAX_DEPTH = 5; // Limit in number of properties/items in a single object const MAX_PROPERTIES = 100; // Used by USE_REFERENCES=true to store the already logged objects const objects = new Map(); const PROMISE_REACTIONS = new WeakMap(); const getAsyncParentFrame = frame => { if (!frame.asyncPromise) { return null; } // We support returning Frame actors for frames that are suspended // at an 'await', and here we want to walk upward to look for the first // frame that will be resumed when the current frame's promise resolves. let reactions = PROMISE_REACTIONS.get(frame.asyncPromise) || frame.asyncPromise.getPromiseReactions(); // eslint-disable-next-line no-constant-condition while (true) { // We loop here because we may have code like: // // async function inner(){ debugger; } // // async function outer() { // await Promise.resolve().then(() => inner()); // } // // where we can see that when `inner` resolves, we will resume from // `outer`, even though there is a layer of promises between, and // that layer could be any number of promises deep. if (!(reactions[0] instanceof Debugger.Object)) { break; } reactions = reactions[0].getPromiseReactions(); } if (reactions[0] instanceof Debugger.Frame) { return reactions[0]; } return null; }; /** * Serialize any arbitrary object to a JSON-serializable object */ function serialize(dbgObj, depth) { // If the variable is initialized after calling dumpScope. if (dbgObj?.uninitialized) { return "(uninitialized)"; } // If for any reason SpiderMonkey could not preserve the arguments. if (dbgObj?.missingArguments) { return "(missing arguments)"; } // If the variable was optimized out by SpiderMonkey. if (dbgObj?.optimizedOut) { return "(optimized out)"; } if (dbgObj?.unsafeDereference) { if (dbgObj.isClassConstructor) { return "Class " + dbgObj.name; } return serializeObject(dbgObj, depth); } return serializePrimitive(dbgObj); } /** * Serialize any JavaScript object (i.e. non primitives) to a JSON-serializable object */ function serializeObject(dbgObj, depth) { depth++; if (depth >= MAX_DEPTH) { return dbgObj.class + " (max depth)"; } if (dbgObj.class == "Function") { return "Function " + dbgObj.displayName; } let clone = isArray(dbgObj) ? [] : {}; if (USE_REFERENCES) { // Avoid dumping the same object twice by using references clone = objects.get(dbgObj); if (clone) { return "(object #" + clone["object #"] + ")"; } clone["object #"] = objects.size; objects.set(dbgObj, clone); } let i = 0; for (const propertyName of dbgObj.getOwnPropertyNames()) { const descriptor = dbgObj.getOwnPropertyDescriptor(propertyName); if (!descriptor) { continue; } if (i >= MAX_PROPERTIES) { clone[propertyName] = "(max properties/items count)"; break; } if (descriptor.getter) { clone[propertyName] = "(getter)"; } else { clone[propertyName] = serialize(descriptor.value, depth); } i++; } return clone; } /** * Serialize any JavaScript primitive value to a JSON-serializable object */ function serializePrimitive(value) { const type = typeof value; if (type === "string") { return value; } 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(); } try { // Ensure that the value is really stringifiable JSON.stringify(value); return value; } catch (e) {} try { // Otherwise we try to stringify it return String(value); } catch (e) {} return "(unserializable: " + type + ")"; } async function saveAsJsonFile(obj) { const jsonString = JSON.stringify(obj, null, 2); const encoder = new TextEncoder(); const jsonBytes = encoder.encode(jsonString); if (!Array.isArray(obj) || !obj.length) { return; } // Build a fileName from the last recorded frame information. // It should usually match with the actual test file from which the failure // was recorded. const { columnNumber, frameScriptUrl, lineNumber } = obj.at(-1).details; const fileName = [ frameScriptUrl.substr(frameScriptUrl.lastIndexOf("/") + 1), lineNumber, columnNumber, ].join("_"); // Add the current timestamp in the filename, it should be impossible to have // two failures for the same frame at the same timestamp. const hash = Date.now(); // Save the JSON file either under MOZ_UPLOAD_DIR or under the profile root. const filePath = PathUtils.join( Services.env.get("MOZ_UPLOAD_DIR") || PathUtils.profileDir, `scope-variables-${hash}-${fileName}.json` ); dump(`[dump-scope] Saving scope variables as a JSON file: ${filePath}\n`); // Write to file await IOUtils.write(filePath, jsonBytes, { compress: false }); } function serializeFrame(frame) { const frameScriptUrl = frame.script.url; const { lineNumber, columnNumber } = frame.script.getOffsetMetadata( frame.offset ); const frameLocation = `${frameScriptUrl} @ ${lineNumber}:${columnNumber}`; dump(`[dump-scope] Serializing variables for frame: ${frameLocation}\n`); if (hiddenSourceURLs.includes(frameScriptUrl)) { return null; } const blocks = []; const obj = { frame: frameLocation, // Details will be used to build the filename for the JSON file. details: { columnNumber, frameScriptUrl, lineNumber, }, blocks, }; let env = frame.environment; while (env && env.type == "declarative" && env.scopeKind != null) { const scope = {}; const names = env.names(); // Serialize each variable found in the current frame. for (const name of names) { scope[name] = serialize(env.getVariable(name), 0); } blocks.push(scope); env = env.parent; } return obj; } /** * @typedef JSONFrameDetails * @property {number} columnNumber * The column number of the exported frame. * @property {string} frameScriptUrl * The URL of the script from which the frame was exported. * @property {number} lineNumber * The line number of the exported frame. */ /** * @typedef JSONFrame * @property {String} frame * The frame location represented as a string built from the original script * url, the line number and the column number * @property {JSONFrameDetails} details * Same information as in frame, but as an object. * @property {Object} scope */ /** * The dumpScope helper will attempt to export variables in the current frame * and all its ancestor frames to a JSON file that can be stored and inspected * later. * * This is typically intended to be used from tests in continious integration. * By default the helper will save all variables in a JSON file stored under * MOZ_UPLOAD_DIR if the environment variable is defined, and otherwise saved * under the profile root folder. * * The export will be a best effort snapshot of the variables. The structure * of the json file will be an Array of JSONFrame. * * @param {object} options * @param {boolean=} saveAsFile * Set to true to save as a JSON file. Set to false to simply return the * object that would have been stringified to a JSON file. */ export const dumpScope = async function ({ saveAsFile = true } = {}) { // This will inject `Debugger` in the global scope // eslint-disable-next-line mozilla/reject-globalThis-modification addDebuggerToGlobal(globalThis); const dbg = new Debugger(); dbg.addAllGlobalsAsDebuggees(); const scopes = []; let frame = dbg.getNewestFrame(); while (frame) { try { const scope = serializeFrame(frame); if (scope) { scopes.push(scope); } } catch (e) { dump("Exception while serializing frame : " + e + "\n"); } frame = frame.older || frame.asyncOlder || getAsyncParentFrame(frame); } objects.clear(); if (saveAsFile) { return saveAsJsonFile(scopes); } return scopes; };