From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../mozscreenshots/extension/TestRunner.sys.mjs | 650 +++++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs (limited to 'browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs') diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs b/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs new file mode 100644 index 0000000000..c0623d6e72 --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs @@ -0,0 +1,650 @@ +/* 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/. */ + +const APPLY_CONFIG_TIMEOUT_MS = 60 * 1000; +const HOME_PAGE = "resource://mozscreenshots/lib/mozscreenshots.html"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { Rect } from "resource://gre/modules/Geometry.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + // Screenshot.sys.mjs must be imported this way for xpcshell tests to work + Screenshot: "resource://mozscreenshots/Screenshot.sys.mjs", +}); + +export var TestRunner = { + combos: null, + completedCombos: 0, + currentComboIndex: 0, + _lastCombo: null, + _libDir: null, + croppingPadding: 0, + mochitestScope: null, + + init(extensionPath) { + this._extensionPath = extensionPath; + this.setupOS(); + }, + + /** + * Initialize the mochitest interface. This allows TestRunner to integrate + * with mochitest functions like is(...) and ok(...). This must be called + * prior to invoking any of the TestRunner functions. Note that this should + * be properly setup in head.js, so you probably don't need to call it. + */ + initTest(mochitestScope) { + this.mochitestScope = mochitestScope; + }, + + setupOS() { + switch (AppConstants.platform) { + case "macosx": { + this.disableNotificationCenter(); + break; + } + } + }, + + disableNotificationCenter() { + let killall = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + killall.initWithPath("/bin/bash"); + + let killallP = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + killallP.init(killall); + let ncPlist = + "/System/Library/LaunchAgents/com.apple.notificationcenterui.plist"; + let killallArgs = [ + "-c", + `/bin/launchctl unload -w ${ncPlist} && ` + + "/usr/bin/killall -v NotificationCenter", + ]; + killallP.run(true, killallArgs, killallArgs.length); + }, + + /** + * Load specified sets, execute all combinations of them, and capture screenshots. + */ + async start(setNames, jobName = null) { + let subDirs = [ + "mozscreenshots", + new Date().toISOString().replace(/:/g, "-") + "_" + Services.appinfo.OS, + ]; + let screenshotPath = PathUtils.join(PathUtils.tempDir, ...subDirs); + + const MOZ_UPLOAD_DIR = Services.env.get("MOZ_UPLOAD_DIR"); + const GECKO_HEAD_REPOSITORY = Services.env.get("GECKO_HEAD_REPOSITORY"); + // We don't want to upload images (from MOZ_UPLOAD_DIR) on integration + // branches in order to reduce bandwidth/storage. + if (MOZ_UPLOAD_DIR && !GECKO_HEAD_REPOSITORY.includes("/integration/")) { + screenshotPath = MOZ_UPLOAD_DIR; + } + + this.mochitestScope.info(`Saving screenshots to: ${screenshotPath}`); + + let screenshotPrefix = Services.appinfo.appBuildID; + if (jobName) { + screenshotPrefix += "-" + jobName; + } + screenshotPrefix += "_"; + lazy.Screenshot.init(screenshotPath, this._extensionPath, screenshotPrefix); + this._libDir = this._extensionPath + .QueryInterface(Ci.nsIFileURL) + .file.clone(); + this._libDir.append("chrome"); + this._libDir.append("mozscreenshots"); + this._libDir.append("lib"); + + let sets = this.loadSets(setNames); + + this.mochitestScope.info(`${sets.length} sets: ${setNames}`); + this.combos = new LazyProduct(sets); + this.mochitestScope.info(this.combos.length + " combinations"); + + this.currentComboIndex = this.completedCombos = 0; + this._lastCombo = null; + + // Setup some prefs + Services.prefs.setCharPref( + "extensions.ui.lastCategory", + "addons://list/extension" + ); + // Don't let the caret blink since it causes false positives for image diffs + Services.prefs.setIntPref("ui.caretBlinkTime", -1); + // Disable some animations that can cause false positives, such as the + // reload/stop button spinning animation. + Services.prefs.setIntPref("ui.prefersReducedMotion", 1); + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + // Prevent the mouse cursor from causing hover styles or tooltips to appear. + browserWindow.windowUtils.disableNonTestMouseEvents(true); + + // When being automated through Marionette, Firefox shows a prominent indication + // in the urlbar and identity block. We don't want this to show when testing browser UI. + // Note that this doesn't prevent subsequently opened windows from showing the automation UI. + browserWindow.document + .getElementById("main-window") + .removeAttribute("remotecontrol"); + + let selectedBrowser = browserWindow.gBrowser.selectedBrowser; + lazy.BrowserTestUtils.loadURIString(selectedBrowser, HOME_PAGE); + await lazy.BrowserTestUtils.browserLoaded(selectedBrowser); + + for (let i = 0; i < this.combos.length; i++) { + this.currentComboIndex = i; + await this._performCombo(this.combos.item(this.currentComboIndex)); + } + + this.mochitestScope.info( + "Done: Completed " + + this.completedCombos + + " out of " + + this.combos.length + + " configurations." + ); + this.cleanup(); + }, + + /** + * Helper function for loadSets. This filters out the restricted configs from setName. + * This was made a helper function to facilitate xpcshell unit testing. + * @param {String} setName - set name to be filtered e.g. "Toolbars[onlyNavBar,allToolbars]" + * @return {Object} Returns an object with two values: the filtered set name and a set of + * restricted configs. + */ + filterRestrictions(setName) { + let match = /\[([^\]]+)\]$/.exec(setName); + if (!match) { + throw new Error(`Invalid restrictions in ${setName}`); + } + // Trim the restrictions from the set name. + setName = setName.slice(0, match.index); + let restrictions = match[1] + .split(",") + .reduce((set, name) => set.add(name.trim()), new Set()); + + return { trimmedSetName: setName, restrictions }; + }, + + /** + * Load sets of configurations from JSMs. + * @param {String[]} setNames - array of set names (e.g. ["Tabs", "WindowSize"]. + * @return {Object[]} Array of sets containing `name` and `configurations` properties. + */ + loadSets(setNames) { + let sets = []; + for (let setName of setNames) { + let restrictions = null; + if (setName.includes("[")) { + let filteredData = this.filterRestrictions(setName); + setName = filteredData.trimmedSetName; + restrictions = filteredData.restrictions; + } + let imported = ChromeUtils.import( + `resource://mozscreenshots/configurations/${setName}.jsm` + ); + imported[setName].init(this._libDir); + let configurationNames = Object.keys(imported[setName].configurations); + if (!configurationNames.length) { + throw new Error( + setName + " has no configurations for this environment" + ); + } + // Checks to see if nonexistent configuration have been specified + if (restrictions) { + let incorrectConfigs = [...restrictions].filter( + r => !configurationNames.includes(r) + ); + if (incorrectConfigs.length) { + throw new Error("non existent configurations: " + incorrectConfigs); + } + } + let configurations = {}; + for (let config of configurationNames) { + // Automatically set the name property of the configuration object to + // its name from the configuration object. + imported[setName].configurations[config].name = config; + // Filter restricted configurations. + if (!restrictions || restrictions.has(config)) { + configurations[config] = imported[setName].configurations[config]; + } + } + sets.push(configurations); + } + return sets; + }, + + cleanup() { + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + let gBrowser = browserWindow.gBrowser; + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab, { animate: false }); + } + gBrowser.unpinTab(gBrowser.selectedTab); + lazy.BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "data:text/html;charset=utf-8,

Done!" + ); + browserWindow.restore(); + Services.prefs.clearUserPref("ui.caretBlinkTime"); + Services.prefs.clearUserPref("ui.prefersReducedMotion"); + browserWindow.windowUtils.disableNonTestMouseEvents(false); + }, + + // helpers + + /** + * Calculate the bounding box based on CSS selector from config for cropping + * + * @param {String[]} selectors - array of CSS selectors for relevant DOM element + * @return {Geometry.sys.mjs Rect} Rect holding relevant x, y, width, height with padding + **/ + _findBoundingBox(selectors, windowType) { + if (!selectors.length) { + throw new Error("No selectors specified."); + } + + // Set window type, default "navigator:browser" + windowType = windowType || "navigator:browser"; + let browserWindow = Services.wm.getMostRecentWindow(windowType); + // Scale for high-density displays + const scale = Cc["@mozilla.org/gfx/screenmanager;1"] + .getService(Ci.nsIScreenManager) + .screenForRect( + browserWindow.screenX, + browserWindow.screenY, + 1, + 1 + ).defaultCSSScaleFactor; + + const windowLeft = browserWindow.screenX * scale; + const windowTop = browserWindow.screenY * scale; + const windowWidth = browserWindow.outerWidth * scale; + const windowHeight = browserWindow.outerHeight * scale; + + let bounds; + const rects = []; + // Grab bounding boxes and find the union + for (let selector of selectors) { + let elements; + // Check for function to find anonymous content + if (typeof selector == "function") { + elements = [selector()]; + } else { + elements = browserWindow.document.querySelectorAll(selector); + } + + if (!elements.length) { + throw new Error(`No element for '${selector}' found.`); + } + + for (let element of elements) { + // Calculate box region, convert to Rect + let elementRect = element.getBoundingClientRect(); + // ownerGlobal doesn't exist in content privileged windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + let win = element.ownerDocument.defaultView; + let rect = new Rect( + (win.mozInnerScreenX + elementRect.left) * scale, + (win.mozInnerScreenY + elementRect.top) * scale, + elementRect.width * scale, + elementRect.height * scale + ); + rect.inflateFixed(this.croppingPadding * scale); + rect.left = Math.max(rect.left, windowLeft); + rect.top = Math.max(rect.top, windowTop); + rect.right = Math.min(rect.right, windowLeft + windowWidth); + rect.bottom = Math.min(rect.bottom, windowTop + windowHeight); + + if (rect.width === 0 && rect.height === 0) { + this.mochitestScope.todo( + false, + `Selector '${selector}' gave a 0x0 rect` + ); + continue; + } + + rects.push(rect); + + if (!bounds) { + bounds = rect; + } else { + bounds = bounds.union(rect); + } + } + } + + return { bounds, rects }; + }, + + _do_skip(reason, combo, config, func) { + const { todo } = reason; + if (todo) { + this.mochitestScope.todo( + false, + `Skipped configuration ` + + `[ ${combo.map(e => e.name).join(", ")} ] for failure in ` + + `${config.name}.${func}: ${todo}` + ); + } else { + this.mochitestScope.info( + `\tSkipped configuration ` + + `[ ${combo.map(e => e.name).join(", ")} ] ` + + `for "${reason}" in ${config.name}.${func}` + ); + } + }, + + async _performCombo(combo) { + let paddedComboIndex = padLeft( + this.currentComboIndex + 1, + String(this.combos.length).length + ); + this.mochitestScope.info( + `Combination ${paddedComboIndex}/${this.combos.length}: ${this._comboName( + combo + ).substring(1)}` + ); + + // Notice that this does need to be a closure, not a function, as otherwise + // "this" gets replaced and we lose access to this.mochitestScope. + const changeConfig = config => { + this.mochitestScope.info("calling " + config.name); + + let applyPromise = Promise.resolve(config.applyConfig()); + let timeoutPromise = new Promise((resolve, reject) => { + setTimeout(reject, APPLY_CONFIG_TIMEOUT_MS, "Timed out"); + }); + + this.mochitestScope.info("called " + config.name); + // Add a default timeout of 700ms to avoid conflicts when configurations + // try to apply at the same time. e.g WindowSize and TabsInTitlebar + return Promise.race([applyPromise, timeoutPromise]).then(result => { + return new Promise(resolve => { + setTimeout(() => resolve(result), 700); + }); + }); + }; + + try { + // First go through and actually apply all of the configs + for (let i = 0; i < combo.length; i++) { + let config = combo[i]; + if (!this._lastCombo || config !== this._lastCombo[i]) { + this.mochitestScope.info(`promising ${config.name}`); + const reason = await changeConfig(config); + if (reason) { + this._do_skip(reason, combo, config, "applyConfig"); + return; + } + } + } + + // Update the lastCombo since it's now been applied regardless of whether it's accepted below. + this.mochitestScope.info( + "fulfilled all applyConfig so setting lastCombo." + ); + this._lastCombo = combo; + + // Then ask configs if the current setup is valid. We can't can do this in + // the applyConfig methods of the config since it doesn't know what configs + // later in the loop will do that may invalidate the combo. + for (let i = 0; i < combo.length; i++) { + let config = combo[i]; + // A configuration can specify an optional verifyConfig method to indicate + // if the current config is valid for a screenshot. This gets called even + // if the this config was used in the lastCombo since another config may + // have invalidated it. + if (config.verifyConfig) { + this.mochitestScope.info( + `checking if the combo is valid with ${config.name}` + ); + const reason = await config.verifyConfig(); + if (reason) { + this._do_skip(reason, combo, config, "applyConfig"); + return; + } + } + } + } catch (ex) { + this.mochitestScope.ok( + false, + `Unexpected exception in [ ${combo + .map(({ name }) => name) + .join(", ")} ]: ${ex.toString()}` + ); + this.mochitestScope.info(`\t${ex}`); + if (ex.stack) { + this.mochitestScope.info(`\t${ex.stack}`); + } + return; + } + this.mochitestScope.info( + `Configured UI for [ ${combo + .map(({ name }) => name) + .join(", ")} ] successfully` + ); + + // Collect selectors from combo configs for cropping region + let windowType; + const finalSelectors = []; + for (const obj of combo) { + if (!windowType) { + windowType = obj.windowType; + } else if (windowType !== obj.windowType) { + this.mochitestScope.ok( + false, + "All configurations in the combo have a single window type" + ); + return; + } + for (const selector of obj.selectors) { + finalSelectors.push(selector); + } + } + + const { bounds, rects } = this._findBoundingBox(finalSelectors, windowType); + this.mochitestScope.ok(bounds, "A valid bounding box was found"); + if (!bounds) { + return; + } + await this._onConfigurationReady(combo, bounds, rects); + }, + + async _onConfigurationReady(combo, bounds, rects) { + let filename = + padLeft(this.currentComboIndex + 1, String(this.combos.length).length) + + this._comboName(combo); + const imagePath = await lazy.Screenshot.captureExternal(filename); + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + await this._cropImage( + browserWindow, + PathUtils.toFileURI(imagePath), + bounds, + rects, + imagePath + ).catch(msg => { + throw new Error( + `Cropping combo [${combo.map(e => e.name).join(", ")}] failed: ${msg}` + ); + }); + this.completedCombos++; + this.mochitestScope.info("_onConfigurationReady"); + }, + + _comboName(combo) { + return combo.reduce(function (a, b) { + return a + "_" + b.name; + }, ""); + }, + + async _cropImage(window, srcPath, bounds, rects, targetPath) { + const { document, Image } = window; + const promise = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + // Clip the cropping region to the size of the screenshot + // This is necessary mostly to deal with offscreen windows, since we + // are capturing an image of the operating system's desktop. + bounds.left = Math.max(0, bounds.left); + bounds.right = Math.min(img.naturalWidth, bounds.right); + bounds.top = Math.max(0, bounds.top); + bounds.bottom = Math.min(img.naturalHeight, bounds.bottom); + + // Create a new offscreen canvas with the width and height given by the + // size of the region we want to crop to + const canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = bounds.width; + canvas.height = bounds.height; + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = "hotpink"; + ctx.fillRect(0, 0, bounds.width, bounds.height); + + for (const rect of rects) { + rect.left = Math.max(0, rect.left); + rect.right = Math.min(img.naturalWidth, rect.right); + rect.top = Math.max(0, rect.top); + rect.bottom = Math.min(img.naturalHeight, rect.bottom); + + const width = rect.width; + const height = rect.height; + + const screenX = rect.left; + const screenY = rect.top; + + const imageX = screenX - bounds.left; + const imageY = screenY - bounds.top; + ctx.drawImage( + img, + screenX, + screenY, + width, + height, + imageX, + imageY, + width, + height + ); + } + + // Converts the canvas to a binary blob, which can be saved to a png + canvas.toBlob(blob => { + // Use a filereader to convert the raw binary blob into a writable buffer + const fr = new FileReader(); + fr.onload = e => { + const buffer = new Uint8Array(e.target.result); + // Save the file and complete the promise + IOUtils.write(targetPath, buffer).then(resolve); + }; + // Do the conversion + fr.readAsArrayBuffer(blob); + }); + }; + + img.onerror = function () { + reject(`error loading image ${srcPath}`); + }; + // Load the src image for drawing + img.src = srcPath; + }); + return promise; + }, + + /** + * Finds the index of the first comma that is not enclosed within square brackets. + * @param {String} envVar - the string that needs to be searched + * @return {Integer} index of valid comma or -1 if not found. + */ + findComma(envVar) { + let nestingDepth = 0; + for (let i = 0; i < envVar.length; i++) { + if (envVar[i] === "[") { + nestingDepth += 1; + } else if (envVar[i] === "]") { + nestingDepth -= 1; + } else if (envVar[i] === "," && nestingDepth === 0) { + return i; + } + } + + return -1; + }, + + /** + * Splits the environment variable around commas not enclosed in brackets. + * @param {String} envVar - The environment variable + * @return {String[]} Array of strings containing the configurations + * e.g. ["Toolbars[onlyNavBar,allToolbars]","DevTools[jsdebugger,webconsole]","Tabs"] + */ + splitEnv(envVar) { + let result = []; + + let commaIndex = this.findComma(envVar); + while (commaIndex != -1) { + result.push(envVar.slice(0, commaIndex).trim()); + envVar = envVar.slice(commaIndex + 1); + commaIndex = this.findComma(envVar); + } + result.push(envVar.trim()); + return result; + }, +}; + +/** + * Helper to lazily compute the Cartesian product of all of the sets of configurations. + **/ +function LazyProduct(sets) { + /** + * An entry for each set with the value being: + * [the number of permutations of the sets with lower index, + * the number of items in the set at the index] + */ + this.sets = sets; + this.lookupTable = []; + let combinations = 1; + for (let i = this.sets.length - 1; i >= 0; i--) { + let set = this.sets[i]; + let setLength = Object.keys(set).length; + this.lookupTable[i] = [combinations, setLength]; + combinations *= setLength; + } +} +LazyProduct.prototype = { + get length() { + let last = this.lookupTable[0]; + if (!last) { + return 0; + } + return last[0] * last[1]; + }, + + item(n) { + // For set i, get the item from the set with the floored value of + // (n / the number of permutations of the sets already chosen from) modulo the length of set i + let result = []; + for (let i = this.sets.length - 1; i >= 0; i--) { + let priorCombinations = this.lookupTable[i][0]; + let setLength = this.lookupTable[i][1]; + let keyIndex = Math.floor(n / priorCombinations) % setLength; + let keys = Object.keys(this.sets[i]); + result[i] = this.sets[i][keys[keyIndex]]; + } + return result; + }, +}; + +function padLeft(number, width, padding = "0") { + return padding.repeat(Math.max(0, width - String(number).length)) + number; +} -- cgit v1.2.3