summaryrefslogtreecommitdiffstats
path: root/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs
blob: c0623d6e72d7b6de4d36938e8f5854afeab608b6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
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,<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.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;
}