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/. */
7 ChromeUtils.defineESModuleGetters(lazy, {
8 clearTimeout: "resource://gre/modules/Timer.sys.mjs",
9 E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
10 setTimeout: "resource://gre/modules/Timer.sys.mjs",
12 AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
13 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
14 capture: "chrome://remote/content/shared/Capture.sys.mjs",
15 Log: "chrome://remote/content/shared/Log.sys.mjs",
16 navigate: "chrome://remote/content/marionette/navigate.sys.mjs",
17 print: "chrome://remote/content/shared/PDF.sys.mjs",
18 windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
21 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
22 lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
25 const XHTML_NS = "http://www.w3.org/1999/xhtml";
26 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
28 const SCREENSHOT_MODE = {
41 const DEFAULT_REFTEST_WIDTH = 600;
42 const DEFAULT_REFTEST_HEIGHT = 600;
44 // reftest-print page dimensions in cm
45 const CM_PER_INCH = 2.54;
46 const DEFAULT_PAGE_WIDTH = 5 * CM_PER_INCH;
47 const DEFAULT_PAGE_HEIGHT = 3 * CM_PER_INCH;
48 const DEFAULT_PAGE_MARGIN = 0.5 * CM_PER_INCH;
50 // CSS 96 pixels per inch, compared to pdf.js default 72 pixels per inch
51 const DEFAULT_PDF_RESOLUTION = 96 / 72;
54 * Implements an fast runner for web-platform-tests format reftests
55 * c.f. http://web-platform-tests.org/writing-tests/reftests.html.
59 export const reftest = {};
65 reftest.Runner = class {
68 this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]]));
70 this.windowUtils = null;
72 this.useRemoteTabs = lazy.AppInfo.browserTabsRemoteAutostart;
73 this.useRemoteSubframes = lazy.AppInfo.fissionAutostart;
77 * Setup the required environment for running reftests.
79 * This will open a non-browser window in which the tests will
80 * be loaded, and set up various caches for the reftest run.
82 * @param {Object<number>} urlCount
83 * Object holding a map of URL: number of times the URL
84 * will be opened during the reftest run, where that's
86 * @param {string} screenshotMode
87 * String enum representing when screenshots should be taken
89 setup(urlCount, screenshotMode, isPrint = false) {
90 this.isPrint = isPrint;
92 lazy.assert.open(this.driver.getBrowsingContext({ top: true }));
93 this.parentWindow = this.driver.getCurrentWindow();
96 SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected;
98 this.urlCount = Object.keys(urlCount || {}).reduce(
99 (map, key) => map.set(key, urlCount[key]),
107 ChromeUtils.registerWindowActor("MarionetteReftest", {
108 kind: "JSWindowActor",
111 "chrome://remote/content/marionette/actors/MarionetteReftestParent.sys.mjs",
115 "chrome://remote/content/marionette/actors/MarionetteReftestChild.sys.mjs",
117 load: { mozSystemGroup: true, capture: true },
125 * Cleanup the environment once the reftest is finished.
128 // Abort the current test if any.
131 // Unregister the JSWindowActors.
132 ChromeUtils.unregisterWindowActor("MarionetteReftest");
135 async ensureWindow(timeout, width, height) {
136 lazy.logger.debug(`ensuring we have a window ${width}x${height}`);
138 if (this.reftestWin && !this.reftestWin.closed) {
139 let browserRect = this.reftestWin.gBrowser.getBoundingClientRect();
140 if (browserRect.width === width && browserRect.height === height) {
141 return this.reftestWin;
143 lazy.logger.debug(`current: ${browserRect.width}x${browserRect.height}`);
147 if (lazy.AppInfo.isAndroid) {
148 lazy.logger.debug("Using current window");
149 reftestWin = this.parentWindow;
150 await lazy.navigate.waitForNavigationCompleted(this.driver, () => {
151 const browsingContext = this.driver.getBrowsingContext();
152 lazy.navigate.navigateTo(browsingContext, "about:blank");
155 lazy.logger.debug("Using separate window");
156 if (this.reftestWin && !this.reftestWin.closed) {
157 this.reftestWin.close();
159 reftestWin = await this.openWindow(width, height);
162 this.setupWindow(reftestWin, width, height);
163 this.windowUtils = reftestWin.windowUtils;
164 this.reftestWin = reftestWin;
166 let windowHandle = lazy.windowManager.getWindowProperties(reftestWin);
167 await this.driver.setWindowHandle(windowHandle, true);
169 const url = await this.driver._getCurrentURL();
170 this.lastURL = url.href;
171 lazy.logger.debug(`loaded initial URL: ${this.lastURL}`);
173 let browserRect = reftestWin.gBrowser.getBoundingClientRect();
174 lazy.logger.debug(`new: ${browserRect.width}x${browserRect.height}`);
179 async openWindow(width, height) {
180 lazy.assert.positiveInteger(width);
181 lazy.assert.positiveInteger(height);
183 let reftestWin = this.parentWindow.open(
184 "chrome://remote/content/marionette/reftest.xhtml",
186 `chrome,height=${height},width=${width}`
189 await new Promise(resolve => {
190 reftestWin.addEventListener("load", resolve, { once: true });
195 setupWindow(reftestWin, width, height) {
197 if (lazy.AppInfo.isAndroid) {
198 browser = reftestWin.document.getElementsByTagName("browser")[0];
199 browser.setAttribute("remote", "false");
201 browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser");
202 browser.permanentKey = {};
203 browser.setAttribute("id", "browser");
204 browser.setAttribute("type", "content");
205 browser.setAttribute("primary", "true");
206 browser.setAttribute("remote", this.useRemoteTabs ? "true" : "false");
208 // Make sure the browser element is exactly the right size, no matter
209 // what size our window is
210 const windowStyle = `
214 min-width: ${width}px; min-height: ${height}px;
215 max-width: ${width}px; max-height: ${height}px;
216 color-scheme: env(-moz-content-preferred-color-scheme);
218 browser.setAttribute("style", windowStyle);
220 if (!lazy.AppInfo.isAndroid) {
221 let doc = reftestWin.document.documentElement;
222 while (doc.firstChild) {
223 doc.firstChild.remove();
225 doc.appendChild(browser);
227 if (reftestWin.BrowserApp) {
228 reftestWin.BrowserApp = browser;
230 reftestWin.gBrowser = browser;
235 if (this.reftestWin && this.reftestWin != this.parentWindow) {
236 await this.driver.closeChromeWindow();
237 let parentHandle = lazy.windowManager.getWindowProperties(
240 await this.driver.setWindowHandle(parentHandle);
242 this.reftestWin = null;
246 * Run a specific reftest.
248 * The assumed semantics are those of web-platform-tests where
249 * references form a tree and each test must meet all the conditions
250 * to reach one leaf node of the tree in order for the overall test
253 * @param {string} testUrl
254 * URL of the test itself.
255 * @param {Array.<Array>} references
256 * Array representing a tree of references to try.
258 * Each item in the array represents a single reference node and
259 * has the form <code>[referenceUrl, references, relation]</code>,
260 * where <var>referenceUrl</var> is a string to the URL, relation
261 * is either <code>==</code> or <code>!=</code> depending on the
262 * type of reftest, and references is another array containing
263 * items of the same form, representing further comparisons treated
264 * as AND with the current item. Sibling entries are treated as OR.
266 * For example with testUrl of T:
269 * references = [[A, [[B, [], ==]], ==]]
270 * Must have T == A AND A == B to pass
272 * references = [[A, [], ==], [B, [], !=]
273 * Must have T == A OR T != B
275 * references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]]
276 * Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D)
279 * @param {string} expected
280 * Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>).
281 * @param {number} timeout
282 * Test timeout in milliseconds.
285 * Result object with fields status, message and extra.
293 width = DEFAULT_REFTEST_WIDTH,
294 height = DEFAULT_REFTEST_HEIGHT
298 let timeoutPromise = new Promise(resolve => {
299 timerId = lazy.setTimeout(() => {
300 resolve({ status: STATUS.TIMEOUT, message: null, extra: {} });
304 let testRunner = (async () => {
307 result = await this.runTest(
318 status: STATUS.ERROR,
327 let result = await Promise.race([testRunner, timeoutPromise]);
328 lazy.clearTimeout(timerId);
329 if (result.status === STATUS.TIMEOUT) {
345 let win = await this.ensureWindow(timeout, width, height);
347 function toBase64(screenshot) {
348 let dataURL = screenshot.canvas.toDataURL();
349 return dataURL.split(",")[1];
359 let screenshotData = [];
362 for (let i = references.length - 1; i >= 0; i--) {
363 let item = references[i];
364 stack.push([testUrl, ...item]);
369 while (stack.length && !done) {
370 let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop();
371 result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`;
375 comparison = await this.compareUrls(
393 if (comparison.msg) {
394 result.message += `${comparison.msg}\n`;
396 if (comparison.error !== null) {
397 result.status = STATUS.ERROR;
398 result.message += String(comparison.error);
399 result.stack = comparison.error.stack;
402 function recordScreenshot() {
403 let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : "";
404 let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : "";
405 screenshotData.push([
406 { url: lhsUrl, screenshot: encodedLHS },
408 { url: rhsUrl, screenshot: encodedRHS },
412 if (this.screenshotMode === SCREENSHOT_MODE.always) {
416 if (comparison.passed) {
417 if (references.length) {
418 for (let i = references.length - 1; i >= 0; i--) {
419 let item = references[i];
420 stack.push([rhsUrl, ...item]);
423 // Reached a leaf node so all of one reference chain passed
424 result.status = STATUS.PASS;
426 this.screenshotMode <= SCREENSHOT_MODE.fail &&
427 expected != result.status
433 } else if (!stack.length || result.status == STATUS.ERROR) {
434 // If we don't have any alternatives to try then this will be
435 // the last iteration, so save the failing screenshots if required.
436 let isFail = this.screenshotMode === SCREENSHOT_MODE.fail;
437 let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected;
438 if (isFail || (isUnexpected && expected != result.status)) {
443 // Return any reusable canvases to the pool
444 let cacheKey = width + "x" + height;
445 let canvasPool = this.canvasCache.get(cacheKey).get(null);
446 [comparison.lhs, comparison.rhs].map(screenshot => {
447 if (screenshot !== null && screenshot.reuseCanvas) {
448 canvasPool.push(screenshot.canvas);
452 `Canvas pool (${cacheKey}) is of length ${canvasPool.length}`
456 if (screenshotData.length) {
457 // For now the tbpl formatter only accepts one screenshot, so just
458 // return the last one we took.
459 let lastScreenshot = screenshotData[screenshotData.length - 1];
460 // eslint-disable-next-line camelcase
461 result.extra.reftest_screenshots = lastScreenshot;
476 lazy.logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`);
478 if (relation !== "==" && relation != "!=") {
479 throw new error.InvalidArgumentError(
480 "Reftest operator should be '==' or '!='"
484 let lhsIter, lhsCount, rhsIter, rhsCount;
486 // Take the reference screenshot first so that if we pause
487 // we see the test rendering
488 rhsIter = [await this.screenshot(win, rhsUrl, timeout)].values();
489 lhsIter = [await this.screenshot(win, lhsUrl, timeout)].values();
490 lhsCount = rhsCount = 1;
492 [rhsIter, rhsCount] = await this.screenshotPaginated(
498 [lhsIter, lhsCount] = await this.screenshotPaginated(
508 let pixelsDifferent = null;
509 let maxDifferences = {};
512 if (lhsCount != rhsCount) {
513 passed = relation == "!=";
515 msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`;
521 lazy.logger.debug(`Comparing ${lhsCount} pages`);
522 if (passed === null) {
523 for (let i = 0; i < lhsCount; i++) {
524 lhs = (await lhsIter.next()).value;
525 rhs = (await rhsIter.next()).value;
527 `lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}`
530 `rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}`
533 lhs.canvas.width != rhs.canvas.width ||
534 lhs.canvas.height != rhs.canvas.height
537 `Got different page sizes; test is ` +
538 `${lhs.canvas.width}x${lhs.canvas.height}px, ref is ` +
539 `${rhs.canvas.width}x${rhs.canvas.height}px`;
544 pixelsDifferent = this.windowUtils.compareCanvases(
555 let areEqual = this.isAcceptableDifference(
556 maxDifferences.value,
561 `Page ${i + 1} maxDifferences: ${maxDifferences.value} ` +
562 `pixelsDifferent: ${pixelsDifferent}`
565 `Page ${i + 1} ${areEqual ? "compare equal" : "compare unequal"}`
568 if (relation == "==") {
571 `Found ${pixelsDifferent} pixels different, ` +
572 `maximum difference per channel ${maxDifferences.value}`;
574 msg += ` on page ${i + 1}`;
584 // If passed isn't set we got to the end without finding differences
585 if (passed === null) {
586 if (relation == "==") {
589 msg = `mismatch reftest has no differences`;
593 return { lhs, rhs, passed, error, msg };
596 isAcceptableDifference(maxDifference, pixelsDifferent, allowed) {
598 lazy.logger.info(`No differences allowed`);
599 return pixelsDifferent === 0;
601 let [allowedDiff, allowedPixels] = allowed;
603 `Allowed ${allowedPixels.join("-")} pixels different, ` +
604 `maximum difference per channel ${allowedDiff.join("-")}`
607 (pixelsDifferent === 0 && allowedPixels[0] == 0) ||
608 (maxDifference === 0 && allowedDiff[0] == 0) ||
609 (maxDifference >= allowedDiff[0] &&
610 maxDifference <= allowedDiff[1] &&
611 (pixelsDifferent >= allowedPixels[0] ||
612 pixelsDifferent <= allowedPixels[1]))
617 const focusManager = Services.focus;
618 if (focusManager.activeWindow != win) {
621 this.driver.curBrowser.contentBrowser.focus();
624 updateBrowserRemotenessByURL(browser, url) {
625 // We don't use remote tabs on Android.
626 if (lazy.AppInfo.isAndroid) {
629 let oa = lazy.E10SUtils.predictOriginAttributes({ browser });
630 let remoteType = lazy.E10SUtils.getRemoteTypeForURI(
633 this.useRemoteSubframes,
634 lazy.E10SUtils.DEFAULT_REMOTE_TYPE,
639 // Only re-construct the browser if its remote type needs to change.
640 if (browser.remoteType !== remoteType) {
641 if (remoteType === lazy.E10SUtils.NOT_REMOTE) {
642 browser.removeAttribute("remote");
643 browser.removeAttribute("remoteType");
645 browser.setAttribute("remote", "true");
646 browser.setAttribute("remoteType", remoteType);
649 browser.changeRemoteness({ remoteType });
654 async loadTestUrl(win, url, timeout, warnOnOverflow = true) {
655 const browsingContext = this.driver.getBrowsingContext({ top: true });
656 const webProgress = browsingContext.webProgress;
658 lazy.logger.debug(`Starting load of ${url}`);
659 if (this.lastURL === url) {
660 lazy.logger.debug(`Refreshing page`);
661 await lazy.navigate.waitForNavigationCompleted(this.driver, () => {
662 lazy.navigate.refresh(browsingContext);
665 // HACK: DocumentLoadListener currently doesn't know how to
666 // process-switch loads in a non-tabbed <browser>. We need to manually
667 // set the browser's remote type in order to ensure that the load
668 // happens in the correct process.
671 this.updateBrowserRemotenessByURL(win.gBrowser, url);
672 lazy.navigate.navigateTo(browsingContext, url);
677 this.ensureFocus(win);
679 // TODO: Move all the wait logic into the parent process (bug 1669787)
680 let isReftestReady = false;
681 while (!isReftestReady) {
682 // Note: We cannot compare the URL here. Before the navigation is complete
683 // currentWindowGlobal.documentURI.spec will still point to the old URL.
685 webProgress.browsingContext.currentWindowGlobal.getActor(
688 isReftestReady = await actor.reftestWait(
696 async screenshot(win, url, timeout) {
697 // On windows the above doesn't *actually* set the window to be the
698 // reftest size; but *does* set the content area to be the right size;
699 // the window is given some extra borders that aren't explicable from CSS
700 let browserRect = win.gBrowser.getBoundingClientRect();
702 let remainingCount = this.urlCount.get(url) || 1;
703 let cache = remainingCount > 1;
704 let cacheKey = browserRect.width + "x" + browserRect.height;
706 `screenshot ${url} remainingCount: ` +
707 `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}`
709 let reuseCanvas = false;
710 let sizedCache = this.canvasCache.get(cacheKey);
711 if (sizedCache.has(url)) {
712 lazy.logger.debug(`screenshot ${url} taken from cache`);
713 canvas = sizedCache.get(url);
715 sizedCache.delete(url);
718 let canvasPool = sizedCache.get(null);
719 if (canvasPool.length) {
720 lazy.logger.debug("reusing canvas from canvas pool");
721 canvas = canvasPool.pop();
723 lazy.logger.debug("using new canvas");
726 reuseCanvas = !cache;
728 let ctxInterface = win.CanvasRenderingContext2D;
730 ctxInterface.DRAWWINDOW_DRAW_CARET |
731 ctxInterface.DRAWWINDOW_DRAW_VIEW |
732 ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS;
736 0 <= browserRect.left &&
737 0 <= browserRect.top &&
738 win.innerWidth >= browserRect.width &&
739 win.innerHeight >= browserRect.height
742 lazy.logger.error(`Invalid window dimensions:
743 browserRect.left: ${browserRect.left}
744 browserRect.top: ${browserRect.top}
745 win.innerWidth: ${win.innerWidth}
746 browserRect.width: ${browserRect.width}
747 win.innerHeight: ${win.innerHeight}
748 browserRect.height: ${browserRect.height}`);
749 throw new Error("Window has incorrect dimensions");
752 url = new URL(url).href; // normalize the URL
754 await this.loadTestUrl(win, url, timeout);
756 canvas = await lazy.capture.canvas(
758 win.docShell.browsingContext,
763 { canvas, flags, readback: true }
767 canvas.width !== browserRect.width ||
768 canvas.height !== browserRect.height
771 `Canvas dimensions changed to ${canvas.width}x${canvas.height}`
777 sizedCache.set(url, canvas);
779 this.urlCount.set(url, remainingCount - 1);
780 return { canvas, reuseCanvas };
783 async screenshotPaginated(win, url, timeout, pageRanges) {
784 url = new URL(url).href; // normalize the URL
785 await this.loadTestUrl(win, url, timeout, false);
787 const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT];
788 const margin = DEFAULT_PAGE_MARGIN;
789 const settings = lazy.print.addDefaultSettings({
803 const printSettings = lazy.print.getPrintSettings(settings);
805 const binaryString = await lazy.print.printToBinaryString(
806 win.gBrowser.browsingContext,
811 const pdf = await this.loadPdf(binaryString);
812 let pages = this.getPages(pageRanges, url, pdf.numPages);
813 return [this.renderPages(pdf, pages), pages.size];
815 lazy.logger.warn(`Loading of pdf failed`);
821 // Ensure pdf.js is loaded in the opener window
822 await new Promise((resolve, reject) => {
823 const doc = this.parentWindow.document;
824 const script = doc.createElement("script");
825 script.type = "module";
826 script.src = "resource://pdf.js/build/pdf.mjs";
827 script.onload = resolve;
828 script.onerror = () => reject(new Error("pdfjs load failed"));
829 doc.documentElement.appendChild(script);
831 this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc =
832 "resource://pdf.js/build/pdf.worker.mjs";
835 async loadPdf(data) {
836 return this.parentWindow.pdfjsLib.getDocument({ data }).promise;
839 async *renderPages(pdf, pages) {
841 for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
842 if (!pages.has(pageNumber)) {
843 lazy.logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`);
846 lazy.logger.info(`Rendering page ${pageNumber}/${pdf.numPages}`);
847 let page = await pdf.getPage(pageNumber);
848 let viewport = page.getViewport({ scale: DEFAULT_PDF_RESOLUTION });
849 // Prepare canvas using PDF page dimensions
850 if (canvas === null) {
851 canvas = this.parentWindow.document.createElementNS(XHTML_NS, "canvas");
852 canvas.height = viewport.height;
853 canvas.width = viewport.width;
856 // Render PDF page into canvas context
857 let context = canvas.getContext("2d");
858 let renderContext = {
859 canvasContext: context,
862 await page.render(renderContext).promise;
863 yield { canvas, reuseCanvas: false };
867 getPages(pageRanges, url, totalPages) {
868 // Extract test id from URL without parsing
869 let afterHost = url.slice(url.indexOf(":") + 3);
870 afterHost = afterHost.slice(afterHost.indexOf("/"));
871 const ranges = pageRanges[afterHost];
875 for (let i = 1; i <= totalPages; i++) {
881 for (let rangePart of ranges) {
882 if (rangePart.length === 1) {
883 rv.add(rangePart[0]);
885 if (rangePart.length !== 2) {
887 `Page ranges must be <int> or <int> '-' <int>, got ${rangePart}`
890 let [lower, upper] = rangePart;
891 if (lower === null) {
894 if (upper === null) {
897 for (let i = lower; i <= upper; i++) {
906 class DefaultMap extends Map {
907 constructor(iterable, defaultFactory) {
909 this.defaultFactory = defaultFactory;
914 return super.get(key);
917 let v = this.defaultFactory();