"use strict"; ChromeUtils.defineModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); ChromeUtils.defineModuleGetter(this, "PlacesTestUtils", "resource://testing-common/PlacesTestUtils.jsm"); /** * This function can be called if the test needs to trigger frame dirtying * outside of the normal mechanism. * * @param win (dom window) * The window in which the frame tree needs to be marked as dirty. */ function dirtyFrame(win) { let dwu = win.windowUtils; try { dwu.ensureDirtyRootFrame(); } catch (e) { // If this fails, we should probably make note of it, but it's not fatal. info("Note: ensureDirtyRootFrame threw an exception:" + e); } } /** * Async utility function to collect the stacks of uninterruptible reflows * occuring during some period of time in a window. * * @param testPromise (Promise) * A promise that is resolved when the data collection should stop. * * @param win (browser window, optional) * The browser window to monitor. Defaults to the current window. * * @return An array of reflow stacks */ async function recordReflows(testPromise, win = window) { // Collect all reflow stacks, we'll process them later. let reflows = []; let observer = { reflow(start, end) { // Gather information about the current code path. reflows.push(new Error().stack); // Just in case, dirty the frame now that we've reflowed. dirtyFrame(win); }, reflowInterruptible(start, end) { // Interruptible reflows are the reflows caused by the refresh // driver ticking. These are fine. }, QueryInterface: ChromeUtils.generateQI([Ci.nsIReflowObserver, Ci.nsISupportsWeakReference]) }; let docShell = win.docShell; docShell.addWeakReflowObserver(observer); let dirtyFrameFn = event => { if (event.type != "MozAfterPaint") { dirtyFrame(win); } }; Services.els.addListenerForAllEvents(win, dirtyFrameFn, true); try { dirtyFrame(win); await testPromise; } finally { Services.els.removeListenerForAllEvents(win, dirtyFrameFn, true); docShell.removeWeakReflowObserver(observer); } return reflows; } /** * Utility function to report unexpected reflows. * * @param reflows (Array) * An array of reflow stacks returned by recordReflows. * * @param expectedReflows (Array, optional) * An Array of Objects representing reflows. * * Example: * * [ * { * // This reflow is caused by lorem ipsum. * // Sometimes, due to unpredictable timings, the reflow may be hit * // less times. * stack: [ * "select@chrome://global/content/bindings/textbox.xml", * "focusAndSelectUrlBar@chrome://browser/content/browser.js", * "openLinkIn@chrome://browser/content/utilityOverlay.js", * "openUILinkIn@chrome://browser/content/utilityOverlay.js", * "BrowserOpenTab@chrome://browser/content/browser.js", * ], * // We expect this particular reflow to happen up to 2 times. * maxCount: 2, * }, * * { * // This reflow is caused by lorem ipsum. We expect this reflow * // to only happen once, so we can omit the "maxCount" property. * stack: [ * "get_scrollPosition@chrome://global/content/bindings/scrollbox.xml", * "_fillTrailingGap@chrome://browser/content/tabbrowser.xml", * "_handleNewTab@chrome://browser/content/tabbrowser.xml", * "onxbltransitionend@chrome://browser/content/tabbrowser.xml", * ], * } * ] * * Note that line numbers are not included in the stacks. * * Order of the reflows doesn't matter. Expected reflows that aren't seen * will cause an assertion failure. When this argument is not passed, * it defaults to the empty Array, meaning no reflows are expected. */ function reportUnexpectedReflows(reflows, expectedReflows = []) { let knownReflows = expectedReflows.map(r => { return {stack: r.stack, path: r.stack.join("|"), count: 0, maxCount: r.maxCount || 1, actualStacks: new Map()}; }); let unexpectedReflows = new Map(); for (let stack of reflows) { let path = stack.split("\n").slice(1) // the first frame which is our test code. .map(line => line.replace(/:\d+:\d+$/, "")) // strip line numbers. .join("|"); // Stack trace is empty. Reflow was triggered by native code, which // we ignore. if (path === "") { continue; } // synthesizeKey from EventUtils.js causes us to reflow. That's the test // harness and we don't care about that, so we'll filter that out. if (path.startsWith("synthesizeKey@chrome://mochikit/content/tests/SimpleTest/EventUtils.js")) { continue; } let index = knownReflows.findIndex(reflow => path.startsWith(reflow.path)); if (index != -1) { let reflow = knownReflows[index]; ++reflow.count; reflow.actualStacks.set(stack, (reflow.actualStacks.get(stack) || 0) + 1); } else { unexpectedReflows.set(stack, (unexpectedReflows.get(stack) || 0) + 1); } } let formatStack = stack => stack.split("\n").slice(1).map(frame => " " + frame).join("\n"); for (let reflow of knownReflows) { let firstFrame = reflow.stack[0]; if (!reflow.count) { Assert.ok(false, `Unused expected reflow at ${firstFrame}:\nStack:\n` + reflow.stack.map(frame => " " + frame).join("\n") + "\n" + "This is probably a good thing - just remove it from the whitelist."); } else { if (reflow.count > reflow.maxCount) { Assert.ok(false, `reflow at ${firstFrame} was encountered ${reflow.count} times,\n` + `it was expected to happen up to ${reflow.maxCount} times.`); } else { todo(false, `known reflow at ${firstFrame} was encountered ${reflow.count} times`); } for (let [stack, count] of reflow.actualStacks) { info("Full stack" + (count > 1 ? ` (hit ${count} times)` : "") + ":\n" + formatStack(stack)); } } } for (let [stack, count] of unexpectedReflows) { let location = stack.split("\n")[1].replace(/:\d+:\d+$/, ""); Assert.ok(false, `unexpected reflow at ${location} hit ${count} times\n` + "Stack:\n" + formatStack(stack)); } Assert.ok(!unexpectedReflows.size, unexpectedReflows.size + " unexpected reflows"); } async function ensureNoPreloadedBrowser(win = window) { // If we've got a preloaded browser, get rid of it so that it // doesn't interfere with the test if it's loading. We have to // do this before we disable preloading or changing the new tab // URL, otherwise _getPreloadedBrowser will return null, despite // the preloaded browser existing. let preloaded = win.gBrowser._getPreloadedBrowser(); if (preloaded) { preloaded.remove(); } await SpecialPowers.pushPrefEnv({ set: [["browser.newtab.preload", false]], }); let aboutNewTabService = Cc["@mozilla.org/browser/aboutnewtab-service;1"] .getService(Ci.nsIAboutNewTabService); aboutNewTabService.newTabURL = "about:blank"; registerCleanupFunction(() => { aboutNewTabService.resetNewTabURL(); }); } /** * The navigation toolbar is overflowable, meaning that some items * will be moved and held within a sub-panel if the window gets too * small to show their icons. The calculation for hiding those items * occurs after resize events, and is debounced using a DeferredTask. * This utility function allows us to fast-forward to just running * that function for that DeferredTask instead of waiting for the * debounce timeout to occur. */ function forceImmediateToolbarOverflowHandling(win) { let overflowableToolbar = win.document.getElementById("nav-bar").overflowable; if (overflowableToolbar._lazyResizeHandler && overflowableToolbar._lazyResizeHandler.isArmed) { overflowableToolbar._lazyResizeHandler.disarm(); // Ensure the root frame is dirty before resize so that, if we're // in the middle of a reflow test, we record the reflows deterministically. let dwu = win.windowUtils; dwu.ensureDirtyRootFrame(); overflowableToolbar._onLazyResize(); } } async function prepareSettledWindow() { let win = await BrowserTestUtils.openNewBrowserWindow(); await ensureNoPreloadedBrowser(win); forceImmediateToolbarOverflowHandling(win); return win; } // Use this function to avoid catching a reflow related to calling focus on the // urlbar and changed rects for its dropmarker when opening new tabs. async function ensureFocusedUrlbar() { // The switchingtabs attribute prevents the historydropmarker opacity // transition, so if we expect a transitionend event when this attribute // is set, we wait forever. (it's removed off a MozAfterPaint event listener) await BrowserTestUtils.waitForCondition(() => !gURLBar.hasAttribute("switchingtabs")); let dropmarker = document.getAnonymousElementByAttribute(gURLBar, "anonid", "historydropmarker"); let opacityPromise = BrowserTestUtils.waitForEvent(dropmarker, "transitionend", false, e => e.propertyName === "opacity"); gURLBar.focus(); await opacityPromise; } /** * Calculate and return how many additional tabs can be fit into the * tabstrip without causing it to overflow. * * @return int * The maximum additional tabs that can be fit into the * tabstrip without causing it to overflow. */ function computeMaxTabCount() { let currentTabCount = gBrowser.tabs.length; let newTabButton = document.getAnonymousElementByAttribute(gBrowser.tabContainer, "anonid", "tabs-newtab-button"); let newTabRect = newTabButton.getBoundingClientRect(); let tabStripRect = gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); let availableTabStripWidth = tabStripRect.width - newTabRect.width; let tabMinWidth = parseInt(getComputedStyle(gBrowser.selectedTab, null).minWidth, 10); let maxTabCount = Math.floor(availableTabStripWidth / tabMinWidth) - currentTabCount; Assert.ok(maxTabCount > 0, "Tabstrip needs to be wide enough to accomodate at least 1 more tab " + "without overflowing."); return maxTabCount; } /** * Helper function that opens up some number of about:blank tabs, and wait * until they're all fully open. * * @param howMany (int) * How many about:blank tabs to open. */ async function createTabs(howMany) { let uris = []; while (howMany--) { uris.push("about:blank"); } gBrowser.loadTabs(uris, { inBackground: true, triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }); await BrowserTestUtils.waitForCondition(() => { return Array.from(gBrowser.tabs).every(tab => tab._fullyOpen); }); } /** * Removes all of the tabs except the originally selected * tab, and waits until all of the DOM nodes have been * completely removed from the tab strip. */ async function removeAllButFirstTab() { await SpecialPowers.pushPrefEnv({ set: [["browser.tabs.warnOnCloseOtherTabs", false]], }); gBrowser.removeAllTabsBut(gBrowser.tabs[0]); await BrowserTestUtils.waitForCondition(() => gBrowser.tabs.length == 1); await SpecialPowers.popPrefEnv(); } /** * Adds some entries to the Places database so that we can * do semi-realistic look-ups in the URL bar. * * @param searchStr (string) * Optional text to add to the search history items. */ async function addDummyHistoryEntries(searchStr = "") { await PlacesUtils.history.clear(); const NUM_VISITS = 10; let visits = []; for (let i = 0; i < NUM_VISITS; ++i) { visits.push({ uri: `http://example.com/urlbar-reflows-${i}`, title: `Reflow test for URL bar entry #${i} - ${searchStr}`, }); } await PlacesTestUtils.addVisits(visits); registerCleanupFunction(async function() { await PlacesUtils.history.clear(); }); } /** * Async utility function to capture a screenshot of each painted frame. * * @param testPromise (Promise) * A promise that is resolved when the data collection should stop. * * @param win (browser window, optional) * The browser window to monitor. Defaults to the current window. * * @return An array of screenshots */ async function recordFrames(testPromise, win = window) { let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); canvas.mozOpaque = true; let ctx = canvas.getContext("2d", {alpha: false, willReadFrequently: true}); let frames = []; let afterPaintListener = event => { let width, height; canvas.width = width = win.innerWidth; canvas.height = height = win.innerHeight; ctx.drawWindow(win, 0, 0, width, height, "white", ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_VIEW | ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES | ctx.DRAWWINDOW_USE_WIDGET_LAYERS); let data = Cu.cloneInto(ctx.getImageData(0, 0, width, height).data, {}); if (frames.length) { // Compare this frame with the previous one to avoid storing duplicate // frames and running out of memory. let previous = frames[frames.length - 1]; if (previous.width == width && previous.height == height) { let equals = true; for (let i = 0; i < data.length; ++i) { if (data[i] != previous.data[i]) { equals = false; break; } } if (equals) { return; } } } frames.push({data, width, height}); }; win.addEventListener("MozAfterPaint", afterPaintListener); // If the test is using an existing window, capture a frame immediately. if (win.document.readyState == "complete") { afterPaintListener(); } try { await testPromise; } finally { win.removeEventListener("MozAfterPaint", afterPaintListener); } return frames; } // How many identical pixels to accept between 2 rects when deciding to merge // them. const kMaxEmptyPixels = 3; function compareFrames(frame, previousFrame) { // Accessing the Math global is expensive as the test executes in a // non-syntactic scope. Accessing it as a lexical variable is enough // to make the code JIT well. const M = Math; function expandRect(x, y, rect) { if (rect.x2 < x) rect.x2 = x; else if (rect.x1 > x) rect.x1 = x; if (rect.y2 < y) rect.y2 = y; } function isInRect(x, y, rect) { return (rect.y2 == y || rect.y2 == y - 1) && rect.x1 - 1 <= x && x <= rect.x2 + 1; } if (frame.height != previousFrame.height || frame.width != previousFrame.width) { // If the frames have different sizes, assume the whole window has // been repainted when the window was resized. return [{x1: 0, x2: frame.width, y1: 0, y2: frame.height}]; } let l = frame.data.length; let different = []; let rects = []; for (let i = 0; i < l; i += 4) { let x = (i / 4) % frame.width; let y = M.floor((i / 4) / frame.width); for (let j = 0; j < 4; ++j) { let index = i + j; if (frame.data[index] != previousFrame.data[index]) { let found = false; for (let rect of rects) { if (isInRect(x, y, rect)) { expandRect(x, y, rect); found = true; break; } } if (!found) rects.unshift({x1: x, x2: x, y1: y, y2: y}); different.push(i); break; } } } rects.reverse(); // The following code block merges rects that are close to each other // (less than kMaxEmptyPixels away). // This is needed to avoid having a rect for each letter when a label moves. let areRectsContiguous = function(r1, r2) { return r1.y2 >= r2.y1 - 1 - kMaxEmptyPixels && r2.x1 - 1 - kMaxEmptyPixels <= r1.x2 && r2.x2 >= r1.x1 - 1 - kMaxEmptyPixels; }; let hasMergedRects; do { hasMergedRects = false; for (let r = rects.length - 1; r > 0; --r) { let rr = rects[r]; for (let s = r - 1; s >= 0; --s) { let rs = rects[s]; if (areRectsContiguous(rs, rr)) { rs.x1 = Math.min(rs.x1, rr.x1); rs.y1 = Math.min(rs.y1, rr.y1); rs.x2 = Math.max(rs.x2, rr.x2); rs.y2 = Math.max(rs.y2, rr.y2); rects.splice(r, 1); hasMergedRects = true; break; } } } } while (hasMergedRects); // For convenience, pre-compute the width and height of each rect. rects.forEach(r => { r.w = r.x2 - r.x1 + 1; r.h = r.y2 - r.y1 + 1; }); return rects; } function dumpFrame({data, width, height}) { let canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); canvas.mozOpaque = true; canvas.width = width; canvas.height = height; canvas.getContext("2d", {alpha: false, willReadFrequently: true}) .putImageData(new ImageData(data, width, height), 0, 0); info(canvas.toDataURL()); } /** * Utility function to report unexpected changed areas on screen. * * @param frames (Array) * An array of frames captured by recordFrames. * * @param expectations (Object) * An Object indicating which changes on screen are expected. * If can contain the following optional fields: * - filter: a function used to exclude changed rects that are expected. * It takes the following parameters: * - rects: an array of changed rects * - frame: the current frame * - previousFrame: the previous frame * It returns an array of rects. This array is typically a copy of * the rects parameter, from which identified expected changes have * been excluded. * - exceptions: an array of objects describing known flicker bugs. * Example: * exceptions: [ * {name: "bug 1nnnnnn - the foo icon shouldn't flicker", * condition: r => r.w == 14 && r.y1 == 0 && ... } * }, * {name: "bug ... * ] */ function reportUnexpectedFlicker(frames, expectations) { info("comparing " + frames.length + " frames"); let unexpectedRects = 0; for (let i = 1; i < frames.length; ++i) { let frame = frames[i], previousFrame = frames[i - 1]; let rects = compareFrames(frame, previousFrame); if (expectations.filter) { rects = expectations.filter(rects, frame, previousFrame); } rects = rects.filter(rect => { let rectText = `${rect.toSource()}, window width: ${frame.width}`; for (let e of (expectations.exceptions || [])) { if (e.condition(rect)) { todo(false, e.name + ", " + rectText); return false; } } ok(false, "unexpected changed rect: " + rectText); return true; }); if (!rects.length) continue; // Before dumping a frame with unexpected differences for the first time, // ensure at least one previous frame has been logged so that it's possible // to see the differences when examining the log. if (!unexpectedRects) { dumpFrame(previousFrame); } unexpectedRects += rects.length; dumpFrame(frame); } is(unexpectedRects, 0, "should have 0 unknown flickering areas"); } /** * This is the main function that performance tests in this folder will call. * * The general idea is that individual tests provide a test function (testFn) * that will perform some user interactions we care about (eg. open a tab), and * this withPerfObserver function takes care of setting up and removing the * observers and listener we need to detect common performance issues. * * Once testFn is done, withPerfObserver will analyse the collected data and * report anything unexpected. * * @param testFn (async function) * An async function that exercises some part of the browser UI. * * @param exceptions (object, optional) * An Array of Objects representing expectations and known issues. * It can contain the following fields: * - expectedReflows: an array of expected reflow stacks. * (see the comment above reportUnexpectedReflows for an example) * - frames: an object setting expectations for what will change * on screen during the test, and the known flicker bugs. * (see the comment above reportUnexpectedFlicker for an example) */ async function withPerfObserver(testFn, exceptions = {}, win = window) { let resolveFn, rejectFn; let promiseTestDone = new Promise((resolve, reject) => { resolveFn = resolve; rejectFn = reject; }); let promiseReflows = recordReflows(promiseTestDone, win); let promiseFrames = recordFrames(promiseTestDone, win); testFn().then(resolveFn, rejectFn); await promiseTestDone; let reflows = await promiseReflows; reportUnexpectedReflows(reflows, exceptions.expectedReflows); let frames = await promiseFrames; reportUnexpectedFlicker(frames, exceptions.frames); }