Bug 1586798 - Use WalkerFront from the currently selected element in onTagEdit()...
[gecko.git] / testing / marionette / reftest.js
blob5fb8f4ee31c5b63445ebbd8552fbc48e15ff2562
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 const { Preferences } = ChromeUtils.import(
8   "resource://gre/modules/Preferences.jsm"
9 );
10 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11 const { XPCOMUtils } = ChromeUtils.import(
12   "resource://gre/modules/XPCOMUtils.jsm"
15 const { assert } = ChromeUtils.import("chrome://marionette/content/assert.js");
16 const { capture } = ChromeUtils.import(
17   "chrome://marionette/content/capture.js"
19 const { InvalidArgumentError } = ChromeUtils.import(
20   "chrome://marionette/content/error.js"
22 const { Log } = ChromeUtils.import("chrome://marionette/content/log.js");
24 XPCOMUtils.defineLazyGetter(this, "logger", Log.get);
26 this.EXPORTED_SYMBOLS = ["reftest"];
28 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
29 const PREF_E10S = "browser.tabs.remote.autostart";
31 const SCREENSHOT_MODE = {
32   unexpected: 0,
33   fail: 1,
34   always: 2,
37 const STATUS = {
38   PASS: "PASS",
39   FAIL: "FAIL",
40   ERROR: "ERROR",
41   TIMEOUT: "TIMEOUT",
44 const DEFAULT_REFTEST_WIDTH = 600;
45 const DEFAULT_REFTEST_HEIGHT = 600;
47 /**
48  * Implements an fast runner for web-platform-tests format reftests
49  * c.f. http://web-platform-tests.org/writing-tests/reftests.html.
50  *
51  * @namespace
52  */
53 this.reftest = {};
55 /**
56  * @memberof reftest
57  * @class Runner
58  */
59 reftest.Runner = class {
60   constructor(driver) {
61     this.driver = driver;
62     this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]]));
63     this.windowUtils = null;
64     this.lastURL = null;
65     this.remote = Preferences.get(PREF_E10S);
66   }
68   /**
69    * Setup the required environment for running reftests.
70    *
71    * This will open a non-browser window in which the tests will
72    * be loaded, and set up various caches for the reftest run.
73    *
74    * @param {Object.<Number>} urlCount
75    *     Object holding a map of URL: number of times the URL
76    *     will be opened during the reftest run, where that's
77    *     greater than 1.
78    * @param {string} screenshotMode
79    *     String enum representing when screenshots should be taken
80    */
81   setup(urlCount, screenshotMode) {
82     this.parentWindow = assert.open(this.driver.getCurrentWindow());
84     this.screenshotMode =
85       SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected;
87     this.urlCount = Object.keys(urlCount || {}).reduce(
88       (map, key) => map.set(key, urlCount[key]),
89       new Map()
90     );
91   }
93   async ensureWindow(timeout, width, height) {
94     logger.debug(`ensuring we have a window ${width}x${height}`);
96     if (this.reftestWin && !this.reftestWin.closed) {
97       let browserRect = this.reftestWin.gBrowser.getBoundingClientRect();
98       if (browserRect.width === width && browserRect.height === height) {
99         return this.reftestWin;
100       }
101       logger.debug(`current: ${browserRect.width}x${browserRect.height}`);
102     }
104     let reftestWin;
105     if (Services.appinfo.OS == "Android") {
106       logger.debug("Using current window");
107       reftestWin = this.parentWindow;
108       await this.driver.listener.get({
109         commandID: this.driver.listener.activeMessageId,
110         pageTimeout: timeout,
111         url: "about:blank",
112         loadEventExpected: false,
113       });
114     } else {
115       logger.debug("Using separate window");
116       if (this.reftestWin && !this.reftestWin.closed) {
117         this.reftestWin.close();
118       }
119       reftestWin = await this.openWindow(width, height);
120     }
122     this.setupWindow(reftestWin, width, height);
123     this.windowUtils = reftestWin.windowUtils;
124     this.reftestWin = reftestWin;
126     let found = this.driver.findWindow([reftestWin], () => true);
127     await this.driver.setWindowHandle(found, true);
129     let browserRect = reftestWin.gBrowser.getBoundingClientRect();
130     logger.debug(`new: ${browserRect.width}x${browserRect.height}`);
132     return reftestWin;
133   }
135   async openWindow(width, height) {
136     assert.positiveInteger(width);
137     assert.positiveInteger(height);
139     let reftestWin = this.parentWindow.open(
140       "chrome://marionette/content/reftest.xul",
141       "reftest",
142       `chrome,height=${height},width=${width}`
143     );
145     await new Promise(resolve => {
146       reftestWin.addEventListener("load", resolve, { once: true });
147     });
148     return reftestWin;
149   }
151   setupWindow(reftestWin, width, height) {
152     let browser;
153     if (Services.appinfo.OS === "Android") {
154       browser = reftestWin.document.getElementsByTagName("browser")[0];
155       browser.setAttribute("remote", "false");
156     } else {
157       browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser");
158       browser.permanentKey = {};
159       browser.setAttribute("id", "browser");
160       browser.setAttribute("anonid", "initialBrowser");
161       browser.setAttribute("type", "content");
162       browser.setAttribute("primary", "true");
163       if (this.remote) {
164         browser.setAttribute("remote", "true");
165         browser.setAttribute("remoteType", "web");
166       } else {
167         browser.setAttribute("remote", "false");
168       }
169     }
170     // Make sure the browser element is exactly the right size, no matter
171     // what size our window is
172     const windowStyle = `padding: 0px; margin: 0px; border:none;
173 min-width: ${width}px; min-height: ${height}px;
174 max-width: ${width}px; max-height: ${height}px`;
175     browser.setAttribute("style", windowStyle);
177     if (Services.appinfo.OS !== "Android") {
178       let doc = reftestWin.document.documentElement;
179       while (doc.firstChild) {
180         doc.firstChild.remove();
181       }
182       doc.appendChild(browser);
183     }
184     if (reftestWin.BrowserApp) {
185       reftestWin.BrowserApp = browser;
186     }
187     reftestWin.gBrowser = browser;
188     return reftestWin;
189   }
191   abort() {
192     if (this.reftestWin) {
193       this.driver.closeChromeWindow();
194     }
195     this.reftestWin = null;
196   }
198   /**
199    * Run a specific reftest.
200    *
201    * The assumed semantics are those of web-platform-tests where
202    * references form a tree and each test must meet all the conditions
203    * to reach one leaf node of the tree in order for the overall test
204    * to pass.
205    *
206    * @param {string} testUrl
207    *     URL of the test itself.
208    * @param {Array.<Array>} references
209    *     Array representing a tree of references to try.
210    *
211    *     Each item in the array represents a single reference node and
212    *     has the form <code>[referenceUrl, references, relation]</code>,
213    *     where <var>referenceUrl</var> is a string to the URL, relation
214    *     is either <code>==</code> or <code>!=</code> depending on the
215    *     type of reftest, and references is another array containing
216    *     items of the same form, representing further comparisons treated
217    *     as AND with the current item. Sibling entries are treated as OR.
218    *
219    *     For example with testUrl of T:
220    *
221    *     <pre><code>
222    *       references = [[A, [[B, [], ==]], ==]]
223    *       Must have T == A AND A == B to pass
224    *
225    *       references = [[A, [], ==], [B, [], !=]
226    *       Must have T == A OR T != B
227    *
228    *       references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]]
229    *       Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D)
230    *     </code></pre>
231    *
232    * @param {string} expected
233    *     Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>).
234    * @param {number} timeout
235    *     Test timeout in milliseconds.
236    *
237    * @return {Object}
238    *     Result object with fields status, message and extra.
239    */
240   async run(
241     testUrl,
242     references,
243     expected,
244     timeout,
245     width = DEFAULT_REFTEST_WIDTH,
246     height = DEFAULT_REFTEST_HEIGHT
247   ) {
248     let timeoutHandle;
250     let timeoutPromise = new Promise(resolve => {
251       timeoutHandle = this.parentWindow.setTimeout(() => {
252         resolve({ status: STATUS.TIMEOUT, message: null, extra: {} });
253       }, timeout);
254     });
256     let testRunner = (async () => {
257       let result;
258       try {
259         result = await this.runTest(
260           testUrl,
261           references,
262           expected,
263           timeout,
264           width,
265           height
266         );
267       } catch (e) {
268         result = {
269           status: STATUS.ERROR,
270           message: String(e),
271           stack: e.stack,
272           extra: {},
273         };
274       }
275       return result;
276     })();
278     let result = await Promise.race([testRunner, timeoutPromise]);
279     this.parentWindow.clearTimeout(timeoutHandle);
280     if (result.status === STATUS.TIMEOUT) {
281       this.abort();
282     }
284     return result;
285   }
287   async runTest(testUrl, references, expected, timeout, width, height) {
288     let win = await this.ensureWindow(timeout, width, height);
290     function toBase64(screenshot) {
291       let dataURL = screenshot.canvas.toDataURL();
292       return dataURL.split(",")[1];
293     }
295     let result = {
296       status: STATUS.FAIL,
297       message: "",
298       stack: null,
299       extra: {},
300     };
302     let screenshotData = [];
304     let stack = [];
305     for (let i = references.length - 1; i >= 0; i--) {
306       let item = references[i];
307       stack.push([testUrl, ...item]);
308     }
310     let done = false;
312     while (stack.length && !done) {
313       let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop();
314       result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`;
316       let comparison;
317       try {
318         comparison = await this.compareUrls(
319           win,
320           lhsUrl,
321           rhsUrl,
322           relation,
323           timeout,
324           extras
325         );
326       } catch (e) {
327         comparison = {
328           lhs: null,
329           rhs: null,
330           passed: false,
331           error: e,
332           msg: null,
333         };
334       }
335       if (comparison.msg) {
336         result.message += `${comparison.msg}\n`;
337       }
338       if (comparison.error !== null) {
339         result.status = STATUS.ERROR;
340         result.message += String(comparison.error);
341         result.stack = comparison.error.stack;
342       }
344       function recordScreenshot() {
345         let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : "";
346         let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : "";
347         screenshotData.push([
348           { url: lhsUrl, screenshot: encodedLHS },
349           relation,
350           { url: rhsUrl, screenshot: encodedRHS },
351         ]);
352       }
354       if (this.screenshotMode === SCREENSHOT_MODE.always) {
355         recordScreenshot();
356       }
358       if (comparison.passed) {
359         if (references.length) {
360           for (let i = references.length - 1; i >= 0; i--) {
361             let item = references[i];
362             stack.push([rhsUrl, ...item]);
363           }
364         } else {
365           // Reached a leaf node so all of one reference chain passed
366           result.status = STATUS.PASS;
367           if (
368             this.screenshotMode <= SCREENSHOT_MODE.fail &&
369             expected != result.status
370           ) {
371             recordScreenshot();
372           }
373           done = true;
374         }
375       } else if (!stack.length || result.status == STATUS.ERROR) {
376         // If we don't have any alternatives to try then this will be
377         // the last iteration, so save the failing screenshots if required.
378         let isFail = this.screenshotMode === SCREENSHOT_MODE.fail;
379         let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected;
380         if (isFail || (isUnexpected && expected != result.status)) {
381           recordScreenshot();
382         }
383       }
385       // Return any reusable canvases to the pool
386       let cacheKey = width + "x" + height;
387       let canvasPool = this.canvasCache.get(cacheKey).get(null);
388       [comparison.lhs, comparison.rhs].map(screenshot => {
389         if (screenshot !== null && screenshot.reuseCanvas) {
390           canvasPool.push(screenshot.canvas);
391         }
392       });
393       logger.debug(
394         `Canvas pool (${cacheKey}) is of length ${canvasPool.length}`
395       );
396     }
398     if (screenshotData.length) {
399       // For now the tbpl formatter only accepts one screenshot, so just
400       // return the last one we took.
401       let lastScreenshot = screenshotData[screenshotData.length - 1];
402       // eslint-disable-next-line camelcase
403       result.extra.reftest_screenshots = lastScreenshot;
404     }
406     return result;
407   }
409   async compareUrls(win, lhsUrl, rhsUrl, relation, timeout, extras) {
410     logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`);
412     // Take the reference screenshot first so that if we pause
413     // we see the test rendering
414     let rhs = await this.screenshot(win, rhsUrl, timeout);
415     let lhs = await this.screenshot(win, lhsUrl, timeout);
417     logger.debug(`lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}`);
418     logger.debug(`rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}`);
420     let passed;
421     let error = null;
422     let pixelsDifferent = null;
423     let maxDifferences = {};
424     let msg = null;
426     try {
427       pixelsDifferent = this.windowUtils.compareCanvases(
428         lhs.canvas,
429         rhs.canvas,
430         maxDifferences
431       );
432     } catch (e) {
433       passed = false;
434       error = e;
435     }
437     if (error === null) {
438       passed = this.isAcceptableDifference(
439         maxDifferences.value,
440         pixelsDifferent,
441         extras.fuzzy
442       );
443       switch (relation) {
444         case "==":
445           if (!passed) {
446             msg =
447               `Found ${pixelsDifferent} pixels different, ` +
448               `maximum difference per channel ${maxDifferences.value}`;
449           }
450           break;
451         case "!=":
452           passed = !passed;
453           break;
454         default:
455           throw new InvalidArgumentError(
456             "Reftest operator should be '==' or '!='"
457           );
458       }
459     }
460     return { lhs, rhs, passed, error, msg };
461   }
463   isAcceptableDifference(maxDifference, pixelsDifferent, allowed) {
464     if (!allowed) {
465       logger.info(`No differences allowed`);
466       return pixelsDifferent === 0;
467     }
468     let [allowedDiff, allowedPixels] = allowed;
469     logger.info(
470       `Allowed ${allowedPixels.join("-")} pixels different, ` +
471         `maximum difference per channel ${allowedDiff.join("-")}`
472     );
473     return (
474       (pixelsDifferent === 0 && allowedPixels[0] == 0) ||
475       (maxDifference === 0 && allowedDiff[0] == 0) ||
476       (maxDifference >= allowedDiff[0] &&
477         maxDifference <= allowedDiff[1] &&
478         (pixelsDifferent >= allowedPixels[0] ||
479           pixelsDifferent <= allowedPixels[1]))
480     );
481   }
483   ensureFocus(win) {
484     const focusManager = Services.focus;
485     if (focusManager.activeWindow != win) {
486       focusManager.activeWindow = win;
487     }
488     this.driver.curBrowser.contentBrowser.focus();
489   }
491   async screenshot(win, url, timeout) {
492     // On windows the above doesn't *actually* set the window to be the
493     // reftest size; but *does* set the content area to be the right size;
494     // the window is given some extra borders that aren't explicable from CSS
495     let browserRect = win.gBrowser.getBoundingClientRect();
496     let canvas = null;
497     let remainingCount = this.urlCount.get(url) || 1;
498     let cache = remainingCount > 1;
499     let cacheKey = browserRect.width + "x" + browserRect.height;
500     logger.debug(
501       `screenshot ${url} remainingCount: ` +
502         `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}`
503     );
504     let reuseCanvas = false;
505     let sizedCache = this.canvasCache.get(cacheKey);
506     if (sizedCache.has(url)) {
507       logger.debug(`screenshot ${url} taken from cache`);
508       canvas = sizedCache.get(url);
509       if (!cache) {
510         sizedCache.delete(url);
511       }
512     } else {
513       let canvasPool = sizedCache.get(null);
514       if (canvasPool.length) {
515         logger.debug("reusing canvas from canvas pool");
516         canvas = canvasPool.pop();
517       } else {
518         logger.debug("using new canvas");
519         canvas = null;
520       }
521       reuseCanvas = !cache;
523       let ctxInterface = win.CanvasRenderingContext2D;
524       let flags =
525         ctxInterface.DRAWWINDOW_DRAW_CARET |
526         ctxInterface.DRAWWINDOW_DRAW_VIEW |
527         ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS;
529       if (
530         !(
531           0 <= browserRect.left &&
532           0 <= browserRect.top &&
533           win.innerWidth >= browserRect.width &&
534           win.innerHeight >= browserRect.height
535         )
536       ) {
537         logger.error(`Invalid window dimensions:
538 browserRect.left: ${browserRect.left}
539 browserRect.top: ${browserRect.top}
540 win.innerWidth: ${win.innerWidth}
541 browserRect.width: ${browserRect.width}
542 win.innerHeight: ${win.innerHeight}
543 browserRect.height: ${browserRect.height}`);
544         throw new Error("Window has incorrect dimensions");
545       }
547       url = new URL(url).href; // normalize the URL
548       logger.debug(`Starting load of ${url}`);
549       let navigateOpts = {
550         commandId: this.driver.listener.activeMessageId,
551         pageTimeout: timeout,
552       };
553       if (this.lastURL === url) {
554         logger.debug(`Refreshing page`);
555         await this.driver.listener.refresh(navigateOpts);
556       } else {
557         navigateOpts.url = url;
558         navigateOpts.loadEventExpected = false;
559         await this.driver.listener.get(navigateOpts);
560         this.lastURL = url;
561       }
563       this.ensureFocus(win);
564       await this.driver.listener.reftestWait(url, this.remote);
566       canvas = await capture.canvas(
567         win,
568         win.docShell.browsingContext,
569         0, // left
570         0, // top
571         browserRect.width,
572         browserRect.height,
573         { canvas, flags, readback: true }
574       );
575     }
576     if (
577       canvas.width !== browserRect.width ||
578       canvas.height !== browserRect.height
579     ) {
580       logger.warn(
581         `Canvas dimensions changed to ${canvas.width}x${canvas.height}`
582       );
583       reuseCanvas = false;
584       cache = false;
585     }
586     if (cache) {
587       sizedCache.set(url, canvas);
588     }
589     this.urlCount.set(url, remainingCount - 1);
590     return { canvas, reuseCanvas };
591   }
594 class DefaultMap extends Map {
595   constructor(iterable, defaultFactory) {
596     super(iterable);
597     this.defaultFactory = defaultFactory;
598   }
600   get(key) {
601     if (this.has(key)) {
602       return super.get(key);
603     }
605     let v = this.defaultFactory();
606     this.set(key, v);
607     return v;
608   }