apz_test_utils.js (45797B)
1 // Utilities for writing APZ tests using the framework added in bug 961289 2 3 // ---------------------------------------------------------------------- 4 // Functions that convert the APZ test data into a more usable form. 5 // Every place we have a WebIDL sequence whose elements are dictionaries 6 // with two elements, a key, and a value, we convert this into a JS 7 // object with a property for each key/value pair. (This is the structure 8 // we really want, but we can't express in directly in WebIDL.) 9 // ---------------------------------------------------------------------- 10 11 // getHitTestConfig() expects apz_test_native_event_utils.js to be loaded as well. 12 /* import-globals-from apz_test_native_event_utils.js */ 13 14 function convertEntries(entries) { 15 var result = {}; 16 for (var i = 0; i < entries.length; ++i) { 17 result[entries[i].key] = entries[i].value; 18 } 19 return result; 20 } 21 22 function parsePoint(str) { 23 var pieces = str.replace(/[()\s]+/g, "").split(","); 24 SimpleTest.is(pieces.length, 2, "expected string of form (x,y)"); 25 for (var i = 0; i < 2; i++) { 26 var eq = pieces[i].indexOf("="); 27 if (eq >= 0) { 28 pieces[i] = pieces[i].substring(eq + 1); 29 } 30 } 31 return { 32 x: parseInt(pieces[0]), 33 y: parseInt(pieces[1]), 34 }; 35 } 36 37 // Given a VisualViewport object, return the visual viewport 38 // rect relative to the page. 39 function getVisualViewportRect(vv) { 40 return { 41 x: vv.pageLeft, 42 y: vv.pageTop, 43 width: vv.width, 44 height: vv.height, 45 }; 46 } 47 48 // Return the offset of the visual viewport relative to the layout viewport. 49 function getRelativeViewportOffset(window) { 50 const offsetX = {}; 51 const offsetY = {}; 52 const utils = SpecialPowers.getDOMWindowUtils(window); 53 utils.getVisualViewportOffsetRelativeToLayoutViewport(offsetX, offsetY); 54 return { 55 x: offsetX.value, 56 y: offsetY.value, 57 }; 58 } 59 60 function parseRect(str) { 61 var pieces = str.replace(/[()\s]+/g, "").split(","); 62 SimpleTest.is(pieces.length, 4, "expected string of form (x,y,w,h)"); 63 for (var i = 0; i < 4; i++) { 64 var eq = pieces[i].indexOf("="); 65 if (eq >= 0) { 66 pieces[i] = pieces[i].substring(eq + 1); 67 } 68 } 69 return { 70 x: parseInt(pieces[0]), 71 y: parseInt(pieces[1]), 72 width: parseInt(pieces[2]), 73 height: parseInt(pieces[3]), 74 }; 75 } 76 77 // These functions expect rects with fields named x/y/width/height, such as 78 // that returned by parseRect(). 79 function rectContains(haystack, needle) { 80 return ( 81 haystack.x <= needle.x && 82 haystack.y <= needle.y && 83 haystack.x + haystack.width >= needle.x + needle.width && 84 haystack.y + haystack.height >= needle.y + needle.height 85 ); 86 } 87 function rectToString(rect) { 88 return ( 89 "(" + rect.x + "," + rect.y + "," + rect.width + "," + rect.height + ")" 90 ); 91 } 92 function assertRectContainment( 93 haystackRect, 94 haystackDesc, 95 needleRect, 96 needleDesc 97 ) { 98 SimpleTest.ok( 99 rectContains(haystackRect, needleRect), 100 haystackDesc + 101 " " + 102 rectToString(haystackRect) + 103 " should contain " + 104 needleDesc + 105 " " + 106 rectToString(needleRect) 107 ); 108 } 109 110 function getPropertyAsRect(scrollFrames, scrollId, prop) { 111 SimpleTest.ok( 112 scrollId in scrollFrames, 113 "expected scroll frame data for scroll id " + scrollId 114 ); 115 var scrollFrameData = scrollFrames[scrollId]; 116 SimpleTest.ok( 117 "displayport" in scrollFrameData, 118 "expected a " + prop + " for scroll id " + scrollId 119 ); 120 var value = scrollFrameData[prop]; 121 return parseRect(value); 122 } 123 124 function convertScrollFrameData(scrollFrames) { 125 var result = {}; 126 for (var i = 0; i < scrollFrames.length; ++i) { 127 result[scrollFrames[i].scrollId] = convertEntries(scrollFrames[i].entries); 128 } 129 return result; 130 } 131 132 function convertBuckets(buckets) { 133 var result = {}; 134 for (var i = 0; i < buckets.length; ++i) { 135 result[buckets[i].sequenceNumber] = convertScrollFrameData( 136 buckets[i].scrollFrames 137 ); 138 } 139 return result; 140 } 141 142 function convertTestData(testData) { 143 var result = {}; 144 result.paints = convertBuckets(testData.paints); 145 result.repaintRequests = convertBuckets(testData.repaintRequests); 146 return result; 147 } 148 149 // Returns the last bucket that has at least one scrollframe. This 150 // is useful for skipping over buckets that are from empty transactions, 151 // because those don't contain any useful data. 152 function getLastNonemptyBucket(buckets) { 153 for (var i = buckets.length - 1; i >= 0; --i) { 154 if (buckets[i].scrollFrames.length) { 155 return buckets[i]; 156 } 157 } 158 return null; 159 } 160 161 // Takes something like "matrix(1, 0, 0, 1, 234.024, 528.29023)"" and returns a number array 162 function parseTransform(transform) { 163 return /matrix\((.*),(.*),(.*),(.*),(.*),(.*)\)/ 164 .exec(transform) 165 .slice(1) 166 .map(parseFloat); 167 } 168 169 function isTransformClose(a, b, name) { 170 is( 171 a.length, 172 b.length, 173 `expected transforms ${a} and ${b} to be the same length` 174 ); 175 for (let i = 0; i < a.length; i++) { 176 ok(Math.abs(a[i] - b[i]) < 0.01, name); 177 } 178 } 179 180 // Given APZ test data for a single paint on the compositor side, 181 // reconstruct the APZC tree structure from the 'parentScrollId' 182 // entries that were logged. More specifically, the subset of the 183 // APZC tree structure corresponding to the layer subtree for the 184 // content process that triggered the paint, is reconstructed (as 185 // the APZ test data only contains information abot this subtree). 186 function buildApzcTree(paint) { 187 // The APZC tree can potentially have multiple root nodes, 188 // so we invent a node that is the parent of all roots. 189 // This 'root' does not correspond to an APZC. 190 var root = { scrollId: -1, children: [] }; 191 for (let scrollId in paint) { 192 paint[scrollId].children = []; 193 paint[scrollId].scrollId = scrollId; 194 } 195 for (let scrollId in paint) { 196 var parentNode = null; 197 if ("hasNoParentWithSameLayersId" in paint[scrollId]) { 198 parentNode = root; 199 } else if ("parentScrollId" in paint[scrollId]) { 200 parentNode = paint[paint[scrollId].parentScrollId]; 201 } 202 parentNode.children.push(paint[scrollId]); 203 } 204 return root; 205 } 206 207 // Given an APZC tree produced by buildApzcTree, return the RCD node in 208 // the tree, or null if there was none. 209 function findRcdNode(apzcTree) { 210 // isRootContent will be undefined or "1" 211 if (apzcTree.isRootContent) { 212 return apzcTree; 213 } 214 for (var i = 0; i < apzcTree.children.length; i++) { 215 var rcd = findRcdNode(apzcTree.children[i]); 216 if (rcd != null) { 217 return rcd; 218 } 219 } 220 return null; 221 } 222 223 // Return whether an element whose id includes |elementId| has been layerized. 224 // Assumes |elementId| will be present in the content description for the 225 // element, and not in the content descriptions of other elements. 226 function isLayerized(elementId) { 227 var contentTestData = 228 SpecialPowers.getDOMWindowUtils(window).getContentAPZTestData(); 229 var nonEmptyBucket = getLastNonemptyBucket(contentTestData.paints); 230 ok(nonEmptyBucket != null, "expected at least one nonempty paint"); 231 var seqno = nonEmptyBucket.sequenceNumber; 232 contentTestData = convertTestData(contentTestData); 233 var paint = contentTestData.paints[seqno]; 234 for (var scrollId in paint) { 235 if ("contentDescription" in paint[scrollId]) { 236 if (paint[scrollId].contentDescription.includes(elementId)) { 237 return true; 238 } 239 } 240 } 241 return false; 242 } 243 244 // Return a rect (or null) that holds the last known content-side displayport 245 // for a given element. (The element selection works the same way, and with 246 // the same assumptions as the isLayerized function above). 247 function getLastContentDisplayportFor( 248 aElementId, 249 aOptions = { expectPainted: true, popupElement: null } 250 ) { 251 var contentTestData = SpecialPowers.getDOMWindowUtils( 252 aOptions.popupElement ? aOptions.popupElement.ownerGlobal : window 253 ).getContentAPZTestData(aOptions.popupElement); 254 if (contentTestData == undefined) { 255 ok(!aOptions.expectPainted, "expected to have apz test data (1)"); 256 return null; 257 } 258 var nonEmptyBucket = getLastNonemptyBucket(contentTestData.paints); 259 if (nonEmptyBucket == null) { 260 ok(!aOptions.expectPainted, "expected to have apz test data (2)"); 261 return null; 262 } 263 var seqno = nonEmptyBucket.sequenceNumber; 264 contentTestData = convertTestData(contentTestData); 265 var paint = contentTestData.paints[seqno]; 266 for (var scrollId in paint) { 267 if ("contentDescription" in paint[scrollId]) { 268 if (paint[scrollId].contentDescription.includes(aElementId)) { 269 if ("displayport" in paint[scrollId]) { 270 return parseRect(paint[scrollId].displayport); 271 } 272 } 273 } 274 } 275 return null; 276 } 277 278 // Return the APZC tree (as produced by buildApzcTree) for the last 279 // non-empty paint received by the compositor. 280 function getLastApzcTree() { 281 let data = SpecialPowers.getDOMWindowUtils(window).getCompositorAPZTestData(); 282 if (data == undefined) { 283 ok(false, "expected to have compositor apz test data"); 284 return null; 285 } 286 if (!data.paints.length) { 287 ok(false, "expected to have at least one compositor paint bucket"); 288 return null; 289 } 290 var seqno = data.paints[data.paints.length - 1].sequenceNumber; 291 data = convertTestData(data); 292 return buildApzcTree(data.paints[seqno]); 293 } 294 295 // Return a promise that is resolved on the next rAF callback 296 function promiseFrame(aWindow = window) { 297 return new Promise(resolve => { 298 aWindow.requestAnimationFrame(resolve); 299 }); 300 } 301 302 // Return a promise that is resolved on the next MozAfterPaint event 303 function promiseAfterPaint() { 304 return new Promise(resolve => { 305 window.addEventListener("MozAfterPaint", resolve, { once: true }); 306 }); 307 } 308 309 // This waits until any pending events on the APZ controller thread are 310 // processed, and any resulting repaint requests are received by the main 311 // thread. Note that while the repaint requests do get processed by the 312 // APZ handler on the main thread, the repaints themselves may not have 313 // occurred by the the returned promise resolves. If you want to wait 314 // for those repaints, consider using promiseApzFlushedRepaints instead. 315 function promiseOnlyApzControllerFlushedWithoutSetTimeout( 316 aWindow = window, 317 aElement 318 ) { 319 return new Promise(function (resolve) { 320 var fail = false; 321 var repaintDone = function () { 322 dump("PromiseApzRepaintsFlushed: APZ flush done\n"); 323 SpecialPowers.Services.obs.removeObserver( 324 repaintDone, 325 "apz-repaints-flushed" 326 ); 327 resolve(!fail); 328 }; 329 SpecialPowers.Services.obs.addObserver(repaintDone, "apz-repaints-flushed"); 330 if (SpecialPowers.getDOMWindowUtils(aWindow).flushApzRepaints(aElement)) { 331 dump( 332 "PromiseApzRepaintsFlushed: Flushed APZ repaints, waiting for callback...\n" 333 ); 334 } else { 335 dump( 336 "PromiseApzRepaintsFlushed: Flushing APZ repaints was a no-op, triggering callback directly...\n" 337 ); 338 fail = true; 339 repaintDone(); 340 } 341 }); 342 } 343 344 // Another variant of the above promiseOnlyApzControllerFlushedWithoutSetTimeout 345 // but with a setTimeout(0) callback. 346 // |aElement| is an optional argument to do 347 // promiseOnlyApzControllerFlushedWithoutSetTimeout for the given |aElement| 348 // rather than |aWindow|. If you want to do "apz-repaints-flushed" in popup 349 // windows, you need to specify the element inside the popup window. 350 function promiseOnlyApzControllerFlushed(aWindow = window, aElement) { 351 return new Promise(resolve => { 352 promiseOnlyApzControllerFlushedWithoutSetTimeout(aWindow, aElement).then( 353 result => { 354 setTimeout(() => resolve(result), 0); 355 } 356 ); 357 }); 358 } 359 360 // Flush repaints, APZ pending repaints, and any repaints resulting from that 361 // flush. This is particularly useful if the test needs to reach some sort of 362 // "idle" state in terms of repaints. Usually just waiting for all paints 363 // followed by flushApzRepaints is sufficient to flush all APZ state back to 364 // the main thread, but it can leave a paint scheduled which will get triggered 365 // at some later time. For tests that specifically test for painting at 366 // specific times, this method is the way to go. Even if in doubt, this is the 367 // preferred method as the extra step is "safe" and shouldn't interfere with 368 // most tests. 369 // If you want to do the flush in popup windows, you need to specify |aPopupElement|. 370 async function promiseApzFlushedRepaints(aPopupElement = null) { 371 if (aPopupElement) { 372 SimpleTest.ok(XULPopupElement.isInstance(aPopupElement)); 373 } 374 await promiseAllPaintsDone(); 375 await promiseOnlyApzControllerFlushed( 376 aPopupElement ? aPopupElement.ownerGlobal : window, 377 aPopupElement 378 ); 379 await promiseAllPaintsDone(); 380 } 381 382 // This function takes a set of subtests to run one at a time in new top-level 383 // windows, and returns a Promise that is resolved once all the subtests are 384 // done running. 385 // 386 // The aSubtests array is an array of objects with the following keys: 387 // file: required, the filename of the subtest. 388 // prefs: optional, an array of arrays containing key-value prefs to set. 389 // dp_suppression: optional, a boolean on whether or not to respect displayport 390 // suppression during the test. 391 // onload: optional, a function that will be registered as a load event listener 392 // for the child window that will hold the subtest. the function will be 393 // passed exactly one argument, which will be the child window. 394 // windowFeatures: optional, will be passed to as the third argument of `window.open`. 395 // See https://developer.mozilla.org/en-US/docs/Web/API/Window/open#windowfeatures 396 // An example of an array is: 397 // aSubtests = [ 398 // { 'file': 'test_file_name.html' }, 399 // { 'file': 'test_file_2.html', 'prefs': [['pref.name', true], ['other.pref', 1000]], 'dp_suppression': false } 400 // { 'file': 'file_3.html', 'onload': function(w) { w.subtestDone(); } } 401 // ]; 402 // 403 // Each subtest should call one of the subtestDone() or subtestFailed() 404 // functions when it is done, to indicate that the window should be torn 405 // down and the next test should run. 406 // These functions are injected into the subtest's window by this 407 // function prior to loading the subtest. For convenience, the |is| and |ok| 408 // functions provided by SimpleTest are also mapped into the subtest's window. 409 // For other things from the parent, the subtest can use window.opener.<whatever> 410 // to access objects. 411 function runSubtestsSeriallyInFreshWindows(aSubtests) { 412 return new Promise(function (resolve, reject) { 413 var testIndex = -1; 414 var w = null; 415 416 // If the "apz.subtest" pref has been set, only a single subtest whose name matches 417 // the pref's value (if any) will be run. 418 var onlyOneSubtest = SpecialPowers.getCharPref( 419 "apz.subtest", 420 /* default = */ "" 421 ); 422 423 function advanceSubtestExecutionWithFailure(msg) { 424 SimpleTest.ok(false, msg); 425 advanceSubtestExecution(); 426 } 427 428 async function advanceSubtestExecution() { 429 var test = aSubtests[testIndex]; 430 if (w) { 431 // Run any cleanup functions registered in the subtest 432 // Guard against the subtest not loading apz_test_utils.js 433 if (w.ApzCleanup) { 434 w.ApzCleanup.execute(); 435 } 436 if (typeof test.dp_suppression != "undefined") { 437 // We modified the suppression when starting the test, so now undo that. 438 SpecialPowers.getDOMWindowUtils(window).respectDisplayPortSuppression( 439 !test.dp_suppression 440 ); 441 } 442 443 if (test.prefs) { 444 // We pushed some prefs for this test, pop them, and re-invoke 445 // advanceSubtestExecution() after that's been processed 446 SpecialPowers.popPrefEnv(function () { 447 w.close(); 448 w = null; 449 advanceSubtestExecution(); 450 }); 451 return; 452 } 453 454 w.close(); 455 } 456 457 testIndex++; 458 if (testIndex >= aSubtests.length) { 459 resolve(); 460 return; 461 } 462 463 await SimpleTest.promiseFocus(window); 464 465 test = aSubtests[testIndex]; 466 467 let recognizedProps = [ 468 "file", 469 "prefs", 470 "dp_suppression", 471 "onload", 472 "windowFeatures", 473 ]; 474 for (let prop in test) { 475 if (!recognizedProps.includes(prop)) { 476 SimpleTest.ok( 477 false, 478 "Subtest " + test.file + " has unrecognized property '" + prop + "'" 479 ); 480 setTimeout(function () { 481 advanceSubtestExecution(); 482 }, 0); 483 return; 484 } 485 } 486 487 if (onlyOneSubtest && onlyOneSubtest != test.file) { 488 SimpleTest.ok( 489 true, 490 "Skipping " + 491 test.file + 492 " because only " + 493 onlyOneSubtest + 494 " is being run" 495 ); 496 setTimeout(function () { 497 advanceSubtestExecution(); 498 }, 0); 499 return; 500 } 501 502 SimpleTest.ok(true, "Starting subtest " + test.file); 503 504 if (typeof test.dp_suppression != "undefined") { 505 // Normally during a test, the displayport will get suppressed during page 506 // load, and unsuppressed at a non-deterministic time during the test. The 507 // unsuppression can trigger a repaint which interferes with the test, so 508 // to avoid that we can force the displayport to be unsuppressed for the 509 // entire test which is more deterministic. 510 SpecialPowers.getDOMWindowUtils(window).respectDisplayPortSuppression( 511 test.dp_suppression 512 ); 513 } 514 515 function spawnTest(aFile) { 516 var subtestUrl = 517 location.href.substring(0, location.href.lastIndexOf("/") + 1) + 518 aFile; 519 w = window.open( 520 subtestUrl, 521 "_blank", 522 test.windowFeatures ? test.windowFeatures : "" 523 ); 524 w.subtestDone = advanceSubtestExecution; 525 w.subtestFailed = advanceSubtestExecutionWithFailure; 526 w.isApzSubtest = true; 527 w.SimpleTest = SimpleTest; 528 w.dump = function (msg) { 529 return dump(aFile + " | " + msg); 530 }; 531 w.info = function (msg) { 532 return info(aFile + " | " + msg); 533 }; 534 w.is = function (a, b, msg) { 535 return is(a, b, aFile + " | " + msg); 536 }; 537 w.isnot = function (a, b, msg) { 538 return isnot(a, b, aFile + " | " + msg); 539 }; 540 w.isfuzzy = function (a, b, eps, msg) { 541 return isfuzzy(a, b, eps, aFile + " | " + msg); 542 }; 543 w.ok = function (cond, msg) { 544 arguments[1] = aFile + " | " + msg; 545 // Forward all arguments to SimpleTest.ok where we will check that ok() was 546 // called with at most 2 arguments. 547 return SimpleTest.ok.apply(SimpleTest, arguments); 548 }; 549 w.todo_is = function (a, b, msg) { 550 return todo_is(a, b, aFile + " | " + msg); 551 }; 552 w.todo = function (cond, msg) { 553 return todo(cond, aFile + " | " + msg); 554 }; 555 if (test.onload) { 556 w.addEventListener( 557 "load", 558 function () { 559 test.onload(w); 560 }, 561 { once: true } 562 ); 563 } 564 function urlResolves(url) { 565 var request = new XMLHttpRequest(); 566 request.open("GET", url, false); 567 request.send(); 568 return request.status !== 404; 569 } 570 if (!urlResolves(subtestUrl)) { 571 SimpleTest.ok( 572 false, 573 "Subtest URL " + 574 subtestUrl + 575 " does not resolve. " + 576 "Be sure it's present in the support-files section of mochitest.toml." 577 ); 578 reject(); 579 return undefined; 580 } 581 return w; 582 } 583 584 if (test.prefs) { 585 // Got some prefs for this subtest, push them 586 await SpecialPowers.pushPrefEnv({ set: test.prefs }); 587 } 588 w = spawnTest(test.file); 589 } 590 591 advanceSubtestExecution(); 592 }).catch(function (e) { 593 SimpleTest.ok(false, "Error occurred while running subtests: " + e); 594 }); 595 } 596 597 function pushPrefs(prefs) { 598 return SpecialPowers.pushPrefEnv({ set: prefs }); 599 } 600 601 async function waitUntilApzStable() { 602 await SimpleTest.promiseFocus(window); 603 dump("WaitUntilApzStable: done promiseFocus\n"); 604 await promiseAllPaintsDone(); 605 dump("WaitUntilApzStable: done promiseAllPaintsDone\n"); 606 await promiseOnlyApzControllerFlushed(); 607 dump("WaitUntilApzStable: all done\n"); 608 } 609 610 // This function returns a promise that is resolved after at least one paint 611 // has been sent and processed by the compositor. This function can force 612 // such a paint to happen if none are pending. This is useful to run after 613 // the waitUntilApzStable() but before reading the compositor-side APZ test 614 // data, because the test data for the content layers id only gets populated 615 // on content layer tree updates *after* the root layer tree has a RefLayer 616 // pointing to the contnet layer tree. waitUntilApzStable itself guarantees 617 // that the root layer tree is pointing to the content layer tree, but does 618 // not guarantee the subsequent paint; this function does that job. 619 async function forceLayerTreeToCompositor() { 620 // Modify a style property to force a layout flush 621 document.body.style.boxSizing = "border-box"; 622 var utils = SpecialPowers.getDOMWindowUtils(window); 623 if (!utils.isMozAfterPaintPending) { 624 dump("Forcing a paint since none was pending already...\n"); 625 var testMode = utils.isTestControllingRefreshes; 626 utils.advanceTimeAndRefresh(0); 627 if (!testMode) { 628 utils.restoreNormalRefresh(); 629 } 630 } 631 await promiseAllPaintsDone(null, true); 632 await promiseOnlyApzControllerFlushed(); 633 } 634 635 function isApzEnabled() { 636 var enabled = SpecialPowers.getDOMWindowUtils(window).asyncPanZoomEnabled; 637 if (!enabled) { 638 // All tests are required to have at least one assertion. Since APZ is 639 // disabled, and the main test is presumably not going to run, we stick in 640 // a dummy assertion here to keep the test passing. 641 SimpleTest.ok(true, "APZ is not enabled; this test will be skipped"); 642 } 643 return enabled; 644 } 645 646 function isKeyApzEnabled() { 647 return isApzEnabled() && SpecialPowers.getBoolPref("apz.keyboard.enabled"); 648 } 649 650 // Take a snapshot of the given rect, *including compositor transforms* (i.e. 651 // includes async scroll transforms applied by APZ). If you don't need the 652 // compositor transforms, you can probably get away with using 653 // SpecialPowers.snapshotWindowWithOptions or one of the friendlier wrappers. 654 // The rect provided is expected to be relative to the screen, for example as 655 // returned by rectRelativeToScreen in apz_test_native_event_utils.js. 656 // Example usage: 657 // var snapshot = getSnapshot(rectRelativeToScreen(myDiv)); 658 // which will take a snapshot of the 'myDiv' element. Note that if part of the 659 // element is obscured by other things on top, the snapshot will include those 660 // things. If it is clipped by a scroll container, the snapshot will include 661 // that area anyway, so you will probably get parts of the scroll container in 662 // the snapshot. If the rect extends outside the browser window then the 663 // results are undefined. 664 // The snapshot is returned in the form of a data URL. 665 function getSnapshot(rect) { 666 function parentProcessSnapshot() { 667 /* eslint-env mozilla/chrome-script */ 668 addMessageListener("snapshot", function (parentRect) { 669 var topWin = Services.wm.getMostRecentWindow("navigator:browser"); 670 if (!topWin) { 671 topWin = Services.wm.getMostRecentWindow("navigator:geckoview"); 672 } 673 674 // reposition the rect relative to the top-level browser window 675 parentRect = JSON.parse(parentRect); 676 parentRect.x -= topWin.mozInnerScreenX; 677 parentRect.y -= topWin.mozInnerScreenY; 678 679 // take the snapshot 680 var canvas = topWin.document.createElementNS( 681 "http://www.w3.org/1999/xhtml", 682 "canvas" 683 ); 684 canvas.width = parentRect.width; 685 canvas.height = parentRect.height; 686 var ctx = canvas.getContext("2d"); 687 ctx.drawWindow( 688 topWin, 689 parentRect.x, 690 parentRect.y, 691 parentRect.width, 692 parentRect.height, 693 "rgb(255,255,255)", 694 ctx.DRAWWINDOW_DRAW_VIEW | 695 ctx.DRAWWINDOW_USE_WIDGET_LAYERS | 696 ctx.DRAWWINDOW_DRAW_CARET 697 ); 698 return canvas.toDataURL(); 699 }); 700 } 701 702 if (typeof getSnapshot.chromeHelper == "undefined") { 703 // This is the first time getSnapshot is being called; do initialization 704 getSnapshot.chromeHelper = SpecialPowers.loadChromeScript( 705 parentProcessSnapshot 706 ); 707 ApzCleanup.register(function () { 708 getSnapshot.chromeHelper.destroy(); 709 }); 710 } 711 712 return getSnapshot.chromeHelper.sendQuery("snapshot", JSON.stringify(rect)); 713 } 714 715 // Takes the document's query string and parses it, assuming the query string 716 // is composed of key-value pairs where the value is in JSON format. The object 717 // returned contains the various values indexed by their respective keys. In 718 // case of duplicate keys, the last value be used. 719 // Examples: 720 // ?key="value"&key2=false&key3=500 721 // produces { "key": "value", "key2": false, "key3": 500 } 722 // ?key={"x":0,"y":50}&key2=[1,2,true] 723 // produces { "key": { "x": 0, "y": 0 }, "key2": [1, 2, true] } 724 function getQueryArgs() { 725 var args = {}; 726 if (location.search.length) { 727 var params = location.search.substr(1).split("&"); 728 for (var p of params) { 729 var [k, v] = p.split("="); 730 args[k] = JSON.parse(v); 731 } 732 } 733 return args; 734 } 735 736 // An async function that inserts a script element with the given URI into 737 // the head of the document of the given window. This function returns when 738 // the load or error event fires on the script element, indicating completion. 739 async function injectScript(aScript, aWindow = window) { 740 var e = aWindow.document.createElement("script"); 741 e.type = "text/javascript"; 742 let loadPromise = new Promise((resolve, reject) => { 743 e.onload = function () { 744 resolve(); 745 }; 746 e.onerror = function () { 747 dump("Script [" + aScript + "] errored out\n"); 748 reject(); 749 }; 750 }); 751 e.src = aScript; 752 aWindow.document.getElementsByTagName("head")[0].appendChild(e); 753 await loadPromise; 754 } 755 756 // Compute some configuration information used for hit testing. 757 // The computed information is cached to avoid recomputing it 758 // each time this function is called. 759 // The computed information is an object with three fields: 760 // utils: the nsIDOMWindowUtils instance for this window 761 // isWindow: true if the platform is Windows 762 // activateAllScrollFrames: true if prefs indicate all scroll frames are 763 // activated with at least a minimal display port 764 function getHitTestConfig() { 765 if (!("hitTestConfig" in window)) { 766 var utils = SpecialPowers.getDOMWindowUtils(window); 767 var isWindows = getPlatform() == "windows"; 768 let activateAllScrollFrames = 769 SpecialPowers.getBoolPref("apz.wr.activate_all_scroll_frames") || 770 (SpecialPowers.getBoolPref( 771 "apz.wr.activate_all_scroll_frames_when_fission" 772 ) && 773 SpecialPowers.Services.appinfo.fissionAutostart); 774 775 window.hitTestConfig = { 776 utils, 777 isWindows, 778 activateAllScrollFrames, 779 }; 780 } 781 return window.hitTestConfig; 782 } 783 784 // Compute the coordinates of the center of the given element. The argument 785 // can either be a string (the id of the element desired) or the element 786 // itself. 787 function centerOf(element) { 788 if (typeof element === "string") { 789 element = document.getElementById(element); 790 } 791 var bounds = element.getBoundingClientRect(); 792 return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; 793 } 794 795 // Peform a compositor hit test at the given point and return the result. 796 // |point| is expected to be in CSS coordinates relative to the layout 797 // viewport, since this is what sendMouseEvent() expects. (Note that this 798 // is different from sendNativeMouseEvent() which expects screen coordinates 799 // relative to the screen.) 800 // The returned object has two fields: 801 // hitInfo: a combination of APZHitResultFlags 802 // scrollId: the view-id of the scroll frame that was hit 803 function hitTest(point, popupElement = null) { 804 var utils = getHitTestConfig().utils; 805 dump("Hit-testing point (" + point.x + ", " + point.y + ")\n"); 806 utils.sendMozMouseHitTestEvent(point.x, point.y, popupElement); 807 var data = utils.getCompositorAPZTestData(popupElement); 808 ok( 809 data.hitResults.length >= 1, 810 "Expected at least one hit result in the APZTestData" 811 ); 812 var result = data.hitResults[data.hitResults.length - 1]; 813 return { 814 hitInfo: result.hitResult, 815 scrollId: result.scrollId, 816 layersId: result.layersId, 817 }; 818 } 819 820 // Returns a canonical stringification of the hitInfo bitfield. 821 function hitInfoToString(hitInfo) { 822 var strs = []; 823 for (var flag in APZHitResultFlags) { 824 if ((hitInfo & APZHitResultFlags[flag]) != 0) { 825 strs.push(flag); 826 } 827 } 828 if (!strs.length) { 829 return "INVISIBLE"; 830 } 831 strs.sort(function (a, b) { 832 return APZHitResultFlags[a] - APZHitResultFlags[b]; 833 }); 834 return strs.join(" | "); 835 } 836 837 // Takes an object returned by hitTest, along with the expected values, and 838 // asserts that they match. Notably, it uses hitInfoToString to provide a 839 // more useful message for the case that the hit info doesn't match 840 function checkHitResult( 841 hitResult, 842 expectedHitInfo, 843 expectedScrollId, 844 expectedLayersId, 845 desc 846 ) { 847 is( 848 hitInfoToString(hitResult.hitInfo), 849 hitInfoToString(expectedHitInfo), 850 desc + " hit info" 851 ); 852 is(hitResult.scrollId, expectedScrollId, desc + " scrollid"); 853 is(hitResult.layersId, expectedLayersId, desc + " layersid"); 854 } 855 856 // Symbolic constants used by hitTestScrollbar(). 857 var ScrollbarTrackLocation = { 858 START: 1, 859 END: 2, 860 }; 861 var LayerState = { 862 ACTIVE: 1, 863 INACTIVE: 2, 864 }; 865 866 // Perform a hit test on the scrollbar(s) of a scroll frame. 867 // This function takes a single argument which is expected to be 868 // an object with the following fields: 869 // element: The scroll frame to perform the hit test on. 870 // directions: The direction(s) of scrollbars to test. 871 // If directions.vertical is true, the vertical scrollbar will be tested. 872 // If directions.horizontal is true, the horizontal scrollbar will be tested. 873 // Both may be true in a single call (in which case two tests are performed). 874 // expectedScrollId: The scroll id that is expected to be hit, if activateAllScrollFrames is false. 875 // expectedLayersId: The layers id that is expected to be hit. 876 // trackLocation: One of ScrollbarTrackLocation.{START, END}. 877 // Determines which end of the scrollbar track is targeted. 878 // expectThumb: Whether the scrollbar thumb is expected to be present 879 // at the targeted end of the scrollbar track. 880 // layerState: Whether the scroll frame is active or inactive. 881 // The function performs the hit tests and asserts that the returned 882 // hit test information is consistent with the passed parameters. 883 // There is no return value. 884 // Tests that use this function must set the pref 885 // "layout.scrollbars.always-layerize-track". 886 function hitTestScrollbar(params) { 887 var config = getHitTestConfig(); 888 889 var elem = params.element; 890 891 var boundingClientRect = elem.getBoundingClientRect(); 892 893 var verticalScrollbarWidth = boundingClientRect.width - elem.clientWidth; 894 var horizontalScrollbarHeight = boundingClientRect.height - elem.clientHeight; 895 896 // On windows, the scrollbar tracks have buttons on the end. When computing 897 // coordinates for hit-testing we need to account for this. We assume the 898 // buttons are square, and so can use the scrollbar width/height to estimate 899 // the size of the buttons 900 var scrollbarArrowButtonHeight = config.isWindows 901 ? verticalScrollbarWidth 902 : 0; 903 var scrollbarArrowButtonWidth = config.isWindows 904 ? horizontalScrollbarHeight 905 : 0; 906 907 // Compute the expected hit result flags. 908 // The direction flag (APZHitResultFlags.SCROLLBAR_VERTICAL) is added in 909 // later, for the vertical test only. 910 // The APZHitResultFlags.SCROLLBAR flag will be present regardless of whether 911 // the layer is active or inactive because we force layerization of scrollbar 912 // tracks. Unfortunately not forcing the layerization results in different 913 // behaviour on different platforms which makes testing harder. 914 var expectedHitInfo = APZHitResultFlags.VISIBLE | APZHitResultFlags.SCROLLBAR; 915 if (params.expectThumb) { 916 // The thumb has listeners which are APZ-aware. 917 expectedHitInfo |= APZHitResultFlags.APZ_AWARE_LISTENERS; 918 var expectActive = 919 config.activateAllScrollFrames || params.layerState == LayerState.ACTIVE; 920 if (!expectActive) { 921 expectedHitInfo |= APZHitResultFlags.INACTIVE_SCROLLFRAME; 922 } 923 // We do not generate the layers for thumbs on inactive scrollframes. 924 if (expectActive) { 925 expectedHitInfo |= APZHitResultFlags.SCROLLBAR_THUMB; 926 } 927 } 928 929 var expectedScrollId = params.expectedScrollId; 930 if (config.activateAllScrollFrames) { 931 expectedScrollId = config.utils.getViewId(params.element); 932 if (params.layerState == LayerState.ACTIVE) { 933 is( 934 expectedScrollId, 935 params.expectedScrollId, 936 "Expected scrollId for active scrollframe should match" 937 ); 938 } 939 } 940 941 var scrollframeMsg = 942 params.layerState == LayerState.ACTIVE 943 ? "active scrollframe" 944 : "inactive scrollframe"; 945 946 // Hit-test the targeted areas, assuming we don't have overlay scrollbars 947 // with zero dimensions. 948 if (params.directions.vertical && verticalScrollbarWidth > 0) { 949 var verticalScrollbarPoint = { 950 x: boundingClientRect.right - verticalScrollbarWidth / 2, 951 y: 952 params.trackLocation == ScrollbarTrackLocation.START 953 ? boundingClientRect.y + scrollbarArrowButtonHeight + 5 954 : boundingClientRect.bottom - 955 horizontalScrollbarHeight - 956 scrollbarArrowButtonHeight - 957 5, 958 }; 959 checkHitResult( 960 hitTest(verticalScrollbarPoint), 961 expectedHitInfo | APZHitResultFlags.SCROLLBAR_VERTICAL, 962 expectedScrollId, 963 params.expectedLayersId, 964 scrollframeMsg + " - vertical scrollbar" 965 ); 966 } 967 968 if (params.directions.horizontal && horizontalScrollbarHeight > 0) { 969 var horizontalScrollbarPoint = { 970 x: 971 params.trackLocation == ScrollbarTrackLocation.START 972 ? boundingClientRect.x + scrollbarArrowButtonWidth + 5 973 : boundingClientRect.right - 974 verticalScrollbarWidth - 975 scrollbarArrowButtonWidth - 976 5, 977 y: boundingClientRect.bottom - horizontalScrollbarHeight / 2, 978 }; 979 checkHitResult( 980 hitTest(horizontalScrollbarPoint), 981 expectedHitInfo, 982 expectedScrollId, 983 params.expectedLayersId, 984 scrollframeMsg + " - horizontal scrollbar" 985 ); 986 } 987 } 988 989 // Return a list of prefs for the given test identifier. 990 function getPrefs(ident) { 991 switch (ident) { 992 case "TOUCH_EVENTS:PAN": 993 return [ 994 // Dropping the touch slop to 0 makes the tests easier to write because 995 // we can just do a one-pixel drag to get over the pan threshold rather 996 // than having to hard-code some larger value. 997 ["apz.touch_start_tolerance", "0.0"], 998 // The touchstart from the drag can turn into a long-tap if the touch-move 999 // events get held up. Try to prevent that by making long-taps require 1000 // a 10 second hold. Note that we also cannot enable chaos mode on this 1001 // test for this reason, since chaos mode can cause the long-press timer 1002 // to fire sooner than the pref dictates. 1003 ["ui.click_hold_context_menus.delay", 10000], 1004 // The subtests in this test do touch-drags to pan the page, but we don't 1005 // want those pans to turn into fling animations, so we increase the 1006 // fling min velocity requirement absurdly high. 1007 ["apz.fling_min_velocity_threshold", "10000"], 1008 // The helper_div_pan's div gets a displayport on scroll, but if the 1009 // test takes too long the displayport can expire before the new scroll 1010 // position is synced back to the main thread. So we disable displayport 1011 // expiry for these tests. 1012 ["apz.displayport_expiry_ms", 0], 1013 // We need to disable touch resampling during these tests because we 1014 // rely on touch move events being processed without delay. Touch 1015 // resampling only processes them once vsync fires. 1016 ["android.touch_resampling.enabled", false], 1017 ]; 1018 case "TOUCH_ACTION": 1019 return [ 1020 ...getPrefs("TOUCH_EVENTS:PAN"), 1021 ["apz.test.fails_with_native_injection", getPlatform() == "windows"], 1022 ]; 1023 default: 1024 return []; 1025 } 1026 } 1027 1028 var ApzCleanup = { 1029 _cleanups: [], 1030 1031 register(func) { 1032 if (!this._cleanups.length) { 1033 if (!window.isApzSubtest) { 1034 SimpleTest.registerCleanupFunction(this.execute.bind(this)); 1035 } // else ApzCleanup.execute is called from runSubtestsSeriallyInFreshWindows 1036 } 1037 this._cleanups.push(func); 1038 }, 1039 1040 execute() { 1041 while (this._cleanups.length) { 1042 var func = this._cleanups.pop(); 1043 try { 1044 func(); 1045 } catch (ex) { 1046 SimpleTest.ok( 1047 false, 1048 "Subtest cleanup function [" + 1049 func.toString() + 1050 "] threw exception [" + 1051 ex + 1052 "] on page [" + 1053 location.href + 1054 "]" 1055 ); 1056 } 1057 } 1058 }, 1059 }; 1060 1061 /** 1062 * Returns a promise that will resolve if `eventTarget` receives an event of the 1063 * given type that passes the given filter. Only the first matching message is 1064 * used. The filter must be a function (or null); it is called with the event 1065 * object and the call must return true to resolve the promise. 1066 */ 1067 function promiseOneEvent(eventTarget, eventType, filter) { 1068 return new Promise((resolve, reject) => { 1069 eventTarget.addEventListener(eventType, function listener(e) { 1070 let success = false; 1071 if (filter == null) { 1072 success = true; 1073 } else if (typeof filter == "function") { 1074 try { 1075 success = filter(e); 1076 } catch (ex) { 1077 dump( 1078 `ERROR: Filter passed to promiseOneEvent threw exception: ${ex}\n` 1079 ); 1080 reject(); 1081 return; 1082 } 1083 } else { 1084 dump( 1085 "ERROR: Filter passed to promiseOneEvent was neither null nor a function\n" 1086 ); 1087 reject(); 1088 return; 1089 } 1090 if (success) { 1091 eventTarget.removeEventListener(eventType, listener); 1092 resolve(e); 1093 } 1094 }); 1095 }); 1096 } 1097 1098 function visualViewportAsZoomedRect() { 1099 let vv = window.visualViewport; 1100 return { 1101 x: vv.pageLeft, 1102 y: vv.pageTop, 1103 w: vv.width, 1104 h: vv.height, 1105 z: vv.scale, 1106 }; 1107 } 1108 1109 // Pulls the latest compositor APZ test data and checks to see if the 1110 // scroller with id `scrollerId` was checkerboarding. It also ensures that 1111 // a scroller with id `scrollerId` was actually found in the test data. 1112 // This function requires that "apz.test.logging_enabled" be set to true, 1113 // in order for the test data to be logged. 1114 function assertNotCheckerboarded(utils, scrollerId, msgPrefix) { 1115 utils.advanceTimeAndRefresh(0); 1116 var data = utils.getCompositorAPZTestData(); 1117 //dump(JSON.stringify(data, null, 4)); 1118 var found = false; 1119 for (apzcData of data.additionalData) { 1120 if (apzcData.key == scrollerId) { 1121 var checkerboarding = apzcData.value 1122 .split(",") 1123 .includes("checkerboarding"); 1124 ok(!checkerboarding, `${msgPrefix}: scroller is not checkerboarding`); 1125 found = true; 1126 } 1127 } 1128 ok(found, `${msgPrefix}: Found the scroller in the APZ data`); 1129 utils.restoreNormalRefresh(); 1130 } 1131 1132 async function waitToClearOutAnyPotentialScrolls(aWindow) { 1133 await promiseFrame(aWindow); 1134 await promiseFrame(aWindow); 1135 await promiseOnlyApzControllerFlushed(aWindow); 1136 await promiseFrame(aWindow); 1137 await promiseFrame(aWindow); 1138 } 1139 1140 function waitForScrollEvent(target) { 1141 return new Promise(resolve => { 1142 target.addEventListener("scroll", resolve, { once: true }); 1143 }); 1144 } 1145 1146 // This is another variant of promiseApzFlushedRepaints. 1147 // We need this function because, unfortunately, there is no easy way to use 1148 // paint_listeners.js' functions and apz_test_utils.js' functions in popup 1149 // contents opened by extensions either as scripts in the popup contents or 1150 // scripts inside SpecialPowers.spawn because we can't use privileged functions 1151 // in the popup contents' script, we can't use functions basically as it as in 1152 // the sandboxed context either. 1153 async function promiseApzFlushedRepaintsInPopup(popup) { 1154 // Flush APZ repaints and waits for MozAfterPaint. 1155 await SpecialPowers.spawn(popup, [], async () => { 1156 const utils = SpecialPowers.getDOMWindowUtils(content.window); 1157 1158 async function promiseAllPaintsDone() { 1159 return new Promise(resolve => { 1160 function waitForPaints() { 1161 if (utils.isMozAfterPaintPending) { 1162 dump("Waits for a MozAfterPaint event\n"); 1163 content.window.addEventListener( 1164 "MozAfterPaint", 1165 () => { 1166 dump("Got a MozAfterPaint event\n"); 1167 waitForPaints(); 1168 }, 1169 { once: true } 1170 ); 1171 } else { 1172 dump("No more pending MozAfterPaint\n"); 1173 content.window.setTimeout(resolve, 0); 1174 } 1175 } 1176 waitForPaints(); 1177 }); 1178 } 1179 await promiseAllPaintsDone(); 1180 1181 await new Promise(resolve => { 1182 var repaintDone = function () { 1183 dump("APZ flush done\n"); 1184 SpecialPowers.Services.obs.removeObserver( 1185 repaintDone, 1186 "apz-repaints-flushed" 1187 ); 1188 content.window.setTimeout(resolve, 0); 1189 }; 1190 SpecialPowers.Services.obs.addObserver( 1191 repaintDone, 1192 "apz-repaints-flushed" 1193 ); 1194 if (utils.flushApzRepaints()) { 1195 dump("Flushed APZ repaints, waiting for callback...\n"); 1196 } else { 1197 dump( 1198 "Flushing APZ repaints was a no-op, triggering callback directly...\n" 1199 ); 1200 repaintDone(); 1201 } 1202 }); 1203 1204 await promiseAllPaintsDone(); 1205 }); 1206 } 1207 1208 // A utility function to make sure there's no scroll animation on the given 1209 // |aElement|. 1210 async function cancelScrollAnimation(aElement, aWindow = window) { 1211 // In fact there's no good way to directly cancel the active animation on the 1212 // element, so we destroy the corresponding scrollable frame then reconstruct 1213 // a new scrollable frame so that it clobbers the animation. 1214 const originalStyle = aElement.style.display; 1215 aElement.style.display = "none"; 1216 await aWindow.promiseApzFlushedRepaints(); 1217 aElement.style.display = originalStyle; 1218 await aWindow.promiseApzFlushedRepaints(); 1219 } 1220 1221 function collectSampledScrollOffsets(aElement, aPopupElement = null) { 1222 const utils = SpecialPowers.getDOMWindowUtils( 1223 aPopupElement ? aPopupElement.ownerGlobal : window 1224 ); 1225 let data = utils.getCompositorAPZTestData(aPopupElement); 1226 let sampledResults = data.sampledResults; 1227 1228 const layersId = utils.getLayersId(aPopupElement); 1229 const scrollId = utils.getViewId(aElement); 1230 1231 return sampledResults.filter( 1232 result => 1233 SpecialPowers.wrap(result).layersId == layersId && 1234 SpecialPowers.wrap(result).scrollId == scrollId 1235 ); 1236 } 1237 1238 function cloneVisualViewport() { 1239 return { 1240 offsetLeft: visualViewport.offsetLeft, 1241 offsetTop: visualViewport.offsetTop, 1242 pageLeft: visualViewport.pageLeft, 1243 pageTop: visualViewport.pageTop, 1244 width: visualViewport.width, 1245 height: visualViewport.height, 1246 scale: visualViewport.scale, 1247 }; 1248 } 1249 1250 function compareVisualViewport( 1251 aVisualViewportValue1, 1252 aVisualViewportValue2, 1253 aMessage 1254 ) { 1255 for (let p in aVisualViewportValue1) { 1256 // Due to the method difference of the calculation for double-tap-zoom in 1257 // OOP iframes, we allow 1.0 difference in each visualViewport value. 1258 // NOTE: Because of our layer pixel snapping (bug 1774315 and bug 1852884) 1259 // the visual viewport metrics can have one more pixel difference so we 1260 // allow it here. 1261 const tolerance = 1.0 + 1.0; 1262 isfuzzy( 1263 aVisualViewportValue1[p], 1264 aVisualViewportValue2[p], 1265 aVisualViewportValue1.scale > 1.0 1266 ? tolerance 1267 : tolerance / aVisualViewportValue1.scale, 1268 `${p} should be same on ${aMessage}` 1269 ); 1270 } 1271 } 1272 1273 // Loads a URL in an iframe and waits until APZ is stable 1274 async function setupIframe(aIFrame, aURL, aIsOffScreen = false) { 1275 const iframeLoadPromise = promiseOneEvent(aIFrame, "load", null); 1276 aIFrame.src = aURL; 1277 await iframeLoadPromise; 1278 1279 if (!aIsOffScreen) { 1280 await SpecialPowers.spawn(aIFrame, [], async () => { 1281 await content.wrappedJSObject.waitUntilApzStable(); 1282 }); 1283 } 1284 } 1285 1286 // Loads a URL in an iframe and replaces its origin to 1287 // create an out-of-process iframe 1288 async function setupCrossOriginIFrame(aIFrame, aUrl, aIsOffScreen = false) { 1289 let iframeURL = SimpleTest.getTestFileURL(aUrl); 1290 iframeURL = iframeURL.replace(window.location.origin, "https://example.com"); 1291 await setupIframe(aIFrame, iframeURL, aIsOffScreen); 1292 if (!aIsOffScreen) { 1293 await SpecialPowers.spawn(aIFrame, [], async () => { 1294 await SpecialPowers.contentTransformsReceived(content); 1295 }); 1296 } 1297 } 1298 1299 // Make sure APZ is ready for the popup. 1300 // With enabling GPU process initiating APZ in the popup takes some time. 1301 // Before the APZ has been initiated, calling flushApzRepaints() for the popup 1302 // returns false. 1303 async function ensureApzReadyForPopup( 1304 aPopupElement, 1305 aWindow = window, 1306 aRetry = 10 1307 ) { 1308 let retry = 0; 1309 while ( 1310 !SpecialPowers.getDOMWindowUtils(aWindow).flushApzRepaints(aPopupElement) 1311 ) { 1312 await promiseFrame(); 1313 retry++; 1314 if (retry > aRetry) { 1315 ok(false, "The popup didn't initialize APZ"); 1316 return; 1317 } 1318 } 1319 }