325 lines
9.1 KiB
JavaScript
325 lines
9.1 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/. */
|
|
|
|
/* 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;
|
|
};
|