browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.jsm
author Kris Maglione <maglione.k@gmail.com>
Wed, 24 Jan 2018 15:48:47 -0800
changeset 455769 d4a7c018420e408fbe0a13ffddd2861623fda5a7
parent 453832 8df2e3d57e945e3445b7aef5f6ffaffebb36e0e7
child 455772 cc87ad81ff86109c7ea0187424fa9a8ed3b4af6c
permissions -rw-r--r--
Bug 1431533: Part 5a - Auto-rewrite code to use ChromeUtils import methods. r=florian MozReview-Commit-ID: 8V1ZT53ReiP

/* 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";

this.EXPORTED_SYMBOLS = ["TestRunner"];

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
const APPLY_CONFIG_TIMEOUT_MS = 60 * 1000;
const HOME_PAGE = "chrome://mozscreenshots/content/lib/mozscreenshots.html";

ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/osfile.jsm");
ChromeUtils.import("resource://gre/modules/Geometry.jsm");

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

this.TestRunner = {
  combos: null,
  completedCombos: 0,
  currentComboIndex: 0,
  _lastCombo: null,
  _libDir: null,
  croppingPadding: 10,
  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 = FileUtils.getFile("TmpD", subDirs).path;

    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 += "_";
    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("browser.aboutHomeSnippets.updateUrl",
                               "data:text/html;charset=utf-8,Generated by mozscreenshots");
    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.setBoolPref("toolkit.cosmeticAnimations.enabled", false);

    let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");

    // 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;
    await BrowserTestUtils.loadURI(selectedBrowser, HOME_PAGE);
    await 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("chrome://mozscreenshots/content/configurations/" + setName + ".jsm",
                         imported);
      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);
    gBrowser.selectedBrowser.loadURI("data:text/html;charset=utf-8,<h1>Done!");
    browserWindow.restore();
    Services.prefs.clearUserPref("toolkit.cosmeticAnimations.enabled");
  },

  // 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 "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 element;
      // Check for function to find anonymous content
      if (typeof(selector) == "function") {
        element = selector();
      } else {
        element = browserWindow.document.querySelector(selector);
      }

      if (!element) {
        throw `No element for '${selector}' found.`;
      }

      // Calculate box region, convert to Rect
      let box = element.ownerDocument.getBoxObjectFor(element);
      let rect = new Rect(box.screenX * scale, box.screenY * scale,
                             box.width * scale, box.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);
      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 500ms 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), 500);
        });
      });
    };

    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 Screenshot.captureExternal(filename);

    let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
    await this._cropImage(browserWindow, OS.Path.toFileURI(imagePath), bounds, rects, imagePath).catch((msg) => {
      throw `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
            OS.File.writeAtomic(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;
}