dump-scope.sys.mjs (9295B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /* eslint-disable no-console */ 6 7 import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs"; 8 9 // Exclude frames from the test harness. 10 const hiddenSourceURLs = [ 11 "chrome://mochikit/content/browser-test.js", 12 "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js", 13 ]; 14 15 const TYPED_ARRAY_CLASSES = [ 16 "Uint8Array", 17 "Uint8ClampedArray", 18 "Uint16Array", 19 "Uint32Array", 20 "Int8Array", 21 "Int16Array", 22 "Int32Array", 23 "Float32Array", 24 "Float64Array", 25 "BigInt64Array", 26 "BigUint64Array", 27 ]; 28 29 /** 30 * Copied from the similar helper at devtools/server/actors/object/utils.js 31 */ 32 function isArray(object) { 33 return TYPED_ARRAY_CLASSES.includes(object.class) || object.class === "Array"; 34 } 35 36 // Avoid serializing any object more than once by using IDs and refering to them 37 const USE_REFERENCES = false; 38 39 // Avoid serializing more than n-th nested object attributes 40 const MAX_DEPTH = 5; 41 42 // Limit in number of properties/items in a single object 43 const MAX_PROPERTIES = 100; 44 45 // Used by USE_REFERENCES=true to store the already logged objects 46 const objects = new Map(); 47 48 const PROMISE_REACTIONS = new WeakMap(); 49 const getAsyncParentFrame = frame => { 50 if (!frame.asyncPromise) { 51 return null; 52 } 53 54 // We support returning Frame actors for frames that are suspended 55 // at an 'await', and here we want to walk upward to look for the first 56 // frame that will be resumed when the current frame's promise resolves. 57 let reactions = 58 PROMISE_REACTIONS.get(frame.asyncPromise) || 59 frame.asyncPromise.getPromiseReactions(); 60 61 // eslint-disable-next-line no-constant-condition 62 while (true) { 63 // We loop here because we may have code like: 64 // 65 // async function inner(){ debugger; } 66 // 67 // async function outer() { 68 // await Promise.resolve().then(() => inner()); 69 // } 70 // 71 // where we can see that when `inner` resolves, we will resume from 72 // `outer`, even though there is a layer of promises between, and 73 // that layer could be any number of promises deep. 74 if (!(reactions[0] instanceof Debugger.Object)) { 75 break; 76 } 77 78 reactions = reactions[0].getPromiseReactions(); 79 } 80 81 if (reactions[0] instanceof Debugger.Frame) { 82 return reactions[0]; 83 } 84 return null; 85 }; 86 87 /** 88 * Serialize any arbitrary object to a JSON-serializable object 89 */ 90 function serialize(dbgObj, depth) { 91 // If the variable is initialized after calling dumpScope. 92 if (dbgObj?.uninitialized) { 93 return "(uninitialized)"; 94 } 95 96 // If for any reason SpiderMonkey could not preserve the arguments. 97 if (dbgObj?.missingArguments) { 98 return "(missing arguments)"; 99 } 100 101 // If the variable was optimized out by SpiderMonkey. 102 if (dbgObj?.optimizedOut) { 103 return "(optimized out)"; 104 } 105 106 if (dbgObj?.unsafeDereference) { 107 if (dbgObj.isClassConstructor) { 108 return "Class " + dbgObj.name; 109 } 110 return serializeObject(dbgObj, depth); 111 } 112 return serializePrimitive(dbgObj); 113 } 114 115 /** 116 * Serialize any JavaScript object (i.e. non primitives) to a JSON-serializable object 117 */ 118 function serializeObject(dbgObj, depth) { 119 depth++; 120 if (depth >= MAX_DEPTH) { 121 return dbgObj.class + " (max depth)"; 122 } 123 if (dbgObj.class == "Function") { 124 return "Function " + dbgObj.displayName; 125 } 126 127 let clone = isArray(dbgObj) ? [] : {}; 128 if (USE_REFERENCES) { 129 // Avoid dumping the same object twice by using references 130 clone = objects.get(dbgObj); 131 if (clone) { 132 return "(object #" + clone["object #"] + ")"; 133 } 134 135 clone["object #"] = objects.size; 136 objects.set(dbgObj, clone); 137 } 138 139 let i = 0; 140 for (const propertyName of dbgObj.getOwnPropertyNames()) { 141 const descriptor = dbgObj.getOwnPropertyDescriptor(propertyName); 142 if (!descriptor) { 143 continue; 144 } 145 if (i >= MAX_PROPERTIES) { 146 clone[propertyName] = "(max properties/items count)"; 147 break; 148 } 149 if (descriptor.getter) { 150 clone[propertyName] = "(getter)"; 151 } else { 152 clone[propertyName] = serialize(descriptor.value, depth); 153 } 154 155 i++; 156 } 157 return clone; 158 } 159 160 /** 161 * Serialize any JavaScript primitive value to a JSON-serializable object 162 */ 163 function serializePrimitive(value) { 164 const type = typeof value; 165 if (type === "string") { 166 return value; 167 } else if (type === "bigint") { 168 return `BigInt(${value})`; 169 } else if (value && typeof value.toString === "function") { 170 // Use toString as it allows to stringify Symbols. Converting them to string throws. 171 return value.toString(); 172 } 173 174 try { 175 // Ensure that the value is really stringifiable 176 JSON.stringify(value); 177 return value; 178 } catch (e) {} 179 180 try { 181 // Otherwise we try to stringify it 182 return String(value); 183 } catch (e) {} 184 return "(unserializable: " + type + ")"; 185 } 186 187 async function saveAsJsonFile(obj) { 188 const jsonString = JSON.stringify(obj, null, 2); 189 const encoder = new TextEncoder(); 190 const jsonBytes = encoder.encode(jsonString); 191 if (!Array.isArray(obj) || !obj.length) { 192 return; 193 } 194 195 // Build a fileName from the last recorded frame information. 196 // It should usually match with the actual test file from which the failure 197 // was recorded. 198 const { columnNumber, frameScriptUrl, lineNumber } = obj.at(-1).details; 199 const fileName = [ 200 frameScriptUrl.substr(frameScriptUrl.lastIndexOf("/") + 1), 201 lineNumber, 202 columnNumber, 203 ].join("_"); 204 205 // Add the current timestamp in the filename, it should be impossible to have 206 // two failures for the same frame at the same timestamp. 207 const hash = Date.now(); 208 209 // Save the JSON file either under MOZ_UPLOAD_DIR or under the profile root. 210 const filePath = PathUtils.join( 211 Services.env.get("MOZ_UPLOAD_DIR") || PathUtils.profileDir, 212 `scope-variables-${hash}-${fileName}.json` 213 ); 214 215 dump(`[dump-scope] Saving scope variables as a JSON file: ${filePath}\n`); 216 217 // Write to file 218 await IOUtils.write(filePath, jsonBytes, { compress: false }); 219 } 220 221 function serializeFrame(frame) { 222 const frameScriptUrl = frame.script.url; 223 const { lineNumber, columnNumber } = frame.script.getOffsetMetadata( 224 frame.offset 225 ); 226 const frameLocation = `${frameScriptUrl} @ ${lineNumber}:${columnNumber}`; 227 dump(`[dump-scope] Serializing variables for frame: ${frameLocation}\n`); 228 229 if (hiddenSourceURLs.includes(frameScriptUrl)) { 230 return null; 231 } 232 233 const blocks = []; 234 const obj = { 235 frame: frameLocation, 236 // Details will be used to build the filename for the JSON file. 237 details: { 238 columnNumber, 239 frameScriptUrl, 240 lineNumber, 241 }, 242 blocks, 243 }; 244 245 let env = frame.environment; 246 while (env && env.type == "declarative" && env.scopeKind != null) { 247 const scope = {}; 248 const names = env.names(); 249 // Serialize each variable found in the current frame. 250 for (const name of names) { 251 scope[name] = serialize(env.getVariable(name), 0); 252 } 253 blocks.push(scope); 254 env = env.parent; 255 } 256 return obj; 257 } 258 259 /** 260 * @typedef JSONFrameDetails 261 * @property {number} columnNumber 262 * The column number of the exported frame. 263 * @property {string} frameScriptUrl 264 * The URL of the script from which the frame was exported. 265 * @property {number} lineNumber 266 * The line number of the exported frame. 267 */ 268 269 /** 270 * @typedef JSONFrame 271 * @property {string} frame 272 * The frame location represented as a string built from the original script 273 * url, the line number and the column number 274 * @property {JSONFrameDetails} details 275 * Same information as in frame, but as an object. 276 * @property {object} scope 277 */ 278 279 /** 280 * The dumpScope helper will attempt to export variables in the current frame 281 * and all its ancestor frames to a JSON file that can be stored and inspected 282 * later. 283 * 284 * This is typically intended to be used from tests in continious integration. 285 * By default the helper will save all variables in a JSON file stored under 286 * MOZ_UPLOAD_DIR if the environment variable is defined, and otherwise saved 287 * under the profile root folder. 288 * 289 * The export will be a best effort snapshot of the variables. The structure 290 * of the json file will be an Array of JSONFrame. 291 * 292 * @param {object} options 293 * @param {boolean=} saveAsFile 294 * Set to true to save as a JSON file. Set to false to simply return the 295 * object that would have been stringified to a JSON file. 296 */ 297 export const dumpScope = async function ({ saveAsFile = true } = {}) { 298 // This will inject `Debugger` in the global scope 299 // eslint-disable-next-line mozilla/reject-globalThis-modification 300 addDebuggerToGlobal(globalThis); 301 302 const dbg = new Debugger(); 303 dbg.addAllGlobalsAsDebuggees(); 304 305 const scopes = []; 306 let frame = dbg.getNewestFrame(); 307 while (frame) { 308 try { 309 const scope = serializeFrame(frame); 310 if (scope) { 311 scopes.push(scope); 312 } 313 } catch (e) { 314 dump("Exception while serializing frame : " + e + "\n"); 315 } 316 frame = frame.older || frame.asyncOlder || getAsyncParentFrame(frame); 317 } 318 objects.clear(); 319 320 if (saveAsFile) { 321 return saveAsJsonFile(scopes); 322 } 323 324 return scopes; 325 };