browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.jsm
author Marian-Vasile Laza <mlaza@mozilla.com>
Sun, 26 Jun 2022 12:41:25 +0300
changeset 622109 55465199f7ba8e2ac51f583aee8a356bb1138195
parent 619825 4d318b699cd5a9b474ea9dc443ddc77aaeb4cf57
permissions -rw-r--r--
Merge autoland to mozilla-central. a=merge

/* 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/. */

"use strict";

var EXPORTED_SYMBOLS = ["TestRunner"];

const env = Cc["@mozilla.org/process/environment;1"].getService(
  Ci.nsIEnvironment
);
const APPLY_CONFIG_TIMEOUT_MS = 60 * 1000;
const HOME_PAGE = "resource://mozscreenshots/lib/mozscreenshots.html";

const { AppConstants } = ChromeUtils.import(
  "resource://gre/modules/AppConstants.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm");
const { Rect } = ChromeUtils.import("resource://gre/modules/Geometry.jsm");

const lazy = {};

ChromeUtils.defineModuleGetter(
  lazy,
  "BrowserTestUtils",
  "resource://testing-common/BrowserTestUtils.jsm"
);
// Screenshot.jsm must be imported this way for xpcshell tests to work
ChromeUtils.defineModuleGetter(
  lazy,
  "Screenshot",
  "resource://mozscreenshots/Screenshot.jsm"
);

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 = env.get("MOZ_UPLOAD_DIR");
    const GECKO_HEAD_REPOSITORY = 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.loadURI(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.loadURI(
      gBrowser.selectedBrowser,
      "data:text/html;charset=utf-8,<h1>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.jsm 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;
}