summaryrefslogtreecommitdiffstats
path: root/gfx/layers/apz/test/mochitest/apz_test_utils.js
diff options
context:
space:
mode:
Diffstat (limited to 'gfx/layers/apz/test/mochitest/apz_test_utils.js')
-rw-r--r--gfx/layers/apz/test/mochitest/apz_test_utils.js1177
1 files changed, 1177 insertions, 0 deletions
diff --git a/gfx/layers/apz/test/mochitest/apz_test_utils.js b/gfx/layers/apz/test/mochitest/apz_test_utils.js
new file mode 100644
index 0000000000..4e1f7012e4
--- /dev/null
+++ b/gfx/layers/apz/test/mochitest/apz_test_utils.js
@@ -0,0 +1,1177 @@
+// Utilities for writing APZ tests using the framework added in bug 961289
+
+// ----------------------------------------------------------------------
+// Functions that convert the APZ test data into a more usable form.
+// Every place we have a WebIDL sequence whose elements are dictionaries
+// with two elements, a key, and a value, we convert this into a JS
+// object with a property for each key/value pair. (This is the structure
+// we really want, but we can't express in directly in WebIDL.)
+// ----------------------------------------------------------------------
+
+// getHitTestConfig() expects apz_test_native_event_utils.js to be loaded as well.
+/* import-globals-from apz_test_native_event_utils.js */
+
+function convertEntries(entries) {
+ var result = {};
+ for (var i = 0; i < entries.length; ++i) {
+ result[entries[i].key] = entries[i].value;
+ }
+ return result;
+}
+
+// TODO: Clean up these rect-handling functions so that e.g. a rect returned
+// by Element.getBoundingClientRect() Just Works with them.
+function parseRect(str) {
+ var pieces = str.replace(/[()\s]+/g, "").split(",");
+ SimpleTest.is(pieces.length, 4, "expected string of form (x,y,w,h)");
+ for (var i = 0; i < 4; i++) {
+ var eq = pieces[i].indexOf("=");
+ if (eq >= 0) {
+ pieces[i] = pieces[i].substring(eq + 1);
+ }
+ }
+ return {
+ x: parseInt(pieces[0]),
+ y: parseInt(pieces[1]),
+ w: parseInt(pieces[2]),
+ h: parseInt(pieces[3]),
+ };
+}
+
+// These functions expect rects with fields named x/y/w/h, such as
+// that returned by parseRect().
+function rectContains(haystack, needle) {
+ return (
+ haystack.x <= needle.x &&
+ haystack.y <= needle.y &&
+ haystack.x + haystack.w >= needle.x + needle.w &&
+ haystack.y + haystack.h >= needle.y + needle.h
+ );
+}
+function rectToString(rect) {
+ return "(" + rect.x + "," + rect.y + "," + rect.w + "," + rect.h + ")";
+}
+function assertRectContainment(
+ haystackRect,
+ haystackDesc,
+ needleRect,
+ needleDesc
+) {
+ SimpleTest.ok(
+ rectContains(haystackRect, needleRect),
+ haystackDesc +
+ " " +
+ rectToString(haystackRect) +
+ " should contain " +
+ needleDesc +
+ " " +
+ rectToString(needleRect)
+ );
+}
+
+function getPropertyAsRect(scrollFrames, scrollId, prop) {
+ SimpleTest.ok(
+ scrollId in scrollFrames,
+ "expected scroll frame data for scroll id " + scrollId
+ );
+ var scrollFrameData = scrollFrames[scrollId];
+ SimpleTest.ok(
+ "displayport" in scrollFrameData,
+ "expected a " + prop + " for scroll id " + scrollId
+ );
+ var value = scrollFrameData[prop];
+ return parseRect(value);
+}
+
+function convertScrollFrameData(scrollFrames) {
+ var result = {};
+ for (var i = 0; i < scrollFrames.length; ++i) {
+ result[scrollFrames[i].scrollId] = convertEntries(scrollFrames[i].entries);
+ }
+ return result;
+}
+
+function convertBuckets(buckets) {
+ var result = {};
+ for (var i = 0; i < buckets.length; ++i) {
+ result[buckets[i].sequenceNumber] = convertScrollFrameData(
+ buckets[i].scrollFrames
+ );
+ }
+ return result;
+}
+
+function convertTestData(testData) {
+ var result = {};
+ result.paints = convertBuckets(testData.paints);
+ result.repaintRequests = convertBuckets(testData.repaintRequests);
+ return result;
+}
+
+// Returns the last bucket that has at least one scrollframe. This
+// is useful for skipping over buckets that are from empty transactions,
+// because those don't contain any useful data.
+function getLastNonemptyBucket(buckets) {
+ for (var i = buckets.length - 1; i >= 0; --i) {
+ if (buckets[i].scrollFrames.length > 0) {
+ return buckets[i];
+ }
+ }
+ return null;
+}
+
+// Takes something like "matrix(1, 0, 0, 1, 234.024, 528.29023)"" and returns a number array
+function parseTransform(transform) {
+ return /matrix\((.*),(.*),(.*),(.*),(.*),(.*)\)/
+ .exec(transform)
+ .slice(1)
+ .map(parseFloat);
+}
+
+function isTransformClose(a, b, name) {
+ is(
+ a.length,
+ b.length,
+ `expected transforms ${a} and ${b} to be the same length`
+ );
+ for (let i = 0; i < a.length; i++) {
+ ok(Math.abs(a[i] - b[i]) < 0.01, name);
+ }
+}
+
+// Given APZ test data for a single paint on the compositor side,
+// reconstruct the APZC tree structure from the 'parentScrollId'
+// entries that were logged. More specifically, the subset of the
+// APZC tree structure corresponding to the layer subtree for the
+// content process that triggered the paint, is reconstructed (as
+// the APZ test data only contains information abot this subtree).
+function buildApzcTree(paint) {
+ // The APZC tree can potentially have multiple root nodes,
+ // so we invent a node that is the parent of all roots.
+ // This 'root' does not correspond to an APZC.
+ var root = { scrollId: -1, children: [] };
+ for (let scrollId in paint) {
+ paint[scrollId].children = [];
+ paint[scrollId].scrollId = scrollId;
+ }
+ for (let scrollId in paint) {
+ var parentNode = null;
+ if ("hasNoParentWithSameLayersId" in paint[scrollId]) {
+ parentNode = root;
+ } else if ("parentScrollId" in paint[scrollId]) {
+ parentNode = paint[paint[scrollId].parentScrollId];
+ }
+ parentNode.children.push(paint[scrollId]);
+ }
+ return root;
+}
+
+// Given an APZC tree produced by buildApzcTree, return the RCD node in
+// the tree, or null if there was none.
+function findRcdNode(apzcTree) {
+ // isRootContent will be undefined or "1"
+ if (apzcTree.isRootContent) {
+ return apzcTree;
+ }
+ for (var i = 0; i < apzcTree.children.length; i++) {
+ var rcd = findRcdNode(apzcTree.children[i]);
+ if (rcd != null) {
+ return rcd;
+ }
+ }
+ return null;
+}
+
+// Return whether an element whose id includes |elementId| has been layerized.
+// Assumes |elementId| will be present in the content description for the
+// element, and not in the content descriptions of other elements.
+function isLayerized(elementId) {
+ var contentTestData = SpecialPowers.getDOMWindowUtils(
+ window
+ ).getContentAPZTestData();
+ var nonEmptyBucket = getLastNonemptyBucket(contentTestData.paints);
+ ok(nonEmptyBucket != null, "expected at least one nonempty paint");
+ var seqno = nonEmptyBucket.sequenceNumber;
+ contentTestData = convertTestData(contentTestData);
+ var paint = contentTestData.paints[seqno];
+ for (var scrollId in paint) {
+ if ("contentDescription" in paint[scrollId]) {
+ if (paint[scrollId].contentDescription.includes(elementId)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// Return a rect (or null) that holds the last known content-side displayport
+// for a given element. (The element selection works the same way, and with
+// the same assumptions as the isLayerized function above).
+function getLastContentDisplayportFor(elementId) {
+ var contentTestData = SpecialPowers.getDOMWindowUtils(
+ window
+ ).getContentAPZTestData();
+ var nonEmptyBucket = getLastNonemptyBucket(contentTestData.paints);
+ ok(nonEmptyBucket != null, "expected at least one nonempty paint");
+ var seqno = nonEmptyBucket.sequenceNumber;
+ contentTestData = convertTestData(contentTestData);
+ var paint = contentTestData.paints[seqno];
+ for (var scrollId in paint) {
+ if ("contentDescription" in paint[scrollId]) {
+ if (paint[scrollId].contentDescription.includes(elementId)) {
+ if ("displayport" in paint[scrollId]) {
+ return parseRect(paint[scrollId].displayport);
+ }
+ }
+ }
+ }
+ return null;
+}
+
+// Return a promise that is resolved on the next rAF callback
+function promiseFrame() {
+ return new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+}
+
+// Return a promise that is resolved on the next MozAfterPaint event
+function promiseAfterPaint() {
+ return new Promise(resolve => {
+ window.addEventListener("MozAfterPaint", resolve, { once: true });
+ });
+}
+
+function promiseApzRepaintsFlushed(aWindow = window) {
+ return new Promise(function(resolve, reject) {
+ var repaintDone = function() {
+ dump("PromiseApzRepaintsFlushed: APZ flush done\n");
+ SpecialPowers.Services.obs.removeObserver(
+ repaintDone,
+ "apz-repaints-flushed"
+ );
+ setTimeout(resolve, 0);
+ };
+ SpecialPowers.Services.obs.addObserver(repaintDone, "apz-repaints-flushed");
+ if (SpecialPowers.getDOMWindowUtils(aWindow).flushApzRepaints()) {
+ dump(
+ "PromiseApzRepaintsFlushed: Flushed APZ repaints, waiting for callback...\n"
+ );
+ } else {
+ dump(
+ "PromiseApzRepaintsFlushed: Flushing APZ repaints was a no-op, triggering callback directly...\n"
+ );
+ repaintDone();
+ }
+ });
+}
+
+function flushApzRepaints(aCallback, aWindow = window) {
+ if (!aCallback) {
+ throw new Error("A callback must be provided!");
+ }
+ promiseApzRepaintsFlushed(aWindow).then(aCallback);
+}
+
+// Flush repaints, APZ pending repaints, and any repaints resulting from that
+// flush. This is particularly useful if the test needs to reach some sort of
+// "idle" state in terms of repaints. Usually just waiting for all paints
+// followed by flushApzRepaints is sufficient to flush all APZ state back to
+// the main thread, but it can leave a paint scheduled which will get triggered
+// at some later time. For tests that specifically test for painting at
+// specific times, this method is the way to go. Even if in doubt, this is the
+// preferred method as the extra step is "safe" and shouldn't interfere with
+// most tests.
+function waitForApzFlushedRepaints(aCallback) {
+ // First flush the main-thread paints and send transactions to the APZ
+ promiseAllPaintsDone()
+ // Then flush the APZ to make sure any repaint requests have been sent
+ // back to the main thread. Note that we need a wrapper function around
+ // promiseApzRepaintsFlushed otherwise the rect produced by
+ // promiseAllPaintsDone gets passed to it as the window parameter.
+ .then(() => promiseApzRepaintsFlushed())
+ // Then flush the main-thread again to process the repaint requests.
+ // Once this is done, we should be in a stable state with nothing
+ // pending, so we can trigger the callback.
+ .then(promiseAllPaintsDone)
+ // Then allow the callback to be triggered.
+ .then(aCallback);
+}
+
+// Same as waitForApzFlushedRepaints, but in async form.
+async function promiseApzFlushedRepaints() {
+ await promiseAllPaintsDone();
+ await promiseApzRepaintsFlushed();
+ await promiseAllPaintsDone();
+}
+
+// This function takes a set of subtests to run one at a time in new top-level
+// windows, and returns a Promise that is resolved once all the subtests are
+// done running.
+//
+// The aSubtests array is an array of objects with the following keys:
+// file: required, the filename of the subtest.
+// prefs: optional, an array of arrays containing key-value prefs to set.
+// dp_suppression: optional, a boolean on whether or not to respect displayport
+// suppression during the test.
+// onload: optional, a function that will be registered as a load event listener
+// for the child window that will hold the subtest. the function will be
+// passed exactly one argument, which will be the child window.
+// An example of an array is:
+// aSubtests = [
+// { 'file': 'test_file_name.html' },
+// { 'file': 'test_file_2.html', 'prefs': [['pref.name', true], ['other.pref', 1000]], 'dp_suppression': false }
+// { 'file': 'file_3.html', 'onload': function(w) { w.subtestDone(); } }
+// ];
+//
+// Each subtest should call one of the subtestDone() or subtestFailed()
+// functions when it is done, to indicate that the window should be torn
+// down and the next test should run.
+// These functions are injected into the subtest's window by this
+// function prior to loading the subtest. For convenience, the |is| and |ok|
+// functions provided by SimpleTest are also mapped into the subtest's window.
+// For other things from the parent, the subtest can use window.opener.<whatever>
+// to access objects.
+function runSubtestsSeriallyInFreshWindows(aSubtests) {
+ return new Promise(function(resolve, reject) {
+ var testIndex = -1;
+ var w = null;
+
+ // If the "apz.subtest" pref has been set, only a single subtest whose name matches
+ // the pref's value (if any) will be run.
+ var onlyOneSubtest = SpecialPowers.getCharPref(
+ "apz.subtest",
+ /* default = */ ""
+ );
+
+ function advanceSubtestExecutionWithFailure(msg) {
+ SimpleTest.ok(false, msg);
+ advanceSubtestExecution();
+ }
+
+ function advanceSubtestExecution() {
+ var test = aSubtests[testIndex];
+ if (w) {
+ // Run any cleanup functions registered in the subtest
+ // Guard against the subtest not loading apz_test_utils.js
+ if (w.ApzCleanup) {
+ w.ApzCleanup.execute();
+ }
+ if (typeof test.dp_suppression != "undefined") {
+ // We modified the suppression when starting the test, so now undo that.
+ SpecialPowers.getDOMWindowUtils(window).respectDisplayPortSuppression(
+ !test.dp_suppression
+ );
+ }
+ if (test.prefs) {
+ // We pushed some prefs for this test, pop them, and re-invoke
+ // advanceSubtestExecution() after that's been processed
+ SpecialPowers.popPrefEnv(function() {
+ w.close();
+ w = null;
+ advanceSubtestExecution();
+ });
+ return;
+ }
+
+ w.close();
+ }
+
+ testIndex++;
+ if (testIndex >= aSubtests.length) {
+ resolve();
+ return;
+ }
+
+ test = aSubtests[testIndex];
+
+ let recognizedProps = ["file", "prefs", "dp_suppression", "onload"];
+ for (let prop in test) {
+ if (!recognizedProps.includes(prop)) {
+ SimpleTest.ok(
+ false,
+ "Subtest " + test.file + " has unrecognized property '" + prop + "'"
+ );
+ setTimeout(function() {
+ advanceSubtestExecution();
+ }, 0);
+ return;
+ }
+ }
+
+ if (onlyOneSubtest && onlyOneSubtest != test.file) {
+ SimpleTest.ok(
+ true,
+ "Skipping " +
+ test.file +
+ " because only " +
+ onlyOneSubtest +
+ " is being run"
+ );
+ setTimeout(function() {
+ advanceSubtestExecution();
+ }, 0);
+ return;
+ }
+
+ SimpleTest.ok(true, "Starting subtest " + test.file);
+
+ if (typeof test.dp_suppression != "undefined") {
+ // Normally during a test, the displayport will get suppressed during page
+ // load, and unsuppressed at a non-deterministic time during the test. The
+ // unsuppression can trigger a repaint which interferes with the test, so
+ // to avoid that we can force the displayport to be unsuppressed for the
+ // entire test which is more deterministic.
+ SpecialPowers.getDOMWindowUtils(window).respectDisplayPortSuppression(
+ test.dp_suppression
+ );
+ }
+
+ function spawnTest(aFile) {
+ w = window.open("", "_blank");
+ w.subtestDone = advanceSubtestExecution;
+ w.subtestFailed = advanceSubtestExecutionWithFailure;
+ w.isApzSubtest = true;
+ w.SimpleTest = SimpleTest;
+ w.dump = function(msg) {
+ return dump(aFile + " | " + msg);
+ };
+ w.is = function(a, b, msg) {
+ return is(a, b, aFile + " | " + msg);
+ };
+ w.isnot = function(a, b, msg) {
+ return isnot(a, b, aFile + " | " + msg);
+ };
+ w.isfuzzy = function(a, b, eps, msg) {
+ return isfuzzy(a, b, eps, aFile + " | " + msg);
+ };
+ w.ok = function(cond, msg) {
+ arguments[1] = aFile + " | " + msg;
+ // Forward all arguments to SimpleTest.ok where we will check that ok() was
+ // called with at most 2 arguments.
+ return SimpleTest.ok.apply(SimpleTest, arguments);
+ };
+ w.todo_is = function(a, b, msg) {
+ return todo_is(a, b, aFile + " | " + msg);
+ };
+ w.todo = function(cond, msg) {
+ return todo(cond, aFile + " | " + msg);
+ };
+ if (test.onload) {
+ w.addEventListener(
+ "load",
+ function(e) {
+ test.onload(w);
+ },
+ { once: true }
+ );
+ }
+ var subtestUrl =
+ location.href.substring(0, location.href.lastIndexOf("/") + 1) +
+ aFile;
+ function urlResolves(url) {
+ var request = new XMLHttpRequest();
+ request.open("GET", url, false);
+ request.send();
+ return request.status !== 404;
+ }
+ if (!urlResolves(subtestUrl)) {
+ SimpleTest.ok(
+ false,
+ "Subtest URL " +
+ subtestUrl +
+ " does not resolve. " +
+ "Be sure it's present in the support-files section of mochitest.ini."
+ );
+ reject();
+ return undefined;
+ }
+ w.location = subtestUrl;
+ return w;
+ }
+
+ if (test.prefs) {
+ // Got some prefs for this subtest, push them
+ SpecialPowers.pushPrefEnv({ set: test.prefs }, function() {
+ w = spawnTest(test.file);
+ });
+ } else {
+ w = spawnTest(test.file);
+ }
+ }
+
+ advanceSubtestExecution();
+ }).catch(function(e) {
+ SimpleTest.ok(false, "Error occurred while running subtests: " + e);
+ });
+}
+
+function pushPrefs(prefs) {
+ return SpecialPowers.pushPrefEnv({ set: prefs });
+}
+
+async function waitUntilApzStable() {
+ if (!SpecialPowers.isMainProcess()) {
+ // We use this waitUntilApzStable function during test initialization
+ // and for those scenarios we want to flush the parent-process layer
+ // tree to the compositor and wait for that as well. That way we know
+ // that not only is the content-process layer tree ready in the compositor,
+ // the parent-process layer tree in the compositor has the appropriate
+ // RefLayer pointing to the content-process layer tree.
+
+ // Sadly this helper function cannot reuse any code from other places because
+ // it must be totally self-contained to be shipped over to the parent process.
+ /* eslint-env mozilla/frame-script */
+ function parentProcessFlush() {
+ function apzFlush() {
+ const { Services } = ChromeUtils.import(
+ "resource://gre/modules/Services.jsm"
+ );
+ var topWin = Services.wm.getMostRecentWindow("navigator:browser");
+ if (!topWin) {
+ topWin = Services.wm.getMostRecentWindow("navigator:geckoview");
+ }
+ var topUtils = topWin.windowUtils;
+
+ var repaintDone = function() {
+ dump("WaitUntilApzStable: APZ flush done in parent proc\n");
+ Services.obs.removeObserver(repaintDone, "apz-repaints-flushed");
+ // send message back to content process
+ sendAsyncMessage("apz-flush-done", null);
+ };
+ var flushRepaint = function() {
+ if (topUtils.isMozAfterPaintPending) {
+ topWin.addEventListener("MozAfterPaint", flushRepaint, {
+ once: true,
+ });
+ return;
+ }
+
+ Services.obs.addObserver(repaintDone, "apz-repaints-flushed");
+ if (topUtils.flushApzRepaints()) {
+ dump(
+ "WaitUntilApzStable: flushed APZ repaints in parent proc, waiting for callback...\n"
+ );
+ } else {
+ dump(
+ "WaitUntilApzStable: flushing APZ repaints in parent proc was a no-op, triggering callback directly...\n"
+ );
+ repaintDone();
+ }
+ };
+
+ // Flush APZ repaints, but wait until all the pending paints have been
+ // sent.
+ flushRepaint();
+ }
+ function cleanup() {
+ removeMessageListener("apz-flush", apzFlush);
+ removeMessageListener("cleanup", cleanup);
+ }
+ addMessageListener("apz-flush", apzFlush);
+ addMessageListener("cleanup", cleanup);
+ }
+
+ // This is the first time waitUntilApzStable is being called, do initialization
+ if (typeof waitUntilApzStable.chromeHelper == "undefined") {
+ waitUntilApzStable.chromeHelper = SpecialPowers.loadChromeScript(
+ parentProcessFlush
+ );
+ ApzCleanup.register(() => {
+ waitUntilApzStable.chromeHelper.sendAsyncMessage("cleanup", null);
+ waitUntilApzStable.chromeHelper.destroy();
+ delete waitUntilApzStable.chromeHelper;
+ });
+ }
+
+ // Actually trigger the parent-process flush and wait for it to finish
+ waitUntilApzStable.chromeHelper.sendAsyncMessage("apz-flush", null);
+ await waitUntilApzStable.chromeHelper.promiseOneMessage("apz-flush-done");
+ dump("WaitUntilApzStable: got apz-flush-done in child proc\n");
+ }
+
+ await SimpleTest.promiseFocus(window);
+ dump("WaitUntilApzStable: done promiseFocus\n");
+ await promiseAllPaintsDone();
+ dump("WaitUntilApzStable: done promiseAllPaintsDone\n");
+ await promiseApzRepaintsFlushed();
+ dump("WaitUntilApzStable: all done\n");
+}
+
+// This function returns a promise that is resolved after at least one paint
+// has been sent and processed by the compositor. This function can force
+// such a paint to happen if none are pending. This is useful to run after
+// the waitUntilApzStable() but before reading the compositor-side APZ test
+// data, because the test data for the content layers id only gets populated
+// on content layer tree updates *after* the root layer tree has a RefLayer
+// pointing to the contnet layer tree. waitUntilApzStable itself guarantees
+// that the root layer tree is pointing to the content layer tree, but does
+// not guarantee the subsequent paint; this function does that job.
+async function forceLayerTreeToCompositor() {
+ // Modify a style property to force a layout flush
+ document.body.style.boxSizing = "border-box";
+ var utils = SpecialPowers.getDOMWindowUtils(window);
+ if (!utils.isMozAfterPaintPending) {
+ dump("Forcing a paint since none was pending already...\n");
+ var testMode = utils.isTestControllingRefreshes;
+ utils.advanceTimeAndRefresh(0);
+ if (!testMode) {
+ utils.restoreNormalRefresh();
+ }
+ }
+ await promiseAllPaintsDone(null, true);
+ await promiseApzRepaintsFlushed();
+}
+
+function isApzEnabled() {
+ var enabled = SpecialPowers.getDOMWindowUtils(window).asyncPanZoomEnabled;
+ if (!enabled) {
+ // All tests are required to have at least one assertion. Since APZ is
+ // disabled, and the main test is presumably not going to run, we stick in
+ // a dummy assertion here to keep the test passing.
+ SimpleTest.ok(true, "APZ is not enabled; this test will be skipped");
+ }
+ return enabled;
+}
+
+function isKeyApzEnabled() {
+ return isApzEnabled() && SpecialPowers.getBoolPref("apz.keyboard.enabled");
+}
+
+// Despite what this function name says, this does not *directly* run the
+// provided continuation testFunction. Instead, it returns a function that
+// can be used to run the continuation. The extra level of indirection allows
+// it to be more easily added to a promise chain, like so:
+// waitUntilApzStable().then(runContinuation(myTest));
+//
+// If you want to run the continuation directly, outside of a promise chain,
+// you can invoke the return value of this function, like so:
+// runContinuation(myTest)();
+function runContinuation(testFunction) {
+ // We need to wrap this in an extra function, so that the call site can
+ // be more readable without running the promise too early. In other words,
+ // if we didn't have this extra function, the promise would start running
+ // during construction of the promise chain, concurrently with the first
+ // promise in the chain.
+ return function() {
+ return new Promise(function(resolve, reject) {
+ var testContinuation = null;
+
+ function driveTest() {
+ if (!testContinuation) {
+ testContinuation = testFunction(driveTest);
+ }
+ var ret = testContinuation.next();
+ if (ret.done) {
+ resolve();
+ }
+ }
+
+ try {
+ driveTest();
+ } catch (ex) {
+ SimpleTest.ok(
+ false,
+ "APZ test continuation failed with exception: " + ex
+ );
+ }
+ });
+ };
+}
+
+// Take a snapshot of the given rect, *including compositor transforms* (i.e.
+// includes async scroll transforms applied by APZ). If you don't need the
+// compositor transforms, you can probably get away with using
+// SpecialPowers.snapshotWindowWithOptions or one of the friendlier wrappers.
+// The rect provided is expected to be relative to the screen, for example as
+// returned by rectRelativeToScreen in apz_test_native_event_utils.js.
+// Example usage:
+// var snapshot = getSnapshot(rectRelativeToScreen(myDiv));
+// which will take a snapshot of the 'myDiv' element. Note that if part of the
+// element is obscured by other things on top, the snapshot will include those
+// things. If it is clipped by a scroll container, the snapshot will include
+// that area anyway, so you will probably get parts of the scroll container in
+// the snapshot. If the rect extends outside the browser window then the
+// results are undefined.
+// The snapshot is returned in the form of a data URL.
+function getSnapshot(rect) {
+ function parentProcessSnapshot() {
+ addMessageListener("snapshot", function(parentRect) {
+ const { Services } = ChromeUtils.import(
+ "resource://gre/modules/Services.jsm"
+ );
+ var topWin = Services.wm.getMostRecentWindow("navigator:browser");
+ if (!topWin) {
+ topWin = Services.wm.getMostRecentWindow("navigator:geckoview");
+ }
+
+ // reposition the rect relative to the top-level browser window
+ parentRect = JSON.parse(parentRect);
+ parentRect.x -= topWin.mozInnerScreenX;
+ parentRect.y -= topWin.mozInnerScreenY;
+
+ // take the snapshot
+ var canvas = topWin.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.width = parentRect.w;
+ canvas.height = parentRect.h;
+ var ctx = canvas.getContext("2d");
+ ctx.drawWindow(
+ topWin,
+ parentRect.x,
+ parentRect.y,
+ parentRect.w,
+ parentRect.h,
+ "rgb(255,255,255)",
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS |
+ ctx.DRAWWINDOW_DRAW_CARET
+ );
+ return canvas.toDataURL();
+ });
+ }
+
+ if (typeof getSnapshot.chromeHelper == "undefined") {
+ // This is the first time getSnapshot is being called; do initialization
+ getSnapshot.chromeHelper = SpecialPowers.loadChromeScript(
+ parentProcessSnapshot
+ );
+ ApzCleanup.register(function() {
+ getSnapshot.chromeHelper.destroy();
+ });
+ }
+
+ return getSnapshot.chromeHelper.sendQuery("snapshot", JSON.stringify(rect));
+}
+
+// Takes the document's query string and parses it, assuming the query string
+// is composed of key-value pairs where the value is in JSON format. The object
+// returned contains the various values indexed by their respective keys. In
+// case of duplicate keys, the last value be used.
+// Examples:
+// ?key="value"&key2=false&key3=500
+// produces { "key": "value", "key2": false, "key3": 500 }
+// ?key={"x":0,"y":50}&key2=[1,2,true]
+// produces { "key": { "x": 0, "y": 0 }, "key2": [1, 2, true] }
+function getQueryArgs() {
+ var args = {};
+ if (location.search.length > 0) {
+ var params = location.search.substr(1).split("&");
+ for (var p of params) {
+ var [k, v] = p.split("=");
+ args[k] = JSON.parse(v);
+ }
+ }
+ return args;
+}
+
+// Return a function that returns a promise to create a script element with the
+// given URI and append it to the head of the document in the given window.
+// As with runContinuation(), the extra function wrapper is for convenience
+// at the call site, so that this can be chained with other promises:
+// waitUntilApzStable().then(injectScript('foo'))
+// .then(injectScript('bar'));
+// If you want to do the injection right away, run the function returned by
+// this function:
+// injectScript('foo')();
+function injectScript(aScript, aWindow = window) {
+ return function() {
+ return new Promise(function(resolve, reject) {
+ var e = aWindow.document.createElement("script");
+ e.type = "text/javascript";
+ e.onload = function() {
+ resolve();
+ };
+ e.onerror = function() {
+ dump("Script [" + aScript + "] errored out\n");
+ reject();
+ };
+ e.src = aScript;
+ aWindow.document.getElementsByTagName("head")[0].appendChild(e);
+ });
+ };
+}
+
+// Compute some configuration information used for hit testing.
+// The computed information is cached to avoid recomputing it
+// each time this function is called.
+// The computed information is an object with three fields:
+// utils: the nsIDOMWindowUtils instance for this window
+// isWebRender: true if WebRender is enabled
+// isWindow: true if the platform is Windows
+function getHitTestConfig() {
+ if (!("hitTestConfig" in window)) {
+ var utils = SpecialPowers.getDOMWindowUtils(window);
+ var isWebRender = utils.layerManagerType == "WebRender";
+ var isWindows = getPlatform() == "windows";
+ window.hitTestConfig = { utils, isWebRender, isWindows };
+ }
+ return window.hitTestConfig;
+}
+
+// Compute the coordinates of the center of the given element. The argument
+// can either be a string (the id of the element desired) or the element
+// itself.
+function centerOf(element) {
+ if (typeof element === "string") {
+ element = document.getElementById(element);
+ }
+ var bounds = element.getBoundingClientRect();
+ return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
+}
+
+// Peform a compositor hit test at the given point and return the result.
+// The returned object has two fields:
+// hitInfo: a combination of APZHitResultFlags
+// scrollId: the view-id of the scroll frame that was hit
+function hitTest(point) {
+ var utils = getHitTestConfig().utils;
+ dump("Hit-testing point (" + point.x + ", " + point.y + ")\n");
+ utils.sendMouseEvent(
+ "MozMouseHittest",
+ point.x,
+ point.y,
+ 0,
+ 0,
+ 0,
+ true,
+ 0,
+ 0,
+ true,
+ true
+ );
+ var data = utils.getCompositorAPZTestData();
+ ok(
+ data.hitResults.length >= 1,
+ "Expected at least one hit result in the APZTestData"
+ );
+ var result = data.hitResults[data.hitResults.length - 1];
+ return {
+ hitInfo: result.hitResult,
+ scrollId: result.scrollId,
+ layersId: result.layersId,
+ };
+}
+
+// Returns a canonical stringification of the hitInfo bitfield.
+function hitInfoToString(hitInfo) {
+ var strs = [];
+ for (var flag in APZHitResultFlags) {
+ if ((hitInfo & APZHitResultFlags[flag]) != 0) {
+ strs.push(flag);
+ }
+ }
+ if (strs.length == 0) {
+ return "INVISIBLE";
+ }
+ strs.sort(function(a, b) {
+ return APZHitResultFlags[a] - APZHitResultFlags[b];
+ });
+ return strs.join(" | ");
+}
+
+// Takes an object returned by hitTest, along with the expected values, and
+// asserts that they match. Notably, it uses hitInfoToString to provide a
+// more useful message for the case that the hit info doesn't match
+function checkHitResult(
+ hitResult,
+ expectedHitInfo,
+ expectedScrollId,
+ expectedLayersId,
+ desc
+) {
+ is(
+ hitInfoToString(hitResult.hitInfo),
+ hitInfoToString(expectedHitInfo),
+ desc + " hit info"
+ );
+ is(hitResult.scrollId, expectedScrollId, desc + " scrollid");
+ is(hitResult.layersId, expectedLayersId, desc + " layersid");
+}
+
+// Symbolic constants used by hitTestScrollbar().
+var ScrollbarTrackLocation = {
+ START: 1,
+ END: 2,
+};
+var LayerState = {
+ ACTIVE: 1,
+ INACTIVE: 2,
+};
+
+// Perform a hit test on the scrollbar(s) of a scroll frame.
+// This function takes a single argument which is expected to be
+// an object with the following fields:
+// element: The scroll frame to perform the hit test on.
+// directions: The direction(s) of scrollbars to test.
+// If directions.vertical is true, the vertical scrollbar will be tested.
+// If directions.horizontal is true, the horizontal scrollbar will be tested.
+// Both may be true in a single call (in which case two tests are performed).
+// expectedScrollId: The scroll id that is expected to be hit.
+// expectedLayersId: The layers id that is expected to be hit.
+// trackLocation: One of ScrollbarTrackLocation.{START, END}.
+// Determines which end of the scrollbar track is targeted.
+// expectThumb: Whether the scrollbar thumb is expected to be present
+// at the targeted end of the scrollbar track.
+// layerState: Whether the scroll frame is active or inactive.
+// The function performs the hit tests and asserts that the returned
+// hit test information is consistent with the passed parameters.
+// There is no return value.
+// Tests that use this function must set the pref
+// "layout.scrollbars.always-layerize-track".
+function hitTestScrollbar(params) {
+ var config = getHitTestConfig();
+
+ var elem = params.element;
+
+ var boundingClientRect = elem.getBoundingClientRect();
+
+ var verticalScrollbarWidth = boundingClientRect.width - elem.clientWidth;
+ var horizontalScrollbarHeight = boundingClientRect.height - elem.clientHeight;
+
+ // On windows, the scrollbar tracks have buttons on the end. When computing
+ // coordinates for hit-testing we need to account for this. We assume the
+ // buttons are square, and so can use the scrollbar width/height to estimate
+ // the size of the buttons
+ var scrollbarArrowButtonHeight = config.isWindows
+ ? verticalScrollbarWidth
+ : 0;
+ var scrollbarArrowButtonWidth = config.isWindows
+ ? horizontalScrollbarHeight
+ : 0;
+
+ // Compute the expected hit result flags.
+ // The direction flag (APZHitResultFlags.SCROLLBAR_VERTICAL) is added in
+ // later, for the vertical test only.
+ // The APZHitResultFlags.SCROLLBAR flag will be present regardless of whether
+ // the layer is active or inactive because we force layerization of scrollbar
+ // tracks. Unfortunately not forcing the layerization results in different
+ // behaviour on different platforms which makes testing harder.
+ var expectedHitInfo = APZHitResultFlags.VISIBLE | APZHitResultFlags.SCROLLBAR;
+ if (params.expectThumb) {
+ // The thumb has listeners which are APZ-aware. With WebRender we are able
+ // to losslessly propagate this flag to APZ, but with non-WebRender the area
+ // ends up in the mDispatchToContentRegion which we then convert back to
+ // a IRREGULAR_AREA flag. This still works correctly since IRREGULAR_AREA
+ // will fall back to the main thread for everything.
+ if (config.isWebRender) {
+ expectedHitInfo |= APZHitResultFlags.APZ_AWARE_LISTENERS;
+ if (params.layerState == LayerState.INACTIVE) {
+ expectedHitInfo |= APZHitResultFlags.INACTIVE_SCROLLFRAME;
+ }
+ } else {
+ expectedHitInfo |= APZHitResultFlags.IRREGULAR_AREA;
+ }
+ // We do not generate the layers for thumbs on inactive scrollframes.
+ if (params.layerState == LayerState.ACTIVE) {
+ expectedHitInfo |= APZHitResultFlags.SCROLLBAR_THUMB;
+ }
+ }
+
+ var scrollframeMsg =
+ params.layerState == LayerState.ACTIVE
+ ? "active scrollframe"
+ : "inactive scrollframe";
+
+ // Hit-test the targeted areas, assuming we don't have overlay scrollbars
+ // with zero dimensions.
+ if (params.directions.vertical && verticalScrollbarWidth > 0) {
+ var verticalScrollbarPoint = {
+ x: boundingClientRect.right - verticalScrollbarWidth / 2,
+ y:
+ params.trackLocation == ScrollbarTrackLocation.START
+ ? boundingClientRect.y + scrollbarArrowButtonHeight + 5
+ : boundingClientRect.bottom -
+ horizontalScrollbarHeight -
+ scrollbarArrowButtonHeight -
+ 5,
+ };
+ checkHitResult(
+ hitTest(verticalScrollbarPoint),
+ expectedHitInfo | APZHitResultFlags.SCROLLBAR_VERTICAL,
+ params.expectedScrollId,
+ params.expectedLayersId,
+ scrollframeMsg + " - vertical scrollbar"
+ );
+ }
+
+ if (params.directions.horizontal && horizontalScrollbarHeight > 0) {
+ var horizontalScrollbarPoint = {
+ x:
+ params.trackLocation == ScrollbarTrackLocation.START
+ ? boundingClientRect.x + scrollbarArrowButtonWidth + 5
+ : boundingClientRect.right -
+ verticalScrollbarWidth -
+ scrollbarArrowButtonWidth -
+ 5,
+ y: boundingClientRect.bottom - horizontalScrollbarHeight / 2,
+ };
+ checkHitResult(
+ hitTest(horizontalScrollbarPoint),
+ expectedHitInfo,
+ params.expectedScrollId,
+ params.expectedLayersId,
+ scrollframeMsg + " - horizontal scrollbar"
+ );
+ }
+}
+
+// Return a list of prefs for the given test identifier.
+function getPrefs(ident) {
+ switch (ident) {
+ case "TOUCH_EVENTS:PAN":
+ return [
+ // Dropping the touch slop to 0 makes the tests easier to write because
+ // we can just do a one-pixel drag to get over the pan threshold rather
+ // than having to hard-code some larger value.
+ ["apz.touch_start_tolerance", "0.0"],
+ // The touchstart from the drag can turn into a long-tap if the touch-move
+ // events get held up. Try to prevent that by making long-taps require
+ // a 10 second hold. Note that we also cannot enable chaos mode on this
+ // test for this reason, since chaos mode can cause the long-press timer
+ // to fire sooner than the pref dictates.
+ ["ui.click_hold_context_menus.delay", 10000],
+ // The subtests in this test do touch-drags to pan the page, but we don't
+ // want those pans to turn into fling animations, so we increase the
+ // fling min velocity requirement absurdly high.
+ ["apz.fling_min_velocity_threshold", "10000"],
+ // The helper_div_pan's div gets a displayport on scroll, but if the
+ // test takes too long the displayport can expire before the new scroll
+ // position is synced back to the main thread. So we disable displayport
+ // expiry for these tests.
+ ["apz.displayport_expiry_ms", 0],
+ // We need to disable touch resampling during these tests because we
+ // rely on touch move events being processed without delay. Touch
+ // resampling only processes them once vsync fires.
+ ["android.touch_resampling.enabled", false],
+ ];
+ case "TOUCH_ACTION":
+ return [
+ ...getPrefs("TOUCH_EVENTS:PAN"),
+ ["layout.css.touch_action.enabled", true],
+ ["apz.test.fails_with_native_injection", getPlatform() == "windows"],
+ ];
+ default:
+ return [];
+ }
+}
+
+var ApzCleanup = {
+ _cleanups: [],
+
+ register(func) {
+ if (this._cleanups.length == 0) {
+ if (!window.isApzSubtest) {
+ SimpleTest.registerCleanupFunction(this.execute.bind(this));
+ } // else ApzCleanup.execute is called from runSubtestsSeriallyInFreshWindows
+ }
+ this._cleanups.push(func);
+ },
+
+ execute() {
+ while (this._cleanups.length > 0) {
+ var func = this._cleanups.pop();
+ try {
+ func();
+ } catch (ex) {
+ SimpleTest.ok(
+ false,
+ "Subtest cleanup function [" +
+ func.toString() +
+ "] threw exception [" +
+ ex +
+ "] on page [" +
+ location.href +
+ "]"
+ );
+ }
+ }
+ },
+};
+
+/**
+ * Returns a promise that will resolve if `eventTarget` receives an event of the
+ * given type that passes the given filter. Only the first matching message is
+ * used. The filter must be a function (or null); it is called with the event
+ * object and the call must return true to resolve the promise.
+ */
+function promiseOneEvent(eventTarget, eventType, filter) {
+ return new Promise((resolve, reject) => {
+ eventTarget.addEventListener(eventType, function listener(e) {
+ let success = false;
+ if (filter == null) {
+ success = true;
+ } else if (typeof filter == "function") {
+ try {
+ success = filter(e);
+ } catch (ex) {
+ dump(
+ `ERROR: Filter passed to promiseOneEvent threw exception: ${ex}\n`
+ );
+ reject();
+ return;
+ }
+ } else {
+ dump(
+ "ERROR: Filter passed to promiseOneEvent was neither null nor a function\n"
+ );
+ reject();
+ return;
+ }
+ if (success) {
+ eventTarget.removeEventListener(eventType, listener);
+ resolve(e);
+ }
+ });
+ });
+}
+
+function visualViewportAsZoomedRect() {
+ let vv = window.visualViewport;
+ return {
+ x: vv.pageLeft,
+ y: vv.pageTop,
+ w: vv.width,
+ h: vv.height,
+ z: vv.scale,
+ };
+}
+
+// Pulls the latest compositor APZ test data and checks to see if the
+// scroller with id `scrollerId` was checkerboarding. It also ensures that
+// a scroller with id `scrollerId` was actually found in the test data.
+// This function requires that "apz.test.logging_enabled" be set to true,
+// in order for the test data to be logged.
+function assertNotCheckerboarded(utils, scrollerId, msgPrefix) {
+ utils.advanceTimeAndRefresh(0);
+ var data = utils.getCompositorAPZTestData();
+ //dump(JSON.stringify(data, null, 4));
+ var found = false;
+ for (apzcData of data.additionalData) {
+ if (apzcData.key == scrollerId) {
+ var checkerboarding = apzcData.value
+ .split(",")
+ .includes("checkerboarding");
+ ok(!checkerboarding, `${msgPrefix}: scroller is not checkerboarding`);
+ found = true;
+ }
+ }
+ ok(found, `${msgPrefix}: Found the scroller in the APZ data`);
+ utils.restoreNormalRefresh();
+}
+
+function waitToClearOutAnyPotentialScrolls(aWindow) {
+ return new Promise(resolve => {
+ aWindow.requestAnimationFrame(() => {
+ aWindow.requestAnimationFrame(() => {
+ flushApzRepaints(() => {
+ aWindow.requestAnimationFrame(() => {
+ aWindow.requestAnimationFrame(resolve);
+ });
+ }, aWindow);
+ });
+ });
+ });
+}