gecko-dev/testing/marionette/reftest.js

875 строки
25 KiB
JavaScript

/* 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";
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const { Preferences } = ChromeUtils.import(
"resource://gre/modules/Preferences.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { assert } = ChromeUtils.import("chrome://marionette/content/assert.js");
const { capture } = ChromeUtils.import(
"chrome://marionette/content/capture.js"
);
const { InvalidArgumentError } = ChromeUtils.import(
"chrome://marionette/content/error.js"
);
const { Log } = ChromeUtils.import("chrome://marionette/content/log.js");
const { print } = ChromeUtils.import("chrome://marionette/content/print.js");
XPCOMUtils.defineLazyGetter(this, "logger", Log.get);
ChromeUtils.defineModuleGetter(
this,
"E10SUtils",
"resource://gre/modules/E10SUtils.jsm"
);
this.EXPORTED_SYMBOLS = ["reftest"];
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const PREF_E10S = "browser.tabs.remote.autostart";
const PREF_FISSION = "fission.autostart";
const SCREENSHOT_MODE = {
unexpected: 0,
fail: 1,
always: 2,
};
const STATUS = {
PASS: "PASS",
FAIL: "FAIL",
ERROR: "ERROR",
TIMEOUT: "TIMEOUT",
};
const DEFAULT_REFTEST_WIDTH = 600;
const DEFAULT_REFTEST_HEIGHT = 600;
// reftest-print page dimensions in cm
const CM_PER_INCH = 2.54;
const DEFAULT_PAGE_WIDTH = 5 * CM_PER_INCH;
const DEFAULT_PAGE_HEIGHT = 3 * CM_PER_INCH;
const DEFAULT_PAGE_MARGIN = 0.5 * CM_PER_INCH;
// CSS 96 pixels per inch, compared to pdf.js default 72 pixels per inch
const DEFAULT_PDF_RESOLUTION = 96 / 72;
/**
* Implements an fast runner for web-platform-tests format reftests
* c.f. http://web-platform-tests.org/writing-tests/reftests.html.
*
* @namespace
*/
this.reftest = {};
/**
* @memberof reftest
* @class Runner
*/
reftest.Runner = class {
constructor(driver) {
this.driver = driver;
this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]]));
this.isPrint = null;
this.windowUtils = null;
this.lastURL = null;
this.useRemoteTabs = Preferences.get(PREF_E10S);
this.useRemoteSubframes = Preferences.get(PREF_FISSION);
}
/**
* Setup the required environment for running reftests.
*
* This will open a non-browser window in which the tests will
* be loaded, and set up various caches for the reftest run.
*
* @param {Object.<Number>} urlCount
* Object holding a map of URL: number of times the URL
* will be opened during the reftest run, where that's
* greater than 1.
* @param {string} screenshotMode
* String enum representing when screenshots should be taken
*/
setup(urlCount, screenshotMode, isPrint = false) {
this.isPrint = isPrint;
this.parentWindow = assert.open(this.driver.getCurrentWindow());
this.screenshotMode =
SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected;
this.urlCount = Object.keys(urlCount || {}).reduce(
(map, key) => map.set(key, urlCount[key]),
new Map()
);
if (isPrint) {
this.loadPdfJs();
}
}
async ensureWindow(timeout, width, height) {
logger.debug(`ensuring we have a window ${width}x${height}`);
if (this.reftestWin && !this.reftestWin.closed) {
let browserRect = this.reftestWin.gBrowser.getBoundingClientRect();
if (browserRect.width === width && browserRect.height === height) {
return this.reftestWin;
}
logger.debug(`current: ${browserRect.width}x${browserRect.height}`);
}
let reftestWin;
if (Services.appinfo.OS == "Android") {
logger.debug("Using current window");
reftestWin = this.parentWindow;
await this.driver.listener.navigateTo({
commandID: this.driver.listener.activeMessageId,
pageTimeout: timeout,
url: "about:blank",
loadEventExpected: false,
});
} else {
logger.debug("Using separate window");
if (this.reftestWin && !this.reftestWin.closed) {
this.reftestWin.close();
}
reftestWin = await this.openWindow(width, height);
}
this.setupWindow(reftestWin, width, height);
this.windowUtils = reftestWin.windowUtils;
this.reftestWin = reftestWin;
let found = this.driver.findWindow([reftestWin], () => true);
await this.driver.setWindowHandle(found, true);
let browserRect = reftestWin.gBrowser.getBoundingClientRect();
logger.debug(`new: ${browserRect.width}x${browserRect.height}`);
return reftestWin;
}
async openWindow(width, height) {
assert.positiveInteger(width);
assert.positiveInteger(height);
let reftestWin = this.parentWindow.open(
"chrome://marionette/content/reftest.xhtml",
"reftest",
`chrome,height=${height},width=${width}`
);
await new Promise(resolve => {
reftestWin.addEventListener("load", resolve, { once: true });
});
return reftestWin;
}
setupWindow(reftestWin, width, height) {
let browser;
if (Services.appinfo.OS === "Android") {
browser = reftestWin.document.getElementsByTagName("browser")[0];
browser.setAttribute("remote", "false");
} else {
browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser");
browser.permanentKey = {};
browser.setAttribute("id", "browser");
browser.setAttribute("type", "content");
browser.setAttribute("primary", "true");
browser.setAttribute("remote", this.useRemoteTabs ? "true" : "false");
}
// Make sure the browser element is exactly the right size, no matter
// what size our window is
const windowStyle = `padding: 0px; margin: 0px; border:none;
min-width: ${width}px; min-height: ${height}px;
max-width: ${width}px; max-height: ${height}px`;
browser.setAttribute("style", windowStyle);
if (Services.appinfo.OS !== "Android") {
let doc = reftestWin.document.documentElement;
while (doc.firstChild) {
doc.firstChild.remove();
}
doc.appendChild(browser);
}
if (reftestWin.BrowserApp) {
reftestWin.BrowserApp = browser;
}
reftestWin.gBrowser = browser;
return reftestWin;
}
async abort() {
if (this.reftestWin && this.reftestWin != this.parentWindow) {
this.driver.closeChromeWindow();
let parentHandle = this.driver.findWindow(
[this.parentWindow],
() => true
);
await this.driver.setWindowHandle(parentHandle);
}
this.reftestWin = null;
}
/**
* Run a specific reftest.
*
* The assumed semantics are those of web-platform-tests where
* references form a tree and each test must meet all the conditions
* to reach one leaf node of the tree in order for the overall test
* to pass.
*
* @param {string} testUrl
* URL of the test itself.
* @param {Array.<Array>} references
* Array representing a tree of references to try.
*
* Each item in the array represents a single reference node and
* has the form <code>[referenceUrl, references, relation]</code>,
* where <var>referenceUrl</var> is a string to the URL, relation
* is either <code>==</code> or <code>!=</code> depending on the
* type of reftest, and references is another array containing
* items of the same form, representing further comparisons treated
* as AND with the current item. Sibling entries are treated as OR.
*
* For example with testUrl of T:
*
* <pre><code>
* references = [[A, [[B, [], ==]], ==]]
* Must have T == A AND A == B to pass
*
* references = [[A, [], ==], [B, [], !=]
* Must have T == A OR T != B
*
* references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]]
* Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D)
* </code></pre>
*
* @param {string} expected
* Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>).
* @param {number} timeout
* Test timeout in milliseconds.
*
* @return {Object}
* Result object with fields status, message and extra.
*/
async run(
testUrl,
references,
expected,
timeout,
pageRanges = {},
width = DEFAULT_REFTEST_WIDTH,
height = DEFAULT_REFTEST_HEIGHT
) {
let timeoutHandle;
let timeoutPromise = new Promise(resolve => {
timeoutHandle = this.parentWindow.setTimeout(() => {
resolve({ status: STATUS.TIMEOUT, message: null, extra: {} });
}, timeout);
});
let testRunner = (async () => {
let result;
try {
result = await this.runTest(
testUrl,
references,
expected,
timeout,
pageRanges,
width,
height
);
} catch (e) {
result = {
status: STATUS.ERROR,
message: String(e),
stack: e.stack,
extra: {},
};
}
return result;
})();
let result = await Promise.race([testRunner, timeoutPromise]);
this.parentWindow.clearTimeout(timeoutHandle);
if (result.status === STATUS.TIMEOUT) {
await this.abort();
}
return result;
}
async runTest(
testUrl,
references,
expected,
timeout,
pageRanges,
width,
height
) {
let win = await this.ensureWindow(timeout, width, height);
function toBase64(screenshot) {
let dataURL = screenshot.canvas.toDataURL();
return dataURL.split(",")[1];
}
let result = {
status: STATUS.FAIL,
message: "",
stack: null,
extra: {},
};
let screenshotData = [];
let stack = [];
for (let i = references.length - 1; i >= 0; i--) {
let item = references[i];
stack.push([testUrl, ...item]);
}
let done = false;
while (stack.length && !done) {
let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop();
result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`;
let comparison;
try {
comparison = await this.compareUrls(
win,
lhsUrl,
rhsUrl,
relation,
timeout,
pageRanges,
extras
);
} catch (e) {
comparison = {
lhs: null,
rhs: null,
passed: false,
error: e,
msg: null,
};
}
if (comparison.msg) {
result.message += `${comparison.msg}\n`;
}
if (comparison.error !== null) {
result.status = STATUS.ERROR;
result.message += String(comparison.error);
result.stack = comparison.error.stack;
}
function recordScreenshot() {
let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : "";
let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : "";
screenshotData.push([
{ url: lhsUrl, screenshot: encodedLHS },
relation,
{ url: rhsUrl, screenshot: encodedRHS },
]);
}
if (this.screenshotMode === SCREENSHOT_MODE.always) {
recordScreenshot();
}
if (comparison.passed) {
if (references.length) {
for (let i = references.length - 1; i >= 0; i--) {
let item = references[i];
stack.push([rhsUrl, ...item]);
}
} else {
// Reached a leaf node so all of one reference chain passed
result.status = STATUS.PASS;
if (
this.screenshotMode <= SCREENSHOT_MODE.fail &&
expected != result.status
) {
recordScreenshot();
}
done = true;
}
} else if (!stack.length || result.status == STATUS.ERROR) {
// If we don't have any alternatives to try then this will be
// the last iteration, so save the failing screenshots if required.
let isFail = this.screenshotMode === SCREENSHOT_MODE.fail;
let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected;
if (isFail || (isUnexpected && expected != result.status)) {
recordScreenshot();
}
}
// Return any reusable canvases to the pool
let cacheKey = width + "x" + height;
let canvasPool = this.canvasCache.get(cacheKey).get(null);
[comparison.lhs, comparison.rhs].map(screenshot => {
if (screenshot !== null && screenshot.reuseCanvas) {
canvasPool.push(screenshot.canvas);
}
});
logger.debug(
`Canvas pool (${cacheKey}) is of length ${canvasPool.length}`
);
}
if (screenshotData.length) {
// For now the tbpl formatter only accepts one screenshot, so just
// return the last one we took.
let lastScreenshot = screenshotData[screenshotData.length - 1];
// eslint-disable-next-line camelcase
result.extra.reftest_screenshots = lastScreenshot;
}
return result;
}
async compareUrls(
win,
lhsUrl,
rhsUrl,
relation,
timeout,
pageRanges,
extras
) {
logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`);
if (relation !== "==" && relation != "!=") {
throw new InvalidArgumentError("Reftest operator should be '==' or '!='");
}
let lhsIter, lhsCount, rhsIter, rhsCount;
if (!this.isPrint) {
// Take the reference screenshot first so that if we pause
// we see the test rendering
rhsIter = [await this.screenshot(win, rhsUrl, timeout)].values();
lhsIter = [await this.screenshot(win, lhsUrl, timeout)].values();
lhsCount = rhsCount = 1;
} else {
[rhsIter, rhsCount] = await this.screenshotPaginated(
win,
rhsUrl,
timeout,
pageRanges
);
[lhsIter, lhsCount] = await this.screenshotPaginated(
win,
lhsUrl,
timeout,
pageRanges
);
}
let passed = null;
let error = null;
let pixelsDifferent = null;
let maxDifferences = {};
let msg = null;
if (lhsCount != rhsCount) {
passed = false;
msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`;
}
let lhs = null;
let rhs = null;
logger.debug(`Comparing ${lhsCount} pages`);
if (passed === null) {
for (let i = 0; i < lhsCount; i++) {
lhs = (await lhsIter.next()).value;
rhs = (await rhsIter.next()).value;
logger.debug(
`lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}`
);
logger.debug(
`rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}`
);
try {
pixelsDifferent = this.windowUtils.compareCanvases(
lhs.canvas,
rhs.canvas,
maxDifferences
);
} catch (e) {
error = e;
passed = false;
break;
}
let areEqual = this.isAcceptableDifference(
maxDifferences.value,
pixelsDifferent,
extras.fuzzy
);
logger.debug(
`Page ${i + 1} maxDifferences: ${maxDifferences.value} ` +
`pixelsDifferent: ${pixelsDifferent}`
);
logger.debug(
`Page ${i + 1} ${areEqual ? "compare equal" : "compare unequal"}`
);
if (!areEqual) {
if (relation == "==") {
passed = false;
msg =
`Found ${pixelsDifferent} pixels different, ` +
`maximum difference per channel ${maxDifferences.value}`;
if (this.isPrint) {
msg += ` on page ${i + 1}`;
}
} else {
passed = true;
}
break;
}
}
}
// If passed isn't set we got to the end without finding differences
if (passed === null) {
if (relation == "==") {
passed = true;
} else {
msg = `mismatch reftest has no differences`;
passed = false;
}
}
return { lhs, rhs, passed, error, msg };
}
isAcceptableDifference(maxDifference, pixelsDifferent, allowed) {
if (!allowed) {
logger.info(`No differences allowed`);
return pixelsDifferent === 0;
}
let [allowedDiff, allowedPixels] = allowed;
logger.info(
`Allowed ${allowedPixels.join("-")} pixels different, ` +
`maximum difference per channel ${allowedDiff.join("-")}`
);
return (
(pixelsDifferent === 0 && allowedPixels[0] == 0) ||
(maxDifference === 0 && allowedDiff[0] == 0) ||
(maxDifference >= allowedDiff[0] &&
maxDifference <= allowedDiff[1] &&
(pixelsDifferent >= allowedPixels[0] ||
pixelsDifferent <= allowedPixels[1]))
);
}
ensureFocus(win) {
const focusManager = Services.focus;
if (focusManager.activeWindow != win) {
win.focus();
}
this.driver.curBrowser.contentBrowser.focus();
}
updateBrowserRemotenessByURL(browser, url) {
// We don't use remote tabs on Android.
if (Services.appinfo.OS === "Android") {
return;
}
let remoteType = E10SUtils.getRemoteTypeForURI(
url,
this.useRemoteTabs,
this.useRemoteSubframes
);
// Only re-construct the browser if its remote type needs to change.
if (browser.remoteType !== remoteType) {
if (remoteType === E10SUtils.NOT_REMOTE) {
browser.removeAttribute("remote");
browser.removeAttribute("remoteType");
} else {
browser.setAttribute("remote", "true");
browser.setAttribute("remoteType", remoteType);
}
browser.changeRemoteness({ remoteType });
browser.construct();
// XXX: This appears to be working fine as is, should we be reinitializing
// something here? If so, what? The listener.js framescript is registered
// on the reftest.xhtml chrome window (which shouldn't be changing?), and
// driver.js uses the global message manager to listen for messages.
}
}
async loadTestUrl(win, url, timeout) {
logger.debug(`Starting load of ${url}`);
let navigateOpts = {
commandId: this.driver.listener.activeMessageId,
pageTimeout: timeout,
};
if (this.lastURL === url) {
logger.debug(`Refreshing page`);
await this.driver.listener.refresh(navigateOpts);
} else {
// HACK: DocumentLoadListener currently doesn't know how to
// process-switch loads in a non-tabbed <browser>. We need to manually
// set the browser's remote type in order to ensure that the load
// happens in the correct process.
//
// See bug 1636169.
this.updateBrowserRemotenessByURL(win.gBrowser, url);
navigateOpts.url = url;
navigateOpts.loadEventExpected = false;
await this.driver.listener.navigateTo(navigateOpts);
this.lastURL = url;
}
this.ensureFocus(win);
await this.driver.listener.reftestWait(url, this.useRemoteTabs);
}
async screenshot(win, url, timeout) {
// On windows the above doesn't *actually* set the window to be the
// reftest size; but *does* set the content area to be the right size;
// the window is given some extra borders that aren't explicable from CSS
let browserRect = win.gBrowser.getBoundingClientRect();
let canvas = null;
let remainingCount = this.urlCount.get(url) || 1;
let cache = remainingCount > 1;
let cacheKey = browserRect.width + "x" + browserRect.height;
logger.debug(
`screenshot ${url} remainingCount: ` +
`${remainingCount} cache: ${cache} cacheKey: ${cacheKey}`
);
let reuseCanvas = false;
let sizedCache = this.canvasCache.get(cacheKey);
if (sizedCache.has(url)) {
logger.debug(`screenshot ${url} taken from cache`);
canvas = sizedCache.get(url);
if (!cache) {
sizedCache.delete(url);
}
} else {
let canvasPool = sizedCache.get(null);
if (canvasPool.length) {
logger.debug("reusing canvas from canvas pool");
canvas = canvasPool.pop();
} else {
logger.debug("using new canvas");
canvas = null;
}
reuseCanvas = !cache;
let ctxInterface = win.CanvasRenderingContext2D;
let flags =
ctxInterface.DRAWWINDOW_DRAW_CARET |
ctxInterface.DRAWWINDOW_DRAW_VIEW |
ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS;
if (
!(
0 <= browserRect.left &&
0 <= browserRect.top &&
win.innerWidth >= browserRect.width &&
win.innerHeight >= browserRect.height
)
) {
logger.error(`Invalid window dimensions:
browserRect.left: ${browserRect.left}
browserRect.top: ${browserRect.top}
win.innerWidth: ${win.innerWidth}
browserRect.width: ${browserRect.width}
win.innerHeight: ${win.innerHeight}
browserRect.height: ${browserRect.height}`);
throw new Error("Window has incorrect dimensions");
}
url = new URL(url).href; // normalize the URL
await this.loadTestUrl(win, url, timeout);
canvas = await capture.canvas(
win,
win.docShell.browsingContext,
0, // left
0, // top
browserRect.width,
browserRect.height,
{ canvas, flags, readback: true }
);
}
if (
canvas.width !== browserRect.width ||
canvas.height !== browserRect.height
) {
logger.warn(
`Canvas dimensions changed to ${canvas.width}x${canvas.height}`
);
reuseCanvas = false;
cache = false;
}
if (cache) {
sizedCache.set(url, canvas);
}
this.urlCount.set(url, remainingCount - 1);
return { canvas, reuseCanvas };
}
async screenshotPaginated(win, url, timeout, pageRanges) {
url = new URL(url).href; // normalize the URL
await this.loadTestUrl(win, url, timeout);
const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT];
const margin = DEFAULT_PAGE_MARGIN;
const settings = print.addDefaultSettings({
page: {
width,
height,
},
margin: {
left: margin,
right: margin,
top: margin,
bottom: margin,
},
shrinkToFit: false,
printBackground: true,
});
const filePath = await print.printToFile(
win.gBrowser.frameLoader,
win.gBrowser.outerWindowID,
settings
);
const fp = await OS.File.open(filePath, { read: true });
try {
const pdf = await this.loadPdf(url, fp);
let pages = this.getPages(pageRanges, url, pdf.numPages);
return [this.renderPages(pdf, pages), pages.size];
} finally {
fp.close();
await OS.File.remove(filePath);
}
}
async loadPdfJs() {
// Ensure pdf.js is loaded in the opener window
await new Promise((resolve, reject) => {
const doc = this.parentWindow.document;
const script = doc.createElement("script");
script.src = "resource://pdf.js/build/pdf.js";
script.onload = resolve;
script.onerror = () => reject(new Error("pdfjs load failed"));
doc.documentElement.appendChild(script);
});
this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc =
"resource://pdf.js/build/pdf.worker.js";
}
async loadPdf(url, fp) {
const data = await fp.read();
return this.parentWindow.pdfjsLib.getDocument({ data }).promise;
}
async *renderPages(pdf, pages) {
let canvas = null;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
if (!pages.has(pageNumber)) {
logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`);
continue;
}
logger.info(`Rendering page ${pageNumber}/${pdf.numPages}`);
let page = await pdf.getPage(pageNumber);
let viewport = page.getViewport({ scale: DEFAULT_PDF_RESOLUTION });
// Prepare canvas using PDF page dimensions
if (canvas === null) {
canvas = this.parentWindow.document.createElementNS(XHTML_NS, "canvas");
canvas.height = viewport.height;
canvas.width = viewport.width;
}
// Render PDF page into canvas context
let context = canvas.getContext("2d");
let renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise;
yield { canvas, reuseCanvas: false };
}
}
getPages(pageRanges, url, totalPages) {
// Extract test id from URL without parsing
let afterHost = url.slice(url.indexOf(":") + 3);
afterHost = afterHost.slice(afterHost.indexOf("/"));
const ranges = pageRanges[afterHost];
let rv = new Set();
if (!ranges) {
for (let i = 1; i <= totalPages; i++) {
rv.add(i);
}
return rv;
}
for (let rangePart of ranges) {
if (rangePart.length === 1) {
rv.add(rangePart[0]);
} else {
if (rangePart.length !== 2) {
throw new Error(
`Page ranges must be <int> or <int> '-' <int>, got ${rangePart}`
);
}
let [lower, upper] = rangePart;
if (lower === null) {
lower = 1;
}
if (upper === null) {
upper = totalPages;
}
for (let i = lower; i <= upper; i++) {
rv.add(i);
}
}
}
return rv;
}
};
class DefaultMap extends Map {
constructor(iterable, defaultFactory) {
super(iterable);
this.defaultFactory = defaultFactory;
}
get(key) {
if (this.has(key)) {
return super.get(key);
}
let v = this.defaultFactory();
this.set(key, v);
return v;
}
}