JSObjectsTestUtils.sys.mjs (10563B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ 3 */ 4 5 import { ObjectUtils } from "resource://gre/modules/ObjectUtils.sys.mjs"; 6 import { TEST_PAGE_HTML, CONTEXTS, AllObjects } from "resource://testing-common/AllJavascriptTypes.mjs"; 7 import { AddonTestUtils } from "resource://testing-common/AddonTestUtils.sys.mjs" 8 9 // Name of the environment variable to set while running the test to update the expected values 10 const UPDATE_SNAPSHOT_ENV = "UPDATE_SNAPSHOT"; 11 12 export { CONTEXTS } from "resource://testing-common/AllJavascriptTypes.mjs"; 13 14 // To avoid totally unrelated exceptions about missing appinfo when running from xpcshell tests 15 const isXpcshell = Services.env.exists("XPCSHELL_TEST_PROFILE_DIR"); 16 if (isXpcshell) { 17 AddonTestUtils.createAppInfo( 18 "xpcshell@tests.mozilla.org", 19 "XPCShell", 20 "42", 21 "42" 22 ); 23 } 24 25 let gTestScope; 26 27 /** 28 * Initialize the test helper. 29 * 30 * @param {object} testScope 31 * XPCShell or mochitest test scope (i.e. the global object of the test currently executed) 32 */ 33 function init(testScope) { 34 if (!testScope?.gTestPath && !testScope?.Assert) { 35 throw new Error("`JSObjectsTestUtils.init()` should be called with the (xpcshell or mochitest) test global object"); 36 } 37 gTestScope = testScope; 38 39 if ("gTestPath" in testScope) { 40 AddonTestUtils.initMochitest(testScope); 41 } else { 42 AddonTestUtils.init(testScope); 43 } 44 45 const server = AddonTestUtils.createHttpServer({ 46 hosts: ["example.com"], 47 }); 48 49 server.registerPathHandler("/", (request, response) => { 50 response.setHeader("Content-Type", "text/html"); 51 response.write(TEST_PAGE_HTML); 52 }); 53 54 // Lookup for all preferences to toggle in order to have all the expected objects type functional 55 const prefValues = new Map(); 56 for (const { prefs } of AllObjects) { 57 if (!prefs) { 58 continue; 59 } 60 for (const elt of prefs) { 61 if (elt.length != 2) { 62 throw new Error("Each pref should be an array of two element [prefName, prefValue]. Got: "+elt); 63 } 64 const [ name, value ] = elt; 65 const otherValue = prefValues.get(name); 66 if (otherValue && otherValue != value) { 67 throw new Error(`Two javascript values in AllJavascriptTypes.mjs are expecting different values for '${name}' preference. (${otherValue} vs ${value})`); 68 } 69 prefValues.set(name, value); 70 if (typeof(value) == "boolean") { 71 Services.prefs.setBoolPref(name, value); 72 gTestScope.registerCleanupFunction(() => { 73 Services.prefs.clearUserPref(name); 74 }); 75 } else { 76 throw new Error("Unsupported pref type: "+name+" = "+value); 77 } 78 } 79 } 80 } 81 82 let gExpectedValuesFilePath; 83 let gCurrentTestFolderUrl; 84 85 const chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( 86 Ci.nsIChromeRegistry 87 ); 88 function loadExpectedValues(expectedValuesFileName) { 89 const isUpdate = Services.env.get(UPDATE_SNAPSHOT_ENV) == "true"; 90 dump(`JS Objects test: ${isUpdate ? "Update" : "Check"} ${expectedValuesFileName}\n`); 91 92 // Depending on the test suite, mochitest will expose `gTextPath` which is a chrome:// 93 // for the current test file. 94 // Otherwise xpcshell will expose `resource://test/` for the current test folder. 95 gCurrentTestFolderUrl = "gTestPath" in gTestScope 96 ? gTestScope.gTestPath.substr(0, gTestScope.gTestPath.lastIndexOf("/")) + "/" 97 : "resource://test/"; 98 99 // Build the URL for the test data file 100 const url = gCurrentTestFolderUrl + expectedValuesFileName; 101 102 // Resolve the test data file URL into a file absolute path 103 if (url.startsWith("chrome")) { 104 const chromeURL = Services.io.newURI(url); 105 gExpectedValuesFilePath = chromeRegistry 106 .convertChromeURL(chromeURL) 107 .QueryInterface(Ci.nsIFileURL).file.path; 108 } else if (url.startsWith("resource")) { 109 const resURL = Services.io.newURI(url); 110 const resHandler = Services.io.getProtocolHandler("resource") 111 .QueryInterface(Ci.nsIResProtocolHandler); 112 gExpectedValuesFilePath = Services.io.newURI(resHandler.resolveURI(resURL)).QueryInterface(Ci.nsIFileURL).file.path; 113 } 114 115 if (!isUpdate) { 116 dump(`Loading test data file: ${url}\n`); 117 return ChromeUtils.importESModule(url).default; 118 } 119 120 return null; 121 } 122 123 async function mayBeSaveExpectedValues(actualValues) { 124 const isUpdate = Services.env.get(UPDATE_SNAPSHOT_ENV) == "true"; 125 if (!isUpdate) { 126 return; 127 } 128 if (!actualValues?.length) { 129 return; 130 } 131 132 const filePath = gExpectedValuesFilePath; 133 const assertionValues = []; 134 let i = 0; 135 136 for (const objectDescription of AllObjects) { 137 if (objectDescription.disabled) { 138 continue; 139 } 140 141 if (i >= actualValues.length) { 142 throw new Error("Unexpected discrepencies between the reported evaled strings and expected values"); 143 } 144 145 const value = actualValues[i++] 146 147 // Ignore this JS object as the test function did not return any actual value. 148 // We assume none of the tests would store "undefined" as a target value. 149 if (value == undefined) { 150 continue; 151 } 152 153 let evaled = objectDescription.expression; 154 // Remove any first empty line 155 evaled = evaled.replace(/^\s*\n/, ""); 156 // remove the unnecessary indentation 157 const m = evaled.match(/^( +)/); 158 if (m && m[1]) { 159 const regexp = new RegExp("^"+m[1], "gm"); 160 evaled = evaled.replace(regexp, ""); 161 } 162 // Ensure prefixing all new lines in the evaled string with " //" 163 // to keep it being in a code comment. 164 evaled = evaled.replace(/\r?\n/g, "\n // "); 165 166 assertionValues.push( 167 " // " + evaled + 168 "\n" + 169 // Ident each newline to match the array item indentation 170 JSON.stringify(value, null, 2).replace(/^/gm, " ") + 171 "," 172 ); 173 } 174 175 if (i != actualValues.length) { 176 throw new Error("Unexpected discrepencies between the reported evaled strings and expected values"); 177 } 178 179 const fileContent = `/* Any copyright is dedicated to the Public Domain. 180 http://creativecommons.org/publicdomain/zero/1.0/ */ 181 182 /* 183 * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. 184 * 185 * More info in https://firefox-source-docs.mozilla.org/devtools/tests/js-object-tests.html 186 */ 187 188 export default [ 189 ${assertionValues.join("\n\n")} 190 ];`; 191 dump("Writing: " + fileContent + " in " + filePath + "\n"); 192 await IOUtils.write(filePath, new TextEncoder().encode(fileContent)); 193 } 194 195 async function testOrUpdateExpectedValues(expectedValuesFileName, actualValues) { 196 let expectedValues = loadExpectedValues(expectedValuesFileName); 197 if (expectedValues) { 198 // Clone the Array as we are going to mutate it via Array.shift(). 199 expectedValues = [...expectedValues]; 200 201 const testPath = "gtestPath" in gTestScope ? gTestScope.gTestPath.replace("chrome://mochitest/content/browser/", "") : "path/to/your/xpcshell/test"; 202 const failureMessage = `This is a JavaScript value processing test, which includes an automatically generated snapshot file (${expectedValuesFileName}).\n` + 203 "You may update this file by running:`\n" + 204 ` $ mach test ${testPath} --headless --setenv ${UPDATE_SNAPSHOT_ENV}=true\n` + 205 "And then carefuly review if the result is valid regarding your ongoing changes.\n" + 206 "`More info in https://firefox-source-docs.mozilla.org/devtools/tests/js-object-tests.html\n"; 207 208 let failed = false; 209 let i = 0; 210 for (const objectDescription of AllObjects) { 211 if (objectDescription.disabled) { 212 continue; 213 } 214 const actual = actualValues[i++]; 215 const expression = objectDescription.expression; 216 217 // Ignore this JS object as the test function did not return any actual value. 218 // We assume none of the tests would store "undefined" as a target value. 219 if (actual == undefined) { 220 continue; 221 } 222 223 const expected = expectedValues.shift(); 224 225 const isMochitest = "gTestPath" in gTestScope; 226 try { 227 gTestScope.Assert.deepEqual(actual, expected, `Got expected output for "${expression}"`); 228 } catch(e) { 229 // deepEqual only throws in case of differences when running in XPCShell tests. Mochitest won't throw and keep running. 230 // XPCShell will stop at the first failing assertion, so ensure showing our failure message and ok() will throw and stop the test. 231 if (!isMochitest) { 232 gTestScope.Assert.ok(false, failureMessage); 233 } 234 throw e; 235 } 236 // As mochitest won't throw when calling deepEqual with differences in the objects, 237 // we have to recompute the difference in order to know if any of the tests failed. 238 if (isMochitest && !failed && !ObjectUtils.deepEqual(actual, expected)) { 239 failed = true; 240 } 241 } 242 243 if (failed) { 244 const failMessage = "This is a JavaScript value processing test, which includes an automatically generated snapshot file.\n" + 245 "If the change made to that snapshot file makes sense, you may simply update them by running:`\n" + 246 ` $ mach test ${testPath} --headless --setenv ${UPDATE_SNAPSHOT_ENV}=true\n` + 247 "`More info in devtools/shared/tests/objects/README.md\n"; 248 gTestScope.Assert.ok(false, failMessage); 249 } 250 } 251 252 mayBeSaveExpectedValues(actualValues); 253 } 254 255 async function runTest(expectedValuesFileName, testFunction) { 256 if (!gTestScope) { 257 throw new Error("`JSObjectsTestUtils.init()` should be called before `runTest()`"); 258 } 259 if (typeof (expectedValuesFileName) != "string") { 260 throw new Error("`JSObjectsTestUtils.runTest()` first argument should be a data file name"); 261 } 262 if (typeof (testFunction) != "function") { 263 throw new Error("`JSObjectsTestUtils.runTest()` second argument should be a test function"); 264 } 265 266 const actualValues = []; 267 268 if (!gTestScope) { 269 throw new Error("`JSObjectsTestUtils.init()` should be called before `runTest()`"); 270 } 271 272 for (const objectDescription of AllObjects) { 273 if (objectDescription.disabled) { 274 continue; 275 } 276 277 const { context, expression } = objectDescription; 278 if (!Object.values(CONTEXTS).includes(context)) { 279 throw new Error("Missing, or invalid context in: " + JSON.stringify(objectDescription)); 280 } 281 282 if (!expression) { 283 throw new Error("Missing a value in: " + JSON.stringify(objectDescription)); 284 } 285 286 const actual = await testFunction({ context, expression }); 287 actualValues.push(actual); 288 } 289 290 testOrUpdateExpectedValues(expectedValuesFileName, actualValues); 291 } 292 293 export const JSObjectsTestUtils = { init, runTest, testOrUpdateExpectedValues };