diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/base/content/test/performance | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/base/content/test/performance')
37 files changed, 5993 insertions, 0 deletions
diff --git a/browser/base/content/test/performance/PerfTestHelpers.sys.mjs b/browser/base/content/test/performance/PerfTestHelpers.sys.mjs new file mode 100644 index 0000000000..caa832c2e5 --- /dev/null +++ b/browser/base/content/test/performance/PerfTestHelpers.sys.mjs @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", +}); + +export var PerfTestHelpers = { + /** + * Maps the entries in the given iterable to the given + * promise-returning task function, and waits for all returned + * promises to have resolved. At most `limit` promises may remain + * unresolved at a time. When the limit is reached, the function will + * wait for some to resolve before spawning more tasks. + */ + async throttledMapPromises(iterable, task, limit = 64) { + let pending = new Set(); + let promises = []; + for (let data of iterable) { + while (pending.size >= limit) { + await Promise.race(pending); + } + + let promise = task(data); + promises.push(promise); + if (promise) { + promise.finally(() => pending.delete(promise)); + pending.add(promise); + } + } + + return Promise.all(promises); + }, + + /** + * Returns a promise which resolves to true if the resource at the + * given URI exists, false if it doesn't. This should only be used + * with local resources, such as from resource:/chrome:/jar:/file: + * URIs. + */ + checkURIExists(uri) { + return new Promise(resolve => { + try { + let channel = lazy.NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + // Avoid crashing for non-existant files. If the file not existing + // is bad, we can deal with it in the test instead. + contentPolicyType: Ci.nsIContentPolicy.TYPE_FETCH, + }); + + channel.asyncOpen({ + onStartRequest(request) { + resolve(Components.isSuccessCode(request.status)); + request.cancel(Cr.NS_BINDING_ABORTED); + }, + + onStopRequest(request, status) { + // We should have already resolved from `onStartRequest`, but + // we resolve again here just as a failsafe. + resolve(Components.isSuccessCode(status)); + }, + }); + } catch (e) { + if ( + e.result != Cr.NS_ERROR_FILE_NOT_FOUND && + e.result != Cr.NS_ERROR_NOT_AVAILABLE + ) { + throw e; + } + resolve(false); + } + }); + }, +}; diff --git a/browser/base/content/test/performance/StartupContentSubframe.sys.mjs b/browser/base/content/test/performance/StartupContentSubframe.sys.mjs new file mode 100644 index 0000000000..a78e456afb --- /dev/null +++ b/browser/base/content/test/performance/StartupContentSubframe.sys.mjs @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * test helper JSWindowActors used by the browser_startup_content_subframe.js test. + */ + +export class StartupContentSubframeParent extends JSWindowActorParent { + receiveMessage(msg) { + // Tell the test about the data we received from the content process. + Services.obs.notifyObservers( + msg.data, + "startup-content-subframe-loaded-scripts" + ); + } +} + +export class StartupContentSubframeChild extends JSWindowActorChild { + async handleEvent(event) { + // When the remote subframe is loaded, an event will be fired to this actor, + // which will cause us to send the `LoadedScripts` message to the parent + // process. + // Wait a spin of the event loop before doing so to ensure we don't + // miss any scripts loaded immediately after the load event. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + const Cm = Components.manager; + Cm.QueryInterface(Ci.nsIServiceManager); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + let collectStacks = AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG; + + let modules = {}; + for (let module of Cu.loadedJSModules) { + modules[module] = collectStacks ? Cu.getModuleImportStack(module) : ""; + } + for (let module of Cu.loadedESModules) { + modules[module] = collectStacks ? Cu.getModuleImportStack(module) : ""; + } + + let services = {}; + for (let contractID of Object.keys(Cc)) { + try { + if (Cm.isServiceInstantiatedByContractID(contractID, Ci.nsISupports)) { + services[contractID] = ""; + } + } catch (e) {} + } + this.sendAsyncMessage("LoadedScripts", { + modules, + services, + }); + } +} diff --git a/browser/base/content/test/performance/browser.toml b/browser/base/content/test/performance/browser.toml new file mode 100644 index 0000000000..bd7d56e762 --- /dev/null +++ b/browser/base/content/test/performance/browser.toml @@ -0,0 +1,115 @@ +[DEFAULT] +# to avoid overhead when running the browser normally, StartupRecorder.sys.mjs will +# do almost nothing unless browser.startup.record is true. +# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be +# set during early startup to have an impact as a canvas will be used by +# StartupRecorder.sys.mjs +prefs = [ + # Skip migration work in BG__migrateUI for browser_startup.js since it isn't + # representative of common startup. + "browser.migration.version=9999999", + "browser.startup.record=true", + "gfx.canvas.willReadFrequently.enable=true", + # The form autofill framescript is only used in certain locales if this + # pref is set to 'detect', which is the default value on non-Nightly. + "extensions.formautofill.addresses.available='on'", + "browser.urlbar.disableExtendForTests=true", + # For perfomance tests do not enable the remote control cue, which gets set + # when Marionette is enabled, but users normally don't see. + "browser.chrome.disableRemoteControlCueForTests=true", + # The Screenshots extension is disabled by default in Mochitests. We re-enable + # it here, since it's a more realistic configuration. + "extensions.screenshots.disabled=false", +] +support-files = ["head.js"] + +["browser_appmenu.js"] +skip-if = [ + "asan", + "debug", + "os == 'win'", # Bug 1775626 + "os == 'linux' && socketprocess_networking", # Bug 1382809, bug 1369959 +] + +["browser_hidden_browser_vsync.js"] + +["browser_panel_vsync.js"] +support-files = ["!/browser/components/downloads/test/browser/head.js"] + +["browser_preferences_usage.js"] +https_first_disabled = true +skip-if = [ + "!debug", + "apple_catalina", # platform migration + "socketprocess_networking", +] + +["browser_startup.js"] + +["browser_startup_content.js"] +support-files = ["file_empty.html"] + +["browser_startup_content_subframe.js"] +skip-if = ["!fission"] +support-files = [ + "file_empty.html", + "StartupContentSubframe.sys.mjs", +] + +["browser_startup_flicker.js"] +run-if = [ + "debug", + "nightly_build", # Requires StartupRecorder.sys.mjs, which isn't shipped everywhere by default +] + +["browser_startup_hiddenwindow.js"] +skip-if = ["os == 'mac'"] + +["browser_tabclose.js"] +skip-if = [ + "os == 'linux' && devedition", # Bug 1737131 + "os == 'mac'", # Bug 1531417 + "os == 'win'", # Bug 1488537, Bug 1497713 +] + +["browser_tabclose_grow.js"] + +["browser_tabdetach.js"] + +["browser_tabopen.js"] +skip-if = [ + "apple_catalina", # Bug 1594274 + "os == 'mac' && !debug", # Bug 1705492 + "os == 'linux' && !debug", # Bug 1705492 +] + +["browser_tabopen_squeeze.js"] + +["browser_tabstrip_overflow_underflow.js"] +skip-if = [ + "os == 'win' && verify && !debug", + "win11_2009 && bits == 32", +] + +["browser_tabswitch.js"] +skip-if = ["os == 'win'"] #Bug 1455054 + +["browser_toolbariconcolor_restyles.js"] + +["browser_urlbar_keyed_search.js"] +skip-if = ["win11_2009 && bits == 32"] # # Disabled on Win32 because of intermittent OOM failures (bug 1448241) + +["browser_urlbar_search.js"] +skip-if = [ + "os == 'linux' && (debug || ccov)", # Disabled on Linux debug and ccov due to intermittent timeouts. Bug 1414126. + "os == 'win' && (debug || ccov)", # Disabled on Windows debug and ccov due to intermittent timeouts. bug 1426611. + "win11_2009 && bits == 32", +] + +["browser_vsync_accessibility.js"] + +["browser_window_resize.js"] + +["browser_windowclose.js"] + +["browser_windowopen.js"] diff --git a/browser/base/content/test/performance/browser_appmenu.js b/browser/base/content/test/performance/browser_appmenu.js new file mode 100644 index 0000000000..37e7482c51 --- /dev/null +++ b/browser/base/content/test/performance/browser_appmenu.js @@ -0,0 +1,130 @@ +"use strict"; +/* global PanelUI */ + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +/** + * WHOA THERE: We should never be adding new things to + * EXPECTED_APPMENU_OPEN_REFLOWS. This list should slowly go + * away as we improve the performance of the front-end. Instead of adding more + * reflows to the list, you should be modifying your code to avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_APPMENU_OPEN_REFLOWS = [ + { + stack: [ + "openPopup/this._openPopupPromise<@resource:///modules/PanelMultiView.sys.mjs", + ], + }, + + { + stack: [ + "_calculateMaxHeight@resource:///modules/PanelMultiView.sys.mjs", + "handleEvent@resource:///modules/PanelMultiView.sys.mjs", + ], + + maxCount: 7, // This number should only ever go down - never up. + }, +]; + +add_task(async function () { + await ensureNoPreloadedBrowser(); + await ensureAnimationsFinished(); + await disableFxaBadge(); + + let textBoxRect = gURLBar + .querySelector("moz-input-box") + .getBoundingClientRect(); + let menuButtonRect = document + .getElementById("PanelUI-menu-button") + .getBoundingClientRect(); + let firstTabRect = gBrowser.selectedTab.getBoundingClientRect(); + let frameExpectations = { + filter: rects => { + // We expect the menu button to get into the active state. + // + // XXX For some reason the menu panel isn't in our screenshots, but + // that's where we actually expect many changes. + return rects.filter(r => !rectInBoundingClientRect(r, menuButtonRect)); + }, + exceptions: [ + { + name: "the urlbar placeholder moves up and down by a few pixels", + condition: r => rectInBoundingClientRect(r, textBoxRect), + }, + { + name: "bug 1547341 - a first tab gets drawn early", + condition: r => rectInBoundingClientRect(r, firstTabRect), + }, + ], + }; + + // First, open the appmenu. + await withPerfObserver(() => gCUITestUtils.openMainMenu(), { + expectedReflows: EXPECTED_APPMENU_OPEN_REFLOWS, + frames: frameExpectations, + }); + + // Now open a series of subviews, and then close the appmenu. We + // should not reflow during any of this. + await withPerfObserver( + async function () { + // This recursive function will take the current main or subview, + // find all of the buttons that navigate to subviews inside it, + // and click each one individually. Upon entering the new view, + // we recurse. When the subviews within a view have been + // exhausted, we go back up a level. + async function openSubViewsRecursively(currentView) { + let navButtons = Array.from( + // Ensure that only enabled buttons are tested + currentView.querySelectorAll(".subviewbutton-nav:not([disabled])") + ); + if (!navButtons) { + return; + } + + for (let button of navButtons) { + info("Click " + button.id); + let promiseViewShown = BrowserTestUtils.waitForEvent( + PanelUI.panel, + "ViewShown" + ); + button.click(); + let viewShownEvent = await promiseViewShown; + + // Workaround until bug 1363756 is fixed, then this can be removed. + let container = PanelUI.multiView.querySelector( + ".panel-viewcontainer" + ); + await TestUtils.waitForCondition(() => { + return !container.hasAttribute("width"); + }); + + info("Shown " + viewShownEvent.originalTarget.id); + await openSubViewsRecursively(viewShownEvent.originalTarget); + promiseViewShown = BrowserTestUtils.waitForEvent( + currentView, + "ViewShown" + ); + PanelUI.multiView.goBack(); + await promiseViewShown; + + // Workaround until bug 1363756 is fixed, then this can be removed. + await TestUtils.waitForCondition(() => { + return !container.hasAttribute("width"); + }); + } + } + + await openSubViewsRecursively(PanelUI.mainView); + + await gCUITestUtils.hideMainMenu(); + }, + { expectedReflows: [], frames: frameExpectations } + ); +}); diff --git a/browser/base/content/test/performance/browser_hidden_browser_vsync.js b/browser/base/content/test/performance/browser_hidden_browser_vsync.js new file mode 100644 index 0000000000..a2e5cc990c --- /dev/null +++ b/browser/base/content/test/performance/browser_hidden_browser_vsync.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_hidden_sidebar() { + let b = document.createXULElement("browser"); + for (let [k, v] of Object.entries({ + type: "content", + disablefullscreen: "true", + disablehistory: "true", + flex: "1", + style: "min-width: 300px", + message: "true", + remote: "true", + maychangeremoteness: "true", + })) { + b.setAttribute(k, v); + } + let mainBrowser = gBrowser.selectedBrowser; + let panel = gBrowser.getPanel(mainBrowser); + panel.append(b); + let loaded = BrowserTestUtils.browserLoaded(b); + BrowserTestUtils.startLoadingURIString( + b, + `data:text/html,<!doctype html><style> + @keyframes fade-in { + from { + opacity: .25; + } + to { + opacity: 1; + } + </style> + <div style=" + animation-name: fade-in; + animation-direction: alternate; + animation-duration: 1s; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + background-color: red; + height: 500px; + width: 100%; + "></div>` + ); + await loaded; + ok(b, "Browser was created."); + await SpecialPowers.spawn(b, [], async () => { + await new Promise(r => + content.requestAnimationFrame(() => content.requestAnimationFrame(r)) + ); + }); + b.hidden = true; + ok(b.hidden, "Browser should be hidden."); + // Now the framework will test to see vsync goes away +}); diff --git a/browser/base/content/test/performance/browser_panel_vsync.js b/browser/base/content/test/performance/browser_panel_vsync.js new file mode 100644 index 0000000000..73c56b9095 --- /dev/null +++ b/browser/base/content/test/performance/browser_panel_vsync.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/downloads/test/browser/head.js", + this +); + +add_task( + async function test_opening_panel_and_closing_should_not_leave_vsync() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.autohideButton", false]], + }); + await promiseButtonShown("downloads-button"); + + const downloadsButton = document.getElementById("downloads-button"); + const shownPromise = promisePanelOpened(); + EventUtils.synthesizeNativeMouseEvent({ + type: "click", + target: downloadsButton, + atCenter: true, + }); + await shownPromise; + + is(DownloadsPanel.panel.state, "open", "Check that panel state is 'open'"); + + await TestUtils.waitForCondition( + () => !ChromeUtils.vsyncEnabled(), + "Make sure vsync disabled" + ); + // Should not already be using vsync + ok(!ChromeUtils.vsyncEnabled(), "vsync should be off initially"); + + if ( + AppConstants.platform == "linux" && + DownloadsPanel.panel.state != "open" + ) { + // Panels sometime receive spurious popuphiding events on Linux. + // Given the main target of this test is Windows, avoid causing + // intermittent failures and just make the test return early. + todo( + false, + "panel should still be 'open', current state: " + + DownloadsPanel.panel.state + ); + return; + } + + const hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}, window); + await hiddenPromise; + await TestUtils.waitForCondition( + () => !ChromeUtils.vsyncEnabled(), + "wait for vsync to be disabled again" + ); + + ok(!ChromeUtils.vsyncEnabled(), "vsync should still be off"); + is( + DownloadsPanel.panel.state, + "closed", + "Check that panel state is 'closed'" + ); + } +); diff --git a/browser/base/content/test/performance/browser_preferences_usage.js b/browser/base/content/test/performance/browser_preferences_usage.js new file mode 100644 index 0000000000..6bc623a360 --- /dev/null +++ b/browser/base/content/test/performance/browser_preferences_usage.js @@ -0,0 +1,272 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +if (SpecialPowers.useRemoteSubframes) { + requestLongerTimeout(2); +} + +const DEFAULT_PROCESS_COUNT = Services.prefs + .getDefaultBranch(null) + .getIntPref("dom.ipc.processCount"); + +/** + * A test that checks whether any preference getter from the given list + * of stats was called more often than the max parameter. + * + * @param {Array} stats - an array of [prefName, accessCount] tuples + * @param {Number} max - the maximum number of times any of the prefs should + * have been called. + * @param {Object} knownProblematicPrefs (optional) - an object that defines + * prefs that should be exempt from checking the + * maximum access. It looks like the following: + * + * pref_name: { + * min: [Number] the minimum amount of times this should have + * been called (to avoid keeping around dead items) + * max: [Number] the maximum amount of times this should have + * been called (to avoid this creeping up further) + * } + */ +function checkPrefGetters(stats, max, knownProblematicPrefs = {}) { + let getterStats = Object.entries(stats).sort( + ([, val1], [, val2]) => val2 - val1 + ); + + // Clone the list to be able to delete entries to check if we + // forgot any later on. + knownProblematicPrefs = Object.assign({}, knownProblematicPrefs); + + for (let [pref, count] of getterStats) { + let prefLimits = knownProblematicPrefs[pref]; + if (!prefLimits) { + Assert.lessOrEqual( + count, + max, + `${pref} should not be accessed more than ${max} times.` + ); + } else { + // Still record how much this pref was accessed even if we don't do any real assertions. + if (!prefLimits.min && !prefLimits.max) { + info( + `${pref} should not be accessed more than ${max} times and was accessed ${count} times.` + ); + } + + if (prefLimits.min) { + Assert.lessOrEqual( + prefLimits.min, + count, + `${pref} should be accessed at least ${prefLimits.min} times.` + ); + } + if (prefLimits.max) { + Assert.lessOrEqual( + count, + prefLimits.max, + `${pref} should be accessed at most ${prefLimits.max} times.` + ); + } + delete knownProblematicPrefs[pref]; + } + } + + // This pref will be accessed by mozJSComponentLoader when loading modules, + // which fails TV runs since they run the test multiple times without restarting. + // We just ignore this pref, since it's for testing only anyway. + if (knownProblematicPrefs["browser.startup.record"]) { + delete knownProblematicPrefs["browser.startup.record"]; + } + + let unusedPrefs = Object.keys(knownProblematicPrefs); + is( + unusedPrefs.length, + 0, + `Should have accessed all known problematic prefs. Remaining: ${unusedPrefs}` + ); +} + +/** + * A helper function to read preference access data + * using the Services.prefs.readStats() function. + */ +function getPreferenceStats() { + let stats = {}; + Services.prefs.readStats((key, value) => (stats[key] = value)); + return stats; +} + +add_task(async function debug_only() { + ok(AppConstants.DEBUG, "You need to run this test on a debug build."); +}); + +// Just checks how many prefs were accessed during startup. +add_task(async function startup() { + let max = 40; + + let knownProblematicPrefs = { + "browser.startup.record": { + // This pref is accessed in Nighly and debug builds only. + min: 200, + max: 400, + }, + "network.loadinfo.skip_type_assertion": { + // This is accessed in debug only. + }, + "chrome.override_package.global": { + min: 0, + max: 50, + }, + }; + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + ok(startupRecorder.data.prefStats, "startupRecorder has prefStats"); + + checkPrefGetters(startupRecorder.data.prefStats, max, knownProblematicPrefs); +}); + +// This opens 10 tabs and checks pref getters. +add_task(async function open_10_tabs() { + // This is somewhat arbitrary. When we had a default of 4 content processes + // the value was 15. We need to scale it as we increase the number of + // content processes so we approximate with 4 * process_count. + const max = 4 * DEFAULT_PROCESS_COUNT; + + let knownProblematicPrefs = { + "browser.startup.record": { + max: 20, + }, + "browser.tabs.remote.logSwitchTiming": { + max: 35, + }, + "network.loadinfo.skip_type_assertion": { + // This is accessed in debug only. + }, + }; + + Services.prefs.resetStats(); + + let tabs = []; + while (tabs.length < 10) { + tabs.push( + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com", + true, + true + ) + ); + } + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } + + checkPrefGetters(getPreferenceStats(), max, knownProblematicPrefs); +}); + +// This navigates to 50 sites and checks pref getters. +add_task(async function navigate_around() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable bfcache so that we can measure more accurately the number of + // pref accesses in the child processes. + // If bfcache is enabled on Fission + // dom.ipc.keepProcessesAlive.webIsolated.perOrigin and + // security.sandbox.content.force-namespace are accessed only a couple of + // times. + ["browser.sessionhistory.max_total_viewers", 0], + ], + }); + + let max = 40; + + let knownProblematicPrefs = { + "network.loadinfo.skip_type_assertion": { + // This is accessed in debug only. + }, + }; + + if (SpecialPowers.useRemoteSubframes) { + // We access this when considering starting a new content process. + // Because there is no complete list of content process types, + // caching this is not trivial. Opening 50 different content + // processes and throwing them away immediately is a bit artificial; + // we're more likely to keep some around so this shouldn't be quite + // this bad in practice. Fixing this is + // https://bugzilla.mozilla.org/show_bug.cgi?id=1600266 + knownProblematicPrefs["dom.ipc.processCount.webIsolated"] = { + min: 50, + max: 51, + }; + // This pref is only accessed in automation to speed up tests. + knownProblematicPrefs["dom.ipc.keepProcessesAlive.webIsolated.perOrigin"] = + { + min: 100, + max: 102, + }; + if (AppConstants.platform == "linux") { + // The following sandbox pref is covered by + // https://bugzilla.mozilla.org/show_bug.cgi?id=1600189 + knownProblematicPrefs["security.sandbox.content.force-namespace"] = { + min: 45, + max: 55, + }; + } else if (AppConstants.platform == "win") { + // The following 2 graphics prefs are covered by + // https://bugzilla.mozilla.org/show_bug.cgi?id=1639497 + knownProblematicPrefs["gfx.canvas.azure.backends"] = { + min: 90, + max: 110, + }; + knownProblematicPrefs["gfx.content.azure.backends"] = { + min: 90, + max: 110, + }; + // The following 2 sandbox prefs are covered by + // https://bugzilla.mozilla.org/show_bug.cgi?id=1639494 + knownProblematicPrefs["security.sandbox.content.read_path_whitelist"] = { + min: 47, + max: 55, + }; + knownProblematicPrefs["security.sandbox.logging.enabled"] = { + min: 47, + max: 55, + }; + } + } + + Services.prefs.resetStats(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com", + true, + true + ); + + let urls = [ + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/", + "https://example.com/", + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.org/", + "https://example.org/", + ]; + + for (let i = 0; i < 50; i++) { + let url = urls[i % urls.length]; + info(`Navigating to ${url}...`); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url); + info(`Loaded ${url}.`); + } + + await BrowserTestUtils.removeTab(tab); + + checkPrefGetters(getPreferenceStats(), max, knownProblematicPrefs); +}); diff --git a/browser/base/content/test/performance/browser_startup.js b/browser/base/content/test/performance/browser_startup.js new file mode 100644 index 0000000000..9d75c14e0f --- /dev/null +++ b/browser/base/content/test/performance/browser_startup.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test records at which phase of startup the JS modules are first + * loaded. + * If you made changes that cause this test to fail, it's likely because you + * are loading more JS code during startup. + * Most code has no reason to run off of the app-startup notification + * (this is very early, before we have selected the user profile, so + * preferences aren't accessible yet). + * If your code isn't strictly required to show the first browser window, + * it shouldn't be loaded before we are done with first paint. + * Finally, if your code isn't really needed during startup, it should not be + * loaded before we have started handling user events. + */ + +"use strict"; + +/* Set this to true only for debugging purpose; it makes the output noisy. */ +const kDumpAllStacks = false; + +const startupPhases = { + // For app-startup, we have an allowlist of acceptable JS files. + // Anything loaded during app-startup must have a compelling reason + // to run before we have even selected the user profile. + // Consider loading your code after first paint instead, + // eg. from BrowserGlue.sys.mjs' _onFirstWindowLoaded method). + "before profile selection": { + allowlist: { + modules: new Set([ + "resource:///modules/BrowserGlue.sys.mjs", + "resource:///modules/StartupRecorder.sys.mjs", + "resource://gre/modules/AppConstants.sys.mjs", + "resource://gre/modules/ActorManagerParent.sys.mjs", + "resource://gre/modules/CustomElementsListener.sys.mjs", + "resource://gre/modules/MainProcessSingleton.sys.mjs", + "resource://gre/modules/XPCOMUtils.sys.mjs", + ]), + }, + }, + + // For the following phases of startup we have only a list of files that + // are **not** allowed to load in this phase, as too many other scripts + // load during this time. + + // We are at this phase after creating the first browser window (ie. after final-ui-startup). + "before opening first browser window": { + denylist: { + modules: new Set([]), + }, + }, + + // We reach this phase right after showing the first browser window. + // This means that anything already loaded at this point has been loaded + // before first paint and delayed it. + "before first paint": { + denylist: { + modules: new Set([ + "resource:///modules/AboutNewTab.sys.mjs", + "resource:///modules/BrowserUsageTelemetry.sys.mjs", + "resource:///modules/ContentCrashHandlers.sys.mjs", + "resource:///modules/ShellService.sys.mjs", + "resource://gre/modules/NewTabUtils.sys.mjs", + "resource://gre/modules/PageThumbs.sys.mjs", + "resource://gre/modules/PlacesUtils.sys.mjs", + "resource://gre/modules/Preferences.sys.mjs", + "resource://gre/modules/SearchService.sys.mjs", + // Sqlite.sys.mjs commented out because of bug 1828735. + // "resource://gre/modules/Sqlite.sys.mjs" + ]), + services: new Set(["@mozilla.org/browser/search-service;1"]), + }, + }, + + // We are at this phase once we are ready to handle user events. + // Anything loaded at this phase or before gets in the way of the user + // interacting with the first browser window. + "before handling user events": { + denylist: { + modules: new Set([ + "resource://gre/modules/Blocklist.sys.mjs", + // Bug 1391495 - BrowserWindowTracker.sys.mjs is intermittently used. + // "resource:///modules/BrowserWindowTracker.sys.mjs", + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + "resource://gre/modules/Bookmarks.sys.mjs", + "resource://gre/modules/ContextualIdentityService.sys.mjs", + "resource://gre/modules/FxAccounts.sys.mjs", + "resource://gre/modules/FxAccountsStorage.sys.mjs", + "resource://gre/modules/PlacesBackups.sys.mjs", + "resource://gre/modules/PlacesExpiration.sys.mjs", + "resource://gre/modules/PlacesSyncUtils.sys.mjs", + "resource://gre/modules/PushComponents.sys.mjs", + ]), + services: new Set(["@mozilla.org/browser/nav-bookmarks-service;1"]), + }, + }, + + // Things that are expected to be completely out of the startup path + // and loaded lazily when used for the first time by the user should + // be listed here. + "before becoming idle": { + denylist: { + modules: new Set([ + "resource://gre/modules/AsyncPrefs.sys.mjs", + "resource://gre/modules/LoginManagerContextMenu.sys.mjs", + "resource://pdf.js/PdfStreamConverter.sys.mjs", + ]), + }, + }, +}; + +if ( + Services.prefs.getBoolPref("browser.startup.blankWindow") && + Services.prefs.getCharPref( + "extensions.activeThemeID", + "default-theme@mozilla.org" + ) == "default-theme@mozilla.org" +) { + startupPhases["before profile selection"].allowlist.modules.add( + "resource://gre/modules/XULStore.sys.mjs" + ); +} + +if (AppConstants.MOZ_CRASHREPORTER) { + startupPhases["before handling user events"].denylist.modules.add( + "resource://gre/modules/CrashSubmit.sys.mjs" + ); +} + +add_task(async function () { + if ( + !AppConstants.NIGHTLY_BUILD && + !AppConstants.MOZ_DEV_EDITION && + !AppConstants.DEBUG + ) { + ok( + !("@mozilla.org/test/startuprecorder;1" in Cc), + "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" + + "non-debug build." + ); + return; + } + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + let data = Cu.cloneInto(startupRecorder.data.code, {}); + function getStack(scriptType, name) { + if (scriptType == "modules") { + return Cu.getModuleImportStack(name); + } + return ""; + } + + // This block only adds debug output to help find the next bugs to file, + // it doesn't contribute to the actual test. + SimpleTest.requestCompleteLog(); + let previous; + for (let phase in data) { + for (let scriptType in data[phase]) { + for (let f of data[phase][scriptType]) { + // phases are ordered, so if a script wasn't loaded yet at the immediate + // previous phase, it wasn't loaded during any of the previous phases + // either, and is new in the current phase. + if (!previous || !data[previous][scriptType].includes(f)) { + info(`${scriptType} loaded ${phase}: ${f}`); + if (kDumpAllStacks) { + info(getStack(scriptType, f)); + } + } + } + } + previous = phase; + } + + for (let phase in startupPhases) { + let loadedList = data[phase]; + let allowlist = startupPhases[phase].allowlist || null; + if (allowlist) { + for (let scriptType in allowlist) { + loadedList[scriptType] = loadedList[scriptType].filter(c => { + if (!allowlist[scriptType].has(c)) { + return true; + } + allowlist[scriptType].delete(c); + return false; + }); + is( + loadedList[scriptType].length, + 0, + `should have no unexpected ${scriptType} loaded ${phase}` + ); + for (let script of loadedList[scriptType]) { + let message = `unexpected ${scriptType}: ${script}`; + record(false, message, undefined, getStack(scriptType, script)); + } + is( + allowlist[scriptType].size, + 0, + `all ${scriptType} allowlist entries should have been used` + ); + for (let script of allowlist[scriptType]) { + ok(false, `unused ${scriptType} allowlist entry: ${script}`); + } + } + } + let denylist = startupPhases[phase].denylist || null; + if (denylist) { + for (let scriptType in denylist) { + for (let file of denylist[scriptType]) { + let loaded = loadedList[scriptType].includes(file); + let message = `${file} is not allowed ${phase}`; + if (!loaded) { + ok(true, message); + } else { + record(false, message, undefined, getStack(scriptType, file)); + } + } + } + + if (denylist.modules) { + let results = await PerfTestHelpers.throttledMapPromises( + denylist.modules, + async uri => ({ + uri, + exists: await PerfTestHelpers.checkURIExists(uri), + }) + ); + + for (let { uri, exists } of results) { + ok(exists, `denylist entry ${uri} for phase "${phase}" must exist`); + } + } + + if (denylist.services) { + for (let contract of denylist.services) { + ok( + contract in Cc, + `denylist entry ${contract} for phase "${phase}" must exist` + ); + } + } + } + } +}); diff --git a/browser/base/content/test/performance/browser_startup_content.js b/browser/base/content/test/performance/browser_startup_content.js new file mode 100644 index 0000000000..b0f861e47f --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_content.js @@ -0,0 +1,186 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test records which services, frame scripts, process scripts, and + * JS modules are loaded when creating a new content process. + * + * If you made changes that cause this test to fail, it's likely because you + * are loading more JS code during content process startup. Please try to + * avoid this. + * + * If your code isn't strictly required to show a page, consider loading it + * lazily. If you can't, consider delaying its load until after we have started + * handling user events. + */ + +"use strict"; + +/* Set this to true only for debugging purpose; it makes the output noisy. */ +const kDumpAllStacks = false; + +const known_scripts = { + modules: new Set([ + "chrome://mochikit/content/ShutdownLeaksCollector.sys.mjs", + + // General utilities + "resource://gre/modules/AppConstants.sys.mjs", + "resource://gre/modules/Timer.sys.mjs", + "resource://gre/modules/XPCOMUtils.sys.mjs", + + // Logging related + // eslint-disable-next-line mozilla/use-console-createInstance + "resource://gre/modules/Log.sys.mjs", + + // Browser front-end + "resource:///actors/AboutReaderChild.sys.mjs", + "resource:///actors/InteractionsChild.sys.mjs", + "resource:///actors/LinkHandlerChild.sys.mjs", + "resource:///actors/SearchSERPTelemetryChild.sys.mjs", + "resource://gre/actors/ContentMetaChild.sys.mjs", + "resource://gre/modules/Readerable.sys.mjs", + + // Telemetry + "resource://gre/modules/TelemetryControllerBase.sys.mjs", // bug 1470339 + "resource://gre/modules/TelemetryControllerContent.sys.mjs", // bug 1470339 + + // Extensions + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + "resource://gre/modules/ExtensionUtils.sys.mjs", + ]), + frameScripts: new Set([ + // Test related + "chrome://mochikit/content/shutdown-leaks-collector.js", + ]), + processScripts: new Set([ + "chrome://global/content/process-content.js", + "resource://gre/modules/extensionProcessScriptLoader.js", + ]), +}; + +if (!Services.appinfo.sessionHistoryInParent) { + known_scripts.modules.add( + "resource:///modules/sessionstore/ContentSessionStore.sys.mjs" + ); +} + +// Items on this list *might* load when creating the process, as opposed to +// items in the main list, which we expect will always load. +const intermittently_loaded_scripts = { + modules: new Set([ + "resource://gre/modules/nsAsyncShutdown.sys.mjs", + "resource://gre/modules/sessionstore/Utils.sys.mjs", + + // Translations code which may be preffed on. + "resource://gre/actors/TranslationsChild.sys.mjs", + "resource://gre/modules/translation/LanguageDetector.sys.mjs", + "resource://gre/modules/ConsoleAPIStorage.sys.mjs", // Logging related. + + // Session store. + "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + + // Cookie banner handling. + "resource://gre/actors/CookieBannerChild.sys.mjs", + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + + // Test related + "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs", + "chrome://remote/content/shared/Log.sys.mjs", + "resource://testing-common/BrowserTestUtilsChild.sys.mjs", + "resource://testing-common/ContentEventListenerChild.sys.mjs", + "resource://specialpowers/AppTestDelegateChild.sys.mjs", + "resource://testing-common/SpecialPowersChild.sys.mjs", + "resource://testing-common/WrapPrivileged.sys.mjs", + ]), + frameScripts: new Set([]), + processScripts: new Set([]), +}; + +const forbiddenScripts = { + services: new Set([ + "@mozilla.org/base/telemetry-startup;1", + "@mozilla.org/embedcomp/default-tooltiptextprovider;1", + "@mozilla.org/push/Service;1", + ]), +}; + +add_task(async function () { + SimpleTest.requestCompleteLog(); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "file_empty.html", + forceNewProcess: true, + }); + + let mm = gBrowser.selectedBrowser.messageManager; + let promise = BrowserTestUtils.waitForMessage(mm, "Test:LoadedScripts"); + + // Load a custom frame script to avoid using ContentTask which loads Task.jsm + mm.loadFrameScript( + "data:text/javascript,(" + + function () { + /* eslint-env mozilla/frame-script */ + const Cm = Components.manager; + Cm.QueryInterface(Ci.nsIServiceManager); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + let collectStacks = AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG; + let modules = {}; + for (let module of Cu.loadedJSModules) { + modules[module] = collectStacks + ? Cu.getModuleImportStack(module) + : ""; + } + for (let module of Cu.loadedESModules) { + modules[module] = collectStacks + ? Cu.getModuleImportStack(module) + : ""; + } + let services = {}; + for (let contractID of Object.keys(Cc)) { + try { + if ( + Cm.isServiceInstantiatedByContractID(contractID, Ci.nsISupports) + ) { + services[contractID] = ""; + } + } catch (e) {} + } + sendAsyncMessage("Test:LoadedScripts", { + modules, + services, + }); + } + + ")()", + false + ); + + let loadedInfo = await promise; + + // Gather loaded frame scripts. + loadedInfo.frameScripts = {}; + for (let [uri] of Services.mm.getDelayedFrameScripts()) { + loadedInfo.frameScripts[uri] = ""; + } + + // Gather loaded process scripts. + loadedInfo.processScripts = {}; + for (let [uri] of Services.ppmm.getDelayedProcessScripts()) { + loadedInfo.processScripts[uri] = ""; + } + + await checkLoadedScripts({ + loadedInfo, + known: known_scripts, + intermittent: intermittently_loaded_scripts, + forbidden: forbiddenScripts, + dumpAllStacks: kDumpAllStacks, + }); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/base/content/test/performance/browser_startup_content_mainthreadio.js b/browser/base/content/test/performance/browser_startup_content_mainthreadio.js new file mode 100644 index 0000000000..e60b95bb3c --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_content_mainthreadio.js @@ -0,0 +1,465 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test records I/O syscalls done on the main thread during startup. + * + * To run this test similar to try server, you need to run: + * ./mach package + * ./mach test --appname=dist <path to test> + * + * If you made changes that cause this test to fail, it's likely because you + * are touching more files or directories during startup. + * Most code has no reason to use main thread I/O. + * If for some reason accessing the file system on the main thread is currently + * unavoidable, consider defering the I/O as long as you can, ideally after + * the end of startup. + */ + +"use strict"; + +/* Set this to true only for debugging purpose; it makes the output noisy. */ +const kDumpAllStacks = false; + +// Shortcuts for conditions. +const LINUX = AppConstants.platform == "linux"; +const WIN = AppConstants.platform == "win"; +const MAC = AppConstants.platform == "macosx"; +const FORK_SERVER = Services.prefs.getBoolPref( + "dom.ipc.forkserver.enable", + false +); + +/* This is an object mapping string process types to lists of known cases + * of IO happening on the main thread. Ideally, IO should not be on the main + * thread, and should happen as late as possible (see above). + * + * Paths in the entries in these lists can: + * - be a full path, eg. "/etc/mime.types" + * - have a prefix which will be resolved using Services.dirsvc + * eg. "GreD:omni.ja" + * It's possible to have only a prefix, in thise case the directory will + * still be resolved, eg. "UAppData:" + * - use * at the begining and/or end as a wildcard + * The folder separator is '/' even for Windows paths, where it'll be + * automatically converted to '\'. + * + * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries; + * without this the test is strict and will fail if the described IO does not + * happen. + * + * Each entry specifies the maximum number of times an operation is expected to + * occur. + * The operations currently reported by the I/O interposer are: + * create/open: only supported on Windows currently. The test currently + * ignores these markers to have a shorter initial list of IO operations. + * Adding Unix support is bug 1533779. + * stat: supported on all platforms when checking the last modified date or + * file size. Supported only on Windows when checking if a file exists; + * fixing this inconsistency is bug 1536109. + * read: supported on all platforms, but unix platforms will only report read + * calls going through NSPR. + * write: supported on all platforms, but Linux will only report write calls + * going through NSPR. + * close: supported only on Unix, and only for close calls going through NSPR. + * Adding Windows support is bug 1524574. + * fsync: supported only on Windows. + * + * If an entry specifies more than one operation, if at least one of them is + * encountered, the test won't report a failure for the entry if other + * operations are not encountered. This helps when listing cases where the + * reported operations aren't the same on all platforms due to the I/O + * interposer inconsistencies across platforms documented above. + */ +const processes = { + "Web Content": [ + { + path: "GreD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // bug 1376994 + path: "XCurProcD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // Exists call in ScopedXREEmbed::SetAppDir + path: "XCurProcD:", + condition: WIN, + stat: 1, + }, + { + // bug 1357205 + path: "XREAppFeat:formautofill@mozilla.org.xpi", + condition: !WIN, + ignoreIfUnused: true, + stat: 1, + }, + { + path: "*ShaderCache*", // Bug 1660480 - seen on hardware + condition: WIN, + ignoreIfUnused: true, + stat: 3, + }, + ], + "Privileged Content": [ + { + path: "GreD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // bug 1376994 + path: "XCurProcD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // Exists call in ScopedXREEmbed::SetAppDir + path: "XCurProcD:", + condition: WIN, + stat: 1, + }, + ], + WebExtensions: [ + { + path: "GreD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // bug 1376994 + path: "XCurProcD:omni.ja", + // Visible on Windows with an open marker. + // The fork server preloads the omnijars. + condition: !WIN && !FORK_SERVER, + stat: 1, + }, + { + // Exists call in ScopedXREEmbed::SetAppDir + path: "XCurProcD:", + condition: WIN, + stat: 1, + }, + ], +}; + +function expandPathWithDirServiceKey(path) { + if (path.includes(":")) { + let [prefix, suffix] = path.split(":"); + let [key, property] = prefix.split("."); + let dir = Services.dirsvc.get(key, Ci.nsIFile); + if (property) { + dir = dir[property]; + } + + // Resolve symLinks. + let dirPath = dir.path; + while (dir && !dir.isSymlink()) { + dir = dir.parent; + } + if (dir) { + dirPath = dirPath.replace(dir.path, dir.target); + } + + path = dirPath; + + if (suffix) { + path += "/" + suffix; + } + } + if (AppConstants.platform == "win") { + path = path.replace(/\//g, "\\"); + } + return path; +} + +function getStackFromProfile(profile, stack) { + const stackPrefixCol = profile.stackTable.schema.prefix; + const stackFrameCol = profile.stackTable.schema.frame; + const frameLocationCol = profile.frameTable.schema.location; + + let result = []; + while (stack) { + let sp = profile.stackTable.data[stack]; + let frame = profile.frameTable.data[sp[stackFrameCol]]; + stack = sp[stackPrefixCol]; + frame = profile.stringTable[frame[frameLocationCol]]; + if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) { + result.push(frame); + } + } + return result; +} + +function getIOMarkersFromProfile(profile) { + const nameCol = profile.markers.schema.name; + const dataCol = profile.markers.schema.data; + + let markers = []; + for (let m of profile.markers.data) { + let markerName = profile.stringTable[m[nameCol]]; + + if (markerName != "FileIO") { + continue; + } + + let markerData = m[dataCol]; + if (markerData.source == "sqlite-mainthread") { + continue; + } + + let samples = markerData.stack.samples; + let stack = samples.data[0][samples.schema.stack]; + markers.push({ + operation: markerData.operation, + filename: markerData.filename, + source: markerData.source, + stackId: stack, + }); + } + + return markers; +} + +function pathMatches(path, filename) { + path = path.toLowerCase(); + return ( + path == filename || // Full match + // Wildcard on both sides of the path + (path.startsWith("*") && + path.endsWith("*") && + filename.includes(path.slice(1, -1))) || + // Wildcard suffix + (path.endsWith("*") && filename.startsWith(path.slice(0, -1))) || + // Wildcard prefix + (path.startsWith("*") && filename.endsWith(path.slice(1))) + ); +} + +add_task(async function () { + if ( + !AppConstants.NIGHTLY_BUILD && + !AppConstants.MOZ_DEV_EDITION && + !AppConstants.DEBUG + ) { + ok( + !("@mozilla.org/test/startuprecorder;1" in Cc), + "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" + + "non-debug build." + ); + return; + } + + TestUtils.assertPackagedBuild(); + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + for (let process in processes) { + processes[process] = processes[process].filter( + entry => !("condition" in entry) || entry.condition + ); + processes[process].forEach(entry => { + entry.listedPath = entry.path; + entry.path = expandPathWithDirServiceKey(entry.path); + }); + } + + let tmpPath = expandPathWithDirServiceKey("TmpD:").toLowerCase(); + let shouldPass = true; + for (let procName in processes) { + let knownIOList = processes[procName]; + info( + `known main thread IO paths for ${procName} process:\n` + + knownIOList + .map(e => { + let operations = Object.keys(e) + .filter(k => !["path", "condition"].includes(k)) + .map(k => `${k}: ${e[k]}`); + return ` ${e.path} - ${operations.join(", ")}`; + }) + .join("\n") + ); + + let profile; + for (let process of startupRecorder.data.profile.processes) { + if (process.threads[0].processName == procName) { + profile = process.threads[0]; + break; + } + } + if (procName == "Privileged Content" && !profile) { + // The Privileged Content is started from an idle task that may not have + // been executed yet at the time we captured the startup profile in + // startupRecorder. + todo(false, `profile for ${procName} process not found`); + } else { + ok(profile, `Found profile for ${procName} process`); + } + if (!profile) { + continue; + } + + let markers = getIOMarkersFromProfile(profile); + for (let marker of markers) { + if (marker.operation == "create/open") { + // TODO: handle these I/O markers once they are supported on + // non-Windows platforms. + continue; + } + + if (!marker.filename) { + // We are still missing the filename on some mainthreadio markers, + // these markers are currently useless for the purpose of this test. + continue; + } + + // Convert to lower case before comparing because the OS X test machines + // have the 'Firefox' folder in 'Library/Application Support' created + // as 'firefox' for some reason. + let filename = marker.filename.toLowerCase(); + + if (!WIN && filename == "/dev/urandom") { + continue; + } + + // /dev/shm is always tmpfs (a memory filesystem); this isn't + // really I/O any more than mmap/munmap are. + if (LINUX && filename.startsWith("/dev/shm/")) { + continue; + } + + // "Files" from memfd_create() are similar to tmpfs but never + // exist in the filesystem; however, they have names which are + // exposed in procfs, and the I/O interposer observes when + // they're close()d. + if (LINUX && filename.startsWith("/memfd:")) { + continue; + } + + // Shared memory uses temporary files on MacOS <= 10.11 to avoid + // a kernel security bug that will never be patched (see + // https://crbug.com/project-zero/1671 for details). This can + // be removed when we no longer support those OS versions. + if (MAC && filename.startsWith(tmpPath + "/org.mozilla.ipc.")) { + continue; + } + + let expected = false; + for (let entry of knownIOList) { + if (pathMatches(entry.path, filename)) { + entry[marker.operation] = (entry[marker.operation] || 0) - 1; + entry._used = true; + expected = true; + break; + } + } + if (!expected) { + record( + false, + `unexpected ${marker.operation} on ${marker.filename} in ${procName} process`, + undefined, + " " + getStackFromProfile(profile, marker.stackId).join("\n ") + ); + shouldPass = false; + } + info(`(${marker.source}) ${marker.operation} - ${marker.filename}`); + if (kDumpAllStacks) { + info( + getStackFromProfile(profile, marker.stackId) + .map(f => " " + f) + .join("\n") + ); + } + } + + if (!knownIOList.length) { + continue; + } + // The I/O interposer is disabled if !RELEASE_OR_BETA, so we expect to have + // no I/O marker in that case, but it's good to keep the test running to check + // that we are still able to produce startup profiles. + is( + !!markers.length, + !AppConstants.RELEASE_OR_BETA, + procName + + " startup profiles should have IO markers in builds that are not RELEASE_OR_BETA" + ); + if (!markers.length) { + // If a profile unexpectedly contains no I/O marker, it's better to return + // early to avoid having a lot of of confusing "no main thread IO when we + // expected some" failures. + continue; + } + + for (let entry of knownIOList) { + for (let op in entry) { + if ( + [ + "listedPath", + "path", + "condition", + "ignoreIfUnused", + "_used", + ].includes(op) + ) { + continue; + } + let message = `${op} on ${entry.path} `; + if (entry[op] == 0) { + message += "as many times as expected"; + } else if (entry[op] > 0) { + message += `allowed ${entry[op]} more times`; + } else { + message += `${entry[op] * -1} more times than expected`; + } + Assert.greaterOrEqual( + entry[op], + 0, + `${message} in ${procName} process` + ); + } + if (!("_used" in entry) && !entry.ignoreIfUnused) { + ok( + false, + `no main thread IO when we expected some for process ${procName}: ${entry.path} (${entry.listedPath})` + ); + shouldPass = false; + } + } + } + + if (shouldPass) { + ok(shouldPass, "No unexpected main thread I/O during startup"); + } else { + const filename = "profile_startup_content_mainthreadio.json"; + let path = Services.env.get("MOZ_UPLOAD_DIR"); + let helpString; + if (path) { + let profilePath = PathUtils.join(path, filename); + await IOUtils.writeJSON(profilePath, startupRecorder.data.profile); + helpString = `open the ${filename} artifact in the Firefox Profiler to see what happened`; + } else { + helpString = + "set the MOZ_UPLOAD_DIR environment variable to record a profile"; + } + ok( + false, + "Unexpected main thread I/O behavior during child process startup; " + + helpString + ); + } +}); diff --git a/browser/base/content/test/performance/browser_startup_content_subframe.js b/browser/base/content/test/performance/browser_startup_content_subframe.js new file mode 100644 index 0000000000..3d1ee6352d --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_content_subframe.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test records which services, JS components, frame scripts, process + * scripts, and JS modules are loaded when creating a new content process for a + * subframe. + * + * If you made changes that cause this test to fail, it's likely because you + * are loading more JS code during content process startup. Please try to + * avoid this. + * + * If your code isn't strictly required to show an iframe, consider loading it + * lazily. If you can't, consider delaying its load until after we have started + * handling user events. + * + * This test differs from browser_startup_content.js in that it tests a process + * with no toplevel browsers opened, but with a single subframe document + * loaded. This leads to a different set of scripts being loaded. + */ + +"use strict"; + +const actorModuleURI = + getRootDirectory(gTestPath) + "StartupContentSubframe.sys.mjs"; +const subframeURI = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "file_empty.html"; + +// Set this to true only for debugging purpose; it makes the output noisy. +const kDumpAllStacks = false; + +const known_scripts = { + modules: new Set([ + // Loaded by this test + actorModuleURI, + + // General utilities + "resource://gre/modules/AppConstants.sys.mjs", + "resource://gre/modules/XPCOMUtils.sys.mjs", + + // Logging related + // eslint-disable-next-line mozilla/use-console-createInstance + "resource://gre/modules/Log.sys.mjs", + + // Telemetry + "resource://gre/modules/TelemetryControllerBase.sys.mjs", // bug 1470339 + "resource://gre/modules/TelemetryControllerContent.sys.mjs", // bug 1470339 + + // Extensions + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + "resource://gre/modules/ExtensionUtils.sys.mjs", + ]), + processScripts: new Set([ + "chrome://global/content/process-content.js", + "resource://gre/modules/extensionProcessScriptLoader.js", + ]), +}; + +// Items on this list *might* load when creating the process, as opposed to +// items in the main list, which we expect will always load. +const intermittently_loaded_scripts = { + modules: new Set([ + "resource://gre/modules/nsAsyncShutdown.sys.mjs", + + // Cookie banner handling. + "resource://gre/actors/CookieBannerChild.sys.mjs", + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + + // Test related + "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs", + "chrome://remote/content/shared/Log.sys.mjs", + "resource://testing-common/BrowserTestUtilsChild.sys.mjs", + "resource://testing-common/ContentEventListenerChild.sys.mjs", + "resource://testing-common/SpecialPowersChild.sys.mjs", + "resource://specialpowers/AppTestDelegateChild.sys.mjs", + "resource://testing-common/WrapPrivileged.sys.mjs", + ]), + processScripts: new Set([]), +}; + +const forbiddenScripts = { + services: new Set([ + "@mozilla.org/base/telemetry-startup;1", + "@mozilla.org/embedcomp/default-tooltiptextprovider;1", + "@mozilla.org/push/Service;1", + ]), +}; + +add_task(async function () { + SimpleTest.requestCompleteLog(); + + // Increase the maximum number of webIsolated content processes to make sure + // our newly-created iframe is spawned into a new content process. + // + // Unfortunately, we don't have something like `forceNewProcess` for subframe + // loads. + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount.webIsolated", 10]], + }); + Services.ppmm.releaseCachedProcesses(); + + // Register a custom window actor which will send us a notification when the + // script loading information is available. + ChromeUtils.registerWindowActor("StartupContentSubframe", { + parent: { + esModuleURI: actorModuleURI, + }, + child: { + esModuleURI: actorModuleURI, + events: { + load: { mozSystemGroup: true, capture: true }, + }, + }, + matches: [subframeURI], + allFrames: true, + }); + + // Create a tab, and load a remote subframe with the specific URI in it. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + SpecialPowers.spawn(tab.linkedBrowser, [subframeURI], uri => { + let iframe = content.document.createElement("iframe"); + iframe.src = uri; + content.document.body.appendChild(iframe); + }); + + // Wait for the reply to come in, remove the XPCOM wrapper, and unregister our actor. + let [subject] = await TestUtils.topicObserved( + "startup-content-subframe-loaded-scripts" + ); + let loadedInfo = subject.wrappedJSObject; + + ChromeUtils.unregisterWindowActor("StartupContentSubframe"); + BrowserTestUtils.removeTab(tab); + + // Gather loaded process scripts. + loadedInfo.processScripts = {}; + for (let [uri] of Services.ppmm.getDelayedProcessScripts()) { + loadedInfo.processScripts[uri] = ""; + } + + await checkLoadedScripts({ + loadedInfo, + known: known_scripts, + intermittent: intermittently_loaded_scripts, + forbidden: forbiddenScripts, + dumpAllStacks: kDumpAllStacks, + }); +}); diff --git a/browser/base/content/test/performance/browser_startup_flicker.js b/browser/base/content/test/performance/browser_startup_flicker.js new file mode 100644 index 0000000000..16300e1525 --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_flicker.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test ensures that there is no unexpected flicker + * on the first window opened during startup. + */ + +add_task(async function () { + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + // Ensure all the frame data is in the test compartment to avoid traversing + // a cross compartment wrapper for each pixel. + let frames = Cu.cloneInto(startupRecorder.data.frames, {}); + ok(!!frames.length, "Should have captured some frames."); + + let unexpectedRects = 0; + let alreadyFocused = false; + for (let i = 1; i < frames.length; ++i) { + let frame = frames[i], + previousFrame = frames[i - 1]; + let rects = compareFrames(frame, previousFrame); + if (!alreadyFocused && isLikelyFocusChange(rects, frame)) { + todo( + false, + "bug 1445161 - the window should be focused at first paint, " + + rects.toSource() + ); + continue; + } + alreadyFocused = true; + + rects = rects.filter(rect => { + let width = frame.width; + + let exceptions = [ + /** + * Please don't add anything new unless justified! + */ + ]; + + let rectText = `${rect.toSource()}, window width: ${width}`; + for (let e of exceptions) { + if (e.condition(rect)) { + todo(false, e.name + ", " + rectText); + return false; + } + } + + ok(false, "unexpected changed rect: " + rectText); + return true; + }); + if (!rects.length) { + info("ignoring identical frame"); + 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"); +}); diff --git a/browser/base/content/test/performance/browser_startup_hiddenwindow.js b/browser/base/content/test/performance/browser_startup_hiddenwindow.js new file mode 100644 index 0000000000..27ff837dc4 --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_hiddenwindow.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + if ( + !AppConstants.NIGHTLY_BUILD && + !AppConstants.MOZ_DEV_EDITION && + !AppConstants.DEBUG + ) { + ok( + !("@mozilla.org/test/startuprecorder;1" in Cc), + "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" + + "non-debug build." + ); + return; + } + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + let extras = Cu.cloneInto(startupRecorder.data.extras, {}); + + let phasesExpectations = { + "before profile selection": false, + "before opening first browser window": false, + "before first paint": AppConstants.platform === "macosx", + // Bug 1531854 + "before handling user events": true, + "before becoming idle": true, + }; + + for (let phase in extras) { + if (!(phase in phasesExpectations)) { + ok(false, `Startup phase '${phase}' should be specified.`); + continue; + } + + is( + extras[phase].hiddenWindowLoaded, + phasesExpectations[phase], + `Hidden window loaded at '${phase}': ${phasesExpectations[phase]}` + ); + } +}); diff --git a/browser/base/content/test/performance/browser_startup_images.js b/browser/base/content/test/performance/browser_startup_images.js new file mode 100644 index 0000000000..5a27b9a8dd --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_images.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test checks that any images we load on startup are actually used, + * so we don't waste IO and cycles loading images the user doesn't see. + * It has a list of known problematic images that we aim to reduce to + * empty. + */ + +/* A list of images that are loaded at startup but not shown. + * List items support the following attributes: + * - file: The location of the loaded image file. + * - hidpi: An alternative hidpi file location for retina screens, if one exists. + * May be the magic string <not loaded> in strange cases where + * only the low-resolution image is loaded but not shown. + * - platforms: An array of the platforms where the issue is occurring. + * Possible values are linux, win, macosx. + * - intermittentNotLoaded: an array of platforms where this image is + * intermittently not loaded, e.g. because it is + * loaded during the time we stop recording. + * - intermittentShown: An array of platforms where this image is + * intermittently shown, even though the list implies + * it might not be shown. + * + * PLEASE do not add items to this list. + * + * PLEASE DO remove items from this list. + */ +const knownUnshownImages = [ + { + file: "chrome://global/skin/icons/arrow-left.svg", + platforms: ["linux", "win", "macosx"], + }, + + { + file: "chrome://browser/skin/toolbar-drag-indicator.svg", + platforms: ["linux", "win", "macosx"], + }, + + { + file: "chrome://global/skin/icons/chevron.svg", + platforms: ["win", "linux", "macosx"], + intermittentShown: ["win", "linux"], + }, + + { + file: "chrome://browser/skin/window-controls/maximize.svg", + platforms: ["win"], + // This is to prevent perma-fails in case Windows machines + // go back to running tests in non-maximized windows. + intermittentShown: ["win"], + // This file is not loaded on Windows 7/8. + intermittentNotLoaded: ["win"], + }, +]; + +add_task(async function () { + if (!AppConstants.DEBUG) { + ok(false, "You need to run this test on a debug build."); + } + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + let data = Cu.cloneInto(startupRecorder.data.images, {}); + let knownImagesForPlatform = knownUnshownImages.filter(el => { + return el.platforms.includes(AppConstants.platform); + }); + + { + let results = await PerfTestHelpers.throttledMapPromises( + knownImagesForPlatform, + async image => ({ + uri: image.file, + exists: await PerfTestHelpers.checkURIExists(image.file), + }) + ); + for (let { uri, exists } of results) { + ok(exists, `Unshown image entry ${uri} must exist`); + } + } + + let loadedImages = data["image-loading"]; + let shownImages = data["image-drawing"]; + + for (let loaded of loadedImages.values()) { + let knownImage = knownImagesForPlatform.find(el => { + if (window.devicePixelRatio >= 2 && el.hidpi && el.hidpi == loaded) { + return true; + } + return el.file == loaded; + }); + if (knownImage) { + if ( + !knownImage.intermittentShown || + !knownImage.intermittentShown.includes(AppConstants.platform) + ) { + todo( + shownImages.has(loaded), + `Image ${loaded} should not have been shown.` + ); + } + continue; + } + ok( + shownImages.has(loaded), + `Loaded image ${loaded} should have been shown.` + ); + } + + // Check for known images that are no longer used. + for (let item of knownImagesForPlatform) { + if ( + !item.intermittentNotLoaded || + !item.intermittentNotLoaded.includes(AppConstants.platform) + ) { + if (window.devicePixelRatio >= 2 && item.hidpi) { + if (item.hidpi != "<not loaded>") { + ok( + loadedImages.has(item.hidpi), + `Image ${item.hidpi} should have been loaded.` + ); + } + } else { + ok( + loadedImages.has(item.file), + `Image ${item.file} should have been loaded.` + ); + } + } + } +}); diff --git a/browser/base/content/test/performance/browser_startup_mainthreadio.js b/browser/base/content/test/performance/browser_startup_mainthreadio.js new file mode 100644 index 0000000000..b65ede26d5 --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_mainthreadio.js @@ -0,0 +1,881 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test records I/O syscalls done on the main thread during startup. + * + * To run this test similar to try server, you need to run: + * ./mach package + * ./mach test --appname=dist <path to test> + * + * If you made changes that cause this test to fail, it's likely because you + * are touching more files or directories during startup. + * Most code has no reason to use main thread I/O. + * If for some reason accessing the file system on the main thread is currently + * unavoidable, consider defering the I/O as long as you can, ideally after + * the end of startup. + * If your code isn't strictly required to show the first browser window, + * it shouldn't be loaded before we are done with first paint. + * Finally, if your code isn't really needed during startup, it should not be + * loaded before we have started handling user events. + */ + +"use strict"; + +/* Set this to true only for debugging purpose; it makes the output noisy. */ +const kDumpAllStacks = false; + +// Shortcuts for conditions. +const LINUX = AppConstants.platform == "linux"; +const WIN = AppConstants.platform == "win"; +const MAC = AppConstants.platform == "macosx"; + +const kSharedFontList = SpecialPowers.getBoolPref("gfx.e10s.font-list.shared"); + +/* This is an object mapping string phases of startup to lists of known cases + * of IO happening on the main thread. Ideally, IO should not be on the main + * thread, and should happen as late as possible (see above). + * + * Paths in the entries in these lists can: + * - be a full path, eg. "/etc/mime.types" + * - have a prefix which will be resolved using Services.dirsvc + * eg. "GreD:omni.ja" + * It's possible to have only a prefix, in thise case the directory will + * still be resolved, eg. "UAppData:" + * - use * at the begining and/or end as a wildcard + * The folder separator is '/' even for Windows paths, where it'll be + * automatically converted to '\'. + * + * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries; + * without this the test is strict and will fail if the described IO does not + * happen. + * + * Each entry specifies the maximum number of times an operation is expected to + * occur. + * The operations currently reported by the I/O interposer are: + * create/open: only supported on Windows currently. The test currently + * ignores these markers to have a shorter initial list of IO operations. + * Adding Unix support is bug 1533779. + * stat: supported on all platforms when checking the last modified date or + * file size. Supported only on Windows when checking if a file exists; + * fixing this inconsistency is bug 1536109. + * read: supported on all platforms, but unix platforms will only report read + * calls going through NSPR. + * write: supported on all platforms, but Linux will only report write calls + * going through NSPR. + * close: supported only on Unix, and only for close calls going through NSPR. + * Adding Windows support is bug 1524574. + * fsync: supported only on Windows. + * + * If an entry specifies more than one operation, if at least one of them is + * encountered, the test won't report a failure for the entry if other + * operations are not encountered. This helps when listing cases where the + * reported operations aren't the same on all platforms due to the I/O + * interposer inconsistencies across platforms documented above. + */ +const startupPhases = { + // Anything done before or during app-startup must have a compelling reason + // to run before we have even selected the user profile. + "before profile selection": [ + { + // bug 1541200 + path: "UAppData:Crash Reports/InstallTime20*", + condition: AppConstants.MOZ_CRASHREPORTER, + stat: 1, // only caught on Windows. + read: 1, + write: 2, + close: 1, + }, + { + // bug 1541200 + path: "UAppData:Crash Reports/LastCrash", + condition: WIN && AppConstants.MOZ_CRASHREPORTER, + stat: 1, // only caught on Windows. + read: 1, + }, + { + // bug 1541200 + path: "UAppData:Crash Reports/LastCrash", + condition: !WIN && AppConstants.MOZ_CRASHREPORTER, + ignoreIfUnused: true, // only if we ever crashed on this machine + read: 1, + close: 1, + }, + { + // At least the read seems unavoidable for a regular startup. + path: "UAppData:profiles.ini", + ignoreIfUnused: true, + condition: MAC, + stat: 1, + read: 1, + close: 1, + }, + { + // At least the read seems unavoidable for a regular startup. + path: "UAppData:profiles.ini", + condition: WIN, + ignoreIfUnused: true, // only if a real profile exists on the system. + read: 1, + stat: 1, + }, + { + // bug 1541226, bug 1363586, bug 1541593 + path: "ProfD:", + condition: WIN, + stat: 1, + }, + { + path: "ProfLD:.startup-incomplete", + condition: !WIN, // Visible on Windows with an open marker + close: 1, + }, + { + // bug 1541491 to stop using this file, bug 1541494 to write correctly. + path: "ProfLD:compatibility.ini", + write: 18, + close: 1, + }, + { + path: "GreD:omni.ja", + condition: !WIN, // Visible on Windows with an open marker + stat: 1, + }, + { + // bug 1376994 + path: "XCurProcD:omni.ja", + condition: !WIN, // Visible on Windows with an open marker + stat: 1, + }, + { + path: "ProfD:parent.lock", + condition: WIN, + stat: 1, + }, + { + // bug 1541603 + path: "ProfD:minidumps", + condition: WIN, + stat: 1, + }, + { + // bug 1543746 + path: "XCurProcD:defaults/preferences", + condition: WIN, + stat: 1, + }, + { + // bug 1544034 + path: "ProfLDS:startupCache/scriptCache-child-current.bin", + condition: WIN, + stat: 1, + }, + { + // bug 1544034 + path: "ProfLDS:startupCache/scriptCache-child.bin", + condition: WIN, + stat: 1, + }, + { + // bug 1544034 + path: "ProfLDS:startupCache/scriptCache-current.bin", + condition: WIN, + stat: 1, + }, + { + // bug 1544034 + path: "ProfLDS:startupCache/scriptCache.bin", + condition: WIN, + stat: 1, + }, + { + // bug 1541601 + path: "PrfDef:channel-prefs.js", + stat: 1, + read: 1, + close: 1, + }, + { + // At least the read seems unavoidable + path: "PrefD:prefs.js", + stat: 1, + read: 1, + close: 1, + }, + { + // bug 1543752 + path: "PrefD:user.js", + stat: 1, + read: 1, + close: 1, + }, + ], + + "before opening first browser window": [ + { + // bug 1541226 + path: "ProfD:", + condition: WIN, + ignoreIfUnused: true, // Sometimes happens in the next phase + stat: 1, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite-journal", + condition: !LINUX, + ignoreIfUnused: true, // Sometimes happens in the next phase + stat: 3, + write: 4, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite", + condition: !LINUX, + ignoreIfUnused: true, // Sometimes happens in the next phase + stat: 2, + read: 3, + write: 1, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite-wal", + ignoreIfUnused: true, // Sometimes happens in the next phase + condition: WIN, + stat: 2, + }, + { + // Seems done by OS X and outside of our control. + path: "*.savedState/restorecount.plist", + condition: MAC, + ignoreIfUnused: true, + write: 1, + }, + { + // Side-effect of bug 1412090, via sandboxing (but the real + // problem there is main-thread CPU use; see bug 1439412) + path: "*ld.so.conf*", + condition: LINUX && !AppConstants.MOZ_CODE_COVERAGE && !kSharedFontList, + read: 22, + close: 11, + }, + { + // bug 1541246 + path: "ProfD:extensions", + ignoreIfUnused: true, // bug 1649590 + condition: WIN, + stat: 1, + }, + { + // bug 1541246 + path: "UAppData:", + ignoreIfUnused: true, // sometimes before opening first browser window, + // sometimes before first paint + condition: WIN, + stat: 1, + }, + { + // bug 1833104 has context - this is artifact-only so doesn't affect + // any real users, will just show up for developer builds and + // artifact trypushes so we include it here. + path: "GreD:jogfile.json", + condition: + WIN && Services.prefs.getBoolPref("telemetry.fog.artifact_build"), + stat: 1, + }, + ], + + // We reach this phase right after showing the first browser window. + // This means that any I/O at this point delayed first paint. + "before first paint": [ + { + // bug 1545119 + path: "OldUpdRootD:", + condition: WIN, + stat: 1, + }, + { + // bug 1446012 + path: "UpdRootD:updates/0/update.status", + condition: WIN, + stat: 1, + }, + { + path: "XREAppFeat:formautofill@mozilla.org.xpi", + condition: !WIN, + stat: 1, + close: 1, + }, + { + path: "XREAppFeat:webcompat@mozilla.org.xpi", + condition: LINUX, + ignoreIfUnused: true, // Sometimes happens in the previous phase + close: 1, + }, + { + // We only hit this for new profiles. + path: "XREAppDist:distribution.ini", + // check we're not msix to disambiguate from the next entry... + condition: WIN && !Services.sysinfo.getProperty("hasWinPackageId"), + stat: 1, + }, + { + // On MSIX, we actually read this file - bug 1833341. + path: "XREAppDist:distribution.ini", + condition: WIN && Services.sysinfo.getProperty("hasWinPackageId"), + stat: 1, + read: 1, + }, + { + // bug 1545139 + path: "*Fonts/StaticCache.dat", + condition: WIN, + ignoreIfUnused: true, // Only on Win7 + read: 1, + }, + { + // Bug 1626738 + path: "SysD:spool/drivers/color/*", + condition: WIN, + read: 1, + }, + { + // Sandbox policy construction + path: "*ld.so.conf*", + condition: LINUX && !AppConstants.MOZ_CODE_COVERAGE, + read: 22, + close: 11, + }, + { + // bug 1541246 + path: "UAppData:", + ignoreIfUnused: true, // sometimes before opening first browser window, + // sometimes before first paint + condition: WIN, + stat: 1, + }, + { + // Not in packaged builds; useful for artifact builds. + path: "GreD:ScalarArtifactDefinitions.json", + condition: WIN && !AppConstants.MOZILLA_OFFICIAL, + stat: 1, + }, + { + // Not in packaged builds; useful for artifact builds. + path: "GreD:EventArtifactDefinitions.json", + condition: WIN && !AppConstants.MOZILLA_OFFICIAL, + stat: 1, + }, + { + // bug 1541226 + path: "ProfD:", + condition: WIN, + ignoreIfUnused: true, // Usually happens in the previous phase + stat: 1, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite-journal", + condition: WIN, + ignoreIfUnused: true, // Usually happens in the previous phase + stat: 3, + write: 4, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite", + condition: WIN, + ignoreIfUnused: true, // Usually happens in the previous phase + stat: 2, + read: 3, + write: 1, + }, + { + // bug 1534745 + path: "ProfD:cookies.sqlite-wal", + condition: WIN, + ignoreIfUnused: true, // Usually happens in the previous phase + stat: 2, + }, + ], + + // We are at this phase once we are ready to handle user events. + // Any IO at this phase or before gets in the way of the user + // interacting with the first browser window. + "before handling user events": [ + { + path: "GreD:update.test", + ignoreIfUnused: true, + condition: LINUX, + close: 1, + }, + { + path: "XREAppFeat:webcompat-reporter@mozilla.org.xpi", + condition: !WIN, + ignoreIfUnused: true, + stat: 1, + close: 1, + }, + { + // Bug 1660582 - access while running on windows10 hardware. + path: "ProfD:wmfvpxvideo.guard", + condition: WIN, + ignoreIfUnused: true, + stat: 1, + close: 1, + }, + { + // Bug 1649590 + path: "ProfD:extensions", + ignoreIfUnused: true, + condition: WIN, + stat: 1, + }, + ], + + // Things that are expected to be completely out of the startup path + // and loaded lazily when used for the first time by the user should + // be listed here. + "before becoming idle": [ + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:cert9.db`, + condition: WIN, + read: 5, + stat: AppConstants.NIGHTLY_BUILD ? 5 : 4, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:cert9.db-journal`, + condition: WIN, + stat: 3, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:cert9.db-wal`, + condition: WIN, + stat: 3, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: "ProfD:pkcs11.txt", + condition: WIN, + read: 2, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:key4.db`, + condition: WIN, + read: 10, + stat: AppConstants.NIGHTLY_BUILD ? 5 : 4, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:key4.db-journal`, + condition: WIN, + stat: 7, + }, + { + // bug 1370516 - NSS should be initialized off main thread. + path: `ProfD:key4.db-wal`, + condition: WIN, + stat: 7, + }, + { + path: "XREAppFeat:screenshots@mozilla.org.xpi", + ignoreIfUnused: true, + close: 1, + }, + { + path: "XREAppFeat:webcompat-reporter@mozilla.org.xpi", + ignoreIfUnused: true, + stat: 1, + close: 1, + }, + { + // bug 1391590 + path: "ProfD:places.sqlite-journal", + ignoreIfUnused: true, + fsync: 1, + stat: 4, + read: 1, + write: 2, + }, + { + // bug 1391590 + path: "ProfD:places.sqlite-wal", + ignoreIfUnused: true, + stat: 4, + fsync: 3, + read: 51, + write: 178, + }, + { + // bug 1391590 + path: "ProfD:places.sqlite-shm", + condition: WIN, + ignoreIfUnused: true, + stat: 1, + }, + { + // bug 1391590 + path: "ProfD:places.sqlite", + ignoreIfUnused: true, + fsync: 2, + read: 4, + stat: 3, + write: 1324, + }, + { + // bug 1391590 + path: "ProfD:favicons.sqlite-journal", + ignoreIfUnused: true, + fsync: 2, + stat: 7, + read: 2, + write: 7, + }, + { + // bug 1391590 + path: "ProfD:favicons.sqlite-wal", + ignoreIfUnused: true, + fsync: 2, + stat: 7, + read: 7, + write: 15, + }, + { + // bug 1391590 + path: "ProfD:favicons.sqlite-shm", + condition: WIN, + ignoreIfUnused: true, + stat: 2, + }, + { + // bug 1391590 + path: "ProfD:favicons.sqlite", + ignoreIfUnused: true, + fsync: 3, + read: 8, + stat: 4, + write: 1300, + }, + { + path: "ProfD:", + condition: WIN, + ignoreIfUnused: true, + stat: 3, + }, + ], +}; + +for (let name of ["d3d11layers", "glcontext", "wmfvpxvideo"]) { + startupPhases["before first paint"].push({ + path: `ProfD:${name}.guard`, + ignoreIfUnused: true, + stat: 1, + }); +} + +function expandPathWithDirServiceKey(path) { + if (path.includes(":")) { + let [prefix, suffix] = path.split(":"); + let [key, property] = prefix.split("."); + let dir = Services.dirsvc.get(key, Ci.nsIFile); + if (property) { + dir = dir[property]; + } + + // Resolve symLinks. + let dirPath = dir.path; + while (dir && !dir.isSymlink()) { + dir = dir.parent; + } + if (dir) { + dirPath = dirPath.replace(dir.path, dir.target); + } + + path = dirPath; + + if (suffix) { + path += "/" + suffix; + } + } + if (AppConstants.platform == "win") { + path = path.replace(/\//g, "\\"); + } + return path; +} + +function getStackFromProfile(profile, stack) { + const stackPrefixCol = profile.stackTable.schema.prefix; + const stackFrameCol = profile.stackTable.schema.frame; + const frameLocationCol = profile.frameTable.schema.location; + + let result = []; + while (stack) { + let sp = profile.stackTable.data[stack]; + let frame = profile.frameTable.data[sp[stackFrameCol]]; + stack = sp[stackPrefixCol]; + frame = profile.stringTable[frame[frameLocationCol]]; + if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) { + result.push(frame); + } + } + return result; +} + +function pathMatches(path, filename) { + path = path.toLowerCase(); + return ( + path == filename || // Full match + // Wildcard on both sides of the path + (path.startsWith("*") && + path.endsWith("*") && + filename.includes(path.slice(1, -1))) || + // Wildcard suffix + (path.endsWith("*") && filename.startsWith(path.slice(0, -1))) || + // Wildcard prefix + (path.startsWith("*") && filename.endsWith(path.slice(1))) + ); +} + +add_task(async function () { + if ( + !AppConstants.NIGHTLY_BUILD && + !AppConstants.MOZ_DEV_EDITION && + !AppConstants.DEBUG + ) { + ok( + !("@mozilla.org/test/startuprecorder;1" in Cc), + "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" + + "non-debug build." + ); + return; + } + + TestUtils.assertPackagedBuild(); + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + // Add system add-ons to the list of known IO dynamically. + // They should go in the omni.ja file (bug 1357205). + { + let addons = await AddonManager.getAddonsByTypes(["extension"]); + for (let addon of addons) { + if (addon.isSystem) { + startupPhases["before opening first browser window"].push({ + path: `XREAppFeat:${addon.id}.xpi`, + stat: 3, + close: 2, + }); + startupPhases["before handling user events"].push({ + path: `XREAppFeat:${addon.id}.xpi`, + condition: WIN, + stat: 2, + }); + } + } + } + + // Check for main thread I/O markers in the startup profile. + let profile = startupRecorder.data.profile.threads[0]; + + let phases = {}; + { + const nameCol = profile.markers.schema.name; + const dataCol = profile.markers.schema.data; + + let markersForCurrentPhase = []; + let foundIOMarkers = false; + + for (let m of profile.markers.data) { + let markerName = profile.stringTable[m[nameCol]]; + if (markerName.startsWith("startupRecorder:")) { + phases[markerName.split("startupRecorder:")[1]] = + markersForCurrentPhase; + markersForCurrentPhase = []; + continue; + } + + if (markerName != "FileIO") { + continue; + } + + let markerData = m[dataCol]; + if (markerData.source == "sqlite-mainthread") { + continue; + } + + let samples = markerData.stack.samples; + let stack = samples.data[0][samples.schema.stack]; + markersForCurrentPhase.push({ + operation: markerData.operation, + filename: markerData.filename, + source: markerData.source, + stackId: stack, + }); + foundIOMarkers = true; + } + + // The I/O interposer is disabled if !RELEASE_OR_BETA, so we expect to have + // no I/O marker in that case, but it's good to keep the test running to check + // that we are still able to produce startup profiles. + is( + foundIOMarkers, + !AppConstants.RELEASE_OR_BETA, + "The IO interposer should be enabled in builds that are not RELEASE_OR_BETA" + ); + if (!foundIOMarkers) { + // If a profile unexpectedly contains no I/O marker, it's better to return + // early to avoid having a lot of of confusing "no main thread IO when we + // expected some" failures. + return; + } + } + + for (let phase in startupPhases) { + startupPhases[phase] = startupPhases[phase].filter( + entry => !("condition" in entry) || entry.condition + ); + startupPhases[phase].forEach(entry => { + entry.listedPath = entry.path; + entry.path = expandPathWithDirServiceKey(entry.path); + }); + } + + let tmpPath = expandPathWithDirServiceKey("TmpD:").toLowerCase(); + let shouldPass = true; + for (let phase in phases) { + let knownIOList = startupPhases[phase]; + info( + `known main thread IO paths during ${phase}:\n` + + knownIOList + .map(e => { + let operations = Object.keys(e) + .filter(k => k != "path") + .map(k => `${k}: ${e[k]}`); + return ` ${e.path} - ${operations.join(", ")}`; + }) + .join("\n") + ); + + let markers = phases[phase]; + for (let marker of markers) { + if (marker.operation == "create/open") { + // TODO: handle these I/O markers once they are supported on + // non-Windows platforms. + continue; + } + + if (!marker.filename) { + // We are still missing the filename on some mainthreadio markers, + // these markers are currently useless for the purpose of this test. + continue; + } + + // Convert to lower case before comparing because the OS X test machines + // have the 'Firefox' folder in 'Library/Application Support' created + // as 'firefox' for some reason. + let filename = marker.filename.toLowerCase(); + + if (!WIN && filename == "/dev/urandom") { + continue; + } + + // /dev/shm is always tmpfs (a memory filesystem); this isn't + // really I/O any more than mmap/munmap are. + if (LINUX && filename.startsWith("/dev/shm/")) { + continue; + } + + // "Files" from memfd_create() are similar to tmpfs but never + // exist in the filesystem; however, they have names which are + // exposed in procfs, and the I/O interposer observes when + // they're close()d. + if (LINUX && filename.startsWith("/memfd:")) { + continue; + } + + // Shared memory uses temporary files on MacOS <= 10.11 to avoid + // a kernel security bug that will never be patched (see + // https://crbug.com/project-zero/1671 for details). This can + // be removed when we no longer support those OS versions. + if (MAC && filename.startsWith(tmpPath + "/org.mozilla.ipc.")) { + continue; + } + + let expected = false; + for (let entry of knownIOList) { + if (pathMatches(entry.path, filename)) { + entry[marker.operation] = (entry[marker.operation] || 0) - 1; + entry._used = true; + expected = true; + break; + } + } + if (!expected) { + record( + false, + `unexpected ${marker.operation} on ${marker.filename} ${phase}`, + undefined, + " " + getStackFromProfile(profile, marker.stackId).join("\n ") + ); + shouldPass = false; + } + info(`(${marker.source}) ${marker.operation} - ${marker.filename}`); + if (kDumpAllStacks) { + info( + getStackFromProfile(profile, marker.stackId) + .map(f => " " + f) + .join("\n") + ); + } + } + + for (let entry of knownIOList) { + for (let op in entry) { + if ( + [ + "listedPath", + "path", + "condition", + "ignoreIfUnused", + "_used", + ].includes(op) + ) { + continue; + } + let message = `${op} on ${entry.path} `; + if (entry[op] == 0) { + message += "as many times as expected"; + } else if (entry[op] > 0) { + message += `allowed ${entry[op]} more times`; + } else { + message += `${entry[op] * -1} more times than expected`; + } + Assert.greaterOrEqual(entry[op], 0, `${message} ${phase}`); + } + if (!("_used" in entry) && !entry.ignoreIfUnused) { + ok( + false, + `no main thread IO when we expected some during ${phase}: ${entry.path} (${entry.listedPath})` + ); + shouldPass = false; + } + } + } + + if (shouldPass) { + ok(shouldPass, "No unexpected main thread I/O during startup"); + } else { + const filename = "profile_startup_mainthreadio.json"; + let path = Services.env.get("MOZ_UPLOAD_DIR"); + let profilePath = PathUtils.join(path, filename); + await IOUtils.writeJSON(profilePath, startupRecorder.data.profile); + ok( + false, + "Unexpected main thread I/O behavior during startup; open the " + + `${filename} artifact in the Firefox Profiler to see what happened` + ); + } +}); diff --git a/browser/base/content/test/performance/browser_startup_syncIPC.js b/browser/base/content/test/performance/browser_startup_syncIPC.js new file mode 100644 index 0000000000..32c9450b0e --- /dev/null +++ b/browser/base/content/test/performance/browser_startup_syncIPC.js @@ -0,0 +1,449 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test sync IPC done on the main thread during startup. */ + +"use strict"; + +// Shortcuts for conditions. +const LINUX = AppConstants.platform == "linux"; +const WIN = AppConstants.platform == "win"; +const MAC = AppConstants.platform == "macosx"; +const WEBRENDER = window.windowUtils.layerManagerType.startsWith("WebRender"); +const SKELETONUI = Services.prefs.getBoolPref( + "browser.startup.preXulSkeletonUI", + false +); + +/* + * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries; + * without this the test is strict and will fail if a list entry isn't used. + */ +const startupPhases = { + // Anything done before or during app-startup must have a compelling reason + // to run before we have even selected the user profile. + "before profile selection": [], + + "before opening first browser window": [], + + // We reach this phase right after showing the first browser window. + // This means that any I/O at this point delayed first paint. + "before first paint": [ + { + name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier", + condition: (MAC || LINUX) && !WEBRENDER, + maxCount: 1, + }, + { + name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier", + condition: WIN && !WEBRENDER, + maxCount: 3, + }, + { + name: "PWebRenderBridge::Msg_EnsureConnected", + condition: WIN && WEBRENDER, + maxCount: 3, + }, + { + name: "PWebRenderBridge::Msg_EnsureConnected", + condition: (MAC || LINUX) && WEBRENDER, + maxCount: 1, + }, + { + // bug 1373773 + name: "PCompositorBridge::Msg_NotifyChildCreated", + condition: !WIN, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_NotifyChildCreated", + condition: WIN, + ignoreIfUnused: true, // Only on Win7 32 + maxCount: 2, + }, + { + name: "PCompositorBridge::Msg_MapAndNotifyChildCreated", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 2, + }, + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: MAC, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: WIN, + ignoreIfUnused: true, // Only on Win7 32 + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 3, + }, + { + name: "PCompositorWidget::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 3, + }, + { + name: "PGPU::Msg_AddLayerTreeIdMapping", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 5, + }, + { + name: "PCompositorBridge::Msg_MakeSnapshot", + condition: WIN && !WEBRENDER, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 1, + }, + { + name: "PWebRenderBridge::Msg_GetSnapshot", + condition: WIN && WEBRENDER, + ignoreIfUnused: true, // Sometimes in the next phase on Windows10 QR + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_WillClose", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 2, + }, + { + name: "PAPZInputBridge::Msg_ProcessUnhandledEvent", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 2, + }, + { + name: "PGPU::Msg_GetDeviceStatus", + // bug 1553740 might want to drop the WEBRENDER clause here. + // Additionally, the skeleton UI causes us to attach "before first paint" to a + // later event, which lets this sneak in. + condition: WIN && (WEBRENDER || SKELETONUI), + // If Init() completes before we call EnsureGPUReady we won't send GetDeviceStatus + // so we can safely ignore if unused. + ignoreIfUnused: true, + maxCount: 1, + }, + { + // bug 1784869 + // We use Resume signal to propagate correct XWindow/wl_surface + // to EGL compositor. + name: "PCompositorBridge::Msg_Resume", + condition: LINUX, + ignoreIfUnused: true, // intermittently occurs in "before handling user events" + maxCount: 1, + }, + ], + + // We are at this phase once we are ready to handle user events. + // Any IO at this phase or before gets in the way of the user + // interacting with the first browser window. + "before handling user events": [ + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: MAC, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: LINUX, + ignoreIfUnused: true, // intermittently occurs in "before becoming idle" + maxCount: 2, + }, + { + name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier", + condition: (!MAC && !WEBRENDER) || (WIN && WEBRENDER), + ignoreIfUnused: true, // intermittently occurs in "before becoming idle" + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 1, + }, + { + name: "PCompositorWidget::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_WillClose", + condition: WIN, + ignoreIfUnused: true, // Only on Win7 32 + maxCount: 2, + }, + { + name: "PCompositorBridge::Msg_MakeSnapshot", + condition: WIN, + ignoreIfUnused: true, // Only on Win7 32 + maxCount: 1, + }, + { + name: "PWebRenderBridge::Msg_GetSnapshot", + condition: WIN && WEBRENDER, + ignoreIfUnused: true, // Sometimes in the next phase on Windows10 QR + maxCount: 1, + }, + { + name: "PAPZInputBridge::Msg_ProcessUnhandledEvent", + condition: WIN, + ignoreIfUnused: true, // intermittently occurs in "before becoming idle" + maxCount: 1, + }, + { + name: "PAPZInputBridge::Msg_ReceiveMouseInputEvent", + condition: WIN, + ignoreIfUnused: true, // intermittently occurs in "before becoming idle" + maxCount: 1, + }, + { + name: "PWebRenderBridge::Msg_EnsureConnected", + condition: WIN && WEBRENDER, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PContent::Reply_BeginDriverCrashGuard", + condition: WIN, + ignoreIfUnused: true, // Bug 1660590 - found while running test on windows hardware + maxCount: 1, + }, + { + name: "PContent::Reply_EndDriverCrashGuard", + condition: WIN, + ignoreIfUnused: true, // Bug 1660590 - found while running test on windows hardware + maxCount: 1, + }, + { + // bug 1784869 + // We use Resume signal to propagate correct XWindow/wl_surface + // to EGL compositor. + name: "PCompositorBridge::Msg_Resume", + condition: LINUX, + ignoreIfUnused: true, // intermittently occurs in "before first paint" + maxCount: 1, + }, + ], + + // Things that are expected to be completely out of the startup path + // and loaded lazily when used for the first time by the user should + // be listed here. + "before becoming idle": [ + { + // bug 1373773 + name: "PCompositorBridge::Msg_NotifyChildCreated", + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PAPZInputBridge::Msg_ProcessUnhandledEvent", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 1, + }, + { + name: "PAPZInputBridge::Msg_ReceiveMouseInputEvent", + condition: WIN, + ignoreIfUnused: true, // Only on Win10 64 + maxCount: 1, + }, + { + // bug 1554234 + name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier", + condition: WIN || LINUX, + ignoreIfUnused: true, // intermittently occurs in "before handling user events" + maxCount: 1, + }, + { + name: "PWebRenderBridge::Msg_EnsureConnected", + condition: (WIN || LINUX) && WEBRENDER, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Intermittently occurs in "before handling user events" + maxCount: 1, + }, + { + name: "PCompositorWidget::Msg_Initialize", + condition: WIN, + ignoreIfUnused: true, // Intermittently occurs in "before handling user events" + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_MapAndNotifyChildCreated", + condition: WIN, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: MAC || SKELETONUI, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_FlushRendering", + condition: LINUX, + ignoreIfUnused: true, // intermittently occurs in "before handling user events" + maxCount: 1, + }, + { + name: "PWebRenderBridge::Msg_GetSnapshot", + condition: WIN && WEBRENDER, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_MakeSnapshot", + condition: WIN, + ignoreIfUnused: true, + maxCount: 1, + }, + { + name: "PCompositorBridge::Msg_WillClose", + condition: WIN, + ignoreIfUnused: true, + maxCount: 2, + }, + // Added for the search-detection built-in add-on. + { + name: "PGPU::Msg_AddLayerTreeIdMapping", + condition: WIN, + ignoreIfUnused: true, + maxCount: 1, + }, + ], +}; + +add_task(async function () { + if ( + !AppConstants.NIGHTLY_BUILD && + !AppConstants.MOZ_DEV_EDITION && + !AppConstants.DEBUG + ) { + ok( + !("@mozilla.org/test/startuprecorder;1" in Cc), + "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" + + "non-debug build." + ); + return; + } + + let startupRecorder = + Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject; + await startupRecorder.done; + + // Check for sync IPC markers in the startup profile. + let profile = startupRecorder.data.profile.threads[0]; + + let phases = {}; + { + const nameCol = profile.markers.schema.name; + const dataCol = profile.markers.schema.data; + const startTimeCol = profile.markers.schema.startTime; + + let markersForCurrentPhase = []; + for (let m of profile.markers.data) { + let markerName = profile.stringTable[m[nameCol]]; + if (markerName.startsWith("startupRecorder:")) { + phases[markerName.split("startupRecorder:")[1]] = + markersForCurrentPhase; + markersForCurrentPhase = []; + continue; + } + + let markerData = m[dataCol]; + if ( + !markerData || + markerData.category != "Sync IPC" || + !m[startTimeCol] + ) { + continue; + } + + markersForCurrentPhase.push(markerName); + } + } + + for (let phase in startupPhases) { + startupPhases[phase] = startupPhases[phase].filter( + entry => !("condition" in entry) || entry.condition + ); + } + + let shouldPass = true; + for (let phase in phases) { + let knownIPCList = startupPhases[phase]; + if (knownIPCList.length) { + info( + `known sync IPC ${phase}:\n` + + knownIPCList + .map(e => ` ${e.name} - at most ${e.maxCount} times`) + .join("\n") + ); + } + + let markers = phases[phase]; + for (let marker of markers) { + let expected = false; + for (let entry of knownIPCList) { + if (marker == entry.name) { + entry.useCount = (entry.useCount || 0) + 1; + expected = true; + break; + } + } + if (!expected) { + ok(false, `unexpected ${marker} sync IPC ${phase}`); + shouldPass = false; + } + } + + for (let entry of knownIPCList) { + // Make sure useCount has been defined. + entry.useCount = entry.useCount || 0; + let message = `sync IPC ${entry.name} `; + if (entry.useCount == entry.maxCount) { + message += "happened as many times as expected"; + } else if (entry.useCount < entry.maxCount) { + message += `allowed ${entry.maxCount} but only happened ${entry.useCount} times`; + } else { + message += `happened ${entry.useCount} but max is ${entry.maxCount}`; + shouldPass = false; + } + Assert.lessOrEqual(entry.useCount, entry.maxCount, `${message} ${phase}`); + + if (entry.useCount == 0 && !entry.ignoreIfUnused) { + ok(false, `unused known IPC entry ${phase}: ${entry.name}`); + shouldPass = false; + } + } + } + + if (shouldPass) { + ok(shouldPass, "No unexpected sync IPC during startup"); + } else { + const filename = "profile_startup_syncIPC.json"; + let path = Services.env.get("MOZ_UPLOAD_DIR"); + let profilePath = PathUtils.join(path, filename); + await IOUtils.writeJSON(profilePath, startupRecorder.data.profile); + ok( + false, + `Unexpected sync IPC behavior during startup; open the ${filename} ` + + "artifact in the Firefox Profiler to see what happened" + ); + } +}); diff --git a/browser/base/content/test/performance/browser_tabclose.js b/browser/base/content/test/performance/browser_tabclose.js new file mode 100644 index 0000000000..961686587f --- /dev/null +++ b/browser/base/content/test/performance/browser_tabclose.js @@ -0,0 +1,108 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when closing new tabs. + */ +add_task(async function () { + // Force-enable tab animations + gReduceMotionOverride = false; + + await ensureNoPreloadedBrowser(); + await disableFxaBadge(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await TestUtils.waitForCondition(() => tab._fullyOpen); + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + let newTabButtonRect = + gBrowser.tabContainer.newTabButton.getBoundingClientRect(); + let inRange = (val, min, max) => min <= val && val <= max; + + // Add a reflow observer and open a new tab. + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + gBrowser.removeTab(tab, { animate: true }); + await BrowserTestUtils.waitForEvent(tab, "TabAnimationEnd"); + await switchDone; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter: rects => + rects.filter( + r => + !( + // We expect all changes to be within the tab strip. + ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right && + // The closed tab should disappear at the same time as the previous + // tab gets selected, causing both tab areas to change color at once: + // this should be a single rect of the width of 2 tabs, and can + // include the '+' button if it starts its animation. + ((r.w > gBrowser.selectedTab.clientWidth && + r.x2 <= newTabButtonRect.right) || + // The '+' icon moves with an animation. At the end of the animation + // the former and new positions can touch each other causing the rect + // to have twice the icon's width. + (r.h == 13 && r.w <= 2 * 13 + kMaxEmptyPixels) || + // We sometimes have a rect for the right most 2px of the '+' button. + (r.h == 2 && r.w == 2)) + ) + ) + ), + exceptions: [ + { + name: + "bug 1444886 - the next tab should be selected at the same time" + + " as the closed one disappears", + condition: r => + // In tab strip + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right && + // Width of one tab plus tab separator(s) + inRange(gBrowser.selectedTab.clientWidth - r.w, 0, 2), + }, + { + name: "bug 1446449 - spurious tab switch spinner", + condition: r => + AppConstants.DEBUG && + // In the content area + r.y1 >= + document.getElementById("appcontent").getBoundingClientRect() + .top, + }, + ], + }, + } + ); + is(EXPECTED_REFLOWS.length, 0, "No reflows are expected when closing a tab"); +}); diff --git a/browser/base/content/test/performance/browser_tabclose_grow.js b/browser/base/content/test/performance/browser_tabclose_grow.js new file mode 100644 index 0000000000..7ad43809cd --- /dev/null +++ b/browser/base/content/test/performance/browser_tabclose_grow.js @@ -0,0 +1,91 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when closing a tab that will + * cause the existing tabs to grow bigger. + */ +add_task(async function () { + // Force-enable tab animations + gReduceMotionOverride = false; + + await ensureNoPreloadedBrowser(); + await disableFxaBadge(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + // At the time of writing, there are no reflows on tab closing with + // tab growth. Mochitest will fail if we have no assertions, so we + // add one here to make sure nobody adds any new ones. + Assert.equal( + EXPECTED_REFLOWS.length, + 0, + "We shouldn't have added any new expected reflows." + ); + + // Compute the number of tabs we can put into the strip without + // overflowing. If we remove one of the tabs, we know that the + // remaining tabs will grow to fill the remaining space in the + // tabstrip. + const TAB_COUNT_FOR_GROWTH = computeMaxTabCount(); + await createTabs(TAB_COUNT_FOR_GROWTH); + + let lastTab = gBrowser.tabs[gBrowser.tabs.length - 1]; + await BrowserTestUtils.switchTab(gBrowser, lastTab); + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + + function isInTabStrip(r) { + return ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right && + // It would make sense for each rect to have a width smaller than + // a tab (ie. tabstrip.width / tabcount), but tabs are small enough + // that they sometimes get reported in the same rect. + // So we accept up to the width of n-1 tabs. + r.w <= + (gBrowser.tabs.length - 1) * + Math.ceil(tabStripRect.width / gBrowser.tabs.length) + ); + } + + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + let tab = gBrowser.tabs[gBrowser.tabs.length - 1]; + gBrowser.removeTab(tab, { animate: true }); + await BrowserTestUtils.waitForEvent(tab, "TabAnimationEnd"); + await switchDone; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter: rects => rects.filter(r => !isInTabStrip(r)), + }, + } + ); + + await removeAllButFirstTab(); +}); diff --git a/browser/base/content/test/performance/browser_tabdetach.js b/browser/base/content/test/performance/browser_tabdetach.js new file mode 100644 index 0000000000..a860362f1f --- /dev/null +++ b/browser/base/content/test/performance/browser_tabdetach.js @@ -0,0 +1,118 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. This + * list should slowly go away as we improve the performance of the front-end. + * Instead of adding more reflows to the list, you should be modifying your code + * to avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + { + stack: [ + "clientX@chrome://browser/content/tabbrowser-tabs.js", + "startTabDrag@chrome://browser/content/tabbrowser-tabs.js", + "on_dragstart@chrome://browser/content/tabbrowser-tabs.js", + "handleEvent@chrome://browser/content/tabbrowser-tabs.js", + "synthesizeMouseAtPoint@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + "synthesizeMouse@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + "synthesizePlainDragAndDrop@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + ], + maxCount: 2, + }, + + { + stack: [ + "startTabDrag@chrome://browser/content/tabbrowser-tabs.js", + "on_dragstart@chrome://browser/content/tabbrowser-tabs.js", + "handleEvent@chrome://browser/content/tabbrowser-tabs.js", + "synthesizeMouseAtPoint@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + "synthesizeMouse@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + "synthesizePlainDragAndDrop@chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + ], + }, +]; + +/** + * This test ensures that there are no unexpected uninterruptible reflows when + * detaching a tab via drag and drop. The first testcase tests a non-overflowed + * tab strip, and the second tests an overflowed one. + */ + +add_task(async function test_detach_not_overflowed() { + await ensureNoPreloadedBrowser(); + await createTabs(1); + + // Make sure we didn't overflow, as expected + await TestUtils.waitForCondition(() => { + return !gBrowser.tabContainer.hasAttribute("overflow"); + }); + + let win; + await withPerfObserver( + async function () { + win = await detachTab(gBrowser.tabs[1]); + }, + { + expectedReflows: EXPECTED_REFLOWS, + // we are opening a whole new window, so there's no point in tracking + // rects being painted + frames: { filter: rects => [] }, + } + ); + + await BrowserTestUtils.closeWindow(win); + win = null; +}); + +add_task(async function test_detach_overflowed() { + const TAB_COUNT_FOR_OVERFLOW = computeMaxTabCount(); + await createTabs(TAB_COUNT_FOR_OVERFLOW + 1); + + // Make sure we overflowed, as expected + await TestUtils.waitForCondition(() => { + return gBrowser.tabContainer.hasAttribute("overflow"); + }); + + let win; + await withPerfObserver( + async function () { + win = await detachTab( + gBrowser.tabs[Math.floor(TAB_COUNT_FOR_OVERFLOW / 2)] + ); + }, + { + expectedReflows: EXPECTED_REFLOWS, + // we are opening a whole new window, so there's no point in tracking + // rects being painted + frames: { filter: rects => [] }, + } + ); + + await BrowserTestUtils.closeWindow(win); + win = null; + + await removeAllButFirstTab(); +}); + +async function detachTab(tab) { + let newWindowPromise = BrowserTestUtils.waitForNewWindow(); + + await EventUtils.synthesizePlainDragAndDrop({ + srcElement: tab, + + // destElement is null because tab detaching happens due + // to a drag'n'drop on an invalid drop target. + destElement: null, + + // don't move horizontally because that could cause a tab move + // animation, and there's code to prevent a tab detaching if + // the dragged tab is released while the animation is running. + stepX: 0, + stepY: 100, + }); + + return newWindowPromise; +} diff --git a/browser/base/content/test/performance/browser_tabopen.js b/browser/base/content/test/performance/browser_tabopen.js new file mode 100644 index 0000000000..2457812cb7 --- /dev/null +++ b/browser/base/content/test/performance/browser_tabopen.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when opening new tabs. + */ +add_task(async function () { + // Force-enable tab animations + gReduceMotionOverride = false; + + // TODO (bug 1702653): Disable tab shadows for tests since the shadow + // can extend outside of the boundingClientRect. The tabRect will need + // to grow to include the shadow size. + gBrowser.tabContainer.setAttribute("noshadowfortests", "true"); + + await ensureNoPreloadedBrowser(); + await disableFxaBadge(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + // Prepare the window to avoid flicker and reflow that's unrelated to our + // tab opening operation. + gURLBar.focus(); + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + + let firstTabRect = gBrowser.selectedTab.getBoundingClientRect(); + let tabPaddingStart = parseFloat( + getComputedStyle(gBrowser.selectedTab).paddingInlineStart + ); + let minTabWidth = firstTabRect.width - 2 * tabPaddingStart; + let maxTabWidth = firstTabRect.width; + let firstTabLabelRect = + gBrowser.selectedTab.textLabel.getBoundingClientRect(); + let newTabButtonRect = document + .getElementById("tabs-newtab-button") + .getBoundingClientRect(); + let textBoxRect = gURLBar + .querySelector("moz-input-box") + .getBoundingClientRect(); + + let inRange = (val, min, max) => min <= val && val <= max; + + info(`tabStripRect=${JSON.stringify(tabStripRect)}`); + info(`firstTabRect=${JSON.stringify(firstTabRect)}`); + info(`tabPaddingStart=${JSON.stringify(tabPaddingStart)}`); + info(`firstTabLabelRect=${JSON.stringify(firstTabLabelRect)}`); + info(`newTabButtonRect=${JSON.stringify(newTabButtonRect)}`); + info(`textBoxRect=${JSON.stringify(textBoxRect)}`); + + let inTabStrip = function (r) { + return ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right + ); + }; + + const kTabCloseIconWidth = 13; + + let isExpectedChange = function (r) { + // We expect all changes to be within the tab strip. + if (!inTabStrip(r)) { + return false; + } + + // The first tab should get deselected at the same time as the next tab + // starts appearing, so we should have one rect that includes the first tab + // but is wider. + if ( + inRange(r.w, minTabWidth, maxTabWidth * 2) && + inRange(r.x1, firstTabRect.x, firstTabRect.x + tabPaddingStart) + ) { + return true; + } + + // The second tab gets painted several times due to tabopen animation. + let isSecondTabRect = + inRange( + r.x1, + // When the animation starts the tab close icon overflows. + // -1 for the border on Win7 + firstTabRect.right - kTabCloseIconWidth - 1, + firstTabRect.right + firstTabRect.width + ) && + r.x2 < + firstTabRect.right + + firstTabRect.width + + // Sometimes the '+' is in the same rect. + newTabButtonRect.width; + + if (isSecondTabRect) { + return true; + } + // The '+' icon moves with an animation. At the end of the animation + // the former and new positions can touch each other causing the rect + // to have twice the icon's width. + if ( + r.h == kTabCloseIconWidth && + r.w <= 2 * kTabCloseIconWidth + kMaxEmptyPixels + ) { + return true; + } + + // We sometimes have a rect for the right most 2px of the '+' button. + if (r.h == 2 && r.w == 2) { + return true; + } + + // Same for the 'X' icon. + if (r.h == 10 && r.w <= 2 * 10) { + return true; + } + + // Other changes are unexpected. + return false; + }; + + // Add a reflow observer and open a new tab. + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserOpenTab(); + await BrowserTestUtils.waitForEvent( + gBrowser.selectedTab, + "TabAnimationEnd" + ); + await switchDone; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter: rects => rects.filter(r => !isExpectedChange(r)), + exceptions: [ + { + name: + "bug 1446452 - the new tab should appear at the same time as the" + + " previous one gets deselected", + condition: r => + // In tab strip + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + // Position and size of the first tab. + r.x1 == firstTabRect.left && + inRange( + r.w, + firstTabRect.width - 1, // -1 as the border doesn't change + firstTabRect.width + ), + }, + { + name: "the urlbar placeolder moves up and down by a few pixels", + // This seems to only happen on the second run in --verify + condition: r => + r.x1 >= textBoxRect.left && + r.x2 <= textBoxRect.right && + r.y1 >= textBoxRect.top && + r.y2 <= textBoxRect.bottom, + }, + { + name: "bug 1477966 - the name of a deselected tab should appear immediately", + condition: r => + AppConstants.platform == "macosx" && + r.x1 >= firstTabLabelRect.x && + r.x2 <= firstTabLabelRect.right && + r.y1 >= firstTabLabelRect.y && + r.y2 <= firstTabLabelRect.bottom, + }, + ], + }, + } + ); + + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await switchDone; +}); diff --git a/browser/base/content/test/performance/browser_tabopen_squeeze.js b/browser/base/content/test/performance/browser_tabopen_squeeze.js new file mode 100644 index 0000000000..f92bdc2ea4 --- /dev/null +++ b/browser/base/content/test/performance/browser_tabopen_squeeze.js @@ -0,0 +1,100 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when opening a new tab that will + * cause the existing tabs to squeeze smaller. + */ +add_task(async function () { + // Force-enable tab animations + gReduceMotionOverride = false; + + await ensureNoPreloadedBrowser(); + await disableFxaBadge(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + // Compute the number of tabs we can put into the strip without + // overflowing, and remove one, so that we can create + // TAB_COUNT_FOR_SQUEEE tabs, and then one more, which should + // cause the tab to squeeze to a smaller size rather than overflow. + const TAB_COUNT_FOR_SQUEEZE = computeMaxTabCount() - 1; + + await createTabs(TAB_COUNT_FOR_SQUEEZE); + + gURLBar.focus(); + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + let textBoxRect = gURLBar + .querySelector("moz-input-box") + .getBoundingClientRect(); + + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserOpenTab(); + await BrowserTestUtils.waitForEvent( + gBrowser.selectedTab, + "TabAnimationEnd" + ); + await switchDone; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter: rects => + rects.filter( + r => + !( + // We expect plenty of changed rects within the tab strip. + ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right && + // It would make sense for each rect to have a width smaller than + // a tab (ie. tabstrip.width / tabcount), but tabs are small enough + // that they sometimes get reported in the same rect. + // So we accept up to the width of n-1 tabs. + r.w <= + (gBrowser.tabs.length - 1) * + Math.ceil(tabStripRect.width / gBrowser.tabs.length) + ) + ) + ), + exceptions: [ + { + name: "the urlbar placeolder moves up and down by a few pixels", + condition: r => + r.x1 >= textBoxRect.left && + r.x2 <= textBoxRect.right && + r.y1 >= textBoxRect.top && + r.y2 <= textBoxRect.bottom, + }, + ], + }, + } + ); + + await removeAllButFirstTab(); +}); diff --git a/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js b/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js new file mode 100644 index 0000000000..1fd33ed836 --- /dev/null +++ b/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js @@ -0,0 +1,200 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_*_REFLOWS. + * This is a (now empty) list of known reflows. + * Instead of adding more reflows to the lists, you should be modifying your + * code to avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_OVERFLOW_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +const EXPECTED_UNDERFLOW_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/** + * This test ensures that there are no unexpected uninterruptible reflows when + * opening a new tab that will cause the existing tabs to overflow and the tab + * strip to become scrollable. It also tests that there are no unexpected + * uninterruptible reflows when closing that tab, which causes the tab strip to + * underflow. + */ +add_task(async function () { + // Force-enable tab animations + gReduceMotionOverride = false; + + await ensureNoPreloadedBrowser(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + const TAB_COUNT_FOR_OVERFLOW = computeMaxTabCount(); + + await createTabs(TAB_COUNT_FOR_OVERFLOW); + + gURLBar.focus(); + await disableFxaBadge(); + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + let textBoxRect = gURLBar + .querySelector("moz-input-box") + .getBoundingClientRect(); + + let ignoreTabstripRects = { + filter: rects => + rects.filter( + r => + !( + // We expect plenty of changed rects within the tab strip. + ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right + ) + ) + ), + exceptions: [ + { + name: "the urlbar placeolder moves up and down by a few pixels", + condition: r => + r.x1 >= textBoxRect.left && + r.x2 <= textBoxRect.right && + r.y1 >= textBoxRect.top && + r.y2 <= textBoxRect.bottom, + }, + { + name: "bug 1446449 - spurious tab switch spinner", + condition: r => + // In the content area + r.y1 >= + document.getElementById("appcontent").getBoundingClientRect().top, + }, + ], + }; + + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserOpenTab(); + await BrowserTestUtils.waitForEvent( + gBrowser.selectedTab, + "TabAnimationEnd" + ); + await switchDone; + await TestUtils.waitForCondition(() => { + return gBrowser.tabContainer.arrowScrollbox.hasAttribute( + "scrolledtoend" + ); + }); + }, + { expectedReflows: EXPECTED_OVERFLOW_REFLOWS, frames: ignoreTabstripRects } + ); + + Assert.ok( + gBrowser.tabContainer.hasAttribute("overflow"), + "Tabs should now be overflowed." + ); + + // Now test that opening and closing a tab while overflowed doesn't cause + // us to reflow. + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserOpenTab(); + await switchDone; + await TestUtils.waitForCondition(() => { + return gBrowser.tabContainer.arrowScrollbox.hasAttribute( + "scrolledtoend" + ); + }); + }, + { expectedReflows: [], frames: ignoreTabstripRects } + ); + + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserTestUtils.removeTab(gBrowser.selectedTab, { animate: true }); + await switchDone; + }, + { expectedReflows: [], frames: ignoreTabstripRects } + ); + + // At this point, we have an overflowed tab strip, and we've got the last tab + // selected. This should mean that the first tab is scrolled out of view. + // Let's test that we don't reflow when switching to that first tab. + let lastTab = gBrowser.selectedTab; + let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox; + + // First, we'll check that the first tab is actually scrolled + // at least partially out of view. + Assert.ok( + arrowScrollbox.scrollPosition > 0, + "First tab should be partially scrolled out of view." + ); + + // Now switch to the first tab. We shouldn't flush layout at all. + await withPerfObserver( + async function () { + let firstTab = gBrowser.tabs[0]; + await BrowserTestUtils.switchTab(gBrowser, firstTab); + await TestUtils.waitForCondition(() => { + return gBrowser.tabContainer.arrowScrollbox.hasAttribute( + "scrolledtostart" + ); + }); + }, + { expectedReflows: [], frames: ignoreTabstripRects } + ); + + // Okay, now close the last tab. The tabstrip should stay overflowed, but removing + // one more after that should underflow it. + BrowserTestUtils.removeTab(lastTab); + + Assert.ok( + gBrowser.tabContainer.hasAttribute("overflow"), + "Tabs should still be overflowed." + ); + + // Depending on the size of the window, it might take one or more tab + // removals to put the tab strip out of the overflow state, so we'll just + // keep testing removals until that occurs. + while (gBrowser.tabContainer.hasAttribute("overflow")) { + lastTab = gBrowser.tabs[gBrowser.tabs.length - 1]; + if (gBrowser.selectedTab !== lastTab) { + await BrowserTestUtils.switchTab(gBrowser, lastTab); + } + + // ... and make sure we don't flush layout when closing it, and exiting + // the overflowed state. + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + BrowserTestUtils.removeTab(lastTab, { animate: true }); + await switchDone; + await TestUtils.waitForCondition(() => !lastTab.isConnected); + }, + { + expectedReflows: EXPECTED_UNDERFLOW_REFLOWS, + frames: ignoreTabstripRects, + } + ); + } + + await removeAllButFirstTab(); +}); diff --git a/browser/base/content/test/performance/browser_tabswitch.js b/browser/base/content/test/performance/browser_tabswitch.js new file mode 100644 index 0000000000..bbbbac3a21 --- /dev/null +++ b/browser/base/content/test/performance/browser_tabswitch.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when switching between two + * tabs that are both fully visible. + */ +add_task(async function () { + // TODO (bug 1702653): Disable tab shadows for tests since the shadow + // can extend outside of the boundingClientRect. The tabRect will need + // to grow to include the shadow size. + gBrowser.tabContainer.setAttribute("noshadowfortests", "true"); + + await ensureNoPreloadedBrowser(); + await disableFxaBadge(); + + // The test starts on about:blank and opens an about:blank + // tab which triggers opening the toolbar since + // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "never"]], + }); + + // At the time of writing, there are no reflows on simple tab switching. + // Mochitest will fail if we have no assertions, so we add one here + // to make sure nobody adds any new ones. + Assert.equal( + EXPECTED_REFLOWS.length, + 0, + "We shouldn't have added any new expected reflows." + ); + + let origTab = gBrowser.selectedTab; + let firstSwitchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + let otherTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await firstSwitchDone; + + let tabStripRect = + gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect(); + let firstTabRect = origTab.getBoundingClientRect(); + let tabPaddingStart = parseFloat( + getComputedStyle(gBrowser.selectedTab).paddingInlineStart + ); + let minTabWidth = firstTabRect.width - 2 * tabPaddingStart; + let maxTabWidth = firstTabRect.width; + let inRange = (val, min, max) => min <= val && val <= max; + + await withPerfObserver( + async function () { + let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone"); + gBrowser.selectedTab = origTab; + await switchDone; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter: rects => + rects.filter( + r => + !( + // We expect all changes to be within the tab strip. + ( + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + r.x1 >= tabStripRect.left && + r.x2 <= tabStripRect.right && + // The tab selection changes between 2 adjacent tabs, so we expect + // both to change color at once: this should be a single rect of the + // width of 2 tabs. + inRange( + r.w, + minTabWidth - 1, // -1 for the border on Win7 + maxTabWidth * 2 + ) + ) + ) + ), + exceptions: [ + { + name: + "bug 1446454 - the border between tabs should be painted at" + + " the same time as the tab switch", + condition: r => + // In tab strip + r.y1 >= tabStripRect.top && + r.y2 <= tabStripRect.bottom && + // 1px border, 1px before the end of the first tab. + r.w == 1 && + r.x1 == firstTabRect.right - 1, + }, + { + name: "bug 1446449 - spurious tab switch spinner", + condition: r => + AppConstants.DEBUG && + // In the content area + r.y1 >= + document.getElementById("appcontent").getBoundingClientRect() + .top, + }, + ], + }, + } + ); + + BrowserTestUtils.removeTab(otherTab); +}); diff --git a/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js b/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js new file mode 100644 index 0000000000..890c8f3c80 --- /dev/null +++ b/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js @@ -0,0 +1,65 @@ +"use strict"; + +/** + * Ensure redundant style flushes are not triggered when switching between windows + */ +add_task(async function test_toolbar_element_restyles_on_activation() { + let restyles = { + win1: {}, + win2: {}, + }; + + // create a window and snapshot the elementsStyled + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + await new Promise(resolve => waitForFocus(resolve, win1)); + + // create a 2nd window and snapshot the elementsStyled + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + await new Promise(resolve => waitForFocus(resolve, win2)); + + // (De)-activate both windows once before we take a measurement. The first + // (de-)activation may flush styles, after that the style data should be + // cached. + win1.focus(); + win2.focus(); + + // Flush any pending styles before we take a measurement. + win1.getComputedStyle(win1.document.firstElementChild); + win2.getComputedStyle(win2.document.firstElementChild); + + // Clear the focused element from each window so that when + // we raise them, the focus of the element doesn't cause an + // unrelated style flush. + Services.focus.clearFocus(win1); + Services.focus.clearFocus(win2); + + let utils1 = SpecialPowers.getDOMWindowUtils(win1); + restyles.win1.initial = utils1.restyleGeneration; + + let utils2 = SpecialPowers.getDOMWindowUtils(win2); + restyles.win2.initial = utils2.restyleGeneration; + + // switch back to 1st window, and snapshot elementsStyled + win1.focus(); + restyles.win1.activate = utils1.restyleGeneration; + restyles.win2.deactivate = utils2.restyleGeneration; + + // switch back to 2nd window, and snapshot elementsStyled + win2.focus(); + restyles.win2.activate = utils2.restyleGeneration; + restyles.win1.deactivate = utils1.restyleGeneration; + + is( + restyles.win1.activate - restyles.win1.deactivate, + 0, + "No elements restyled when re-activating/deactivating a window" + ); + is( + restyles.win2.activate - restyles.win2.deactivate, + 0, + "No elements restyled when re-activating/deactivating a window" + ); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/base/content/test/performance/browser_urlbar_keyed_search.js b/browser/base/content/test/performance/browser_urlbar_keyed_search.js new file mode 100644 index 0000000000..a44e5d4822 --- /dev/null +++ b/browser/base/content/test/performance/browser_urlbar_keyed_search.js @@ -0,0 +1,27 @@ +"use strict"; + +// This tests searching in the urlbar (a.k.a. the quantumbar). + +/** + * WHOA THERE: We should never be adding new things to + * EXPECTED_REFLOWS_FIRST_OPEN or EXPECTED_REFLOWS_SECOND_OPEN. + * Instead of adding reflows to these lists, you should be modifying your code + * to avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ + +/* These reflows happen only the first time the panel opens. */ +const EXPECTED_REFLOWS_FIRST_OPEN = []; + +/* These reflows happen every time the panel opens. */ +const EXPECTED_REFLOWS_SECOND_OPEN = []; + +add_task(async function quantumbar() { + await runUrlbarTest( + true, + EXPECTED_REFLOWS_FIRST_OPEN, + EXPECTED_REFLOWS_SECOND_OPEN + ); +}); diff --git a/browser/base/content/test/performance/browser_urlbar_search.js b/browser/base/content/test/performance/browser_urlbar_search.js new file mode 100644 index 0000000000..35961c641f --- /dev/null +++ b/browser/base/content/test/performance/browser_urlbar_search.js @@ -0,0 +1,27 @@ +"use strict"; + +// This tests searching in the urlbar (a.k.a. the quantumbar). + +/** + * WHOA THERE: We should never be adding new things to + * EXPECTED_REFLOWS_FIRST_OPEN or EXPECTED_REFLOWS_SECOND_OPEN. + * Instead of adding reflows to these lists, you should be modifying your code + * to avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ + +/* These reflows happen only the first time the panel opens. */ +const EXPECTED_REFLOWS_FIRST_OPEN = []; + +/* These reflows happen every time the panel opens. */ +const EXPECTED_REFLOWS_SECOND_OPEN = []; + +add_task(async function quantumbar() { + await runUrlbarTest( + false, + EXPECTED_REFLOWS_FIRST_OPEN, + EXPECTED_REFLOWS_SECOND_OPEN + ); +}); diff --git a/browser/base/content/test/performance/browser_vsync_accessibility.js b/browser/base/content/test/performance/browser_vsync_accessibility.js new file mode 100644 index 0000000000..64e3dc0b85 --- /dev/null +++ b/browser/base/content/test/performance/browser_vsync_accessibility.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await TestUtils.waitForCondition( + () => !ChromeUtils.vsyncEnabled(), + "wait for vsync to be disabled at the start of the test" + ); + Assert.ok(!ChromeUtils.vsyncEnabled(), "vsync should be disabled"); + Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + await TestUtils.waitForCondition( + () => !ChromeUtils.vsyncEnabled(), + "wait for vsync to be disabled after initializing the accessibility service" + ); + Assert.ok(!ChromeUtils.vsyncEnabled(), "vsync should still be disabled"); +}); diff --git a/browser/base/content/test/performance/browser_window_resize.js b/browser/base/content/test/performance/browser_window_resize.js new file mode 100644 index 0000000000..b529eec040 --- /dev/null +++ b/browser/base/content/test/performance/browser_window_resize.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +const gToolbar = document.getElementById("PersonalToolbar"); + +/** + * Sets the visibility state on the Bookmarks Toolbar, and + * waits for it to transition to fully visible. + * + * @param visible (bool) + * Whether or not the bookmarks toolbar should be made visible. + * @returns Promise + */ +async function toggleBookmarksToolbar(visible) { + let transitionPromise = BrowserTestUtils.waitForEvent( + gToolbar, + "transitionend", + e => e.propertyName == "max-height" + ); + + setToolbarVisibility(gToolbar, visible); + await transitionPromise; +} + +/** + * Resizes a browser window to a particular width and height, and + * waits for it to reach a "steady state" with respect to its overflowing + * toolbars. + * @param win (browser window) + * The window to resize. + * @param width (int) + * The width to resize the window to. + * @param height (int) + * The height to resize the window to. + * @returns Promise + */ +async function resizeWindow(win, width, height) { + let toolbarEvent = BrowserTestUtils.waitForEvent( + win, + "BookmarksToolbarVisibilityUpdated" + ); + let resizeEvent = BrowserTestUtils.waitForEvent(win, "resize"); + win.windowUtils.ensureDirtyRootFrame(); + win.resizeTo(width, height); + await resizeEvent; + await toolbarEvent; +} + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows when resizing windows. + */ +add_task(async function () { + const BOOKMARKS_COUNT = 150; + const STARTING_WIDTH = 600; + const STARTING_HEIGHT = 400; + const SMALL_WIDTH = 150; + const SMALL_HEIGHT = 150; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Add a bunch of bookmarks to display in the Bookmarks toolbar + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: Array(BOOKMARKS_COUNT) + .fill("") + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + .map((_, i) => ({ url: `http://test.places.${i}.x/` })), + }); + + let wasCollapsed = gToolbar.collapsed; + Assert.ok(wasCollapsed, "The toolbar is collapsed by default"); + if (wasCollapsed) { + let promiseReady = BrowserTestUtils.waitForEvent( + gToolbar, + "BookmarksToolbarVisibilityUpdated" + ); + await toggleBookmarksToolbar(true); + await promiseReady; + } + + registerCleanupFunction(async () => { + if (wasCollapsed) { + await toggleBookmarksToolbar(false); + } + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + let win = await prepareSettledWindow(); + + if ( + win.screen.availWidth < STARTING_WIDTH || + win.screen.availHeight < STARTING_HEIGHT + ) { + Assert.ok( + false, + "This test is running on too small a display - " + + `(${STARTING_WIDTH}x${STARTING_HEIGHT} min)` + ); + return; + } + + await resizeWindow(win, STARTING_WIDTH, STARTING_HEIGHT); + + await withPerfObserver( + async function () { + await resizeWindow(win, SMALL_WIDTH, SMALL_HEIGHT); + await resizeWindow(win, STARTING_WIDTH, STARTING_HEIGHT); + }, + { expectedReflows: EXPECTED_REFLOWS, frames: { filter: () => [] } }, + win + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/base/content/test/performance/browser_windowclose.js b/browser/base/content/test/performance/browser_windowclose.js new file mode 100644 index 0000000000..7d11779acc --- /dev/null +++ b/browser/base/content/test/performance/browser_windowclose.js @@ -0,0 +1,67 @@ +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/** + * This test ensures that there are no unexpected + * uninterruptible reflows when closing windows. When the + * window is closed, the test waits until the original window + * has activated. + */ +add_task(async function () { + // Ensure that this browser window starts focused. This seems to be + // necessary to avoid intermittent failures when running this test + // on repeat. + await new Promise(resolve => { + waitForFocus(resolve, window); + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await new Promise(resolve => { + waitForFocus(resolve, win); + }); + + // At the time of writing, there are no reflows on window closing. + // Mochitest will fail if we have no assertions, so we add one here + // to make sure nobody adds any new ones. + Assert.equal( + EXPECTED_REFLOWS.length, + 0, + "We shouldn't have added any new expected reflows for window close." + ); + + await withPerfObserver( + async function () { + let promiseOrigBrowserFocused = TestUtils.waitForCondition(() => { + return Services.focus.activeWindow == window; + }); + await BrowserTestUtils.closeWindow(win); + await promiseOrigBrowserFocused; + }, + { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter(rects, frame, previousFrame) { + // Ignore the focus-out animation. + if (isLikelyFocusChange(rects, frame)) { + return []; + } + return rects; + }, + }, + }, + win + ); +}); diff --git a/browser/base/content/test/performance/browser_windowopen.js b/browser/base/content/test/performance/browser_windowopen.js new file mode 100644 index 0000000000..02c6172948 --- /dev/null +++ b/browser/base/content/test/performance/browser_windowopen.js @@ -0,0 +1,164 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. + * Instead of adding reflows to the list, you should be modifying your code to + * avoid the reflow. + * + * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html + * for tips on how to do that. + */ +const EXPECTED_REFLOWS = [ + /** + * Nothing here! Please don't add anything new! + */ +]; + +/* + * This test ensures that there are no unexpected + * uninterruptible reflows or flickering areas when opening new windows. + */ +add_task(async function () { + // Flushing all caches helps to ensure that we get consistent + // behaviour when opening a new window, even if windows have been + // opened in previous tests. + Services.obs.notifyObservers(null, "startupcache-invalidate"); + Services.obs.notifyObservers(null, "chrome-flush-caches"); + + let bookmarksToolbarRect = await getBookmarksToolbarRect(); + + let win = window.openDialog( + AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,all,dialog=no,remote,suppressanimation", + "about:home" + ); + + await disableFxaBadge(); + + let alreadyFocused = false; + let inRange = (val, min, max) => min <= val && val <= max; + let expectations = { + expectedReflows: EXPECTED_REFLOWS, + frames: { + filter(rects, frame, previousFrame) { + // The first screenshot we get in OSX / Windows shows an unfocused browser + // window for some reason. See bug 1445161. + if (!alreadyFocused && isLikelyFocusChange(rects, frame)) { + todo( + false, + "bug 1445161 - the window should be focused at first paint, " + + rects.toSource() + ); + return []; + } + alreadyFocused = true; + return rects; + }, + exceptions: [ + { + name: "bug 1421463 - reload toolbar icon shouldn't flicker", + condition: r => + inRange(r.h, 13, 14) && + inRange(r.w, 14, 16) && // icon size + inRange(r.y1, 40, 80) && // in the toolbar + inRange(r.x1, 65, 100), // near the left side of the screen + }, + { + name: "bug 1555842 - the urlbar shouldn't flicker", + condition: r => { + let inputFieldRect = win.gURLBar.inputField.getBoundingClientRect(); + + return ( + (!AppConstants.DEBUG || + (AppConstants.platform == "linux" && AppConstants.ASAN)) && + r.x1 >= inputFieldRect.left && + r.x2 <= inputFieldRect.right && + r.y1 >= inputFieldRect.top && + r.y2 <= inputFieldRect.bottom + ); + }, + }, + { + name: "Initial bookmark icon appearing after startup", + condition: r => + r.w == 16 && + r.h == 16 && // icon size + inRange( + r.y1, + bookmarksToolbarRect.top, + bookmarksToolbarRect.top + bookmarksToolbarRect.height / 2 + ) && // in the toolbar + inRange(r.x1, 11, 13), // very close to the left of the screen + }, + { + // Note that the length and x values here are a bit weird because on + // some fonts, we appear to detect the two words separately. + name: "Initial bookmark text ('Getting Started' or 'Get Involved') appearing after startup", + condition: r => + inRange(r.w, 25, 120) && // length of text + inRange(r.h, 9, 15) && // height of text + inRange( + r.y1, + bookmarksToolbarRect.top, + bookmarksToolbarRect.top + bookmarksToolbarRect.height / 2 + ) && // in the toolbar + inRange(r.x1, 30, 90), // close to the left of the screen + }, + ], + }, + }; + + await withPerfObserver( + async function () { + // Avoid showing the remotecontrol UI. + await new Promise(resolve => { + win.addEventListener( + "DOMContentLoaded", + () => { + delete win.Marionette; + win.Marionette = { running: false }; + resolve(); + }, + { once: true } + ); + }); + + await TestUtils.topicObserved( + "browser-delayed-startup-finished", + subject => subject == win + ); + + let promises = [ + BrowserTestUtils.firstBrowserLoaded(win, false), + BrowserTestUtils.browserStopped( + win.gBrowser.selectedBrowser, + "about:home" + ), + ]; + + await Promise.all(promises); + + await new Promise(resolve => { + // 10 is an arbitrary value here, it needs to be at least 2 to avoid + // races with code initializing itself using idle callbacks. + (function waitForIdle(count = 10) { + if (!count) { + resolve(); + return; + } + Services.tm.idleDispatchToMainThread(() => { + waitForIdle(count - 1); + }); + })(); + }); + }, + expectations, + win + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/base/content/test/performance/file_empty.html b/browser/base/content/test/performance/file_empty.html new file mode 100644 index 0000000000..865879c583 --- /dev/null +++ b/browser/base/content/test/performance/file_empty.html @@ -0,0 +1 @@ +<!-- this file intentionally left blank --> diff --git a/browser/base/content/test/performance/head.js b/browser/base/content/test/performance/head.js new file mode 100644 index 0000000000..29722e6bbe --- /dev/null +++ b/browser/base/content/test/performance/head.js @@ -0,0 +1,1001 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + PerfTestHelpers: "resource://testing-common/PerfTestHelpers.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +/** + * 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([ + "nsIReflowObserver", + "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(); + + if (knownReflows.some(r => r.path.includes("*"))) { + Assert.ok( + false, + "Do not include async frames in the stack, as " + + "that feature is not available on all trees." + ); + } + + 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; + } + + // Functions from EventUtils.js calculate coordinates and + // dimensions, causing us to reflow. That's the test + // harness and we don't care about that, so we'll filter that out. + if ( + /^(synthesize|send|createDragEventObject).*?@chrome:\/\/mochikit.*?EventUtils\.js/.test( + path + ) + ) { + 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 list of reflows." + ); + } 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. + NewTabPagePreloading.removePreloadedBrowser(win); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + + AboutNewTab.newTabURL = "about:blank"; + + registerCleanupFunction(() => { + AboutNewTab.resetNewTabURL(); + }); +} + +// Onboarding puts a badge on the fxa toolbar button a while after startup +// which confuses tests that look at repaints in the toolbar. Use this +// function to cancel the badge update. +function disableFxaBadge() { + let { ToolbarBadgeHub } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs" + ); + ToolbarBadgeHub.removeAllNotifications(); + + // Also prevent a new timer from being set + return SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.toolbar.accessed", true]], + }); +} + +function rectInBoundingClientRect(r, bcr) { + return ( + bcr.x <= r.x1 && + bcr.y <= r.y1 && + bcr.x + bcr.width >= r.x2 && + bcr.y + bcr.height >= r.y2 + ); +} + +async function getBookmarksToolbarRect() { + // Temporarily open the bookmarks toolbar to measure its rect + let bookmarksToolbar = gNavToolbox.querySelector("#PersonalToolbar"); + let wasVisible = !bookmarksToolbar.collapsed; + if (!wasVisible) { + setToolbarVisibility(bookmarksToolbar, true, false, false); + await TestUtils.waitForCondition( + () => bookmarksToolbar.getBoundingClientRect().height > 0, + "wait for non-zero bookmarks toolbar height" + ); + } + let bookmarksToolbarRect = bookmarksToolbar.getBoundingClientRect(); + if (!wasVisible) { + setToolbarVisibility(bookmarksToolbar, false, false, false); + await TestUtils.waitForCondition( + () => bookmarksToolbar.getBoundingClientRect().height == 0, + "wait for zero bookmarks toolbar height" + ); + } + return bookmarksToolbarRect; +} + +async function ensureAnimationsFinished(win = window) { + let animations = win.document.getAnimations(); + info(`Waiting for ${animations.length} animations`); + await Promise.allSettled(animations.map(a => a.finished)); +} + +async function prepareSettledWindow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await ensureNoPreloadedBrowser(win); + await ensureAnimationsFinished(win); + return win; +} + +/** + * 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 = gBrowser.tabContainer.newTabButton; + 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 TestUtils.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 TestUtils.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({ + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + 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); + + let rectText = r => `${r.toSource()}, window width: ${frame.width}`; + + rects = rects.filter(rect => { + for (let e of expectations.exceptions || []) { + if (e.condition(rect)) { + todo(false, e.name + ", " + rectText(rect)); + return false; + } + } + return true; + }); + + if (expectations.filter) { + rects = expectations.filter(rects, frame, previousFrame); + } + + if (!rects.length) { + continue; + } + + ok( + false, + `unexpected ${rects.length} changed rects: ${rects + .map(rectText) + .join(", ")}` + ); + + // 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); +} + +/** + * This test ensures that there are no unexpected + * uninterruptible reflows when typing into the URL bar + * with the default values in Places. + * + * @param {bool} keyed + * Pass true to synthesize typing the search string one key at a time. + * @param {array} expectedReflowsFirstOpen + * The array of expected reflow stacks when the panel is first opened. + * @param {array} [expectedReflowsSecondOpen] + * The array of expected reflow stacks when the panel is subsequently + * opened, if you're testing opening the panel twice. + */ +async function runUrlbarTest( + keyed, + expectedReflowsFirstOpen, + expectedReflowsSecondOpen = null +) { + const SEARCH_TERM = keyed ? "" : "urlbar-reflows-" + Date.now(); + await addDummyHistoryEntries(SEARCH_TERM); + + let win = await prepareSettledWindow(); + + let URLBar = win.gURLBar; + + URLBar.focus(); + URLBar.value = SEARCH_TERM; + let testFn = async function () { + let popup = URLBar.view; + let oldOnQueryResults = popup.onQueryResults.bind(popup); + let oldOnQueryFinished = popup.onQueryFinished.bind(popup); + + // We need to invalidate the frame tree outside of the normal + // mechanism since invalidations and result additions to the + // URL bar occur without firing JS events (which is how we + // normally know to dirty the frame tree). + popup.onQueryResults = context => { + dirtyFrame(win); + oldOnQueryResults(context); + }; + + popup.onQueryFinished = context => { + dirtyFrame(win); + oldOnQueryFinished(context); + }; + + let waitExtra = async () => { + // There are several setTimeout(fn, 0); calls inside autocomplete.xml + // that we need to wait for. Since those have higher priority than + // idle callbacks, we can be sure they will have run once this + // idle callback is called. The timeout seems to be required in + // automation - presumably because the machines can be pretty busy + // especially if it's GC'ing from previous tests. + await new Promise(resolve => + win.requestIdleCallback(resolve, { timeout: 1000 }) + ); + }; + + if (keyed) { + // Only keying in 6 characters because the number of reflows triggered + // is so high that we risk timing out the test if we key in any more. + let searchTerm = "ows-10"; + for (let i = 0; i < searchTerm.length; ++i) { + let char = searchTerm[i]; + EventUtils.synthesizeKey(char, {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + await waitExtra(); + } + } else { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus: SimpleTest.waitForFocus, + value: URLBar.value, + }); + await waitExtra(); + } + + await UrlbarTestUtils.promisePopupClose(win); + }; + + let urlbarRect = URLBar.textbox.getBoundingClientRect(); + const SHADOW_SIZE = 17; + let expectedRects = { + filter: rects => { + // We put text into the urlbar so expect its textbox to change. + // We expect many changes in the results view. + // So we just allow changes anywhere in the urlbar. We don't check the + // bottom of the rect because the result view height varies depending on + // the results. + // We use floor/ceil because the Urlbar dimensions aren't always + // integers. + return rects.filter( + r => + !( + r.x1 >= Math.floor(urlbarRect.left) - SHADOW_SIZE && + r.x2 <= Math.ceil(urlbarRect.right) + SHADOW_SIZE && + r.y1 >= Math.floor(urlbarRect.top) - SHADOW_SIZE + ) + ); + }, + }; + + info("First opening"); + await withPerfObserver( + testFn, + { expectedReflows: expectedReflowsFirstOpen, frames: expectedRects }, + win + ); + + if (expectedReflowsSecondOpen) { + info("Second opening"); + await withPerfObserver( + testFn, + { expectedReflows: expectedReflowsSecondOpen, frames: expectedRects }, + win + ); + } + + await BrowserTestUtils.closeWindow(win); +} + +/** + * Helper method for checking which scripts are loaded on content process + * startup, used by `browser_startup_content.js` and + * `browser_startup_content_subframe.js`. + * + * Parameters to this function are passed in an object literal to avoid + * confusion about parameter order. + * + * @param loadedInfo (Object) + * Mapping from script type to a set of scripts which have been loaded + * of that type. + * + * @param known (Object) + * Mapping from script type to a set of scripts which must have been + * loaded of that type. + * + * @param intermittent (Object) + * Mapping from script type to a set of scripts which may have been + * loaded of that type. There must be a script type map for every type + * in `known`. + * + * @param forbidden (Object) + * Mapping from script type to a set of scripts which must not have been + * loaded of that type. + * + * @param dumpAllStacks (bool) + * If true, dump the stacks for all loaded modules. Makes the output + * noisy. + */ +async function checkLoadedScripts({ + loadedInfo, + known, + intermittent, + forbidden, + dumpAllStacks, +}) { + let loadedList = {}; + + async function checkAllExist(scriptType, list, listType) { + if (scriptType == "services") { + for (let contract of list) { + ok( + contract in Cc, + `${listType} entry ${contract} for content process startup must exist` + ); + } + } else { + let results = await PerfTestHelpers.throttledMapPromises( + list, + async uri => ({ + uri, + exists: await PerfTestHelpers.checkURIExists(uri), + }) + ); + + for (let { uri, exists } of results) { + ok( + exists, + `${listType} entry ${uri} for content process startup must exist` + ); + } + } + } + + for (let scriptType in known) { + loadedList[scriptType] = Object.keys(loadedInfo[scriptType]).filter(c => { + if (!known[scriptType].has(c)) { + return true; + } + known[scriptType].delete(c); + return false; + }); + + loadedList[scriptType] = loadedList[scriptType].filter(c => { + return !intermittent[scriptType].has(c); + }); + + if (loadedList[scriptType].length) { + console.log("Unexpected scripts:", loadedList[scriptType]); + } + is( + loadedList[scriptType].length, + 0, + `should have no unexpected ${scriptType} loaded on content process startup` + ); + + for (let script of loadedList[scriptType]) { + record( + false, + `Unexpected ${scriptType} loaded during content process startup: ${script}`, + undefined, + loadedInfo[scriptType][script] + ); + } + + await checkAllExist(scriptType, intermittent[scriptType], "intermittent"); + + is( + known[scriptType].size, + 0, + `all known ${scriptType} scripts should have been loaded` + ); + + for (let script of known[scriptType]) { + ok( + false, + `${scriptType} is expected to load for content process startup but wasn't: ${script}` + ); + } + + if (dumpAllStacks) { + info(`Stacks for all loaded ${scriptType}:`); + for (let file in loadedInfo[scriptType]) { + if (loadedInfo[scriptType][file]) { + info( + `${file}\n------------------------------------\n` + + loadedInfo[scriptType][file] + + "\n" + ); + } + } + } + } + + for (let scriptType in forbidden) { + for (let script of forbidden[scriptType]) { + let loaded = script in loadedInfo[scriptType]; + if (loaded) { + record( + false, + `Forbidden ${scriptType} loaded during content process startup: ${script}`, + undefined, + loadedInfo[scriptType][script] + ); + } + } + + await checkAllExist(scriptType, forbidden[scriptType], "forbidden"); + } +} + +// The first screenshot we get in OSX / Windows shows an unfocused browser +// window for some reason. See bug 1445161. This function allows to deal with +// that in a central place. +function isLikelyFocusChange(rects, frame) { + if (rects.length > 3 && rects.every(r => r.y2 < 100)) { + // There are at least 4 areas that changed near the top of the screen. + // Note that we need a bit more leeway than the titlebar height, because on + // OSX other toolbarbuttons in the navigation toolbar also get disabled + // state. + return true; + } + if ( + rects.every(r => r.y1 == 0 && r.x1 == 0 && r.w == frame.width && r.y2 < 100) + ) { + // Full-width rect in the top of the titlebar. + return true; + } + return false; +} diff --git a/browser/base/content/test/performance/hidpi/browser.toml b/browser/base/content/test/performance/hidpi/browser.toml new file mode 100644 index 0000000000..afcc961963 --- /dev/null +++ b/browser/base/content/test/performance/hidpi/browser.toml @@ -0,0 +1,8 @@ +[DEFAULT] +prefs = [ + "browser.startup.recordImages=true", + "layout.css.devPixelsPerPx='2'", +] + +["../browser_startup_images.js"] +skip-if = ["!debug"] diff --git a/browser/base/content/test/performance/io/browser.toml b/browser/base/content/test/performance/io/browser.toml new file mode 100644 index 0000000000..e581849028 --- /dev/null +++ b/browser/base/content/test/performance/io/browser.toml @@ -0,0 +1,38 @@ +[DEFAULT] +# Currently disabled on debug due to debug-only failures, see bug 1549723. +# Disabled on Linux asan due to bug 1549729. +# Disabled on Windows asan due to intermittent startup hangs, bug 1629824. +skip-if = [ + "debug", + "tsan", + "asan", +] +# to avoid overhead when running the browser normally, StartupRecorder.sys.mjs will +# do almost nothing unless browser.startup.record is true. +# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be +# set during early startup to have an impact as a canvas will be used by +# StartupRecorder.sys.mjs +prefs = [ + "browser.startup.record=true", + "gfx.canvas.willReadFrequently.enable=true", + "extensions.screenshots.disabled=false", # The Screenshots extension is disabled by default in Mochitests. We re-enable it here, since it's a more realistic configuration. +] +environment = [ + "GNOME_ACCESSIBILITY=0", + "MOZ_PROFILER_STARTUP=1", + "MOZ_PROFILER_STARTUP_PERFORMANCE_TEST=1", + "MOZ_PROFILER_STARTUP_FEATURES=js,mainthreadio", + "MOZ_PROFILER_STARTUP_ENTRIES=10000000", +] + +["../browser_startup_content_mainthreadio.js"] + +["../browser_startup_mainthreadio.js"] +skip-if = [ + "apple_silicon", # bug 1707724 + "socketprocess_networking", + "win11_2009 && bits == 32", + "os == 'win' && msix", # Bug 1833639 +] + +["../browser_startup_syncIPC.js"] diff --git a/browser/base/content/test/performance/lowdpi/browser.toml b/browser/base/content/test/performance/lowdpi/browser.toml new file mode 100644 index 0000000000..391bff58af --- /dev/null +++ b/browser/base/content/test/performance/lowdpi/browser.toml @@ -0,0 +1,8 @@ +[DEFAULT] +prefs = [ + "browser.startup.recordImages=true", + "layout.css.devPixelsPerPx='1'", +] + +["../browser_startup_images.js"] +skip-if = ["!debug"] diff --git a/browser/base/content/test/performance/moz.build b/browser/base/content/test/performance/moz.build new file mode 100644 index 0000000000..dce33b938d --- /dev/null +++ b/browser/base/content/test/performance/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +BROWSER_CHROME_MANIFESTS += [ + "browser.toml", + "hidpi/browser.toml", + "io/browser.toml", + "lowdpi/browser.toml", +] + +TESTING_JS_MODULES += [ + "PerfTestHelpers.sys.mjs", +] diff --git a/browser/base/content/test/performance/triage.json b/browser/base/content/test/performance/triage.json new file mode 100644 index 0000000000..e5a8051bd2 --- /dev/null +++ b/browser/base/content/test/performance/triage.json @@ -0,0 +1,70 @@ +{ + "triagers": { + "Gijs": { + "bzmail": "gijskruitbosch+bugs@gmail.com" + }, + "Mike Conley": { + "bzmail": "mconley@mozilla.com" + }, + "Florian Quèze": { + "bzmail": "florian@mozilla.com" + }, + "Alex Thayer": { + "bzmail": "dothayer@mozilla.com" + } + }, + "duty-start-dates": { + "2023-12-07": "Mike Conley", + "2023-12-14": "Florian Quèze", + "2023-12-21": "Alex Thayer", + "2023-12-28": "Gijs Kruitbosch", + "2024-01-04": "Mike Conley", + "2024-01-11": "Florian Quèze", + "2024-01-18": "Alex Thayer", + "2024-01-25": "Gijs Kruitbosch", + "2024-02-01": "Mike Conley", + "2024-02-08": "Florian Quèze", + "2024-02-15": "Alex Thayer", + "2024-02-22": "Gijs Kruitbosch", + "2024-02-29": "Mike Conley", + "2024-03-07": "Florian Quèze", + "2024-03-14": "Alex Thayer", + "2024-03-21": "Gijs Kruitbosch", + "2024-03-28": "Mike Conley", + "2024-04-04": "Florian Quèze", + "2024-04-11": "Alex Thayer", + "2024-04-18": "Gijs Kruitbosch", + "2024-04-25": "Mike Conley", + "2024-05-02": "Florian Quèze", + "2024-05-09": "Alex Thayer", + "2024-05-16": "Gijs Kruitbosch", + "2024-05-23": "Mike Conley", + "2024-05-30": "Florian Quèze", + "2024-06-06": "Alex Thayer", + "2024-06-13": "Gijs Kruitbosch", + "2024-06-20": "Mike Conley", + "2024-06-27": "Florian Quèze", + "2024-07-04": "Alex Thayer", + "2024-07-11": "Gijs Kruitbosch", + "2024-07-18": "Mike Conley", + "2024-07-25": "Florian Quèze", + "2024-08-01": "Alex Thayer", + "2024-08-08": "Gijs Kruitbosch", + "2024-08-15": "Mike Conley", + "2024-08-22": "Florian Quèze", + "2024-08-29": "Alex Thayer", + "2024-09-05": "Gijs Kruitbosch", + "2024-09-12": "Mike Conley", + "2024-09-19": "Florian Quèze", + "2024-09-26": "Alex Thayer", + "2024-10-03": "Gijs Kruitbosch", + "2024-10-10": "Mike Conley", + "2024-10-17": "Florian Quèze", + "2024-10-24": "Alex Thayer", + "2024-10-31": "Gijs Kruitbosch", + "2024-11-07": "Mike Conley", + "2024-11-14": "Florian Quèze", + "2024-11-21": "Gijs Kruitbosch", + "2024-11-28": "Alex Thayer" + } +} |