summaryrefslogtreecommitdiffstats
path: root/browser/modules/test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/modules/test
parentInitial commit. (diff)
downloadfirefox-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/modules/test')
-rw-r--r--browser/modules/test/browser/blank_iframe.html7
-rw-r--r--browser/modules/test/browser/browser.toml87
-rw-r--r--browser/modules/test/browser/browser_BrowserWindowTracker.js234
-rw-r--r--browser/modules/test/browser/browser_EveryWindow.js161
-rw-r--r--browser/modules/test/browser/browser_HomePage_add_button.js157
-rw-r--r--browser/modules/test/browser/browser_PageActions.js1402
-rw-r--r--browser/modules/test/browser/browser_PageActions_contextMenus.js226
-rw-r--r--browser/modules/test/browser/browser_PageActions_newWindow.js377
-rw-r--r--browser/modules/test/browser/browser_PermissionUI.js692
-rw-r--r--browser/modules/test/browser/browser_PermissionUI_prompts.js284
-rw-r--r--browser/modules/test/browser/browser_ProcessHangNotifications.js484
-rw-r--r--browser/modules/test/browser/browser_SitePermissions.js227
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_combinations.js144
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_expiry.js44
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_tab_urls.js128
-rw-r--r--browser/modules/test/browser/browser_TabUnloader.js381
-rw-r--r--browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js53
-rw-r--r--browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js134
-rw-r--r--browser/modules/test/browser/browser_UnsubmittedCrashHandler.js815
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry.js696
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js33
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_domains.js196
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_interaction.js955
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js164
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_toolbars.js550
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js87
-rw-r--r--browser/modules/test/browser/browser_preloading_tab_moving.js150
-rw-r--r--browser/modules/test/browser/browser_taskbar_preview.js129
-rw-r--r--browser/modules/test/browser/browser_urlBar_zoom.js107
-rw-r--r--browser/modules/test/browser/contain_iframe.html7
-rw-r--r--browser/modules/test/browser/file_webrtc.html11
-rw-r--r--browser/modules/test/browser/formValidation/browser.toml13
-rw-r--r--browser/modules/test/browser/formValidation/browser_form_validation.js522
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_iframe.js67
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_invisible.js67
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_navigation.js49
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_other_popups.js123
-rw-r--r--browser/modules/test/browser/head.js331
-rw-r--r--browser/modules/test/unit/test_E10SUtils_nested_URIs.js90
-rw-r--r--browser/modules/test/unit/test_HomePage.js85
-rw-r--r--browser/modules/test/unit/test_HomePage_ignore.js128
-rw-r--r--browser/modules/test/unit/test_InstallationTelemetry.js234
-rw-r--r--browser/modules/test/unit/test_LaterRun.js244
-rw-r--r--browser/modules/test/unit/test_ProfileCounter.js239
-rw-r--r--browser/modules/test/unit/test_Sanitizer_interrupted.js139
-rw-r--r--browser/modules/test/unit/test_SiteDataManager.js278
-rw-r--r--browser/modules/test/unit/test_SiteDataManagerContainers.js140
-rw-r--r--browser/modules/test/unit/test_SitePermissions.js403
-rw-r--r--browser/modules/test/unit/test_SitePermissions_temporary.js710
-rw-r--r--browser/modules/test/unit/test_TabUnloader.js449
-rw-r--r--browser/modules/test/unit/test_discovery.js143
-rw-r--r--browser/modules/test/unit/xpcshell.toml32
52 files changed, 13608 insertions, 0 deletions
diff --git a/browser/modules/test/browser/blank_iframe.html b/browser/modules/test/browser/blank_iframe.html
new file mode 100644
index 0000000000..88cd26088f
--- /dev/null
+++ b/browser/modules/test/browser/blank_iframe.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body><iframe></iframe></body>
+</html>
diff --git a/browser/modules/test/browser/browser.toml b/browser/modules/test/browser/browser.toml
new file mode 100644
index 0000000000..21b3cdf18c
--- /dev/null
+++ b/browser/modules/test/browser/browser.toml
@@ -0,0 +1,87 @@
+[DEFAULT]
+support-files = ["head.js"]
+prefs = ["telemetry.number_of_site_origin.min_interval=0"]
+
+["browser_BrowserWindowTracker.js"]
+
+["browser_EveryWindow.js"]
+
+["browser_HomePage_add_button.js"]
+
+["browser_PageActions.js"]
+
+["browser_PageActions_contextMenus.js"]
+
+["browser_PageActions_newWindow.js"]
+
+["browser_PermissionUI.js"]
+
+["browser_PermissionUI_prompts.js"]
+
+["browser_ProcessHangNotifications.js"]
+
+["browser_SitePermissions.js"]
+
+["browser_SitePermissions_combinations.js"]
+
+["browser_SitePermissions_expiry.js"]
+
+["browser_SitePermissions_tab_urls.js"]
+https_first_disabled = true
+
+["browser_TabUnloader.js"]
+support-files = [
+ "file_webrtc.html",
+ "../../../base/content/test/tabs/dummy_page.html",
+ "../../../base/content/test/tabs/file_mediaPlayback.html",
+ "../../../base/content/test/general/audio.ogg",
+]
+
+["browser_Telemetry_numberOfSiteOrigins.js"]
+support-files = ["contain_iframe.html"]
+
+["browser_Telemetry_numberOfSiteOriginsPerDocument.js"]
+support-files = [
+ "contain_iframe.html",
+ "blank_iframe.html",
+]
+
+["browser_UnsubmittedCrashHandler.js"]
+run-if = ["crashreporter"]
+
+["browser_UsageTelemetry.js"]
+https_first_disabled = true
+
+["browser_UsageTelemetry_content_aboutRestartRequired.js"]
+
+["browser_UsageTelemetry_domains.js"]
+https_first_disabled = true
+
+["browser_UsageTelemetry_interaction.js"]
+https_first_disabled = true
+
+["browser_UsageTelemetry_private_and_restore.js"]
+https_first_disabled = true
+skip-if = ["verify && debug"]
+
+["browser_UsageTelemetry_toolbars.js"]
+
+["browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js"]
+https_first_disabled = true
+
+["browser_preloading_tab_moving.js"]
+skip-if = ["os == 'linux' && tsan"] # Bug 1720203
+
+["browser_taskbar_preview.js"]
+skip-if = [
+ "os != 'win'", # bug 1456807
+ "os == 'win' && bits == 64", # bug 1456807
+]
+
+["browser_urlBar_zoom.js"]
+skip-if = [
+ "os == 'mac'", # Bug 1528429
+ "os == 'linux' && bits == 64 && os_version == '18.04'", # Bug 1619835
+ "win10_2009 && bits == 64", # Bug 1619835
+ "win11_2009 && bits == 32 && debug", # Bug 1619835
+]
diff --git a/browser/modules/test/browser/browser_BrowserWindowTracker.js b/browser/modules/test/browser/browser_BrowserWindowTracker.js
new file mode 100644
index 0000000000..ea6f75c0e3
--- /dev/null
+++ b/browser/modules/test/browser/browser_BrowserWindowTracker.js
@@ -0,0 +1,234 @@
+"use strict";
+
+const TEST_WINDOW = window;
+
+function windowActivated(win) {
+ if (Services.ww.activeWindow == win) {
+ return Promise.resolve();
+ }
+ return BrowserTestUtils.waitForEvent(win, "activate");
+}
+
+async function withOpenWindows(amount, cont) {
+ let windows = [];
+ for (let i = 0; i < amount; ++i) {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await windowActivated(win);
+ windows.push(win);
+ }
+ await cont(windows);
+ await Promise.all(
+ windows.map(window => BrowserTestUtils.closeWindow(window))
+ );
+}
+
+add_task(async function test_getTopWindow() {
+ await withOpenWindows(5, async function (windows) {
+ // Without options passed in.
+ let window = BrowserWindowTracker.getTopWindow();
+ let expectedMostRecentIndex = windows.length - 1;
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Last opened window should be the most recent one."
+ );
+
+ // Mess with the focused window things a bit.
+ for (let idx of [3, 1]) {
+ let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+ Services.focus.focusedWindow = windows[idx];
+ await promise;
+ window = BrowserWindowTracker.getTopWindow();
+ Assert.equal(
+ window,
+ windows[idx],
+ "Lastly focused window should be the most recent one."
+ );
+ // For this test it's useful to keep the array of created windows in order.
+ windows.splice(idx, 1);
+ windows.push(window);
+ }
+ // Update the pointer to the most recent opened window.
+ expectedMostRecentIndex = windows.length - 1;
+
+ // With 'private' option.
+ window = BrowserWindowTracker.getTopWindow({ private: true });
+ Assert.equal(window, null, "No private windows opened yet.");
+ window = BrowserWindowTracker.getTopWindow({ private: 1 });
+ Assert.equal(window, null, "No private windows opened yet.");
+ windows.push(
+ await BrowserTestUtils.openNewBrowserWindow({ private: true })
+ );
+ ++expectedMostRecentIndex;
+ window = BrowserWindowTracker.getTopWindow({ private: true });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Private window available."
+ );
+ window = BrowserWindowTracker.getTopWindow({ private: 1 });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Private window available."
+ );
+ // Private window checks seems to mysteriously fail on Linux in this test.
+ if (AppConstants.platform != "linux") {
+ window = BrowserWindowTracker.getTopWindow({ private: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex - 1],
+ "Private window available, but should not be returned."
+ );
+ }
+
+ // With 'allowPopups' option.
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the private window should be the most recent one."
+ );
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the private window should be the most recent one."
+ );
+ let popupWindowPromise = BrowserTestUtils.waitForNewWindow();
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ let features =
+ "location=no, personalbar=no, toolbar=no, scrollbars=no, menubar=no, status=no";
+ content.window.open("about:blank", "_blank", features);
+ });
+ let popupWindow = await popupWindowPromise;
+ await windowActivated(popupWindow);
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+ Assert.equal(
+ window,
+ popupWindow,
+ "The popup window should be the most recent one, when requested."
+ );
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the popup window should be the most recent one."
+ );
+ popupWindow.close();
+ });
+});
+
+add_task(async function test_orderedWindows() {
+ await withOpenWindows(10, async function (windows) {
+ Assert.equal(
+ BrowserWindowTracker.windowCount,
+ 11,
+ "Number of tracked windows, including the test window"
+ );
+ let ordered = BrowserWindowTracker.orderedWindows.filter(
+ w => w != TEST_WINDOW
+ );
+ Assert.deepEqual(
+ [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
+ ordered.map(w => windows.indexOf(w)),
+ "Order of opened windows should be as opened."
+ );
+
+ // Mess with the focused window things a bit.
+ for (let idx of [4, 6, 1]) {
+ let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+ Services.focus.focusedWindow = windows[idx];
+ await promise;
+ }
+
+ let ordered2 = BrowserWindowTracker.orderedWindows.filter(
+ w => w != TEST_WINDOW
+ );
+ // After the shuffle, we expect window '1' to be the top-most window, because
+ // it was the last one we called focus on. Then '6', the window we focused
+ // before-last, followed by '4'. The order of the other windows remains
+ // unchanged.
+ let expected = [1, 6, 4, 9, 8, 7, 5, 3, 2, 0];
+ Assert.deepEqual(
+ expected,
+ ordered2.map(w => windows.indexOf(w)),
+ "After shuffle of focused windows, the order should've changed."
+ );
+ });
+});
+
+add_task(async function test_pendingWindows() {
+ Assert.equal(
+ BrowserWindowTracker.windowCount,
+ 1,
+ "Number of tracked windows, including the test window"
+ );
+
+ let pending = BrowserWindowTracker.getPendingWindow();
+ Assert.equal(pending, null, "Should be no pending window");
+
+ let expectedWin = BrowserWindowTracker.openWindow();
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow({ private: true }),
+ "Should not be a pending private window"
+ );
+ Assert.equal(
+ pending,
+ BrowserWindowTracker.getPendingWindow({ private: false }),
+ "Should be the same non-private window pending"
+ );
+
+ let foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+
+ expectedWin = BrowserWindowTracker.openWindow({ private: true });
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow({ private: false }),
+ "Should not be a pending non-private window"
+ );
+ Assert.equal(
+ pending,
+ BrowserWindowTracker.getPendingWindow({ private: true }),
+ "Should be the same private window pending"
+ );
+
+ foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+
+ expectedWin = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,dialog=no,all",
+ null
+ );
+ BrowserWindowTracker.registerOpeningWindow(expectedWin, false);
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+
+ foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+});
diff --git a/browser/modules/test/browser/browser_EveryWindow.js b/browser/modules/test/browser/browser_EveryWindow.js
new file mode 100644
index 0000000000..de1a7bbf9a
--- /dev/null
+++ b/browser/modules/test/browser/browser_EveryWindow.js
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const { EveryWindow } = ChromeUtils.importESModule(
+ "resource:///modules/EveryWindow.sys.mjs"
+);
+
+async function windowInited(aId, aWin) {
+ // TestUtils.topicObserved returns [subject, data]. We return the
+ // subject, which in this case is the window.
+ return (
+ await TestUtils.topicObserved(`${aId}:init`, win => {
+ return aWin ? win == aWin : true;
+ })
+ )[0];
+}
+
+function windowUninited(aId, aWin, aClosing) {
+ return TestUtils.topicObserved(`${aId}:uninit`, (win, closing) => {
+ if (aWin && aWin != win) {
+ return false;
+ }
+ if (!aWin) {
+ return true;
+ }
+ if (!!aClosing != !!closing) {
+ return false;
+ }
+ return true;
+ });
+}
+
+function registerEWCallback(id) {
+ EveryWindow.registerCallback(
+ id,
+ win => {
+ Services.obs.notifyObservers(win, `${id}:init`);
+ },
+ (win, closing) => {
+ Services.obs.notifyObservers(win, `${id}:uninit`, closing);
+ }
+ );
+}
+
+function unregisterEWCallback(id, aCallUninit) {
+ EveryWindow.unregisterCallback(id, aCallUninit);
+}
+
+add_task(async function test_stuff() {
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ let win3 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let callbackId1 = "EveryWindow:test:1";
+ let callbackId2 = "EveryWindow:test:2";
+
+ let initPromise = Promise.all([
+ windowInited(callbackId1, window),
+ windowInited(callbackId1, win2),
+ windowInited(callbackId1, win3),
+ windowInited(callbackId2, window),
+ windowInited(callbackId2, win2),
+ windowInited(callbackId2, win3),
+ ]);
+
+ registerEWCallback(callbackId1);
+ registerEWCallback(callbackId2);
+
+ await initPromise;
+ ok(true, "Init called for all existing windows for all registered consumers");
+
+ let uninitPromise = Promise.all([
+ windowUninited(callbackId1, window, false),
+ windowUninited(callbackId1, win2, false),
+ windowUninited(callbackId1, win3, false),
+ windowUninited(callbackId2, window, false),
+ windowUninited(callbackId2, win2, false),
+ windowUninited(callbackId2, win3, false),
+ ]);
+
+ unregisterEWCallback(callbackId1);
+ unregisterEWCallback(callbackId2);
+ await uninitPromise;
+ ok(true, "Uninit called for all existing windows");
+
+ initPromise = Promise.all([
+ windowInited(callbackId1, window),
+ windowInited(callbackId1, win2),
+ windowInited(callbackId1, win3),
+ windowInited(callbackId2, window),
+ windowInited(callbackId2, win2),
+ windowInited(callbackId2, win3),
+ ]);
+
+ registerEWCallback(callbackId1);
+ registerEWCallback(callbackId2);
+
+ await initPromise;
+ ok(true, "Init called for all existing windows for all registered consumers");
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win2, true),
+ windowUninited(callbackId2, win2, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win2);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win2 for all registered consumers"
+ );
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win3, true),
+ windowUninited(callbackId2, win3, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win3);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win3 for all registered consumers"
+ );
+
+ initPromise = windowInited(callbackId1);
+ let initPromise2 = windowInited(callbackId2);
+ win2 = await BrowserTestUtils.openNewBrowserWindow();
+ is(await initPromise, win2, "Init called for new window for callback 1");
+ is(await initPromise2, win2, "Init called for new window for callback 2");
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win2, true),
+ windowUninited(callbackId2, win2, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win2);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win2 for all registered consumers"
+ );
+
+ uninitPromise = windowUninited(callbackId1, window, false);
+ unregisterEWCallback(callbackId1);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called for main window without closing flag for the unregistered consumer"
+ );
+
+ uninitPromise = windowUninited(callbackId2, window, false);
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ unregisterEWCallback(callbackId2, false);
+ let result = await Promise.race([uninitPromise, timeoutPromise]);
+ is(
+ result,
+ undefined,
+ "Uninit not called when unregistering a consumer with aCallUninit=false"
+ );
+});
diff --git a/browser/modules/test/browser/browser_HomePage_add_button.js b/browser/modules/test/browser/browser_HomePage_add_button.js
new file mode 100644
index 0000000000..c185b45a6b
--- /dev/null
+++ b/browser/modules/test/browser/browser_HomePage_add_button.js
@@ -0,0 +1,157 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+});
+
+const kPrefHomePage = "browser.startup.homepage";
+const kPrefExtensionControlled =
+ "browser.startup.homepage_override.extensionControlled";
+const kPrefHomeButtonRemoved = "browser.engagement.home-button.has-removed";
+const kHomeButtonId = "home-button";
+const kUrlbarWidgetId = "urlbar-container";
+
+// eslint-disable-next-line no-empty-pattern
+async function withTestSetup({} = {}, testFn) {
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [kPrefHomeButtonRemoved, false],
+ [kPrefHomePage, "about:home"],
+ [kPrefExtensionControlled, false],
+ ],
+ });
+
+ HomePage._addCustomizableUiListener();
+
+ try {
+ await testFn();
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ await CustomizableUI.reset();
+ }
+}
+
+function assertHomeButtonInArea(area) {
+ let placement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(placement.area, area, "home button in area");
+}
+
+function assertHomeButtonNotPlaced() {
+ ok(
+ !CustomizableUI.getPlacementOfWidget(kHomeButtonId),
+ "home button not placed"
+ );
+}
+
+function assertHasRemovedPref(val) {
+ is(
+ Services.prefs.getBoolPref(kPrefHomeButtonRemoved),
+ val,
+ "Expected removed pref value"
+ );
+}
+
+async function runAddButtonTest() {
+ await withTestSetup({}, async () => {
+ // Setting the homepage once should add to the toolbar.
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+
+ await HomePage.set("https://example.com/");
+
+ assertHomeButtonInArea("nav-bar");
+ assertHasRemovedPref(false);
+
+ // After removing the home button, a new homepage shouldn't add it.
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+
+ await HomePage.set("https://mozilla.org/");
+ assertHomeButtonNotPlaced();
+ });
+}
+
+add_task(async function testAddHomeButtonOnSet() {
+ await runAddButtonTest();
+});
+
+add_task(async function testHomeButtonDoesNotMove() {
+ await withTestSetup({}, async () => {
+ // Setting the homepage should not move the home button.
+ CustomizableUI.addWidgetToArea(kHomeButtonId, "TabsToolbar");
+ assertHasRemovedPref(false);
+ assertHomeButtonInArea("TabsToolbar");
+
+ await HomePage.set("https://example.com/");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonInArea("TabsToolbar");
+ });
+});
+
+add_task(async function testHomeButtonNotAddedBlank() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ assertHasRemovedPref(false);
+
+ await HomePage.set("about:blank");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+
+ await HomePage.set("about:home");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+ });
+});
+
+add_task(async function testHomeButtonNotAddedExtensionControlled() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ assertHasRemovedPref(false);
+ Services.prefs.setBoolPref(kPrefExtensionControlled, true);
+
+ await HomePage.set("https://search.example.com/?q=%s");
+
+ assertHomeButtonNotPlaced();
+ });
+});
+
+add_task(async function testHomeButtonPlacement() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ HomePage._maybeAddHomeButtonToToolbar("https://example.com");
+ let homePlacement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(homePlacement.area, "nav-bar", "Home button is in the nav-bar");
+ is(homePlacement.position, 3, "Home button is after stop/refresh");
+
+ let addressBarPlacement =
+ CustomizableUI.getPlacementOfWidget(kUrlbarWidgetId);
+ is(
+ addressBarPlacement.position,
+ 5,
+ "There's a space between home and urlbar"
+ );
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+ Services.prefs.setBoolPref(kPrefHomeButtonRemoved, false);
+
+ try {
+ CustomizableUI.addWidgetToArea(kUrlbarWidgetId, "nav-bar", 1);
+ HomePage._maybeAddHomeButtonToToolbar("https://example.com");
+ homePlacement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(homePlacement.area, "nav-bar", "Home button is in the nav-bar");
+ is(homePlacement.position, 1, "Home button is right before the urlbar");
+ } finally {
+ CustomizableUI.addWidgetToArea(
+ kUrlbarWidgetId,
+ addressBarPlacement.area,
+ addressBarPlacement.position
+ );
+ }
+ });
+});
diff --git a/browser/modules/test/browser/browser_PageActions.js b/browser/modules/test/browser/browser_PageActions.js
new file mode 100644
index 0000000000..4f86962a01
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions.js
@@ -0,0 +1,1402 @@
+"use strict";
+
+// This is a test for PageActions.sys.mjs, specifically the generalized parts that
+// add and remove page actions and toggle them in the urlbar. This does not
+// test the built-in page actions; browser_page_action_menu.js does that.
+
+// Initialization. Must run first.
+add_setup(async function () {
+ // The page action urlbar button, and therefore the panel, is only shown when
+ // the current tab is actionable -- i.e., a normal web page. about:blank is
+ // not, so open a new tab first thing, and close it when this test is done.
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "http://example.com/",
+ });
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ await initPageActionsTest();
+});
+
+// Tests a simple non-built-in action without an iframe or subview. Also
+// thoroughly checks most of the action's properties, methods, and DOM nodes, so
+// it's not necessary to do that in general in other test tasks.
+add_task(async function simple() {
+ let iconURL = "chrome://browser/skin/mail.svg";
+ let id = "test-simple";
+ let title = "Test simple";
+ let tooltip = "Test simple tooltip";
+
+ let onCommandCallCount = 0;
+ let onPlacedInPanelCallCount = 0;
+ let onPlacedInUrlbarCallCount = 0;
+ let onShowingInPanelCallCount = 0;
+ let onCommandExpectedButtonID;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ // Open the panel so that actions are added to it, and then close it.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ let initialActions = PageActions.actions;
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+ let initialActionsInUrlbar = PageActions.actionsInUrlbar(window);
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL,
+ id,
+ title,
+ tooltip,
+ onCommand(event, buttonNode) {
+ onCommandCallCount++;
+ Assert.ok(event, "event should be non-null: " + event);
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, onCommandExpectedButtonID, "buttonNode.id");
+ },
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ onShowingInPanel(buttonNode) {
+ onShowingInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ })
+ );
+
+ Assert.equal(action.getIconURL(), iconURL, "iconURL");
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.pinnedToUrlbar, true, "pinnedToUrlbar");
+ Assert.equal(action.getDisabled(), false, "disabled");
+ Assert.equal(action.getDisabled(window), false, "disabled in window");
+ Assert.equal(action.getTitle(), title, "title");
+ Assert.equal(action.getTitle(window), title, "title in window");
+ Assert.equal(action.getTooltip(), tooltip, "tooltip");
+ Assert.equal(action.getTooltip(window), tooltip, "tooltip in window");
+ Assert.equal(action.getWantsSubview(), false, "subview");
+ Assert.equal(action.getWantsSubview(window), false, "subview in window");
+ Assert.equal(action.urlbarIDOverride, null, "urlbarIDOverride");
+ Assert.equal(action.wantsIframe, false, "wantsIframe");
+
+ Assert.ok(!("__insertBeforeActionID" in action), "__insertBeforeActionID");
+ Assert.ok(!("__isSeparator" in action), "__isSeparator");
+ Assert.ok(!("__urlbarNodeInMarkup" in action), "__urlbarNodeInMarkup");
+ Assert.ok(!("__transient" in action), "__transient");
+
+ // The action shouldn't be placed in the panel until it opens for the first
+ // time.
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 0,
+ "onPlacedInPanelCallCount should remain 0"
+ );
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount after adding the action"
+ );
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 0,
+ "onShowingInPanelCallCount should remain 0"
+ );
+
+ // Open the panel so that actions are added to it, and then close it.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 1,
+ "onPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 1,
+ "onShowingInPanelCallCount should be inc'ed"
+ );
+
+ // Build an array of the expected actions in the panel and compare it to the
+ // actual actions. Don't assume that there are or aren't already other non-
+ // built-in actions.
+ let sepIndex = initialActionsInPanel.findIndex(
+ a => a.id == PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ );
+ let initialSepIndex = sepIndex;
+ let indexInPanel;
+ if (sepIndex < 0) {
+ // No prior non-built-in actions.
+ indexInPanel = initialActionsInPanel.length;
+ } else {
+ // Prior non-built-in actions. Find the index where the action goes.
+ for (
+ indexInPanel = sepIndex + 1;
+ indexInPanel < initialActionsInPanel.length;
+ indexInPanel++
+ ) {
+ let a = initialActionsInPanel[indexInPanel];
+ if (a.getTitle().localeCompare(action.getTitle()) < 1) {
+ break;
+ }
+ }
+ }
+ let expectedActionsInPanel = initialActionsInPanel.slice();
+ expectedActionsInPanel.splice(indexInPanel, 0, action);
+ // The separator between the built-ins and non-built-ins should be present
+ // if it's not already.
+ if (sepIndex < 0) {
+ expectedActionsInPanel.splice(
+ indexInPanel,
+ 0,
+ new PageActions.Action({
+ id: PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ _isSeparator: true,
+ })
+ );
+ sepIndex = indexInPanel;
+ indexInPanel++;
+ }
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window),
+ expectedActionsInPanel,
+ "Actions in panel after adding the action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window),
+ [action].concat(initialActionsInUrlbar),
+ "Actions in urlbar after adding the action"
+ );
+
+ // Check the set of all actions.
+ Assert.deepEqual(
+ new Set(PageActions.actions),
+ new Set(initialActions.concat([action])),
+ "All actions after adding the action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionForID(action.id),
+ action,
+ "actionForID should be action"
+ );
+
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "PageActions should record action in its list of seen actions"
+ );
+
+ // The action's panel button should have been created.
+ let panelButtonNode =
+ BrowserPageActions.mainViewBodyNode.children[indexInPanel];
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+ Assert.equal(panelButtonNode.id, panelButtonID, "panelButtonID");
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ action.getTitle(),
+ "label"
+ );
+
+ // The separator between the built-ins and non-built-ins should exist.
+ let sepNode = BrowserPageActions.mainViewBodyNode.children[sepIndex];
+ Assert.notEqual(sepNode, null, "sepNode");
+ Assert.equal(
+ sepNode.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ ),
+ "sepNode.id"
+ );
+
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(!!urlbarButtonNode, true, "urlbarButtonNode");
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 2,
+ "onShowingInPanelCallCount should be inc'ed"
+ );
+ onCommandExpectedButtonID = panelButtonID;
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.equal(onCommandCallCount, 1, "onCommandCallCount should be inc'ed");
+
+ // Show the action's button in the urlbar.
+ action.pinnedToUrlbar = true;
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount should be inc'ed"
+ );
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Disable the action. The button in the urlbar should be removed, and the
+ // button in the panel should be disabled.
+ action.setDisabled(true);
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbar button should be removed");
+ Assert.equal(
+ panelButtonNode.disabled,
+ true,
+ "panel button should be disabled"
+ );
+
+ // Enable the action. The button in the urlbar should be added back, and the
+ // button in the panel should be enabled.
+ action.setDisabled(false);
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbar button should be added back");
+ Assert.equal(
+ panelButtonNode.disabled,
+ false,
+ "panel button should not be disabled"
+ );
+
+ // Click the urlbar button.
+ onCommandExpectedButtonID = urlbarButtonID;
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
+
+ // Set a new title.
+ let newTitle = title + " new title";
+ action.setTitle(newTitle);
+ Assert.equal(action.getTitle(), newTitle, "New title");
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ action.getTitle(),
+ "New label"
+ );
+
+ // Now that pinnedToUrlbar has been toggled, make sure that it sticks across
+ // app restarts. Simulate that by "unregistering" the action (not by removing
+ // it, which is more permanent) and then registering it again.
+
+ // unregister
+ PageActions._actionsByID.delete(action.id);
+ let index = PageActions._nonBuiltInActions.findIndex(a => a.id == action.id);
+ Assert.ok(index >= 0, "Action should be in _nonBuiltInActions to begin with");
+ PageActions._nonBuiltInActions.splice(index, 1);
+
+ // register again
+ PageActions._registerAction(action);
+
+ // check relevant properties
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "PageActions should have 'seen' the action"
+ );
+ Assert.ok(
+ PageActions._persistedActions.idsInUrlbar.includes(action.id),
+ "idsInUrlbar should still include the action"
+ );
+ Assert.ok(action.pinnedToUrlbar, "pinnedToUrlbar should still be true");
+ Assert.ok(
+ action._pinnedToUrlbar,
+ "_pinnedToUrlbar should still be true, for good measure"
+ );
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+ let separatorNode = document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ );
+ if (initialSepIndex < 0) {
+ // The separator between the built-in actions and non-built-in actions
+ // should be gone now, too.
+ Assert.equal(separatorNode, null, "No separator");
+ Assert.ok(
+ !BrowserPageActions.mainViewBodyNode.lastElementChild.localName.includes(
+ "separator"
+ ),
+ "Last child should not be separator"
+ );
+ } else {
+ // The separator should still be present.
+ Assert.notEqual(separatorNode, null, "Separator should still exist");
+ }
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window),
+ initialActionsInPanel,
+ "Actions in panel should go back to initial"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window),
+ initialActionsInUrlbar,
+ "Actions in urlbar should go back to initial"
+ );
+ Assert.deepEqual(
+ PageActions.actions,
+ initialActions,
+ "Actions should go back to initial"
+ );
+ Assert.equal(
+ PageActions.actionForID(action.id),
+ null,
+ "actionForID should be null"
+ );
+
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "Action ID should remain in cache until purged"
+ );
+ PageActions._purgeUnregisteredPersistedActions();
+ Assert.ok(
+ !PageActions._persistedActions.ids.includes(action.id),
+ "Action ID should be removed from cache after being purged"
+ );
+});
+
+// Tests a non-built-in action with a subview.
+add_task(async function withSubview() {
+ let id = "test-subview";
+
+ let onActionPlacedInPanelCallCount = 0;
+ let onActionPlacedInUrlbarCallCount = 0;
+ let onSubviewPlacedCount = 0;
+ let onSubviewShowingCount = 0;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ let panelViewIDPanel = BrowserPageActions._panelViewNodeIDForActionID(
+ id,
+ false
+ );
+ let panelViewIDUrlbar = BrowserPageActions._panelViewNodeIDForActionID(
+ id,
+ true
+ );
+
+ let onSubviewPlacedExpectedPanelViewID = panelViewIDPanel;
+ let onSubviewShowingExpectedPanelViewID;
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id,
+ pinnedToUrlbar: true,
+ title: "Test subview",
+ wantsSubview: true,
+ onPlacedInPanel(buttonNode) {
+ onActionPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onActionPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ onSubviewPlaced(panelViewNode) {
+ onSubviewPlacedCount++;
+ Assert.ok(
+ panelViewNode,
+ "panelViewNode should be non-null: " + panelViewNode
+ );
+ Assert.equal(
+ panelViewNode.id,
+ onSubviewPlacedExpectedPanelViewID,
+ "panelViewNode.id"
+ );
+ },
+ onSubviewShowing(panelViewNode) {
+ onSubviewShowingCount++;
+ Assert.ok(
+ panelViewNode,
+ "panelViewNode should be non-null: " + panelViewNode
+ );
+ Assert.equal(
+ panelViewNode.id,
+ onSubviewShowingExpectedPanelViewID,
+ "panelViewNode.id"
+ );
+ },
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.getWantsSubview(), true, "subview");
+ Assert.equal(action.getWantsSubview(window), true, "subview in window");
+
+ // The action shouldn't be placed in the panel until it opens for the first
+ // time.
+ Assert.equal(
+ onActionPlacedInPanelCallCount,
+ 0,
+ "onActionPlacedInPanelCallCount should be 0"
+ );
+ Assert.equal(onSubviewPlacedCount, 0, "onSubviewPlacedCount should be 0");
+
+ // But it should be placed in the urlbar.
+ Assert.equal(
+ onActionPlacedInUrlbarCallCount,
+ 1,
+ "onActionPlacedInUrlbarCallCount should be 0"
+ );
+
+ // Open the panel, which should place the action in it.
+ await promiseOpenPageActionPanel();
+
+ Assert.equal(
+ onActionPlacedInPanelCallCount,
+ 1,
+ "onActionPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewPlacedCount,
+ 1,
+ "onSubviewPlacedCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewShowingCount,
+ 0,
+ "onSubviewShowingCount should remain 0"
+ );
+
+ // The action's panel button and view (in the main page action panel) should
+ // have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The action's urlbar button should have been created.
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Click the action's button in the panel. The subview should be shown.
+ Assert.equal(
+ onSubviewShowingCount,
+ 0,
+ "onSubviewShowingCount should remain 0"
+ );
+ let subviewShownPromise = promisePageActionViewShown();
+ onSubviewShowingExpectedPanelViewID = panelViewIDPanel;
+ panelButtonNode.click();
+ await subviewShownPromise;
+
+ // Click the main button to hide the main panel.
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ // Click the action's urlbar button, which should open the activated-action
+ // panel showing the subview.
+ onSubviewPlacedExpectedPanelViewID = panelViewIDUrlbar;
+ onSubviewShowingExpectedPanelViewID = panelViewIDUrlbar;
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(
+ onSubviewPlacedCount,
+ 2,
+ "onSubviewPlacedCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewShowingCount,
+ 2,
+ "onSubviewShowingCount should be inc'ed"
+ );
+
+ // Click the urlbar button again. The activated-action panel should close.
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+ let panelViewNodePanel = document.getElementById(panelViewIDPanel);
+ Assert.equal(panelViewNodePanel, null, "panelViewNodePanel");
+ let panelViewNodeUrlbar = document.getElementById(panelViewIDUrlbar);
+ Assert.equal(panelViewNodeUrlbar, null, "panelViewNodeUrlbar");
+});
+
+// Tests a non-built-in action with an iframe.
+add_task(async function withIframe() {
+ let id = "test-iframe";
+
+ let onCommandCallCount = 0;
+ let onPlacedInPanelCallCount = 0;
+ let onPlacedInUrlbarCallCount = 0;
+ let onIframeShowingCount = 0;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id,
+ pinnedToUrlbar: true,
+ title: "Test iframe",
+ wantsIframe: true,
+ onCommand(event, buttonNode) {
+ onCommandCallCount++;
+ },
+ onIframeShowing(iframeNode, panelNode) {
+ onIframeShowingCount++;
+ Assert.ok(iframeNode, "iframeNode should be non-null: " + iframeNode);
+ Assert.equal(iframeNode.localName, "iframe", "iframe localName");
+ Assert.ok(panelNode, "panelNode should be non-null: " + panelNode);
+ Assert.equal(
+ panelNode.id,
+ BrowserPageActions._activatedActionPanelID,
+ "panelNode.id"
+ );
+ },
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.wantsIframe, true, "wantsIframe");
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 1,
+ "onPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount should be inc'ed"
+ );
+ Assert.equal(onIframeShowingCount, 0, "onIframeShowingCount should remain 0");
+ Assert.equal(onCommandCallCount, 0, "onCommandCallCount should remain 0");
+
+ // The action's panel button should have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The action's urlbar button should have been created.
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ Assert.equal(onIframeShowingCount, 0, "onIframeShowingCount should remain 0");
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 1, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 1,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, anchored to the action's
+ // urlbar button.
+ let aaPanel = document.getElementById(
+ BrowserPageActions._activatedActionPanelID
+ );
+ Assert.notEqual(aaPanel, null, "activated-action panel");
+ Assert.equal(aaPanel.anchorNode.id, urlbarButtonID, "aaPanel.anchorNode.id");
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Click the action's urlbar button.
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 2,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, again anchored to the
+ // action's urlbar button.
+ aaPanel = document.getElementById(BrowserPageActions._activatedActionPanelID);
+ Assert.notEqual(aaPanel, null, "aaPanel");
+ Assert.equal(aaPanel.anchorNode.id, urlbarButtonID, "aaPanel.anchorNode.id");
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Hide the action's button in the urlbar.
+ action.pinnedToUrlbar = false;
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 3, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 3,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, this time anchored to the
+ // main page action button in the urlbar.
+ aaPanel = document.getElementById(BrowserPageActions._activatedActionPanelID);
+ Assert.notEqual(aaPanel, null, "aaPanel");
+ Assert.equal(
+ aaPanel.anchorNode.id,
+ BrowserPageActions.mainButtonNode.id,
+ "aaPanel.anchorNode.id"
+ );
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+});
+
+// Tests an action with the _insertBeforeActionID option set.
+add_task(async function insertBeforeActionID() {
+ let id = "test-insertBeforeActionID";
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+
+ let initialActions = PageActions.actionsInPanel(window);
+ let initialBuiltInActions = PageActions._builtInActions.slice();
+ let initialNonBuiltInActions = PageActions._nonBuiltInActions.slice();
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test insertBeforeActionID",
+ _insertBeforeActionID: PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.ok("__insertBeforeActionID" in action, "__insertBeforeActionID");
+ Assert.equal(
+ action.__insertBeforeActionID,
+ PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+ "action.__insertBeforeActionID"
+ );
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ let newActions = PageActions.actionsInPanel(window);
+ Assert.equal(
+ newActions.length,
+ initialActions.length + 1,
+ "PageActions.actions.length should be updated"
+ );
+ Assert.equal(
+ PageActions._builtInActions.length,
+ initialBuiltInActions.length + 1,
+ "PageActions._builtInActions.length should be updated"
+ );
+ Assert.equal(
+ PageActions._nonBuiltInActions.length,
+ initialNonBuiltInActions.length,
+ "PageActions._nonBuiltInActions.length should remain the same"
+ );
+
+ // The action's panel button should have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The separator between the built-in and non-built-in actions should not have
+ // been created.
+ Assert.equal(
+ document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ ),
+ null,
+ "Separator should be gone"
+ );
+
+ action.remove();
+});
+
+// Tests that the ordering in the panel of multiple non-built-in actions is
+// alphabetical.
+add_task(async function multipleNonBuiltInOrdering() {
+ let idPrefix = "test-multipleNonBuiltInOrdering-";
+ let titlePrefix = "Test multipleNonBuiltInOrdering ";
+
+ let initialActions = PageActions.actionsInPanel(window);
+ let initialBuiltInActions = PageActions._builtInActions.slice();
+ let initialNonBuiltInActions = PageActions._nonBuiltInActions.slice();
+
+ // Create some actions in an out-of-order order.
+ let actions = [2, 1, 4, 3].map(index => {
+ return PageActions.addAction(
+ new PageActions.Action({
+ id: idPrefix + index,
+ title: titlePrefix + index,
+ })
+ );
+ });
+
+ // + 1 for the separator between built-in and non-built-in actions.
+ Assert.equal(
+ PageActions.actionsInPanel(window).length,
+ initialActions.length + actions.length + 1,
+ "PageActions.actionsInPanel().length should be updated"
+ );
+
+ Assert.equal(
+ PageActions._builtInActions.length,
+ initialBuiltInActions.length,
+ "PageActions._builtInActions.length should be same"
+ );
+ Assert.equal(
+ PageActions._nonBuiltInActions.length,
+ initialNonBuiltInActions.length + actions.length,
+ "PageActions._nonBuiltInActions.length should be updated"
+ );
+
+ // Look at the final actions.length actions in PageActions.actions, from first
+ // to last.
+ for (let i = 0; i < actions.length; i++) {
+ let expectedIndex = i + 1;
+ let actualAction = PageActions._nonBuiltInActions[i];
+ Assert.equal(
+ actualAction.id,
+ idPrefix + expectedIndex,
+ "actualAction.id for index: " + i
+ );
+ }
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ // Check the button nodes in the panel.
+ let expectedIndex = 1;
+ let buttonNode = document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex)
+ );
+ Assert.notEqual(buttonNode, null, "buttonNode");
+ Assert.notEqual(
+ buttonNode.previousElementSibling,
+ null,
+ "buttonNode.previousElementSibling"
+ );
+ Assert.equal(
+ buttonNode.previousElementSibling.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ ),
+ "buttonNode.previousElementSibling.id"
+ );
+ for (let i = 0; i < actions.length; i++) {
+ Assert.notEqual(buttonNode, null, "buttonNode at index: " + i);
+ Assert.equal(
+ buttonNode.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex),
+ "buttonNode.id at index: " + i
+ );
+ buttonNode = buttonNode.nextElementSibling;
+ expectedIndex++;
+ }
+ Assert.equal(buttonNode, null, "Nothing should come after the last button");
+
+ for (let action of actions) {
+ action.remove();
+ }
+
+ // The separator between the built-in and non-built-in actions should be gone.
+ Assert.equal(
+ document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ ),
+ null,
+ "Separator should be gone"
+ );
+});
+
+// Makes sure the panel is correctly updated when a non-built-in action is
+// added before the built-in actions; and when all built-in actions are removed
+// and added back.
+add_task(async function nonBuiltFirst() {
+ let initialActions = PageActions.actions;
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+
+ // Remove all actions.
+ for (let action of initialActions) {
+ action.remove();
+ }
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ [],
+ "PageActions.actions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ [],
+ "PageActions._builtInActions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [],
+ "PageActions._nonBuiltInActions should be empty"
+ );
+
+ // Check the panel.
+ Assert.equal(
+ BrowserPageActions.mainViewBodyNode.children.length,
+ 0,
+ "All nodes should be gone"
+ );
+
+ // Add a non-built-in action.
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-nonBuiltFirst",
+ title: "Test nonBuiltFirst",
+ })
+ );
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ [action.id],
+ "Action should be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ [],
+ "PageActions._builtInActions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [action.id],
+ "Action should be in PageActions._nonBuiltInActions"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ [BrowserPageActions.panelButtonNodeIDForActionID(action.id)],
+ "Action should be in panel"
+ );
+
+ // Now add back all the actions.
+ for (let a of initialActions) {
+ PageActions.addAction(a);
+ }
+
+ // Check the actions.
+ Assert.deepEqual(
+ new Set(PageActions.actions.map(a => a.id)),
+ new Set(initialActions.map(a => a.id).concat([action.id])),
+ "All actions should be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ initialActions.filter(a => !a.__transient).map(a => a.id),
+ "PageActions._builtInActions should be initial actions"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [action.id],
+ "PageActions._nonBuiltInActions should contain action"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR], [action.id]),
+ "All actions should be in PageActions.actionsInPanel()"
+ );
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR], [action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Panel should contain all actions"
+ );
+
+ // Remove the test action.
+ action.remove();
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ initialActions.map(a => a.id),
+ "Action should no longer be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ initialActions.filter(a => !a.__transient).map(a => a.id),
+ "PageActions._builtInActions should be initial actions"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [],
+ "Action should no longer be in PageActions._nonBuiltInActions"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel.map(a => a.id),
+ "Action should no longer be in PageActions.actionsInPanel()"
+ );
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel.map(a =>
+ BrowserPageActions.panelButtonNodeIDForActionID(a.id)
+ ),
+ "Action should no longer be in panel"
+ );
+});
+
+// Adds an action, changes its placement in the urlbar to something non-default,
+// removes the action, and then adds it back. Since the action was removed and
+// re-added without restarting the app (or more accurately without calling
+// PageActions._purgeUnregisteredPersistedActions), the action should remain in
+// persisted state and retain its last placement in the urlbar.
+add_task(async function removeRetainState() {
+ // Get the list of actions initially in the urlbar.
+ let initialActionsInUrlbar = PageActions.actionsInUrlbar(window);
+ Assert.ok(
+ !!initialActionsInUrlbar.length,
+ "This test expects there to be at least one action in the urlbar initially (like the bookmark star)"
+ );
+
+ // Add a test action.
+ let id = "test-removeRetainState";
+ let testAction = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test removeRetainState",
+ })
+ );
+
+ // Show its button in the urlbar.
+ testAction.pinnedToUrlbar = true;
+
+ // "Move" the test action to the front of the urlbar by toggling
+ // pinnedToUrlbar for all the other actions in the urlbar.
+ for (let action of initialActionsInUrlbar) {
+ action.pinnedToUrlbar = false;
+ action.pinnedToUrlbar = true;
+ }
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ [testAction].concat(initialActionsInUrlbar).map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order: testAction followed by all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ [testAction]
+ .concat(initialActionsInUrlbar)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "urlbar nodes should be in expected order: testAction followed by all initial actions"
+ );
+
+ // Remove the test action.
+ testAction.remove();
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ initialActionsInUrlbar.map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order after removing test action: all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ initialActionsInUrlbar.map(a =>
+ BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)
+ ),
+ "urlbar nodes should be in expected order after removing test action: all initial actions"
+ );
+
+ // Add the test action again.
+ testAction = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test removeRetainState",
+ })
+ );
+
+ // Show its button in the urlbar again.
+ testAction.pinnedToUrlbar = true;
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ [testAction].concat(initialActionsInUrlbar).map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order after re-adding test action: testAction followed by all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ [testAction]
+ .concat(initialActionsInUrlbar)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "urlbar nodes should be in expected order after re-adding test action: testAction followed by all initial actions"
+ );
+
+ // Done, clean up.
+ testAction.remove();
+});
+
+// Tests transient actions.
+add_task(async function transient() {
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+
+ let onPlacedInPanelCount = 0;
+ let onBeforePlacedInWindowCount = 0;
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-transient",
+ title: "Test transient",
+ _transient: true,
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCount++;
+ },
+ onBeforePlacedInWindow(win) {
+ onBeforePlacedInWindowCount++;
+ },
+ })
+ );
+
+ Assert.equal(action.__transient, true, "__transient");
+
+ Assert.equal(onPlacedInPanelCount, 0, "onPlacedInPanelCount should remain 0");
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 1,
+ "onBeforePlacedInWindowCount after adding transient action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 1,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 1,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Disable the action. It should be removed from the panel.
+ action.setDisabled(true, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel.map(a => a.id),
+ "PageActions.actionsInPanel() should revert to initial"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel.map(a =>
+ BrowserPageActions.panelButtonNodeIDForActionID(a.id)
+ ),
+ "Actions in panel should be correct"
+ );
+
+ // Enable the action. It should be added back to the panel.
+ action.setDisabled(false, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 2,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 2,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Add another non-built in but non-transient action.
+ let otherAction = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-transient2",
+ title: "Test transient 2",
+ })
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 2,
+ "onPlacedInPanelCount should remain the same"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 2,
+ "onBeforePlacedInWindowCount should remain the same"
+ );
+
+ // Disable the action again. It should be removed from the panel.
+ action.setDisabled(true, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR, otherAction.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR, otherAction.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ // Enable the action again. It should be added back to the panel.
+ action.setDisabled(false, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 3,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 3,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Done, clean up.
+ action.remove();
+ otherAction.remove();
+});
diff --git a/browser/modules/test/browser/browser_PageActions_contextMenus.js b/browser/modules/test/browser/browser_PageActions_contextMenus.js
new file mode 100644
index 0000000000..378c55c706
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions_contextMenus.js
@@ -0,0 +1,226 @@
+"use strict";
+
+// This is a test for PageActions.sys.mjs, specifically the context menus.
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+// Initialization. Must run first.
+add_setup(async function () {
+ // The page action urlbar button, and therefore the panel, is only shown when
+ // the current tab is actionable -- i.e., a normal web page. about:blank is
+ // not, so open a new tab first thing, and close it when this test is done.
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "http://example.com/",
+ });
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ await initPageActionsTest();
+});
+
+// Opens the context menu on a non-built-in action. (The context menu for
+// built-in actions is tested in browser_page_action_menu.js.)
+add_task(async function contextMenu() {
+ // Add an extension with a page action so we can test its context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Page action test",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ // Open the main panel.
+ await promiseOpenPageActionPanel();
+ let panelButton = BrowserPageActions.panelButtonNodeForActionID(actionId);
+ let cxmenu = document.getElementById("pageActionContextMenu");
+
+ let contextMenuPromise;
+ let menuItems;
+
+ // Open the context menu again on the action's button in the panel. (The
+ // panel should still be open.)
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(panelButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Click the "manage extension" context menu item. about:addons should open.
+ let manageItemIndex = 0;
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ let aboutAddonsPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:addons"
+ );
+ cxmenu.activateItem(menuItems[manageItemIndex]);
+ let values = await Promise.all([aboutAddonsPromise, contextMenuPromise]);
+ let aboutAddonsTab = values[0];
+ BrowserTestUtils.removeTab(aboutAddonsTab);
+
+ // Wait for the urlbar button to become visible again after about:addons is
+ // closed and the test tab becomes selected.
+ await BrowserTestUtils.waitForCondition(() => {
+ return BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ }, "Waiting for urlbar button to be added back");
+
+ // Open the context menu on the action's urlbar button.
+ let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Click the "manage" context menu item. about:addons should open.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ aboutAddonsPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons");
+ cxmenu.activateItem(menuItems[manageItemIndex]);
+ values = await Promise.all([aboutAddonsPromise, contextMenuPromise]);
+ aboutAddonsTab = values[0];
+ BrowserTestUtils.removeTab(aboutAddonsTab);
+
+ // Wait for the urlbar button to become visible again after about:addons is
+ // closed and the test tab becomes selected.
+ await BrowserTestUtils.waitForCondition(() => {
+ return BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ }, "Waiting for urlbar button to be added back");
+
+ // Open the context menu on the action's urlbar button.
+ urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Below we'll click the "remove extension" context menu item, which first
+ // opens a prompt using the prompt service and requires confirming the prompt.
+ // Set up a mock prompt service that returns 0 to indicate that the user
+ // pressed the OK button.
+ let { prompt } = Services;
+ let promptService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx() {
+ return 0;
+ },
+ };
+ Services.prompt = promptService;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+
+ // Now click the "remove extension" context menu item.
+ let removeItemIndex = manageItemIndex + 1;
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ let promiseUninstalled = promiseAddonUninstalled(extension.id);
+ cxmenu.activateItem(menuItems[removeItemIndex]);
+ await contextMenuPromise;
+ await promiseUninstalled;
+ await extension.unload();
+ Services.prompt = prompt;
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+// The context menu shouldn't open on separators in the panel.
+add_task(async function contextMenuOnSeparator() {
+ // Add a non-built-in action so the built-in separator will appear in the
+ // panel.
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "contextMenuOnSeparator",
+ title: "contextMenuOnSeparator",
+ pinnedToUrlbar: true,
+ })
+ );
+
+ // Open the panel and get the built-in separator.
+ await promiseOpenPageActionPanel();
+ let separator = BrowserPageActions.panelButtonNodeForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ );
+ Assert.ok(separator, "The built-in separator should be in the panel");
+
+ // Context-click it. popupshowing should be fired, but by the time the event
+ // reaches this listener, preventDefault should have been called on it.
+ let showingPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("pageActionContextMenu"),
+ "popupshowing",
+ false
+ );
+ EventUtils.synthesizeMouseAtCenter(separator, {
+ type: "contextmenu",
+ button: 2,
+ });
+ let event = await showingPromise;
+ Assert.ok(
+ event.defaultPrevented,
+ "defaultPrevented should be true on popupshowing event"
+ );
+
+ // Click the main button to hide the main panel.
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ action.remove();
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+function collectContextMenuItems() {
+ let contextMenu = document.getElementById("pageActionContextMenu");
+ return Array.prototype.filter.call(contextMenu.children, node => {
+ return window.getComputedStyle(node).visibility == "visible";
+ });
+}
+
+function makeMenuItemSpecs(elements) {
+ return elements.map(e =>
+ e.localName == "menuseparator" ? {} : { label: e.label }
+ );
+}
+
+function makeContextMenuItemSpecs() {
+ let items = [
+ { label: "Manage Extension\u2026" },
+ { label: "Remove Extension" },
+ ];
+ return items;
+}
+
+function promiseAddonUninstalled(addonId) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
diff --git a/browser/modules/test/browser/browser_PageActions_newWindow.js b/browser/modules/test/browser/browser_PageActions_newWindow.js
new file mode 100644
index 0000000000..ade50c6b2c
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions_newWindow.js
@@ -0,0 +1,377 @@
+"use strict";
+
+// This is a test for PageActions.sys.mjs, specifically the generalized parts that
+// add and remove page actions and toggle them in the urlbar. This does not
+// test the built-in page actions; browser_page_action_menu.js does that.
+
+// Initialization. Must run first.
+add_setup(async function () {
+ await initPageActionsTest();
+});
+
+// Makes sure that urlbar nodes appear in the correct order in a new window.
+add_task(async function urlbarOrderNewWindow() {
+ // Make some new actions.
+ let actions = [0, 1, 2].map(i => {
+ return PageActions.addAction(
+ new PageActions.Action({
+ id: `test-urlbarOrderNewWindow-${i}`,
+ title: `Test urlbarOrderNewWindow ${i}`,
+ pinnedToUrlbar: true,
+ })
+ );
+ });
+
+ // Make sure PageActions knows they're inserted before the bookmark action in
+ // the urlbar.
+ Assert.deepEqual(
+ PageActions._persistedActions.idsInUrlbar.slice(
+ PageActions._persistedActions.idsInUrlbar.length - (actions.length + 1)
+ ),
+ actions.map(a => a.id).concat([PageActions.ACTION_ID_BOOKMARK]),
+ "PageActions._persistedActions.idsInUrlbar has new actions inserted"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window)
+ .slice(PageActions.actionsInUrlbar(window).length - (actions.length + 1))
+ .map(a => a.id),
+ actions.map(a => a.id).concat([PageActions.ACTION_ID_BOOKMARK]),
+ "PageActions.actionsInUrlbar has new actions inserted"
+ );
+
+ // Reach into _persistedActions to move the new actions to the front of the
+ // urlbar, same as if the user moved them. That way we can test that insert-
+ // before IDs are correctly non-null when the urlbar nodes are inserted in the
+ // new window below.
+ PageActions._persistedActions.idsInUrlbar.splice(
+ PageActions._persistedActions.idsInUrlbar.length - (actions.length + 1),
+ actions.length
+ );
+ for (let i = 0; i < actions.length; i++) {
+ PageActions._persistedActions.idsInUrlbar.splice(i, 0, actions[i].id);
+ }
+
+ // Save the right-ordered IDs to use below, just in case they somehow get
+ // changed when the new window opens, which shouldn't happen, but maybe
+ // there's bugs.
+ let ids = PageActions._persistedActions.idsInUrlbar.slice();
+
+ // Make sure that worked.
+ Assert.deepEqual(
+ ids.slice(0, actions.length),
+ actions.map(a => a.id),
+ "PageActions._persistedActions.idsInUrlbar now has new actions at front"
+ );
+
+ // _persistedActions will contain the IDs of test actions added and removed
+ // above (unless PageActions._purgeUnregisteredPersistedActions() was called
+ // for all of them, which it wasn't). Filter them out because they should
+ // not appear in the new window (or any window at this point).
+ ids = ids.filter(id => PageActions.actionForID(id));
+
+ // Open the new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ // Collect its urlbar nodes.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = win.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+
+ // Now check that they're in the right order.
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ ids.map(id => win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)),
+ "Expected actions in new window's urlbar"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(win);
+ for (let action of actions) {
+ action.remove();
+ }
+});
+
+// Stores version-0 (unversioned actually) persisted actions and makes sure that
+// migrating to version 1 works.
+add_task(async function migrate1() {
+ // Add a test action so we can test a non-built-in action below.
+ let actionId = "test-migrate1";
+ PageActions.addAction(
+ new PageActions.Action({
+ id: actionId,
+ title: "Test migrate1",
+ pinnedToUrlbar: true,
+ })
+ );
+
+ // Add the bookmark action first to make sure it ends up last after migration.
+ // Also include a non-default action to make sure we're not accidentally
+ // testing default behavior.
+ let ids = [PageActions.ACTION_ID_BOOKMARK, actionId];
+ let persisted = ids.reduce(
+ (memo, id) => {
+ memo.ids[id] = true;
+ memo.idsInUrlbar.push(id);
+ return memo;
+ },
+ { ids: {}, idsInUrlbar: [] }
+ );
+
+ Services.prefs.setStringPref(
+ PageActions.PREF_PERSISTED_ACTIONS,
+ JSON.stringify(persisted)
+ );
+
+ // Migrate.
+ PageActions._loadPersistedActions();
+
+ Assert.equal(PageActions._persistedActions.version, 1, "Correct version");
+
+ // expected order
+ let orderedIDs = [actionId, PageActions.ACTION_ID_BOOKMARK];
+
+ // Check the ordering.
+ Assert.deepEqual(
+ PageActions._persistedActions.idsInUrlbar,
+ orderedIDs,
+ "PageActions._persistedActions.idsInUrlbar has right order"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ orderedIDs,
+ "PageActions.actionsInUrlbar has right order"
+ );
+
+ // Open a new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: "http://example.com/",
+ });
+
+ // Collect its urlbar nodes.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = win.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+
+ // Now check that they're in the right order.
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ orderedIDs.map(id =>
+ win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)
+ ),
+ "Expected actions in new window's urlbar"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(win);
+ Services.prefs.clearUserPref(PageActions.PREF_PERSISTED_ACTIONS);
+ PageActions.actionForID(actionId).remove();
+});
+
+// Opens a new browser window and makes sure per-window state works right.
+add_task(async function perWindowState() {
+ // Add a test action.
+ let title = "Test perWindowState";
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id: "test-perWindowState",
+ pinnedToUrlbar: true,
+ title,
+ })
+ );
+
+ let actionsInUrlbar = PageActions.actionsInUrlbar(window);
+
+ // Open a new browser window and load an actionable page so that the action
+ // shows up in it.
+ let newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: newWindow.gBrowser,
+ url: "http://example.com/",
+ });
+
+ // Set a new title globally.
+ let newGlobalTitle = title + " new title";
+ action.setTitle(newGlobalTitle);
+ Assert.equal(action.getTitle(), newGlobalTitle, "Title: global");
+ Assert.equal(action.getTitle(window), newGlobalTitle, "Title: old window");
+ Assert.equal(action.getTitle(newWindow), newGlobalTitle, "Title: new window");
+
+ // Initialize panel nodes in the windows
+ document.getElementById("pageActionButton").click();
+ await BrowserTestUtils.waitForEvent(document, "popupshowing", true);
+ newWindow.document.getElementById("pageActionButton").click();
+ await BrowserTestUtils.waitForEvent(newWindow.document, "popupshowing", true);
+
+ // The action's panel button nodes should be updated in both windows.
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(
+ action.id
+ );
+ for (let win of [window, newWindow]) {
+ win.BrowserPageActions.placeLazyActionsInPanel();
+ let panelButtonNode = win.document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ newGlobalTitle,
+ "Panel button label should be global title"
+ );
+ }
+
+ // Set a new title in the new window.
+ let newPerWinTitle = title + " new title in new window";
+ action.setTitle(newPerWinTitle, newWindow);
+ Assert.equal(
+ action.getTitle(),
+ newGlobalTitle,
+ "Title: global should remain same"
+ );
+ Assert.equal(
+ action.getTitle(window),
+ newGlobalTitle,
+ "Title: old window should remain same"
+ );
+ Assert.equal(
+ action.getTitle(newWindow),
+ newPerWinTitle,
+ "Title: new window should be new"
+ );
+
+ // The action's panel button node should be updated in the new window but the
+ // same in the old window.
+ let panelButtonNode1 = document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode1.getAttribute("label"),
+ newGlobalTitle,
+ "Panel button label in old window"
+ );
+ let panelButtonNode2 = newWindow.document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode2.getAttribute("label"),
+ newPerWinTitle,
+ "Panel button label in new window"
+ );
+
+ // Disable the action in the new window.
+ action.setDisabled(true, newWindow);
+ Assert.equal(
+ action.getDisabled(),
+ false,
+ "Disabled: global should remain false"
+ );
+ Assert.equal(
+ action.getDisabled(window),
+ false,
+ "Disabled: old window should remain false"
+ );
+ Assert.equal(
+ action.getDisabled(newWindow),
+ true,
+ "Disabled: new window should be true"
+ );
+
+ // Check PageActions.actionsInUrlbar for each window.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ actionsInUrlbar.map(a => a.id),
+ "PageActions.actionsInUrlbar: old window should have all actions in urlbar"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(newWindow).map(a => a.id),
+ actionsInUrlbar.map(a => a.id).filter(id => id != action.id),
+ "PageActions.actionsInUrlbar: new window should have all actions in urlbar except the test action"
+ );
+
+ // Check the urlbar nodes for the old window.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ actionsInUrlbar.map(a =>
+ BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)
+ ),
+ "Old window should have all nodes in urlbar"
+ );
+
+ // Check the urlbar nodes for the new window.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = newWindow.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ actionsInUrlbar
+ .filter(a => a.id != action.id)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "New window should have all nodes in urlbar except for the test action's"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(newWindow);
+ action.remove();
+});
+
+add_task(async function action_disablePrivateBrowsing() {
+ let id = "testWidget";
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ disablePrivateBrowsing: true,
+ title: "title",
+ disabled: false,
+ pinnedToUrlbar: true,
+ })
+ );
+ // Open an actionable page so that the main page action button appears.
+ let url = "http://example.com/";
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await BrowserTestUtils.openNewForegroundTab(
+ privateWindow.gBrowser,
+ url,
+ true,
+ true
+ );
+
+ Assert.ok(action.canShowInWindow(window), "should show in default window");
+ Assert.ok(
+ !action.canShowInWindow(privateWindow),
+ "should not show in private browser"
+ );
+ Assert.ok(action.shouldShowInUrlbar(window), "should show in default urlbar");
+ Assert.ok(
+ !action.shouldShowInUrlbar(privateWindow),
+ "should not show in default urlbar"
+ );
+ Assert.ok(action.shouldShowInPanel(window), "should show in default urlbar");
+ Assert.ok(
+ !action.shouldShowInPanel(privateWindow),
+ "should not show in default urlbar"
+ );
+
+ action.remove();
+
+ privateWindow.close();
+});
diff --git a/browser/modules/test/browser/browser_PermissionUI.js b/browser/modules/test/browser/browser_PermissionUI.js
new file mode 100644
index 0000000000..8b66734093
--- /dev/null
+++ b/browser/modules/test/browser/browser_PermissionUI.js
@@ -0,0 +1,692 @@
+/**
+ * These tests test the ability for the PermissionUI module to open
+ * permission prompts to the user. It also tests to ensure that
+ * add-ons can introduce their own permission prompts.
+ */
+
+"use strict";
+
+const { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+/**
+ * Tests the PermissionPromptForRequest prototype to ensure that a prompt
+ * can be displayed. Does not test permission handling.
+ */
+add_task(async function test_permission_prompt_for_request() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com/",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(mockRequest.principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that if the PermissionPrompt sets displayURI to false in popupOptions,
+ * then there is no URI shown on the popupnotification.
+ */
+add_task(async function test_permission_prompt_for_popupOptions() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com/",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ get popupOptions() {
+ return {
+ displayURI: false,
+ };
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ Assert.ok(
+ !notification.options.displayURI,
+ "Should not show the URI of the requesting page"
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that if the PermissionPrompt has the permissionKey
+ * set that permissions can be set properly by the user. Also
+ * ensures that callbacks for promptActions are properly fired.
+ */
+add_task(async function test_with_permission_key() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ const kTestPermissionKey = "test-permission-key";
+
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ action: SitePermissions.ALLOW,
+ callback() {
+ allowed = true;
+ },
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ action: SitePermissions.BLOCK,
+ callback() {
+ denied = true;
+ },
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let principal = mockRequest.principal;
+ registerCleanupFunction(function () {
+ PermissionTestUtils.remove(principal.URI, kTestPermissionKey);
+ });
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get permissionKey() {
+ return kTestPermissionKey;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ get popupOptions() {
+ return {
+ checkbox: {
+ label: "Remember this decision",
+ show: true,
+ checked: true,
+ },
+ };
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ let curPerm = SitePermissions.getForPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.UNKNOWN,
+ "Should be no permission set to begin with."
+ );
+
+ // First test denying the permission request without the checkbox checked.
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = false;
+
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ Assert.deepEqual(
+ curPerm,
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Should have denied the action temporarily"
+ );
+ // Try getting the permission without passing the browser object (should fail).
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm,
+ null,
+ "Should have made no permanent permission entry"
+ );
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+
+ // Clear the permission and pretend we never denied
+ SitePermissions.removeFromPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ denied = false;
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Test denying the permission request.
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm.capability,
+ Services.perms.DENY_ACTION,
+ "Should have denied the action"
+ );
+ Assert.equal(curPerm.expireTime, 0, "Deny should be permanent");
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+
+ // Clear the permission and pretend we never denied
+ PermissionTestUtils.remove(principal.URI, kTestPermissionKey);
+ denied = false;
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Test allowing the permission request.
+ await clickMainAction();
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm.capability,
+ Services.perms.ALLOW_ACTION,
+ "Should have allowed the action"
+ );
+ Assert.equal(curPerm.expireTime, 0, "Allow should be permanent");
+ Assert.ok(!denied, "The secondaryAction callback should not have fired");
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ Assert.ok(
+ !mockRequest._cancelled,
+ "The request should not have been cancelled"
+ );
+ Assert.ok(mockRequest._allowed, "The request should have been allowed");
+ }
+ );
+});
+
+/**
+ * Tests that the onBeforeShow method will be called before
+ * the popup appears.
+ */
+add_task(async function test_on_before_show() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+
+ let mainAction = {
+ label: "Test action",
+ accessKey: "T",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let beforeShown = false;
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction];
+ }
+ get popupOptions() {
+ return {
+ checkbox: {
+ label: "Remember this decision",
+ show: true,
+ checked: true,
+ },
+ };
+ }
+ onBeforeShow() {
+ beforeShown = true;
+ return true;
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that we can open a PermissionPrompt without wrapping a
+ * nsIContentPermissionRequest.
+ */
+add_task(async function test_no_request() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ callback() {
+ allowed = true;
+ },
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ callback() {
+ denied = true;
+ },
+ };
+
+ const kTestMessage = "Test message with no request";
+ let principal = browser.contentPrincipal;
+ let beforeShown = false;
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get principal() {
+ return principal;
+ }
+ get browser() {
+ return browser;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ onBeforeShow() {
+ beforeShown = true;
+ return true;
+ }
+ }
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ // First test denying the permission request.
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Next test allowing the permission request.
+ await clickMainAction();
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ }
+ );
+});
+
+/**
+ * Tests that when the tab is moved to a different window, the notification
+ * is transferred to the new window.
+ */
+add_task(async function test_window_swap() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+
+ let mainAction = {
+ label: "Test action",
+ accessKey: "T",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ let newWindowOpened = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ let newWindow = await newWindowOpened;
+ // We may have already opened the panel, because it was open before we moved the tab.
+ if (newWindow.PopupNotifications.panel.state != "open") {
+ shownPromise = BrowserTestUtils.waitForEvent(
+ newWindow.PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ }
+
+ let notification = newWindow.PopupNotifications.getNotification(
+ kTestNotificationID,
+ newWindow.gBrowser.selectedBrowser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(mockRequest.principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ await BrowserTestUtils.closeWindow(newWindow);
+ }
+ );
+});
diff --git a/browser/modules/test/browser/browser_PermissionUI_prompts.js b/browser/modules/test/browser/browser_PermissionUI_prompts.js
new file mode 100644
index 0000000000..777e5a4a86
--- /dev/null
+++ b/browser/modules/test/browser/browser_PermissionUI_prompts.js
@@ -0,0 +1,284 @@
+/**
+ * These tests test the ability for the PermissionUI module to open
+ * permission prompts to the user. It also tests to ensure that
+ * add-ons can introduce their own permission prompts.
+ */
+
+"use strict";
+
+const { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+);
+const { SITEPERMS_ADDON_PROVIDER_PREF } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+);
+
+// Tests that GeolocationPermissionPrompt works as expected
+add_task(async function test_geo_permission_prompt() {
+ await testPrompt(PermissionUI.GeolocationPermissionPrompt);
+});
+
+// Tests that GeolocationPermissionPrompt works as expected with local files
+add_task(async function test_geo_permission_prompt_local_file() {
+ await testPrompt(PermissionUI.GeolocationPermissionPrompt, true);
+});
+
+// Tests that XRPermissionPrompt works as expected
+add_task(async function test_xr_permission_prompt() {
+ await testPrompt(PermissionUI.XRPermissionPrompt);
+});
+
+// Tests that XRPermissionPrompt works as expected with local files
+add_task(async function test_xr_permission_prompt_local_file() {
+ await testPrompt(PermissionUI.XRPermissionPrompt, true);
+});
+
+// Tests that DesktopNotificationPermissionPrompt works as expected
+add_task(async function test_desktop_notification_permission_prompt() {
+ Services.prefs.setBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "permissions.desktop-notification.notNow.enabled",
+ true
+ );
+ await testPrompt(PermissionUI.DesktopNotificationPermissionPrompt);
+ Services.prefs.clearUserPref("dom.webnotifications.requireuserinteraction");
+ Services.prefs.clearUserPref(
+ "permissions.desktop-notification.notNow.enabled"
+ );
+});
+
+// Tests that PersistentStoragePermissionPrompt works as expected
+add_task(async function test_persistent_storage_permission_prompt() {
+ await testPrompt(PermissionUI.PersistentStoragePermissionPrompt);
+});
+
+// Tests that MidiPrompt works as expected
+add_task(async function test_midi_permission_prompt() {
+ if (Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, false)) {
+ ok(
+ true,
+ "PermissionUI.MIDIPermissionPrompt uses SitePermsAddon install flow"
+ );
+ return;
+ }
+ await testPrompt(PermissionUI.MIDIPermissionPrompt);
+});
+
+// Tests that MidiPrompt works as expected with local files
+add_task(async function test_midi_permission_prompt_local_file() {
+ if (Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, false)) {
+ ok(
+ true,
+ "PermissionUI.MIDIPermissionPrompt uses SitePermsAddon install flow"
+ );
+ return;
+ }
+ await testPrompt(PermissionUI.MIDIPermissionPrompt, true);
+});
+
+// Tests that StoragePermissionPrompt works as expected
+add_task(async function test_storage_access_permission_prompt() {
+ Services.prefs.setBoolPref("dom.storage_access.auto_grants", false);
+ await testPrompt(PermissionUI.StorageAccessPermissionPrompt);
+ Services.prefs.clearUserPref("dom.storage_access.auto_grants");
+});
+
+async function testPrompt(Prompt, useLocalFile = false) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: useLocalFile ? `file://${PathUtils.tempDir}` : "http://example.com",
+ },
+ async function (browser) {
+ let mockRequest = makeMockPermissionRequest(browser);
+ let principal = mockRequest.principal;
+ let TestPrompt = new Prompt(mockRequest);
+ let { usePermissionManager, permissionKey } = TestPrompt;
+
+ registerCleanupFunction(function () {
+ if (permissionKey) {
+ SitePermissions.removeFromPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ }
+ });
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ TestPrompt.notificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ let curPerm;
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.UNKNOWN,
+ "Should be no permission set to begin with."
+ );
+ }
+
+ // First test denying the permission request without the checkbox checked.
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = false;
+
+ let isNotificationPrompt =
+ Prompt == PermissionUI.DesktopNotificationPermissionPrompt;
+
+ let expectedSecondaryActionsCount = isNotificationPrompt ? 2 : 1;
+ Assert.equal(
+ notification.secondaryActions.length,
+ expectedSecondaryActionsCount,
+ "There should only be " +
+ expectedSecondaryActionsCount +
+ " secondary action(s)"
+ );
+ await clickSecondaryAction();
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.deepEqual(
+ curPerm,
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Should have denied the action temporarily"
+ );
+
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+ }
+
+ SitePermissions.removeFromPrincipal(
+ principal,
+ TestPrompt.permissionKey,
+ browser
+ );
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+
+ // Test denying the permission request with the checkbox checked (for geolocation)
+ // or by clicking the "never" option from the dropdown (for notifications and persistent-storage).
+ popupNotification = getPopupNotificationNode();
+ let secondaryActionToClickIndex = 0;
+ if (isNotificationPrompt) {
+ secondaryActionToClickIndex = 1;
+ } else {
+ popupNotification.checkbox.checked = true;
+ }
+
+ Assert.equal(
+ notification.secondaryActions.length,
+ expectedSecondaryActionsCount,
+ "There should only be " +
+ expectedSecondaryActionsCount +
+ " secondary action(s)"
+ );
+ await clickSecondaryAction(secondaryActionToClickIndex);
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.BLOCK,
+ "Should have denied the action"
+ );
+
+ let expectedScope = usePermissionManager
+ ? SitePermissions.SCOPE_PERSISTENT
+ : SitePermissions.SCOPE_TEMPORARY;
+ Assert.equal(
+ curPerm.scope,
+ expectedScope,
+ `Deny should be ${usePermissionManager ? "persistent" : "temporary"}`
+ );
+
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+ }
+
+ SitePermissions.removeFromPrincipal(principal, permissionKey, browser);
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+
+ // Test allowing the permission request with the checkbox checked.
+ popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = true;
+
+ await clickMainAction();
+ // If the prompt does not use the permission manager, it can not set a
+ // persistent allow. Temporary allow is not supported.
+ if (usePermissionManager && permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.ALLOW,
+ "Should have allowed the action"
+ );
+ Assert.equal(
+ curPerm.scope,
+ SitePermissions.SCOPE_PERSISTENT,
+ "Allow should be permanent"
+ );
+ Assert.ok(
+ !mockRequest._cancelled,
+ "The request should not have been cancelled"
+ );
+ Assert.ok(mockRequest._allowed, "The request should have been allowed");
+ }
+ }
+ );
+}
diff --git a/browser/modules/test/browser/browser_ProcessHangNotifications.js b/browser/modules/test/browser/browser_ProcessHangNotifications.js
new file mode 100644
index 0000000000..d176f911ef
--- /dev/null
+++ b/browser/modules/test/browser/browser_ProcessHangNotifications.js
@@ -0,0 +1,484 @@
+/* globals ProcessHangMonitor */
+
+const { WebExtensionPolicy } = Cu.getGlobalForObject(Services);
+
+function promiseNotificationShown(aWindow, aName) {
+ return new Promise(resolve => {
+ let notificationBox = aWindow.gNotificationBox;
+ notificationBox.stack.addEventListener(
+ "AlertActive",
+ function () {
+ is(
+ notificationBox.allNotifications.length,
+ 1,
+ "Notification Displayed."
+ );
+ resolve(notificationBox);
+ },
+ { once: true }
+ );
+ });
+}
+
+function pushPrefs(...aPrefs) {
+ return SpecialPowers.pushPrefEnv({ set: aPrefs });
+}
+
+function popPrefs() {
+ return SpecialPowers.popPrefEnv();
+}
+
+const TEST_ACTION_UNKNOWN = 0;
+const TEST_ACTION_CANCELLED = 1;
+const TEST_ACTION_TERMSCRIPT = 2;
+const TEST_ACTION_TERMGLOBAL = 3;
+const SLOW_SCRIPT = 1;
+const ADDON_HANG = 3;
+const ADDON_ID = "fake-addon";
+
+/**
+ * A mock nsIHangReport that we can pass through nsIObserverService
+ * to trigger notifications.
+ *
+ * @param hangType
+ * One of SLOW_SCRIPT, ADDON_HANG.
+ * @param browser (optional)
+ * The <xul:browser> that this hang should be associated with.
+ * If not supplied, the hang will be associated with every browser,
+ * but the nsIHangReport.scriptBrowser attribute will return the
+ * currently selected browser in this window's gBrowser.
+ */
+let TestHangReport = function (
+ hangType = SLOW_SCRIPT,
+ browser = gBrowser.selectedBrowser
+) {
+ this.promise = new Promise((resolve, reject) => {
+ this._resolver = resolve;
+ });
+
+ if (hangType == ADDON_HANG) {
+ // Add-on hangs need an associated add-on ID for us to blame.
+ this._addonId = ADDON_ID;
+ }
+
+ this._browser = browser;
+};
+
+TestHangReport.prototype = {
+ get addonId() {
+ return this._addonId;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIHangReport"]),
+
+ userCanceled() {
+ this._resolver(TEST_ACTION_CANCELLED);
+ },
+
+ terminateScript() {
+ this._resolver(TEST_ACTION_TERMSCRIPT);
+ },
+
+ isReportForBrowserOrChildren(aFrameLoader) {
+ if (this._browser) {
+ return this._browser.frameLoader === aFrameLoader;
+ }
+
+ return true;
+ },
+
+ get scriptBrowser() {
+ return this._browser;
+ },
+
+ // Shut up warnings about this property missing:
+ get scriptFileName() {
+ return "chrome://browser/content/browser.js";
+ },
+};
+
+// on dev edition we add a button for js debugging of hung scripts.
+let buttonCount = AppConstants.MOZ_DEV_EDITION ? 2 : 1;
+
+add_setup(async function () {
+ // Create a fake WebExtensionPolicy that we can use for
+ // the add-on hang notification.
+ const uuidGen = Services.uuid;
+ const uuid = uuidGen.generateUUID().number.slice(1, -1);
+ let policy = new WebExtensionPolicy({
+ name: "Scapegoat",
+ id: ADDON_ID,
+ mozExtensionHostname: uuid,
+ baseURL: "file:///",
+ allowedOrigins: new MatchPatternSet([]),
+ localizeCallback() {},
+ });
+ policy.active = true;
+
+ registerCleanupFunction(() => {
+ policy.active = false;
+ });
+});
+
+/**
+ * Test if hang reports receive a terminate script callback when the user selects
+ * stop in response to a script hang.
+ */
+add_task(async function terminateScriptTest() {
+ let promise = promiseNotificationShown(window, "process-hang");
+ let hangReport = new TestHangReport();
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ let notification = await promise;
+
+ let buttons =
+ notification.currentNotification.buttonContainer.getElementsByTagName(
+ "button"
+ );
+ is(buttons.length, buttonCount, "proper number of buttons");
+
+ // Click the "Stop" button, we should get a terminate script callback
+ buttons[0].click();
+ let action = await hangReport.promise;
+ is(
+ action,
+ TEST_ACTION_TERMSCRIPT,
+ "Clicking 'Stop' should have terminated the script."
+ );
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after a user selects wait
+ * and the browser frees up from a script hang on its own.
+ */
+add_task(async function waitForScriptTest() {
+ let hangReport = new TestHangReport();
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ let notification = await promise;
+
+ let buttons =
+ notification.currentNotification.buttonContainer.getElementsByTagName(
+ "button"
+ );
+ is(buttons.length, buttonCount, "proper number of buttons");
+
+ await pushPrefs(["browser.hangNotification.waitPeriod", 1000]);
+
+ let ignoringReport = true;
+
+ hangReport.promise.then(action => {
+ if (ignoringReport) {
+ ok(
+ false,
+ "Hang report was somehow dealt with when it " +
+ "should have been ignored."
+ );
+ } else {
+ is(
+ action,
+ TEST_ACTION_CANCELLED,
+ "Hang report should have been cancelled."
+ );
+ }
+ });
+
+ // Click the "Close" button this time, we shouldn't get a callback at all.
+ notification.currentNotification.closeButtonEl.click();
+
+ // send another hang pulse, we should not get a notification here
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ is(
+ notification.currentNotification,
+ null,
+ "no notification should be visible"
+ );
+
+ // Make sure that any queued Promises have run to give our report-ignoring
+ // then() a chance to fire.
+ await Promise.resolve();
+
+ ignoringReport = false;
+ Services.obs.notifyObservers(hangReport, "clear-hang-report");
+
+ await popPrefs();
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after the content
+ * process stops sending hang notifications.
+ */
+add_task(async function hangGoesAwayTest() {
+ await pushPrefs(["browser.hangNotification.expiration", 1000]);
+
+ let hangReport = new TestHangReport();
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ await promise;
+
+ Services.obs.notifyObservers(hangReport, "clear-hang-report");
+ let action = await hangReport.promise;
+ is(action, TEST_ACTION_CANCELLED, "Hang report should have been cancelled.");
+
+ await popPrefs();
+});
+
+/**
+ * Tests that if we're shutting down, any pre-existing hang reports will
+ * be terminated appropriately.
+ */
+add_task(async function terminateAtShutdown() {
+ let pausedHang = new TestHangReport(SLOW_SCRIPT);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(window);
+ ok(
+ ProcessHangMonitor.findPausedReport(gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT);
+ let addonHang = new TestHangReport(ADDON_HANG);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ // Simulate the browser being told to shutdown. This should cause
+ // hangs to terminate scripts.
+ ProcessHangMonitor.onQuitApplicationGranted();
+
+ // In case this test happens to throw before it can finish, make
+ // sure to reset the shutting-down state.
+ registerCleanupFunction(() => {
+ ProcessHangMonitor._shuttingDown = false;
+ });
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for add-on hang."
+ );
+
+ // ProcessHangMonitor should now be in the "shutting down" state,
+ // meaning that any further hangs should be handled immediately
+ // without user interaction.
+ let scriptHang2 = new TestHangReport(SLOW_SCRIPT);
+ let addonHang2 = new TestHangReport(ADDON_HANG);
+
+ [scriptHang2, addonHang2].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ let scriptAction2 = await scriptHang.promise;
+ let addonAction2 = await addonHang.promise;
+
+ is(
+ scriptAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for script hang."
+ );
+ is(
+ addonAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for add-on hang."
+ );
+
+ ProcessHangMonitor._shuttingDown = false;
+});
+
+/**
+ * Test that if there happens to be no open browser windows, that any
+ * hang reports that exist or appear while in this state will be handled
+ * automatically.
+ */
+add_task(async function terminateNoWindows() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ let pausedHang = new TestHangReport(
+ SLOW_SCRIPT,
+ testWin.gBrowser.selectedBrowser
+ );
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT);
+ let addonHang = new TestHangReport(ADDON_HANG);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ // Quick and dirty hack to trick the window mediator into thinking there
+ // are no browser windows without actually closing all browser windows.
+ document.documentElement.setAttribute(
+ "windowtype",
+ "navigator:browsertestdummy"
+ );
+
+ // In case this test happens to throw before it can finish, make
+ // sure to reset this.
+ registerCleanupFunction(() => {
+ document.documentElement.setAttribute("windowtype", "navigator:browser");
+ });
+
+ await BrowserTestUtils.closeWindow(testWin);
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for add-on hang."
+ );
+
+ // ProcessHangMonitor should notice we're in the "no windows" state,
+ // so any further hangs should be handled immediately without user
+ // interaction.
+ let scriptHang2 = new TestHangReport(SLOW_SCRIPT);
+ let addonHang2 = new TestHangReport(ADDON_HANG);
+
+ [scriptHang2, addonHang2].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ let scriptAction2 = await scriptHang.promise;
+ let addonAction2 = await addonHang.promise;
+
+ is(
+ scriptAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for script hang."
+ );
+ is(
+ addonAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for add-on hang."
+ );
+
+ document.documentElement.setAttribute("windowtype", "navigator:browser");
+});
+
+/**
+ * Test that if a script hang occurs in one browser window, and that
+ * browser window goes away, that we clear the hang. For plug-in hangs,
+ * we do the conservative thing and terminate any plug-in hangs when a
+ * window closes, even though we don't exactly know which window it
+ * belongs to.
+ */
+add_task(async function terminateClosedWindow() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+ let testBrowser = testWin.gBrowser.selectedBrowser;
+
+ let pausedHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ let addonHang = new TestHangReport(ADDON_HANG, testBrowser);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ await BrowserTestUtils.closeWindow(testWin);
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for a paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for add-on hang."
+ );
+});
+
+/**
+ * Test that permitUnload (used for closing or discarding tabs) does not
+ * try to talk to the hung child
+ */
+add_task(async function permitUnload() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+ let testTab = testWin.gBrowser.selectedTab;
+
+ // Ensure we don't close the window:
+ BrowserTestUtils.addTab(testWin.gBrowser, "about:blank");
+
+ // Set up the test tab and another tab so we can check what happens when
+ // they are closed:
+ let otherTab = BrowserTestUtils.addTab(testWin.gBrowser, "about:blank");
+ let permitUnloadCount = 0;
+ for (let tab of [testTab, otherTab]) {
+ let browser = tab.linkedBrowser;
+ // Fake before unload state:
+ Object.defineProperty(browser, "hasBeforeUnload", { value: true });
+ // Increment permitUnloadCount if we ask for unload permission:
+ browser.asyncPermitUnload = () => {
+ permitUnloadCount++;
+ return Promise.resolve({ permitUnload: true });
+ };
+ }
+
+ // Set up a hang for the selected tab:
+ let testBrowser = testTab.linkedBrowser;
+ let pausedHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the browser we're about to remove."
+ );
+
+ BrowserTestUtils.removeTab(otherTab);
+ BrowserTestUtils.removeTab(testWin.gBrowser.getTabForBrowser(testBrowser));
+ is(
+ permitUnloadCount,
+ 1,
+ "Should have called asyncPermitUnload once (not for the hung tab)."
+ );
+
+ await BrowserTestUtils.closeWindow(testWin);
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions.js b/browser/modules/test/browser/browser_SitePermissions.js
new file mode 100644
index 0000000000..d8542f8f85
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions.js
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This tests the SitePermissions.getAllPermissionDetailsForBrowser function.
+add_task(async function testGetAllPermissionDetailsForBrowser() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ principal.spec
+ );
+
+ Services.prefs.setIntPref("permissions.default.shortcuts", 2);
+
+ let browser = tab.linkedBrowser;
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ "cookie",
+ SitePermissions.ALLOW_COOKIES_FOR_SESSION
+ );
+ SitePermissions.setForPrincipal(principal, "popup", SitePermissions.BLOCK);
+ SitePermissions.setForPrincipal(
+ principal,
+ "geo",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_SESSION
+ );
+ SitePermissions.setForPrincipal(
+ principal,
+ "shortcuts",
+ SitePermissions.ALLOW
+ );
+
+ SitePermissions.setForPrincipal(principal, "xr", SitePermissions.ALLOW);
+
+ let permissions = SitePermissions.getAllPermissionDetailsForBrowser(browser);
+
+ let camera = permissions.find(({ id }) => id === "camera");
+ Assert.deepEqual(camera, {
+ id: "camera",
+ label: "Use the camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that removed permissions (State.UNKNOWN) are skipped.
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ permissions = SitePermissions.getAllPermissionDetailsForBrowser(browser);
+
+ camera = permissions.find(({ id }) => id === "camera");
+ Assert.equal(camera, undefined);
+
+ let cookie = permissions.find(({ id }) => id === "cookie");
+ Assert.deepEqual(cookie, {
+ id: "cookie",
+ label: "Set cookies",
+ state: SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let popup = permissions.find(({ id }) => id === "popup");
+ Assert.deepEqual(popup, {
+ id: "popup",
+ label: "Open pop-up windows",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let geo = permissions.find(({ id }) => id === "geo");
+ Assert.deepEqual(geo, {
+ id: "geo",
+ label: "Access your location",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ });
+
+ let shortcuts = permissions.find(({ id }) => id === "shortcuts");
+ Assert.deepEqual(shortcuts, {
+ id: "shortcuts",
+ label: "Override keyboard shortcuts",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let xr = permissions.find(({ id }) => id === "xr");
+ Assert.deepEqual(xr, {
+ id: "xr",
+ label: "Access virtual reality devices",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ SitePermissions.removeFromPrincipal(principal, "cookie");
+ SitePermissions.removeFromPrincipal(principal, "popup");
+ SitePermissions.removeFromPrincipal(principal, "geo");
+ SitePermissions.removeFromPrincipal(principal, "shortcuts");
+
+ SitePermissions.removeFromPrincipal(principal, "xr");
+
+ Services.prefs.clearUserPref("permissions.default.shortcuts");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function testTemporaryChangeEvent() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ principal.spec
+ );
+
+ let browser = tab.linkedBrowser;
+
+ let changeEventCount = 0;
+ function listener() {
+ changeEventCount++;
+ }
+
+ browser.addEventListener("PermissionStateChange", listener);
+
+ // Test browser-specific permissions.
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "autoplay-media",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_GLOBAL,
+ browser
+ );
+ is(changeEventCount, 1, "Should've changed");
+
+ // Setting the same value shouldn't dispatch a change event.
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "autoplay-media",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_GLOBAL,
+ browser
+ );
+ is(changeEventCount, 1, "Shouldn't have changed");
+
+ browser.removeEventListener("PermissionStateChange", listener);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testInvalidPrincipal() {
+ // Check that an error is thrown when an invalid principal argument is passed.
+ try {
+ SitePermissions.isSupportedPrincipal("file:///example.js");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.removeFromPrincipal(null, "canvas");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Atleast one of the arguments, either principal or browser should not be null."
+ );
+ }
+ try {
+ SitePermissions.setForPrincipal(
+ "blah",
+ "camera",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_PERSISTENT,
+ gBrowser.selectedBrowser
+ );
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.getAllByPrincipal("blah");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.getAllByPrincipal(null);
+ } catch (e) {
+ Assert.equal(e.message, "principal argument cannot be null.");
+ }
+ try {
+ SitePermissions.getForPrincipal(5, "camera");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ // Check that no error is thrown when passing valid principal and browser arguments.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(gBrowser.contentPrincipal, "camera"),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, "camera", gBrowser.selectedBrowser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_combinations.js b/browser/modules/test/browser/browser_SitePermissions_combinations.js
new file mode 100644
index 0000000000..e6267f72cc
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_combinations.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This function applies combinations of different permissions and
+// checks how they override each other.
+async function checkPermissionCombinations(combinations) {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ await BrowserTestUtils.withNewTab(principal.spec, function (browser) {
+ let id = "geo";
+ for (let { reverse, states, result } of combinations) {
+ let loop = () => {
+ for (let [state, scope] of states) {
+ SitePermissions.setForPrincipal(principal, id, state, scope, browser);
+ }
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ result
+ );
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+ };
+
+ loop();
+
+ if (reverse) {
+ states.reverse();
+ loop();
+ }
+ }
+ });
+}
+
+// Test that passing null as scope becomes SCOPE_PERSISTENT.
+add_task(async function testDefaultScope() {
+ await checkPermissionCombinations([
+ {
+ states: [[SitePermissions.ALLOW, null]],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that "wide" scopes like PERSISTENT always override "narrower" ones like TAB.
+add_task(async function testScopeOverrides() {
+ await checkPermissionCombinations([
+ {
+ // The behavior of SCOPE_SESSION is not in line with the general behavior
+ // because of the legacy nsIPermissionManager implementation.
+ states: [
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+ ],
+ result: {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ },
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ {
+ reverse: true,
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_SESSION],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ },
+ {
+ reverse: true,
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that clearing a temporary permission also removes a
+// persistent permission that was set for the same URL.
+add_task(async function testClearTempPermission() {
+ await checkPermissionCombinations([
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.UNKNOWN, SitePermissions.SCOPE_TEMPORARY],
+ ],
+ result: {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that states override each other when applied with the same scope.
+add_task(async function testStateOverride() {
+ await checkPermissionCombinations([
+ {
+ states: [
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_expiry.js b/browser/modules/test/browser/browser_SitePermissions_expiry.js
new file mode 100644
index 0000000000..c5806a8008
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_expiry.js
@@ -0,0 +1,44 @@
+/* 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/. */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const EXPIRE_TIME_MS = 100;
+const TIMEOUT_MS = 500;
+
+// This tests the time delay to expire temporary permission entries.
+add_task(async function testTemporaryPermissionExpiry() {
+ SpecialPowers.pushPrefEnv({
+ set: [["privacy.temporary_permission_expire_time_ms", EXPIRE_TIME_MS]],
+ });
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ let id = "camera";
+
+ await BrowserTestUtils.withNewTab(principal.spec, async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ await new Promise(c => setTimeout(c, TIMEOUT_MS));
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+ });
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_tab_urls.js b/browser/modules/test/browser/browser_SitePermissions_tab_urls.js
new file mode 100644
index 0000000000..f259486282
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_tab_urls.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+function newPrincipal(origin) {
+ return Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ origin
+ );
+}
+
+// This tests the key used to store the URI -> permission map on a tab.
+add_task(async function testTemporaryPermissionTabURLs() {
+ // Prevent showing a dialog for https://name:password@example.com
+ SpecialPowers.pushPrefEnv({
+ set: [["network.http.phishy-userpass-length", 2048]],
+ });
+
+ // This usually takes about 60 seconds on 32bit Linux debug,
+ // due to the combinatory nature of the test that is hard to fix.
+ requestLongerTimeout(2);
+
+ let same = [
+ newPrincipal("https://example.com"),
+ newPrincipal("https://example.com:443"),
+ newPrincipal("https://test1.example.com"),
+ newPrincipal("https://name:password@example.com"),
+ newPrincipal("http://example.com"),
+ ];
+ let different = [
+ newPrincipal("https://example.com"),
+ newPrincipal("http://example.org"),
+ newPrincipal("http://example.net"),
+ ];
+
+ let id = "microphone";
+
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ for (let principal of same) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal.spec
+ );
+ BrowserTestUtils.startLoadingURIString(browser, principal.spec);
+ await loaded;
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ for (let principal2 of same) {
+ let loaded2 = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal2.URI.spec
+ );
+ BrowserTestUtils.startLoadingURIString(browser, principal2.URI.spec);
+ await loaded2;
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal2, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ `${principal.spec} should share tab permissions with ${principal2.spec}`
+ );
+ }
+
+ SitePermissions.clearTemporaryBlockPermissions(browser);
+ }
+
+ for (let principal of different) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal.spec
+ );
+ BrowserTestUtils.startLoadingURIString(browser, principal.spec);
+ await loaded;
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ for (let principal2 of different) {
+ loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal2.URI.spec
+ );
+ BrowserTestUtils.startLoadingURIString(browser, principal2.URI.spec);
+ await loaded;
+
+ if (principal2 != principal) {
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal2, id, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ `${principal.spec} should not share tab permissions with ${principal2.spec}`
+ );
+ }
+ }
+
+ SitePermissions.clearTemporaryBlockPermissions(browser);
+ }
+ });
+});
diff --git a/browser/modules/test/browser/browser_TabUnloader.js b/browser/modules/test/browser/browser_TabUnloader.js
new file mode 100644
index 0000000000..d564abb620
--- /dev/null
+++ b/browser/modules/test/browser/browser_TabUnloader.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { TabUnloader } = ChromeUtils.importESModule(
+ "resource:///modules/TabUnloader.sys.mjs"
+);
+
+const BASE_URL = "https://example.com/browser/browser/modules/test/browser/";
+
+async function play(tab) {
+ let browser = tab.linkedBrowser;
+
+ let waitForAudioPromise = BrowserTestUtils.waitForEvent(
+ tab,
+ "TabAttrModified",
+ false,
+ event => {
+ return (
+ event.detail.changed.includes("soundplaying") &&
+ tab.hasAttribute("soundplaying")
+ );
+ }
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let audio = content.document.querySelector("audio");
+ await audio.play();
+ });
+
+ await waitForAudioPromise;
+}
+
+async function addTab(win = window) {
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "dummy_page.html",
+ waitForLoad: true,
+ });
+}
+
+async function addPrivTab(win = window) {
+ const tab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ BASE_URL + "dummy_page.html"
+ );
+ const browser = win.gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+async function addAudioTab(win = window) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "file_mediaPlayback.html",
+ waitForLoad: true,
+ waitForStateStop: true,
+ });
+
+ await play(tab);
+ return tab;
+}
+
+async function addWebRTCTab(win = window) {
+ let popupPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "popupshown",
+ function () {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "file_webrtc.html",
+ waitForLoad: true,
+ waitForStateStop: true,
+ });
+
+ await popupPromise;
+
+ let recordingPromise = BrowserTestUtils.contentTopicObserved(
+ tab.linkedBrowser.browsingContext,
+ "recording-device-events"
+ );
+ win.PopupNotifications.panel.firstElementChild.button.click();
+ await recordingPromise;
+
+ return tab;
+}
+
+async function pressure() {
+ let tabDiscarded = BrowserTestUtils.waitForEvent(
+ document,
+ "TabBrowserDiscarded",
+ true
+ );
+ TabUnloader.unloadTabAsync(null);
+ return tabDiscarded;
+}
+
+function pressureAndObserve(aExpectedTopic) {
+ const promise = new Promise(resolve => {
+ const observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ observe(aSubject, aTopicInner, aData) {
+ if (aTopicInner == aExpectedTopic) {
+ Services.obs.removeObserver(observer, aTopicInner);
+ resolve(aData);
+ }
+ },
+ };
+ Services.obs.addObserver(observer, aExpectedTopic);
+ });
+ TabUnloader.unloadTabAsync(null);
+ return promise;
+}
+
+async function compareTabOrder(expectedOrder) {
+ let tabInfo = await TabUnloader.getSortedTabs(null);
+
+ is(
+ tabInfo.length,
+ expectedOrder.length,
+ "right number of tabs in discard sort list"
+ );
+ for (let idx = 0; idx < expectedOrder.length; idx++) {
+ is(tabInfo[idx].tab, expectedOrder[idx], "index " + idx + " is correct");
+ }
+}
+
+const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+const PREF_AUDIO_LOOPBACK = "media.audio_loopback_dev";
+const PREF_VIDEO_LOOPBACK = "media.video_loopback_dev";
+const PREF_FAKE_STREAMS = "media.navigator.streams.fake";
+const PREF_ENABLE_UNLOADER = "browser.tabs.unloadOnLowMemory";
+const PREF_MAC_LOW_MEM_RESPONSE = "browser.lowMemoryResponseMask";
+
+add_task(async function test() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_ENABLE_UNLOADER);
+ if (AppConstants.platform == "macosx") {
+ Services.prefs.clearUserPref(PREF_MAC_LOW_MEM_RESPONSE);
+ }
+ });
+ Services.prefs.setBoolPref(PREF_ENABLE_UNLOADER, true);
+
+ // On Mac, tab unloading and memory pressure notifications are limited
+ // to Nightly so force them on for this test for non-Nightly builds. i.e.,
+ // tests on Release and Beta builds. Mac tab unloading and memory pressure
+ // notifications require this pref to be set.
+ if (AppConstants.platform == "macosx") {
+ Services.prefs.setIntPref(PREF_MAC_LOW_MEM_RESPONSE, 3);
+ }
+
+ TabUnloader.init();
+
+ // Set some WebRTC simulation preferences.
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ // Set up 6 tabs, three normal ones, one pinned, one playing sound and one
+ // pinned playing sound
+ let tab0 = gBrowser.tabs[0];
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let pinnedTab = await addTab();
+ gBrowser.pinTab(pinnedTab);
+ let soundTab = await addAudioTab();
+ let pinnedSoundTab = await addAudioTab();
+ gBrowser.pinTab(pinnedSoundTab);
+
+ // Open a new private window and add a tab
+ const windowPriv = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ const tabPriv0 = windowPriv.gBrowser.tabs[0];
+ const tabPriv1 = await addPrivTab(windowPriv);
+
+ // Move the original window to the foreground to pass the tests
+ gBrowser.selectedTab = tab0;
+ tab0.ownerGlobal.focus();
+
+ // Pretend we've visited the tabs
+ await BrowserTestUtils.switchTab(windowPriv.gBrowser, tabPriv1);
+ await BrowserTestUtils.switchTab(windowPriv.gBrowser, tabPriv0);
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedTab);
+ await BrowserTestUtils.switchTab(gBrowser, soundTab);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedSoundTab);
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+
+ // Checks the tabs are in the state we expect them to be
+ ok(pinnedTab.pinned, "tab is pinned");
+ ok(pinnedSoundTab.soundPlaying, "tab is playing sound");
+ ok(
+ pinnedSoundTab.pinned && pinnedSoundTab.soundPlaying,
+ "tab is pinned and playing sound"
+ );
+
+ await compareTabOrder([
+ tab1,
+ tab2,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ // Check that the tabs are present
+ ok(
+ tab1.linkedPanel &&
+ tab2.linkedPanel &&
+ pinnedTab.linkedPanel &&
+ soundTab.linkedPanel &&
+ pinnedSoundTab.linkedPanel &&
+ tabPriv0.linkedPanel &&
+ tabPriv1.linkedPanel,
+ "tabs are present"
+ );
+
+ // Check that low-memory memory-pressure events unload tabs
+ await pressure();
+ ok(
+ !tab1.linkedPanel,
+ "low-memory memory-pressure notification unloaded the LRU tab"
+ );
+
+ await compareTabOrder([
+ tab2,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ // If no normal tab is available unload pinned tabs
+ await pressure();
+ ok(!tab2.linkedPanel, "unloaded a second tab in LRU order");
+ await compareTabOrder([
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ ok(soundTab.soundPlaying, "tab is still playing sound");
+
+ await pressure();
+ ok(!pinnedTab.linkedPanel, "unloaded a pinned tab");
+ await compareTabOrder([tabPriv1, soundTab, tab0, pinnedSoundTab, tabPriv0]);
+
+ ok(pinnedSoundTab.soundPlaying, "tab is still playing sound");
+
+ // There are no unloadable tabs.
+ TabUnloader.unloadTabAsync(null);
+ ok(tabPriv1.linkedPanel, "a tab in a private window is never unloaded");
+
+ const histogram = TelemetryTestUtils.getAndClearHistogram(
+ "TAB_UNLOAD_TO_RELOAD"
+ );
+
+ // It's possible that we're already in the memory-pressure state
+ // and we may receive the "ongoing" message.
+ const message = await pressureAndObserve("memory-pressure");
+ Assert.ok(
+ message == "low-memory" || message == "low-memory-ongoing",
+ "observed the memory-pressure notification because of no discardable tab"
+ );
+
+ // Add a WebRTC tab and another sound tab.
+ let webrtcTab = await addWebRTCTab();
+ let anotherSoundTab = await addAudioTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedTab);
+
+ const hist = histogram.snapshot();
+ const numEvents = Object.values(hist.values).reduce((a, b) => a + b);
+ Assert.equal(numEvents, 2, "two tabs have been reloaded.");
+
+ // tab0 has never been unloaded. No data is added to the histogram.
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+
+ await compareTabOrder([
+ tab1,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ await BrowserTestUtils.closeWindow(windowPriv);
+
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2tab1 = window2.gBrowser.selectedTab;
+ let win2tab2 = await addTab(window2);
+ let win2winrtcTab = await addWebRTCTab(window2);
+ let win2tab3 = await addTab(window2);
+
+ await compareTabOrder([
+ tab1,
+ win2tab1,
+ win2tab2,
+ pinnedTab,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ win2winrtcTab,
+ tab0,
+ win2tab3,
+ pinnedSoundTab,
+ ]);
+
+ await BrowserTestUtils.closeWindow(window2);
+
+ await compareTabOrder([
+ tab1,
+ pinnedTab,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ tab0,
+ pinnedSoundTab,
+ ]);
+
+ // Cleanup
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(pinnedTab);
+ BrowserTestUtils.removeTab(soundTab);
+ BrowserTestUtils.removeTab(pinnedSoundTab);
+ BrowserTestUtils.removeTab(webrtcTab);
+ BrowserTestUtils.removeTab(anotherSoundTab);
+
+ await awaitWebRTCClose();
+});
+
+// Wait for the WebRTC indicator window to close.
+function awaitWebRTCClose() {
+ if (
+ Services.prefs.getBoolPref("privacy.webrtc.legacyGlobalIndicator", false) ||
+ AppConstants.platform == "macosx"
+ ) {
+ return null;
+ }
+
+ let win = Services.wm.getMostRecentWindow("Browser:WebRTCGlobalIndicator");
+ if (!win) {
+ return null;
+ }
+
+ return new Promise(resolve => {
+ win.addEventListener("unload", function listener(e) {
+ if (e.target == win.document) {
+ win.removeEventListener("unload", listener);
+ executeSoon(resolve);
+ }
+ });
+ });
+}
diff --git a/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js
new file mode 100644
index 0000000000..9ce5602eda
--- /dev/null
+++ b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js
@@ -0,0 +1,53 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * This file tests page reload key combination telemetry
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const gTestRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://mochi.test:8888"
+);
+
+const { TimedPromise } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/sync.sys.mjs"
+);
+
+async function run_test(count) {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(
+ "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS"
+ );
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: gTestRoot + "contain_iframe.html",
+ waitForStateStop: true,
+ });
+
+ await new Promise(resolve =>
+ setTimeout(function () {
+ window.requestIdleCallback(resolve);
+ }, 1000)
+ );
+
+ if (count < 2) {
+ await BrowserTestUtils.removeTab(newTab);
+ await run_test(count + 1);
+ } else {
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+ await BrowserTestUtils.removeTab(newTab);
+ }
+}
+
+add_task(async function test_telemetryMoreSiteOrigin() {
+ await run_test(1);
+});
diff --git a/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js
new file mode 100644
index 0000000000..d60660960f
--- /dev/null
+++ b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const histogramName = "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_DOCUMENT";
+const testRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://mochi.test:8888"
+);
+
+function windowGlobalDestroyed(id) {
+ return BrowserUtils.promiseObserved(
+ "window-global-destroyed",
+ aWGP => aWGP.innerWindowId == id
+ );
+}
+
+async function openAndCloseTab(uri) {
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: uri,
+ });
+
+ const innerWindowId =
+ tab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId;
+
+ const wgpDestroyed = windowGlobalDestroyed(innerWindowId);
+ BrowserTestUtils.removeTab(tab);
+ await wgpDestroyed;
+}
+
+add_task(async function test_numberOfSiteOriginsAfterTabClose() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}contain_iframe.html`;
+
+ await openAndCloseTab(testPage);
+
+ // testPage contains two origins: mochi.test:8888 and example.com.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+});
+
+add_task(async function test_numberOfSiteOriginsAboutBlank() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+
+ await openAndCloseTab("about:blank");
+
+ const { values } = histogram.snapshot();
+ Assert.deepEqual(
+ values,
+ {},
+ `Histogram should have no values; had ${JSON.stringify(values)}`
+ );
+});
+
+add_task(async function test_numberOfSiteOriginsMultipleNavigations() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}contain_iframe.html`;
+
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForStateStop: true,
+ });
+
+ const wgpDestroyedPromises = [
+ windowGlobalDestroyed(tab.linkedBrowser.innerWindowID),
+ ];
+
+ // Navigate to an interstitial page.
+ BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Navigate to another test page.
+ BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, testPage);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ wgpDestroyedPromises.push(
+ windowGlobalDestroyed(tab.linkedBrowser.innerWindowID)
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await Promise.all(wgpDestroyedPromises);
+
+ // testPage has been loaded twice and contains two origins: mochi.test:8888
+ // and example.com.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 2);
+});
+
+add_task(async function test_numberOfSiteOriginsAddAndRemove() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}blank_iframe.html`;
+
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForStateStop: true,
+ });
+
+ // Load a subdocument in the page's iframe.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const iframe = content.window.document.querySelector("iframe");
+ const loaded = new Promise(resolve => {
+ iframe.addEventListener("load", () => resolve(), { once: true });
+ });
+ iframe.src = "http://example.com";
+
+ await loaded;
+ });
+
+ // Load a *new* subdocument in the page's iframe. This will result in the page
+ // having had three different origins, but only two at any one time.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const iframe = content.window.document.querySelector("iframe");
+ const loaded = new Promise(resolve => {
+ iframe.addEventListener("load", () => resolve(), { once: true });
+ });
+ iframe.src = "http://example.org";
+
+ await loaded;
+ });
+
+ const wgpDestroyed = windowGlobalDestroyed(tab.linkedBrowser.innerWindowID);
+ BrowserTestUtils.removeTab(tab);
+ await wgpDestroyed;
+
+ // The page only ever had two origins at once.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+});
diff --git a/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js
new file mode 100644
index 0000000000..4305d7f7df
--- /dev/null
+++ b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js
@@ -0,0 +1,815 @@
+"use strict";
+
+/**
+ * This suite tests the "unsubmitted crash report" notification
+ * that is seen when we detect pending crash reports on startup.
+ */
+
+const { UnsubmittedCrashHandler } = ChromeUtils.importESModule(
+ "resource:///modules/ContentCrashHandlers.sys.mjs"
+);
+
+const { makeFakeAppDir } = ChromeUtils.importESModule(
+ "resource://testing-common/AppData.sys.mjs"
+);
+
+const DAY = 24 * 60 * 60 * 1000; // milliseconds
+const SERVER_URL =
+ "http://example.com/browser/toolkit/crashreporter/test/browser/crashreport.sjs";
+
+/**
+ * Returns the directly where the browsing is storing the
+ * pending crash reports.
+ *
+ * @returns nsIFile
+ */
+function getPendingCrashReportDir() {
+ // The fake UAppData directory that makeFakeAppDir provides
+ // is just UAppData under the profile directory.
+ return FileUtils.getDir("ProfD", ["UAppData", "Crash Reports", "pending"]);
+}
+
+/**
+ * Synchronously deletes all entries inside the pending
+ * crash report directory.
+ */
+function clearPendingCrashReports() {
+ let dir = getPendingCrashReportDir();
+ let entries = dir.directoryEntries;
+
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ if (entry.isFile()) {
+ entry.remove(false);
+ }
+ }
+}
+
+/**
+ * Randomly generates howMany crash report .dmp and .extra files
+ * to put into the pending crash report directory. We're not
+ * actually creating real crash reports here, just stubbing
+ * out enough of the files to satisfy our notification and
+ * submission code.
+ *
+ * @param howMany (int)
+ * How many pending crash reports to put in the pending
+ * crash report directory.
+ * @param accessDate (Date, optional)
+ * What date to set as the last accessed time on the created
+ * crash reports. This defaults to the current date and time.
+ * @returns Promise
+ */
+function createPendingCrashReports(howMany, accessDate) {
+ let dir = getPendingCrashReportDir();
+ if (!accessDate) {
+ accessDate = new Date();
+ }
+
+ /**
+ * Helper function for creating a file in the pending crash report
+ * directory.
+ *
+ * @param fileName (string)
+ * The filename for the crash report, not including the
+ * extension. This is usually a UUID.
+ * @param extension (string)
+ * The file extension for the created file.
+ * @param accessDate (Date, optional)
+ * The date to set lastAccessed to, if anything.
+ * @param contents (string, optional)
+ * Set this to whatever the file needs to contain, if anything.
+ * @returns Promise
+ */
+ let createFile = async (fileName, extension, lastAccessedDate, contents) => {
+ let file = dir.clone();
+ file.append(fileName + "." + extension);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ if (contents) {
+ await IOUtils.writeUTF8(file.path, contents, {
+ tmpPath: file.path + ".tmp",
+ });
+ }
+
+ if (lastAccessedDate) {
+ await IOUtils.setAccessTime(file.path, lastAccessedDate.valueOf());
+ }
+ };
+
+ let uuidGenerator = Services.uuid;
+ // Some annotations are always present in the .extra file and CrashSubmit.jsm
+ // expects there to be a ServerURL entry, so we'll add them here.
+ let extraFileContents = JSON.stringify({
+ ServerURL: SERVER_URL,
+ TelemetryServerURL: "http://telemetry.mozilla.org/",
+ TelemetryClientId: "c69e7487-df10-4c98-ab1a-c85660feecf3",
+ TelemetrySessionId: "22af5a41-6e84-4112-b1f7-4cb12cb6f6a5",
+ });
+
+ return (async function () {
+ let uuids = [];
+ for (let i = 0; i < howMany; ++i) {
+ let uuid = uuidGenerator.generateUUID().toString();
+ // Strip the {}...
+ uuid = uuid.substring(1, uuid.length - 1);
+ await createFile(uuid, "dmp", accessDate);
+ await createFile(uuid, "extra", accessDate, extraFileContents);
+ uuids.push(uuid);
+ }
+ return uuids;
+ })();
+}
+
+/**
+ * Returns a Promise that resolves once CrashSubmit starts sending
+ * success notifications for crash submission matching the reportIDs
+ * being passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have sent.
+ * @param extraCheck (Function, optional)
+ * A function that receives the annotations of the crash report and can
+ * be used for checking them
+ * @returns Promise
+ */
+function waitForSubmittedReports(reportIDs, extraCheck) {
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let promise = TestUtils.topicObserved(
+ "crash-report-status",
+ (subject, data) => {
+ if (data == "success") {
+ let propBag = subject.QueryInterface(Ci.nsIPropertyBag2);
+ let dumpID = propBag.getPropertyAsAString("minidumpID");
+ if (dumpID == reportID) {
+ if (extraCheck) {
+ let extra = propBag.getPropertyAsInterface(
+ "extra",
+ Ci.nsIPropertyBag2
+ );
+
+ extraCheck(extra);
+ }
+
+ return true;
+ }
+ }
+ return false;
+ }
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+}
+
+/**
+ * Returns a Promise that resolves once a .dmp.ignore file is created for
+ * the crashes in the pending directory matching the reportIDs being
+ * passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have been
+ * marked for ignoring.
+ * @returns Promise
+ */
+function waitForIgnoredReports(reportIDs) {
+ let dir = getPendingCrashReportDir();
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let file = dir.clone();
+ file.append(reportID + ".dmp.ignore");
+ promises.push(IOUtils.exists(file.path));
+ }
+ return Promise.all(promises);
+}
+
+add_setup(async function () {
+ // Pending crash reports are stored in the UAppData folder,
+ // which exists outside of the profile folder. In order to
+ // not overwrite / clear pending crash reports for the poor
+ // soul who runs this test, we use AppData.sys.mjs to point to
+ // a special made-up directory inside the profile
+ // directory.
+ await makeFakeAppDir();
+ // We'll assume that the notifications will be shown in the current
+ // browser window's global notification box.
+
+ // If we happen to already be seeing the unsent crash report
+ // notification, it's because the developer running this test
+ // happened to have some unsent reports in their UAppDir.
+ // We'll remove the notification without touching those reports.
+ let notification = gNotificationBox.getNotificationWithValue(
+ "pending-crash-reports"
+ );
+ if (notification) {
+ notification.close();
+ }
+
+ let oldServerURL = Services.env.get("MOZ_CRASHREPORTER_URL");
+ Services.env.set("MOZ_CRASHREPORTER_URL", SERVER_URL);
+
+ // nsBrowserGlue starts up UnsubmittedCrashHandler automatically
+ // on a timer, so at this point, it can be in one of several states:
+ //
+ // 1. The timer hasn't yet finished, and an automatic scan for crash
+ // reports is pending.
+ // 2. The timer has already gone off and the scan has already completed.
+ // 3. The handler is disabled.
+ //
+ // To collapse all of these possibilities, we uninit the UnsubmittedCrashHandler
+ // to cancel the timer, make sure it's preffed on, and then restart it (which
+ // doesn't restart the timer). Note that making the component initialize
+ // even when it's disabled is an intentional choice, as this allows for easier
+ // simulation of startup and shutdown.
+ UnsubmittedCrashHandler.uninit();
+
+ // While we're here, let's test that we don't show the notification
+ // if we're disabled and something happens to check for unsubmitted
+ // crash reports.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.enabled", false]],
+ });
+
+ await createPendingCrashReports(1);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(!notification, "There should not be a notification");
+
+ clearPendingCrashReports();
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.enabled", true]],
+ });
+ UnsubmittedCrashHandler.init();
+
+ registerCleanupFunction(function () {
+ clearPendingCrashReports();
+ Services.env.set("MOZ_CRASHREPORTER_URL", oldServerURL);
+ });
+});
+
+/**
+ * Tests that if there are no pending crash reports, then the
+ * notification will not show up.
+ */
+add_task(async function test_no_pending_no_notification() {
+ // Make absolutely sure there are no pending crash reports first...
+ clearPendingCrashReports();
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should not be a notification if there are no " +
+ "pending crash reports"
+ );
+});
+
+/**
+ * Tests that there is a notification if there is one pending
+ * crash report.
+ */
+add_task(async function test_one_pending() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that an ignored crash report does not suppress a notification that
+ * would be trigged by another, unignored crash report.
+ */
+add_task(async function test_other_ignored() {
+ let toIgnore = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss notification, creating the .dmp.ignore file
+ notification.closeButtonEl.click();
+ gNotificationBox.removeNotification(notification, true);
+ await waitForIgnoredReports(toIgnore);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(!notification, "There should not be a notification");
+
+ await createPendingCrashReports(1);
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is a notification if there is more than one
+ * pending crash report.
+ */
+add_task(async function test_several_pending() {
+ await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is no notification if the only pending crash
+ * reports are over 28 days old. Also checks that if we put a newer
+ * crash with that older set, that we can still get a notification.
+ */
+add_task(async function test_several_pending() {
+ // Let's create some crash reports from 30 days ago.
+ let oldDate = new Date(Date.now() - 30 * DAY);
+ await createPendingCrashReports(3, oldDate);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should not be a notification if there are only " +
+ "old pending crash reports"
+ );
+ // Now let's create a new one and check again
+ await createPendingCrashReports(1);
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit a report.
+ */
+add_task(async function test_can_submit() {
+ function extraCheck(extra) {
+ const blockedAnnotations = [
+ "ServerURL",
+ "TelemetryClientId",
+ "TelemetryServerURL",
+ "TelemetrySessionId",
+ ];
+ for (const key of blockedAnnotations) {
+ Assert.ok(
+ !extra.hasKey(key),
+ "The " + key + " annotation should have been stripped away"
+ );
+ }
+
+ Assert.equal(extra.get("SubmittedFrom"), "Infobar");
+ Assert.equal(extra.get("Throttleable"), "1");
+ }
+
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the first button.
+ let submit = buttons[0];
+ let promiseReports = waitForSubmittedReports(reportIDs, extraCheck);
+ info("Sending crash report");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit multiple reports.
+ */
+add_task(async function test_can_submit_several() {
+ let reportIDs = await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the first button.
+ let submit = buttons[0];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that choosing "Send Always" flips the autoSubmit pref
+ * and sends the pending crash reports.
+ */
+add_task(async function test_can_submit_always() {
+ let pref = "browser.crashReports.unsubmittedCheck.autoSubmit2";
+ Assert.equal(
+ Services.prefs.getBoolPref(pref),
+ false,
+ "We should not be auto-submitting by default"
+ );
+
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the send all
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the second button.
+ let sendAll = buttons[1];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ sendAll.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+
+ // Make sure the pref was set
+ Assert.equal(
+ Services.prefs.getBoolPref(pref),
+ true,
+ "The autoSubmit pref should have been set"
+ );
+
+ // Create another report
+ reportIDs = await createPendingCrashReports(1);
+ let result = await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+
+ // Check that the crash was auto-submitted
+ Assert.equal(result, null, "The notification should not be shown");
+ promiseReports = await waitForSubmittedReports(reportIDs, extra => {
+ Assert.equal(extra.get("SubmittedFrom"), "Auto");
+ Assert.equal(extra.get("Throttleable"), "1");
+ });
+
+ // And revert back to default now.
+ Services.prefs.clearUserPref(pref);
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the user has chosen to automatically send
+ * crash reports that no notification is displayed to the
+ * user.
+ */
+add_task(async function test_can_auto_submit() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.autoSubmit2", true]],
+ });
+
+ let reportIDs = await createPendingCrashReports(3);
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+
+ clearPendingCrashReports();
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that if the user chooses to dismiss the notification,
+ * then the current pending requests won't cause the notification
+ * to appear again in the future.
+ */
+add_task(async function test_can_ignore() {
+ let reportIDs = await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ notification.closeButtonEl.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+ await waitForIgnoredReports(reportIDs);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the notification is shown, then the
+ * lastShownDate is set for today.
+ */
+add_task(async function test_last_shown_date() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate, "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit with a
+ * notification still being shown, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set to true.
+ */
+add_task(async function test_shutdown_while_showing() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("shutdownWhileShowing");
+ UnsubmittedCrashHandler.init();
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit after
+ * the notification has been closed, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * not set in prefs.
+ */
+add_task(async function test_shutdown_while_not_showing() {
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ notification.closeButtonEl.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ await waitForIgnoredReports(reportIDs);
+
+ UnsubmittedCrashHandler.uninit();
+ Assert.throws(
+ () => {
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ },
+ /NS_ERROR_UNEXPECTED/,
+ "We should have noticed that the notification had closed before uninitting."
+ );
+ UnsubmittedCrashHandler.init();
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is today, then we don't decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(async function test_dont_decrement_chances_on_same_day() {
+ let initChances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+ Assert.greater(initChances, 1, "We should start with at least 1 chance.");
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate, "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+
+ Assert.equal(initChances, chances, "We should not have decremented chances.");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is before today, then we decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(async function test_decrement_chances_on_other_day() {
+ let initChances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+ Assert.greater(initChances, 1, "We should start with at least 1 chance.");
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+
+ // Now pretend that the notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+
+ Assert.equal(
+ initChances - 1,
+ chances,
+ "We should have decremented our chances."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if we've shutdown too many times showing the
+ * notification, and we've run out of chances, then
+ * browser.crashReports.unsubmittedCheck.suppressUntilDate is
+ * set for some days into the future.
+ */
+add_task(async function test_can_suppress_after_chances() {
+ // Pretend that a notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+ UnsubmittedCrashHandler.prefs.setBoolPref("shutdownWhileShowing", true);
+ UnsubmittedCrashHandler.prefs.setIntPref("chancesUntilSuppress", 0);
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should be no notification if we've run out of chances"
+ );
+
+ // We should have set suppressUntilDate into the future
+ let suppressUntilDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("suppressUntilDate");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ Assert.ok(
+ suppressUntilDate > today,
+ "We should be suppressing until some days into the future."
+ );
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if there's a suppression date set, then no notification
+ * will be shown even if there are pending crash reports.
+ */
+add_task(async function test_suppression() {
+ let future = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() + DAY * 5)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", future);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(
+ UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should be suppressed."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
+
+/**
+ * Tests that if there's a suppression date set, but we've exceeded
+ * it, then we can show the notification again.
+ */
+add_task(async function test_end_suppression() {
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", yesterday);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(
+ !UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should not be suppressed."
+ );
+ Assert.ok(
+ !UnsubmittedCrashHandler.prefs.prefHasUserValue("suppressUntilDate"),
+ "The suppression date should been cleared from preferences."
+ );
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry.js b/browser/modules/test/browser/browser_UsageTelemetry.js
new file mode 100644
index 0000000000..9934723f37
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry.js
@@ -0,0 +1,696 @@
+"use strict";
+
+requestLongerTimeout(2);
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const MAX_TAB_PINNED = "browser.engagement.max_concurrent_tab_pinned_count";
+const TAB_PINNED_EVENT = "browser.engagement.tab_pinned_event_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE =
+ "browser.engagement.total_uri_count_normal_and_private_mode";
+
+const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
+
+const RESTORE_ON_DEMAND_PREF = "browser.sessionstore.restore_on-demand";
+
+ChromeUtils.defineESModuleGetters(this, {
+ MINIMUM_TAB_COUNT_INTERVAL_MS:
+ "resource:///modules/BrowserUsageTelemetry.sys.mjs",
+});
+
+const { ObjectUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ObjectUtils.sys.mjs"
+);
+
+BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = 0;
+registerCleanupFunction(() => {
+ BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = undefined;
+});
+
+// Reset internal URI counter in case URIs were opened by other tests.
+Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+/**
+ * Get a snapshot of the scalars and check them against the provided values.
+ */
+let checkScalars = (countsObject, skipGleanCheck = false) => {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check the expected values. Scalars that are never set must not be reported.
+ const checkScalar = (key, val, msg) =>
+ val > 0
+ ? TelemetryTestUtils.assertScalar(scalars, key, val, msg)
+ : TelemetryTestUtils.assertScalarUnset(scalars, key);
+ checkScalar(
+ MAX_CONCURRENT_TABS,
+ countsObject.maxTabs,
+ "The maximum tab count must match the expected value."
+ );
+ checkScalar(
+ TAB_EVENT_COUNT,
+ countsObject.tabOpenCount,
+ "The number of open tab event count must match the expected value."
+ );
+ checkScalar(
+ MAX_TAB_PINNED,
+ countsObject.maxTabsPinned,
+ "The maximum tabs pinned count must match the expected value."
+ );
+ checkScalar(
+ TAB_PINNED_EVENT,
+ countsObject.tabPinnedCount,
+ "The number of tab pinned event count must match the expected value."
+ );
+ checkScalar(
+ MAX_CONCURRENT_WINDOWS,
+ countsObject.maxWindows,
+ "The maximum window count must match the expected value."
+ );
+ checkScalar(
+ WINDOW_OPEN_COUNT,
+ countsObject.windowsOpenCount,
+ "The number of window open event count must match the expected value."
+ );
+ checkScalar(
+ TOTAL_URI_COUNT,
+ countsObject.totalURIs,
+ "The total URI count must match the expected value."
+ );
+ checkScalar(
+ UNIQUE_DOMAINS_COUNT,
+ countsObject.domainCount,
+ "The unique domains count must match the expected value."
+ );
+ checkScalar(
+ UNFILTERED_URI_COUNT,
+ countsObject.totalUnfilteredURIs,
+ "The unfiltered URI count must match the expected value."
+ );
+ checkScalar(
+ TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE,
+ countsObject.totalURIsNormalAndPrivateMode,
+ "The total URI count for both normal and private mode must match the expected value."
+ );
+ if (!skipGleanCheck) {
+ if (countsObject.totalURIsNormalAndPrivateMode == 0) {
+ Assert.equal(
+ Glean.browserEngagement.uriCount.testGetValue(),
+ undefined,
+ "Total URI count reported in Glean must be unset."
+ );
+ } else {
+ Assert.equal(
+ countsObject.totalURIsNormalAndPrivateMode,
+ Glean.browserEngagement.uriCount.testGetValue(),
+ "The total URI count reported in Glean must be as expected."
+ );
+ }
+ }
+};
+
+add_task(async function test_tabsAndWindows() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+
+ let openedTabs = [];
+ let expectedTabOpenCount = 0;
+ let expectedWinOpenCount = 0;
+ let expectedMaxTabs = 0;
+ let expectedMaxWins = 0;
+ let expectedMaxTabsPinned = 0;
+ let expectedTabPinned = 0;
+ let expectedTotalURIs = 0;
+
+ // Add a new tab and check that the count is right.
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+
+ gBrowser.pinTab(openedTabs[0]);
+ gBrowser.unpinTab(openedTabs[0]);
+
+ expectedTabOpenCount = 1;
+ expectedMaxTabs = 2;
+ expectedMaxTabsPinned = 1;
+ expectedTabPinned += 1;
+ // This, and all the checks below, also check that initial pages (about:newtab, about:blank, ..)
+ // are not counted by the total_uri_count and the unfiltered_uri_count probes.
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Add two new tabs in the same window.
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+
+ gBrowser.pinTab(openedTabs[1]);
+ gBrowser.pinTab(openedTabs[2]);
+ gBrowser.unpinTab(openedTabs[2]);
+ gBrowser.unpinTab(openedTabs[1]);
+
+ expectedTabOpenCount += 2;
+ expectedMaxTabs += 2;
+ expectedMaxTabsPinned = 2;
+ expectedTabPinned += 2;
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Add a new window and then some tabs in it. An empty new windows counts as a tab.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ // The new window started with a new tab, so account for it.
+ expectedTabOpenCount += 4;
+ expectedWinOpenCount += 1;
+ expectedMaxWins = 2;
+ expectedMaxTabs += 4;
+
+ // Remove a tab from the first window, the max shouldn't change.
+ BrowserTestUtils.removeTab(openedTabs.pop());
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+
+ // Make sure all the scalars still have the expected values.
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+});
+
+add_task(async function test_subsessionSplit() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // Add a new window (that will have 4 tabs).
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ let openedTabs = [];
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:mozilla")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://www.example.com"
+ )
+ );
+
+ // Check that the scalars have the right values. We expect 2 unfiltered URI loads
+ // (about:mozilla and www.example.com, but no about:blank) and 1 URI totalURIs
+ // (only www.example.com).
+ let expectedTotalURIs = 1;
+
+ checkScalars({
+ maxTabs: 5,
+ tabOpenCount: 4,
+ maxWindows: 2,
+ windowsOpenCount: 1,
+ totalURIs: expectedTotalURIs,
+ domainCount: 1,
+ totalUnfilteredURIs: 2,
+ maxTabsPinned: 0,
+ tabPinnedCount: 0,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Remove a tab.
+ BrowserTestUtils.removeTab(openedTabs.pop());
+
+ // Simulate a subsession split by clearing the scalars (via |getSnapshotForScalars|) and
+ // notifying the subsession split topic.
+ Services.telemetry.getSnapshotForScalars("main", true /* clearScalars */);
+ Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+ // After a subsession split, only the MAX_CONCURRENT_* scalars must be available
+ // and have the correct value. No tabs, windows or URIs were opened so other scalars
+ // must not be reported.
+ expectedTotalURIs = 0;
+
+ checkScalars(
+ {
+ maxTabs: 4,
+ tabOpenCount: 0,
+ maxWindows: 2,
+ windowsOpenCount: 0,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: 0,
+ tabPinnedCount: 0,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ },
+ true
+ );
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+function checkTabCountHistogram(result, expected, message) {
+ Assert.deepEqual(result.values, expected, message);
+}
+
+add_task(async function test_tabsHistogram() {
+ let openedTabs = [];
+ let tabCountHist = TelemetryTestUtils.getAndClearHistogram("TAB_COUNT");
+
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ {},
+ "TAB_COUNT telemetry - initial tab counts"
+ );
+
+ // Add a new tab and check that the count is right.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 0 },
+ "TAB_COUNT telemetry - opening tabs"
+ );
+
+ // Open a different page and check the counts.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ openedTabs.push(tab);
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ BrowserTestUtils.startLoadingURIString(
+ tab.linkedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 0 },
+ "TAB_COUNT telemetry - loading page"
+ );
+
+ // Open another tab
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 0 },
+ "TAB_COUNT telemetry - opening more tabs"
+ );
+
+ // Add a new window and then some tabs in it. A new window starts with one tab.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT telemetry - opening window"
+ );
+
+ // Do not trigger a recount if _lastRecordTabCount is recent on new tab
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS / 2;
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT telemetry - new tab, recount event ignored"
+ );
+ Assert.equal(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount unchanged"
+ );
+ }
+
+ // Trigger a recount if _lastRecordTabCount has passed on new tab
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - (MINIMUM_TAB_COUNT_INTERVAL_MS + 1000);
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 1, 8: 0 },
+ "TAB_COUNT telemetry - new tab, recount event included"
+ );
+ Assert.notEqual(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount updated"
+ );
+ Assert.greater(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS,
+ "TAB_COUNT telemetry - _lastRecordTabCount invariant"
+ );
+ }
+
+ // Do not trigger a recount if _lastRecordTabCount is recent on page load
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS / 2;
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ BrowserTestUtils.startLoadingURIString(
+ tab.linkedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 1, 8: 0 },
+ "TAB_COUNT telemetry - page load, recount event ignored"
+ );
+ Assert.equal(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount unchanged"
+ );
+ }
+
+ // Trigger a recount if _lastRecordTabCount has passed on page load
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - (MINIMUM_TAB_COUNT_INTERVAL_MS + 1000);
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ BrowserTestUtils.startLoadingURIString(
+ tab.linkedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 2, 8: 0 },
+ "TAB_COUNT telemetry - page load, recount event included"
+ );
+ Assert.notEqual(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount updated"
+ );
+ Assert.greater(
+ BrowserUsageTelemetry._lastRecordTabCount,
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS,
+ "TAB_COUNT telemetry - _lastRecordTabCount invariant"
+ );
+ }
+
+ // Remove all the extra windows and tabs.
+ for (let openTab of openedTabs) {
+ BrowserTestUtils.removeTab(openTab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_loadedTabsHistogram() {
+ Services.prefs.setBoolPref(RESTORE_ON_DEMAND_PREF, true);
+ registerCleanupFunction(() =>
+ Services.prefs.clearUserPref(RESTORE_ON_DEMAND_PREF)
+ );
+
+ function resetTimestamps() {
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ BrowserUsageTelemetry._lastRecordLoadedTabCount = 0;
+ }
+
+ resetTimestamps();
+ const tabCount = TelemetryTestUtils.getAndClearHistogram("TAB_COUNT");
+ const loadedTabCount =
+ TelemetryTestUtils.getAndClearHistogram("LOADED_TAB_COUNT");
+
+ checkTabCountHistogram(tabCount.snapshot(), {}, "TAB_COUNT - initial count");
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ {},
+ "LOADED_TAB_COUNT - initial count"
+ );
+
+ resetTimestamps();
+ const tabs = [
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"),
+ ];
+
+ // There are two tabs open: the mochi.test tab and the foreground tab.
+ const snapshot = loadedTabCount.snapshot();
+ checkTabCountHistogram(snapshot, { 1: 0, 2: 1, 3: 0 }, "TAB_COUNT - new tab");
+
+ // Open a pending tab, as if by session restore.
+ resetTimestamps();
+ const lazyTab = BrowserTestUtils.addTab(gBrowser, "about:mozilla", {
+ createLazyBrowser: true,
+ });
+ tabs.push(lazyTab);
+
+ await BrowserTestUtils.waitForCondition(
+ () => !ObjectUtils.deepEqual(snapshot, tabCount.snapshot())
+ );
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 1, 4: 0 },
+ "TAB_COUNT - Added pending tab"
+ );
+
+ // Only the mochi.test and foreground tab are loaded.
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 0 },
+ "LOADED_TAB_COUNT - Added pending tab"
+ );
+
+ resetTimestamps();
+ const restoredEvent = BrowserTestUtils.waitForEvent(lazyTab, "SSTabRestored");
+ await BrowserTestUtils.switchTab(gBrowser, lazyTab);
+ await restoredEvent;
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 1, 4: 0 },
+ "TAB_COUNT - Restored pending tab"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 1, 4: 0 },
+ "LOADED_TAB_COUNT - Restored pending tab"
+ );
+
+ resetTimestamps();
+
+ await Promise.all([
+ BrowserTestUtils.startLoadingURIString(
+ lazyTab.linkedBrowser,
+ "http://example.com/"
+ ),
+ BrowserTestUtils.browserLoaded(
+ lazyTab.linkedBrowser,
+ false,
+ "http://example.com/"
+ ),
+ ]);
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 0 },
+ "TAB_COUNT - Navigated in existing tab"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 0 },
+ "LOADED_TAB_COUNT - Navigated in existing tab"
+ );
+
+ resetTimestamps();
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+
+ // The new window will have a new tab.
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 0 },
+ "TAB_COUNT - Opened new window"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 1, 5: 0 },
+ "LOADED_TAB_COUNT - Opened new window"
+ );
+
+ resetTimestamps();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:robots");
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT - Opened new tab in new window"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "LOADED_TAB_COUNT - Opened new tab in new window"
+ );
+
+ for (const tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_restored_max_pinned_count() {
+ // Following pinned tab testing example from
+ // https://searchfox.org/mozilla-central/rev/1843375acbbca68127713e402be222350ac99301/browser/components/sessionstore/test/browser_pinned_tabs.js
+ Services.telemetry.clearScalars();
+ const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+ );
+ const BACKUP_STATE = SessionStore.getBrowserState();
+ const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sessionstore.restore_on_demand", true],
+ ["browser.sessionstore.restore_tabs_lazily", true],
+ ],
+ });
+ let sessionRestoredPromise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "sessionstore-browser-state-restored");
+ });
+
+ info("Set browser state to 1 pinned tab.");
+ await SessionStore.setBrowserState(
+ JSON.stringify({
+ windows: [
+ {
+ selected: 1,
+ tabs: [
+ {
+ pinned: true,
+ entries: [
+ { url: "https://example.com", triggeringPrincipal_base64 },
+ ],
+ },
+ ],
+ },
+ ],
+ })
+ );
+
+ info("Await `sessionstore-browser-state-restored` promise.");
+ await sessionRestoredPromise;
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ MAX_TAB_PINNED,
+ 1,
+ "The maximum tabs pinned count must match the expected value."
+ );
+
+ gBrowser.unpinTab(gBrowser.selectedTab);
+
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ MAX_TAB_PINNED,
+ 1,
+ "The maximum tabs pinned count must match the expected value."
+ );
+
+ sessionRestoredPromise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "sessionstore-browser-state-restored");
+ });
+ await SessionStore.setBrowserState(BACKUP_STATE);
+ await SpecialPowers.popPrefEnv();
+ await sessionRestoredPromise;
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js b/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js
new file mode 100644
index 0000000000..3baa336d79
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js
@@ -0,0 +1,33 @@
+"use strict";
+
+const SCALAR_BUILDID_MISMATCH = "dom.contentprocess.buildID_mismatch";
+
+add_task(async function test_aboutRestartRequired() {
+ const { TabCrashHandler } = ChromeUtils.importESModule(
+ "resource:///modules/ContentCrashHandlers.sys.mjs"
+ );
+
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check preconditions
+ is(
+ scalars[SCALAR_BUILDID_MISMATCH],
+ undefined,
+ "Build ID mismatch count should be undefined"
+ );
+
+ // Simulate buildID mismatch
+ TabCrashHandler._crashedTabCount = 1;
+ TabCrashHandler.sendToRestartRequiredPage(gBrowser.selectedTab.linkedBrowser);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ is(
+ scalars[SCALAR_BUILDID_MISMATCH],
+ 1,
+ "Build ID mismatch count should be 1."
+ );
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_domains.js b/browser/modules/test/browser/browser_UsageTelemetry_domains.js
new file mode 100644
index 0000000000..453272ea6b
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_domains.js
@@ -0,0 +1,196 @@
+"use strict";
+
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
+
+// Reset internal URI counter in case URIs were opened by other tests.
+Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+/**
+ * Waits for the web progress listener associated with this tab to fire an
+ * onLocationChange for a non-error page.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ *
+ * @return {Promise}
+ * @resolves When navigating to a non-error page.
+ */
+function browserLocationChanged(browser) {
+ return new Promise(resolve => {
+ let wpl = {
+ onStateChange() {},
+ onSecurityChange() {},
+ onStatusChange() {},
+ onContentBlockingEvent() {},
+ onLocationChange(aWebProgress, aRequest, aURI, aFlags) {
+ if (!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE)) {
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(wpl);
+ resolve();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ ]),
+ };
+ const filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(wpl, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ });
+}
+
+add_task(async function test_URIAndDomainCounts() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let checkCounts = countsObject => {
+ // Get a snapshot of the scalars and then clear them.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ TOTAL_URI_COUNT,
+ countsObject.totalURIs,
+ "The URI scalar must contain the expected value."
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ UNIQUE_DOMAINS_COUNT,
+ countsObject.domainCount,
+ "The unique domains scalar must contain the expected value."
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ UNFILTERED_URI_COUNT,
+ countsObject.totalUnfilteredURIs,
+ "The unfiltered URI scalar must contain the expected value."
+ );
+ };
+
+ // Check that about:blank doesn't get counted in the URI total.
+ let firstTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ TOTAL_URI_COUNT
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ UNIQUE_DOMAINS_COUNT
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ UNFILTERED_URI_COUNT
+ );
+
+ // Open a different page and check the counts.
+ BrowserTestUtils.startLoadingURIString(
+ firstTab.linkedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(firstTab.linkedBrowser);
+ checkCounts({ totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1 });
+
+ // Activating a different tab must not increase the URI count.
+ let secondTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+ checkCounts({ totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1 });
+ BrowserTestUtils.removeTab(secondTab);
+
+ // Open a new window and set the tab to a new address.
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ BrowserTestUtils.startLoadingURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2 });
+
+ // We should not count AJAX requests.
+ const XHR_URL = "http://example.com/r";
+ await SpecialPowers.spawn(
+ newWin.gBrowser.selectedBrowser,
+ [XHR_URL],
+ function (url) {
+ return new Promise(resolve => {
+ var xhr = new content.window.XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = () => resolve();
+ xhr.send();
+ });
+ }
+ );
+ checkCounts({ totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2 });
+
+ // Check that we're counting page fragments.
+ let loadingStopped = browserLocationChanged(newWin.gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://example.com/#2"
+ );
+ await loadingStopped;
+ checkCounts({ totalURIs: 3, domainCount: 1, totalUnfilteredURIs: 3 });
+
+ // Check that a different URI from the example.com domain doesn't increment the unique count.
+ BrowserTestUtils.startLoadingURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://test1.example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 4, domainCount: 1, totalUnfilteredURIs: 4 });
+
+ // Make sure that the unique domains counter is incrementing for a different domain.
+ BrowserTestUtils.startLoadingURIString(
+ newWin.gBrowser.selectedBrowser,
+ "https://example.org/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5 });
+
+ // Check that we only account for top level loads (e.g. we don't count URIs from
+ // embedded iframes).
+ await SpecialPowers.spawn(
+ newWin.gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ let promiseIframeLoaded = ContentTaskUtils.waitForEvent(
+ iframe,
+ "load",
+ false
+ );
+ iframe.src = "https://example.org/test";
+ doc.body.insertBefore(iframe, doc.body.firstElementChild);
+ await promiseIframeLoaded;
+ }
+ );
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5 });
+
+ // Check that uncommon protocols get counted in the unfiltered URI probe.
+ const TEST_PAGE =
+ "data:text/html,<a id='target' href='%23par1'>Click me</a><a name='par1'>The paragraph.</a>";
+ BrowserTestUtils.startLoadingURIString(
+ newWin.gBrowser.selectedBrowser,
+ TEST_PAGE
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 6 });
+
+ // Clean up.
+ BrowserTestUtils.removeTab(firstTab);
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_interaction.js b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js
new file mode 100644
index 0000000000..2bc60d9697
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js
@@ -0,0 +1,955 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+gReduceMotionOverride = true;
+
+const AREAS = [
+ "keyboard",
+ "menu_bar",
+ "tabs_bar",
+ "nav_bar",
+ "bookmarks_bar",
+ "app_menu",
+ "tabs_context",
+ "content_context",
+ "overflow_menu",
+ "pinned_overflow_menu",
+ "pageaction_urlbar",
+ "pageaction_panel",
+
+ "preferences_paneHome",
+ "preferences_paneGeneral",
+ "preferences_panePrivacy",
+ "preferences_paneSearch",
+ "preferences_paneSearchResults",
+ "preferences_paneSync",
+ "preferences_paneContainers",
+];
+
+// Checks that the correct number of clicks are registered against the correct
+// keys in the scalars. Also runs keyed scalar checks against non-area types
+// passed in through expectedOther.
+function assertInteractionScalars(expectedAreas, expectedOther = {}) {
+ let processScalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {};
+
+ let compareSourceWithExpectations = (source, expected = {}) => {
+ let scalars = processScalars?.[`browser.ui.interaction.${source}`] ?? {};
+
+ let expectedKeys = new Set(
+ Object.keys(scalars).concat(Object.keys(expected))
+ );
+
+ for (let key of expectedKeys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for ${key} in ${source}.`
+ );
+ }
+ };
+
+ for (let source of AREAS) {
+ compareSourceWithExpectations(source, expectedAreas[source]);
+ }
+
+ for (let source in expectedOther) {
+ compareSourceWithExpectations(source, expectedOther[source]);
+ }
+}
+
+const elem = id => document.getElementById(id);
+const click = el => {
+ if (typeof el == "string") {
+ el = elem(el);
+ }
+
+ EventUtils.synthesizeMouseAtCenter(el, {}, window);
+};
+
+add_task(async function toolbarButtons() {
+ await BrowserTestUtils.withNewTab("https://example.com", async () => {
+ let customButton = await new Promise(resolve => {
+ CustomizableUI.createWidget({
+ // In CSS identifiers cannot start with a number but CustomizableUI accepts that.
+ id: "12foo",
+ label: "12foo",
+ onCreated: resolve,
+ defaultArea: "nav-bar",
+ });
+ });
+
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tabClose = BrowserTestUtils.waitForTabClosing(newTab);
+
+ let tabs = elem("tabbrowser-tabs");
+ if (!tabs.hasAttribute("overflow")) {
+ tabs.setAttribute("overflow", "true");
+ registerCleanupFunction(() => {
+ tabs.removeAttribute("overflow");
+ });
+ }
+
+ // We intentionally turn off a11y_checks for these click events, because the
+ // test is checking the telemetry functionality and the following 3 clicks
+ // are targeting disabled controls to test the changes in scalars (for more
+ // refer to the bug 1864576 comment 2 and bug 1854999 comment 4):
+ AccessibilityUtils.setEnv({
+ mustBeEnabled: false,
+ });
+ click("stop-reload-button");
+ click("back-button");
+ click("back-button");
+ AccessibilityUtils.resetEnv();
+
+ // Make sure the all tabs panel is in the document.
+ gTabsPanel.initElements();
+ let view = elem("allTabsMenu-allTabsView");
+ let shown = BrowserTestUtils.waitForEvent(view, "ViewShown");
+ click("alltabs-button");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(view, "ViewHiding");
+ gTabsPanel.hideAllTabsPanel();
+ await hidden;
+
+ click(newTab.querySelector(".tab-close-button"));
+ await tabClose;
+
+ let bookmarksToolbar = gNavToolbox.querySelector("#PersonalToolbar");
+
+ let bookmarksToolbarReady = BrowserTestUtils.waitForMutationCondition(
+ bookmarksToolbar,
+ { attributes: true },
+ () => {
+ return (
+ bookmarksToolbar.getAttribute("collapsed") != "true" &&
+ bookmarksToolbar.getAttribute("initialized") == "true"
+ );
+ }
+ );
+
+ window.setToolbarVisibility(
+ bookmarksToolbar,
+ true /* isVisible */,
+ false /* persist */,
+ false /* animated */
+ );
+ registerCleanupFunction(() => {
+ window.setToolbarVisibility(
+ bookmarksToolbar,
+ false /* isVisible */,
+ false /* persist */,
+ false /* animated */
+ );
+ });
+ await bookmarksToolbarReady;
+
+ // The Bookmarks Toolbar does some optimizations to try not to jank the
+ // browser when populating itself, and does so asynchronously. We wait
+ // until a bookmark item is available in the DOM before continuing.
+ let placesToolbarItems = document.getElementById("PlacesToolbarItems");
+ await BrowserTestUtils.waitForMutationCondition(
+ placesToolbarItems,
+ { childList: true },
+ () => placesToolbarItems.querySelector(".bookmark-item") != null
+ );
+
+ click(placesToolbarItems.querySelector(".bookmark-item"));
+
+ click(customButton);
+
+ assertInteractionScalars(
+ {
+ nav_bar: {
+ "stop-reload-button": 1,
+ "back-button": 2,
+ "12foo": 1,
+ },
+ tabs_bar: {
+ "alltabs-button": 1,
+ "tab-close-button": 1,
+ },
+ bookmarks_bar: {
+ "bookmark-item": 1,
+ },
+ },
+ {
+ all_tabs_panel_entrypoint: {
+ "alltabs-button": 1,
+ },
+ }
+ );
+ CustomizableUI.destroyWidget("12foo");
+ });
+});
+
+add_task(async function contextMenu() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let context = elem("tabContextMenu");
+ let shown = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.activateItem(document.getElementById("context_toggleMuteTab"));
+ await hidden;
+
+ assertInteractionScalars({
+ tabs_context: {
+ "context-toggleMuteTab": 1,
+ },
+ });
+
+ // Check that tab-related items in the toolbar menu also register telemetry:
+ context = elem("toolbar-context-menu");
+ shown = BrowserTestUtils.waitForEvent(context, "popupshown");
+ let scrollbox = elem("tabbrowser-arrowscrollbox");
+ EventUtils.synthesizeMouse(
+ scrollbox,
+ // offset within the scrollbox - somewhere near the end:
+ scrollbox.getBoundingClientRect().width - 20,
+ 5,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await shown;
+
+ hidden = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.activateItem(
+ document.getElementById("toolbar-context-selectAllTabs")
+ );
+ await hidden;
+
+ assertInteractionScalars({
+ tabs_context: {
+ "toolbar-context-selectAllTabs": 1,
+ },
+ });
+ // tidy up:
+ gBrowser.clearMultiSelectedTabs();
+ });
+});
+
+add_task(async function contextMenu_entrypoints() {
+ /**
+ * A utility function for this test task that opens the tab context
+ * menu for a particular trigger node, chooses the "Reload Tab" item,
+ * and then waits for the context menu to close.
+ *
+ * @param {Element} triggerNode
+ * The node that the tab context menu should be triggered with.
+ * @returns {Promise<undefined>}
+ * Resolves after the context menu has fired the popuphidden event.
+ */
+ let openAndCloseTabContextMenu = async triggerNode => {
+ let contextMenu = document.getElementById("tabContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(triggerNode, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShown;
+
+ let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ let menuitem = document.getElementById("context_reloadTab");
+ contextMenu.activateItem(menuitem);
+ await popupHidden;
+ };
+
+ const TAB_CONTEXTMENU_ENTRYPOINT_SCALAR =
+ "browser.ui.interaction.tabs_context_entrypoint";
+ Services.telemetry.clearScalars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertScalarUnset(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR
+ );
+
+ await openAndCloseTabContextMenu(gBrowser.selectedTab);
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR,
+ "tabs-bar",
+ 1
+ );
+
+ gTabsPanel.initElements();
+ let allTabsView = document.getElementById("allTabsMenu-allTabsView");
+ let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ gTabsPanel.showAllTabsPanel(null);
+ await allTabsPopupShownPromise;
+
+ let firstTabItem = gTabsPanel.allTabsViewTabs.children[0];
+ await openAndCloseTabContextMenu(firstTabItem);
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR,
+ "alltabs-menu",
+ 1
+ );
+
+ let allTabsPopupHiddenPromise = BrowserTestUtils.waitForEvent(
+ allTabsView.panelMultiView,
+ "PanelMultiViewHidden"
+ );
+ gTabsPanel.hideAllTabsPanel();
+ await allTabsPopupHiddenPromise;
+});
+
+add_task(async function appMenu() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popuphidden"
+ );
+
+ let findButtonID = "appMenu-find-button2";
+ click(findButtonID);
+ await hidden;
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+ app_menu: {},
+ };
+ expectedScalars.app_menu[findButtonID] = 1;
+
+ assertInteractionScalars(expectedScalars);
+ });
+});
+
+add_task(async function devtools() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-more-button2");
+ shown = BrowserTestUtils.waitForEvent(
+ elem("appmenu-moreTools"),
+ "ViewShown"
+ );
+ await shown;
+
+ let tabOpen = BrowserTestUtils.waitForNewTab(gBrowser);
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popuphidden"
+ );
+ click(
+ document.querySelector(
+ "#appmenu-moreTools toolbarbutton[key='key_viewSource']"
+ )
+ );
+ await hidden;
+
+ let tab = await tabOpen;
+ BrowserTestUtils.removeTab(tab);
+
+ // Note that item ID's have '_' converted to '-'.
+ assertInteractionScalars({
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+ app_menu: {
+ "appMenu-more-button2": 1,
+ "key-viewSource": 1,
+ },
+ });
+ });
+});
+
+add_task(async function webextension() {
+ BrowserUsageTelemetry._resetAddonIds();
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ function background() {
+ browser.commands.onCommand.addListener(() => {
+ browser.test.sendMessage("oncommand");
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-sidebar-action") {
+ browser.test.sendMessage("sidebar-opened");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ show_matches: ["https://example.com/*"],
+ },
+ commands: {
+ test_command: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ _execute_sidebar_action: {
+ suggested_key: {
+ default: "Alt+Shift+Q",
+ },
+ },
+ },
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ open_at_install: false,
+ },
+ },
+ files: {
+ "sidebar.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="sidebar.js"></script>
+ </head>
+ </html>
+ `,
+
+ "sidebar.js": function () {
+ browser.runtime.sendMessage("from-sidebar-action");
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // As the first add-on interacted with this should show up as `addon0`.
+
+ click("random_addon_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon0: 1,
+ },
+ });
+
+ // Wait for the element to show up.
+ await TestUtils.waitForCondition(() =>
+ elem("pageAction-urlbar-random_addon_example_com")
+ );
+
+ click("pageAction-urlbar-random_addon_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("q", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("sidebar-opened");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ const extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon2@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ show_matches: ["https://example.com/*"],
+ },
+ commands: {
+ test_command: {
+ suggested_key: {
+ default: "Alt+Shift+9",
+ },
+ },
+ },
+ },
+ background,
+ });
+
+ await extension2.startup();
+ await extension2.awaitMessage("ready");
+
+ // A second extension should be `addon1`.
+
+ click("random_addon2_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon1: 1,
+ },
+ });
+
+ // Wait for the element to show up.
+ await TestUtils.waitForCondition(() =>
+ elem("pageAction-urlbar-random_addon2_example_com")
+ );
+
+ click("pageAction-urlbar-random_addon2_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon1: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("9", { altKey: true, shiftKey: true });
+ await extension2.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon1: 1,
+ },
+ });
+
+ // The first should have retained its ID.
+ click("random_addon_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ click("pageAction-urlbar-random_addon_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon0: 1,
+ },
+ });
+
+ await extension.unload();
+
+ // Clear the last opened ID so if this test runs again the sidebar won't
+ // automatically open when the extension is installed.
+ window.SidebarUI.lastOpenedId = null;
+
+ // The second should retain its ID.
+ click("random_addon2_example_com-browser-action");
+ click("random_addon2_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon1: 2,
+ },
+ });
+
+ click("pageAction-urlbar-random_addon2_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon1: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("9", { altKey: true, shiftKey: true });
+ await extension2.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon1: 1,
+ },
+ });
+
+ await extension2.unload();
+
+ // Now test that browser action items in the add-ons panel also get
+ // telemetry recorded for them.
+ const extension3 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon3@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ },
+ },
+ });
+
+ await extension3.startup();
+
+ const shown = BrowserTestUtils.waitForPopupEvent(
+ gUnifiedExtensions.panel,
+ "shown"
+ );
+ await gUnifiedExtensions.togglePanel();
+ await shown;
+
+ click("random_addon3_example_com-browser-action");
+ assertInteractionScalars({
+ unified_extensions_area: {
+ addon2: 1,
+ },
+ });
+ const hidden = BrowserTestUtils.waitForPopupEvent(
+ gUnifiedExtensions.panel,
+ "hidden"
+ );
+ await gUnifiedExtensions.panel.hidePopup();
+ await hidden;
+
+ await extension3.unload();
+ });
+});
+
+add_task(async function mainMenu() {
+ // macOS does not use the menu bar.
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ BrowserUsageTelemetry._resetAddonIds();
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("menu_EditPopup"),
+ "popupshown"
+ );
+ click("edit-menu");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("menu_EditPopup"),
+ "popuphidden"
+ );
+ click("menu_selectAll");
+ await hidden;
+
+ assertInteractionScalars({
+ menu_bar: {
+ // Note that the _ is replaced with - for telemetry identifiers.
+ "menu-selectAll": 1,
+ },
+ });
+
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", false);
+ });
+});
+
+add_task(async function preferences() {
+ let finalPaneEvent = Services.prefs.getBoolPref("identity.fxaccounts.enabled")
+ ? "sync-pane-loaded"
+ : "privacy-pane-loaded";
+ let finalPrefPaneLoaded = TestUtils.topicObserved(finalPaneEvent, () => true);
+ await BrowserTestUtils.withNewTab("about:preferences", async browser => {
+ await finalPrefPaneLoaded;
+
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#browserRestoreSession",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#category-search",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#category-privacy",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+ await BrowserTestUtils.waitForCondition(() =>
+ gBrowser.selectedBrowser.contentDocument.getElementById(
+ "contentBlockingLearnMore"
+ )
+ );
+
+ const onLearnMoreOpened = BrowserTestUtils.waitForNewTab(gBrowser);
+ gBrowser.selectedBrowser.contentDocument
+ .getElementById("contentBlockingLearnMore")
+ .scrollIntoView();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#contentBlockingLearnMore",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+ await onLearnMoreOpened;
+ gBrowser.removeCurrentTab();
+
+ assertInteractionScalars({
+ preferences_paneGeneral: {
+ browserRestoreSession: 1,
+ },
+ preferences_panePrivacy: {
+ contentBlockingLearnMore: 1,
+ },
+ });
+ });
+});
+
+/**
+ * Context click on a history or bookmark link and open it in a new window.
+ *
+ * @param {Element} link - The link to open.
+ */
+async function openLinkUsingContextMenu(link) {
+ const placesContext = document.getElementById("placesContext");
+ const promisePopup = BrowserTestUtils.waitForEvent(
+ placesContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(link, {
+ button: 2,
+ type: "contextmenu",
+ });
+ await promisePopup;
+ const promiseNewWindow = BrowserTestUtils.waitForNewWindow();
+ placesContext.activateItem(
+ document.getElementById("placesContext_open:newwindow")
+ );
+ const win = await promiseNewWindow;
+ await BrowserTestUtils.closeWindow(win);
+}
+
+async function history_appMenu(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-history-button");
+ shown = BrowserTestUtils.waitForEvent(elem("PanelUI-history"), "ViewShown");
+ await shown;
+
+ let list = document.getElementById("appMenu_historyMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+
+ app_menu: { "history-item": 1, "appMenu-history-button": 1 },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+}
+
+add_task(async function history_appMenu_click() {
+ await history_appMenu(false);
+});
+
+add_task(async function history_appMenu_context_click() {
+ await history_appMenu(true);
+});
+
+async function bookmarks_appMenu(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+
+ shown = BrowserTestUtils.waitForEvent(elem("appMenu-popup"), "popupshown");
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-bookmarks-button");
+ shown = BrowserTestUtils.waitForEvent(
+ elem("PanelUI-bookmarks"),
+ "ViewShown"
+ );
+ await shown;
+
+ let list = document.getElementById("panelMenu_bookmarksMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+
+ app_menu: { "bookmark-item": 1, "appMenu-bookmarks-button": 1 },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+}
+
+add_task(async function bookmarks_appMenu_click() {
+ await bookmarks_appMenu(false);
+});
+
+add_task(async function bookmarks_appMenu_context_click() {
+ await bookmarks_appMenu(true);
+});
+
+async function bookmarks_library_navbar(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ CustomizableUI.addWidgetToArea("library-button", "nav-bar");
+ let button = document.getElementById("library-button");
+ button.click();
+ await BrowserTestUtils.waitForEvent(
+ elem("appMenu-libraryView"),
+ "ViewShown"
+ );
+
+ click("appMenu-library-bookmarks-button");
+ await BrowserTestUtils.waitForEvent(elem("PanelUI-bookmarks"), "ViewShown");
+
+ let list = document.getElementById("panelMenu_bookmarksMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "library-button": 1,
+ "bookmark-item": 1,
+ "appMenu-library-bookmarks-button": 1,
+ },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+
+ CustomizableUI.removeWidgetFromArea("library-button");
+}
+
+add_task(async function bookmarks_library_navbar_click() {
+ await bookmarks_library_navbar(false);
+});
+
+add_task(async function bookmarks_library_navbar_context_click() {
+ await bookmarks_library_navbar(true);
+});
+
+async function history_library_navbar(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ CustomizableUI.addWidgetToArea("library-button", "nav-bar");
+ let button = document.getElementById("library-button");
+ button.click();
+ await BrowserTestUtils.waitForEvent(
+ elem("appMenu-libraryView"),
+ "ViewShown"
+ );
+
+ click("appMenu-library-history-button");
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("PanelUI-history"),
+ "ViewShown"
+ );
+ await shown;
+
+ let list = document.getElementById("appMenu_historyMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "library-button": 1,
+ "history-item": 1,
+ "appMenu-library-history-button": 1,
+ },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+
+ CustomizableUI.removeWidgetFromArea("library-button");
+}
+
+add_task(async function history_library_navbar_click() {
+ await history_library_navbar(false);
+});
+
+add_task(async function history_library_navbar_context_click() {
+ await history_library_navbar(true);
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js b/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js
new file mode 100644
index 0000000000..89222739be
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js
@@ -0,0 +1,164 @@
+"use strict";
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE =
+ "browser.engagement.total_uri_count_normal_and_private_mode";
+
+BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = 0;
+registerCleanupFunction(() => {
+ BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = undefined;
+});
+
+function promiseBrowserStateRestored() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ Services.obs.removeObserver(
+ observer,
+ "sessionstore-browser-state-restored"
+ );
+ resolve();
+ }, "sessionstore-browser-state-restored");
+ });
+}
+
+add_task(async function test_privateMode() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+
+ // Open a private window and load a website in it.
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await BrowserTestUtils.firstBrowserLoaded(privateWin);
+ BrowserTestUtils.startLoadingURIString(
+ privateWin.gBrowser.selectedBrowser,
+ "https://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(
+ privateWin.gBrowser.selectedBrowser,
+ false,
+ "https://example.com/"
+ );
+
+ // Check that tab and window count is recorded.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ ok(
+ !(TOTAL_URI_COUNT in scalars),
+ "We should not track URIs in private mode."
+ );
+ ok(
+ !(UNFILTERED_URI_COUNT in scalars),
+ "We should not track URIs in private mode."
+ );
+ ok(
+ !(UNIQUE_DOMAINS_COUNT in scalars),
+ "We should not track unique domains in private mode."
+ );
+ is(
+ scalars[TAB_EVENT_COUNT],
+ 1,
+ "The number of open tab event count must match the expected value."
+ );
+ is(
+ scalars[MAX_CONCURRENT_TABS],
+ 2,
+ "The maximum tab count must match the expected value."
+ );
+ is(
+ scalars[WINDOW_OPEN_COUNT],
+ 1,
+ "The number of window open event count must match the expected value."
+ );
+ is(
+ scalars[MAX_CONCURRENT_WINDOWS],
+ 2,
+ "The maximum window count must match the expected value."
+ );
+ is(
+ scalars[TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE],
+ 1,
+ "We should include URIs in private mode as part of the actual total URI count."
+ );
+ is(
+ Glean.browserEngagement.uriCount.testGetValue(),
+ 1,
+ "We should record the URI count in Glean as well."
+ );
+
+ // Clean up.
+ await BrowserTestUtils.closeWindow(privateWin);
+});
+
+add_task(async function test_sessionRestore() {
+ const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand";
+ Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND);
+ });
+
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // The first window will be put into the already open window and the second
+ // window will be opened with _openWindowWithState, which is the source of the problem.
+ const state = {
+ windows: [
+ {
+ tabs: [
+ {
+ entries: [
+ { url: "http://example.org", triggeringPrincipal_base64 },
+ ],
+ extData: { uniq: 3785 },
+ },
+ ],
+ selected: 1,
+ },
+ ],
+ };
+
+ // Save the current session.
+ let { SessionStore } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/SessionStore.sys.mjs"
+ );
+
+ // Load the custom state and wait for SSTabRestored, as we want to make sure
+ // that the URI counting code was hit.
+ let tabRestored = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "SSTabRestored"
+ );
+ SessionStore.setBrowserState(JSON.stringify(state));
+ await tabRestored;
+
+ // Check that the URI is not recorded.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ ok(
+ !(TOTAL_URI_COUNT in scalars),
+ "We should not track URIs from restored sessions."
+ );
+ ok(
+ !(UNFILTERED_URI_COUNT in scalars),
+ "We should not track URIs from restored sessions."
+ );
+ ok(
+ !(UNIQUE_DOMAINS_COUNT in scalars),
+ "We should not track unique domains from restored sessions."
+ );
+
+ // Restore the original session and cleanup.
+ let sessionRestored = promiseBrowserStateRestored();
+ SessionStore.setBrowserState(JSON.stringify(state));
+ await sessionRestored;
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js b/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js
new file mode 100644
index 0000000000..aade03ec84
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js
@@ -0,0 +1,550 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+gReduceMotionOverride = true;
+
+function enterCustomizationMode(win = window) {
+ let customizationReadyPromise = BrowserTestUtils.waitForEvent(
+ win.gNavToolbox,
+ "customizationready"
+ );
+ win.gCustomizeMode.enter();
+ return customizationReadyPromise;
+}
+
+function leaveCustomizationMode(win = window) {
+ let customizationDonePromise = BrowserTestUtils.waitForEvent(
+ win.gNavToolbox,
+ "aftercustomization"
+ );
+ win.gCustomizeMode.exit();
+ return customizationDonePromise;
+}
+
+Services.prefs.setBoolPref("browser.uiCustomization.skipSourceNodeCheck", true);
+registerCleanupFunction(() => {
+ CustomizableUI.reset();
+ Services.prefs.clearUserPref("browser.uiCustomization.skipSourceNodeCheck");
+});
+
+// Stolen from browser/components/customizableui/tests/browser/head.js
+function simulateItemDrag(aToDrag, aTarget, aEvent = {}, aOffset = 2) {
+ let ev = aEvent;
+ if (ev == "end" || ev == "start") {
+ let win = aTarget.ownerGlobal;
+ const dwu = win.windowUtils;
+ let bounds = dwu.getBoundsWithoutFlushing(aTarget);
+ if (ev == "end") {
+ ev = {
+ clientX: bounds.right - aOffset,
+ clientY: bounds.bottom - aOffset,
+ };
+ } else {
+ ev = { clientX: bounds.left + aOffset, clientY: bounds.top + aOffset };
+ }
+ }
+ ev._domDispatchOnly = true;
+ EventUtils.synthesizeDrop(
+ aToDrag.parentNode,
+ aTarget,
+ null,
+ null,
+ aToDrag.ownerGlobal,
+ aTarget.ownerGlobal,
+ ev
+ );
+ // Ensure dnd suppression is cleared.
+ EventUtils.synthesizeMouseAtCenter(
+ aTarget,
+ { type: "mouseup" },
+ aTarget.ownerGlobal
+ );
+}
+
+function organizeToolbars(state = {}) {
+ // Set up the defaults for the state.
+ let targetState = Object.assign(
+ {
+ // Areas where widgets can be placed, set to an array of widget IDs.
+ "toolbar-menubar": undefined,
+ PersonalToolbar: undefined,
+ TabsToolbar: ["tabbrowser-tabs", "alltabs-button"],
+ "widget-overflow-fixed-list": undefined,
+ "nav-bar": ["back-button", "forward-button", "urlbar-container"],
+
+ // The page action's that should be in the URL bar.
+ pageActionsInUrlBar: [],
+
+ // Areas to show or hide.
+ titlebarVisible: false,
+ menubarVisible: false,
+ personalToolbarVisible: false,
+ },
+ state
+ );
+
+ for (let area of CustomizableUI.areas) {
+ // Clear out anything there already.
+ for (let widgetId of CustomizableUI.getWidgetIdsInArea(area)) {
+ CustomizableUI.removeWidgetFromArea(widgetId);
+ }
+
+ if (targetState[area]) {
+ // We specify the position explicitly to support the toolbars that have
+ // fixed widgets.
+ let position = 0;
+ for (let widgetId of targetState[area]) {
+ CustomizableUI.addWidgetToArea(widgetId, area, position++);
+ }
+ }
+ }
+
+ CustomizableUI.setToolbarVisibility(
+ "toolbar-menubar",
+ targetState.menubarVisible
+ );
+ CustomizableUI.setToolbarVisibility(
+ "PersonalToolbar",
+ targetState.personalToolbarVisible
+ );
+
+ Services.prefs.setIntPref(
+ "browser.tabs.inTitlebar",
+ !targetState.titlebarVisible
+ );
+
+ for (let action of PageActions.actions) {
+ action.pinnedToUrlbar = targetState.pageActionsInUrlBar.includes(action.id);
+ }
+
+ // Clear out the existing telemetry.
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+}
+
+function assertVisibilityScalars(expected) {
+ let scalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent?.[
+ "browser.ui.toolbar_widgets"
+ ] ?? {};
+
+ // Only some platforms have the menubar items.
+ if (AppConstants.MENUBAR_CAN_AUTOHIDE) {
+ expected.push("menubar-items_pinned_menu-bar");
+ }
+
+ let keys = new Set(expected.concat(Object.keys(scalars)));
+ for (let key of keys) {
+ Assert.ok(expected.includes(key), `Scalar key ${key} was unexpected.`);
+ Assert.ok(scalars[key], `Expected to see see scalar key ${key} be true.`);
+ }
+}
+
+function assertCustomizeScalars(expected) {
+ let scalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent?.[
+ "browser.ui.customized_widgets"
+ ] ?? {};
+
+ let keys = new Set(Object.keys(expected).concat(Object.keys(scalars)));
+ for (let key of keys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for scalar ${key}.`
+ );
+ }
+}
+
+add_task(async function widgetPositions() {
+ organizeToolbars();
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ ]);
+
+ organizeToolbars({
+ PersonalToolbar: [
+ "fxa-toolbar-menu-button",
+ "new-tab-button",
+ "developer-button",
+ ],
+
+ TabsToolbar: [
+ "stop-reload-button",
+ "tabbrowser-tabs",
+ "personal-bookmarks",
+ ],
+
+ "nav-bar": [
+ "home-button",
+ "forward-button",
+ "downloads-button",
+ "urlbar-container",
+ "back-button",
+ "library-button",
+ ],
+
+ personalToolbarVisible: true,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_on",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "stop-reload-button_pinned_tabs-bar",
+ "personal-bookmarks_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "home-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "downloads-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "fxa-toolbar-menu-button_pinned_bookmarks-bar",
+ "new-tab-button_pinned_bookmarks-bar",
+ "developer-button_pinned_bookmarks-bar",
+ ]);
+
+ CustomizableUI.reset();
+});
+
+add_task(async function customizeMode() {
+ // Create a default state.
+ organizeToolbars({
+ PersonalToolbar: ["personal-bookmarks"],
+
+ TabsToolbar: ["tabbrowser-tabs", "new-tab-button"],
+
+ "nav-bar": [
+ "back-button",
+ "forward-button",
+ "stop-reload-button",
+ "urlbar-container",
+ "home-button",
+ "library-button",
+ ],
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "new-tab-button_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "back-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "stop-reload-button_pinned_nav-bar-start",
+ "home-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "personal-bookmarks_pinned_bookmarks-bar",
+ ]);
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ await enterCustomizationMode(win);
+
+ let toolbarButton = win.document.getElementById(
+ "customization-toolbar-visibility-button"
+ );
+ let toolbarPopup = win.document.getElementById("customization-toolbar-menu");
+ let popupShown = BrowserTestUtils.waitForEvent(toolbarPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(toolbarButton, {}, win);
+ await popupShown;
+
+ let barMenu = win.document.getElementById("toggle_PersonalToolbar");
+ let popupHidden = BrowserTestUtils.waitForEvent(toolbarPopup, "popuphidden");
+ let subMenu = barMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(barMenu, {}, win);
+ await popupShown;
+ let alwaysButton = barMenu.querySelector('*[data-visibility-enum="always"]');
+ EventUtils.synthesizeMouseAtCenter(alwaysButton, {}, win);
+ await popupHidden;
+
+ let navbar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("nav-bar")
+ );
+ let bookmarksBar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("PersonalToolbar")
+ );
+ let tabBar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("TabsToolbar")
+ );
+
+ simulateItemDrag(win.document.getElementById("home-button"), navbar, "start");
+ simulateItemDrag(win.document.getElementById("library-button"), bookmarksBar);
+ simulateItemDrag(win.document.getElementById("stop-reload-button"), tabBar);
+ simulateItemDrag(
+ win.document.getElementById("stop-reload-button"),
+ navbar,
+ "start"
+ );
+ simulateItemDrag(win.document.getElementById("stop-reload-button"), tabBar);
+
+ await leaveCustomizationMode(win);
+
+ await BrowserTestUtils.closeWindow(win);
+
+ assertCustomizeScalars({
+ "home-button_move_nav-bar-end_nav-bar-start_drag": 1,
+ "library-button_move_nav-bar-end_bookmarks-bar_drag": 1,
+ "stop-reload-button_move_nav-bar-start_tabs-bar_drag": 2,
+ "stop-reload-button_move_tabs-bar_nav-bar-start_drag": 1,
+ "bookmarks-bar_move_off_always_customization-toolbar-menu": 1,
+ });
+
+ CustomizableUI.reset();
+});
+
+add_task(async function contextMenus() {
+ // Create a default state.
+ organizeToolbars({
+ PersonalToolbar: ["personal-bookmarks"],
+
+ TabsToolbar: ["tabbrowser-tabs", "new-tab-button"],
+
+ "nav-bar": [
+ "back-button",
+ "forward-button",
+ "stop-reload-button",
+ "urlbar-container",
+ "home-button",
+ "library-button",
+ ],
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "new-tab-button_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "back-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "stop-reload-button_pinned_nav-bar-start",
+ "home-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "personal-bookmarks_pinned_bookmarks-bar",
+ ]);
+
+ let menu = document.getElementById("toolbar-context-menu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ let button = document.getElementById("stop-reload-button");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await popupShown;
+
+ let barMenu = document.getElementById("toggle_PersonalToolbar");
+ let popupHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let subMenu = barMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ barMenu.openMenu(true);
+ await popupShown;
+ let alwaysButton = subMenu.querySelector('*[data-visibility-enum="always"]');
+ subMenu.activateItem(alwaysButton);
+ await popupHidden;
+
+ popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await popupShown;
+
+ popupHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let removeButton = document.querySelector(
+ "#toolbar-context-menu .customize-context-removeFromToolbar"
+ );
+ menu.activateItem(removeButton);
+ await popupHidden;
+
+ assertCustomizeScalars({
+ "bookmarks-bar_move_off_always_toolbar-context-menu": 1,
+ "stop-reload-button_remove_nav-bar-start_na_toolbar-context-menu": 1,
+ });
+
+ CustomizableUI.reset();
+});
+
+add_task(async function extensions() {
+ // The page action button is only visible when a page is loaded.
+ await BrowserTestUtils.withNewTab("http://example.com", async () => {
+ organizeToolbars();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_add_na_nav-bar-end_addon": 1,
+ "random-addon-example-com_add_na_pageaction-urlbar_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+
+ "random-addon-example-com_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "random-addon-example-com_pinned_pageaction-urlbar",
+ ]);
+
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_remove_nav-bar-end_na_addon": 1,
+ "random-addon-example-com_remove_pageaction-urlbar_na_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ "unified-extensions-button_pinned_nav-bar-end",
+ ]);
+
+ await addon.enable();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_add_na_nav-bar-end_addon": 1,
+ "random-addon-example-com_add_na_pageaction-urlbar_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+
+ "random-addon-example-com_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "random-addon-example-com_pinned_pageaction-urlbar",
+ ]);
+
+ await addon.reload();
+
+ assertCustomizeScalars({});
+
+ await enterCustomizationMode();
+
+ let navbar = CustomizableUI.getCustomizationTarget(
+ document.getElementById("nav-bar")
+ );
+
+ simulateItemDrag(
+ document.getElementById("random_addon_example_com-browser-action"),
+ navbar,
+ "start"
+ );
+
+ await leaveCustomizationMode();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_move_nav-bar-end_nav-bar-start_drag": 1,
+ });
+
+ await extension.unload();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_remove_nav-bar-start_na_addon": 1,
+ "random-addon-example-com_remove_pageaction-urlbar_na_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ "unified-extensions-button_pinned_nav-bar-end",
+ ]);
+ });
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js b/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js
new file mode 100644
index 0000000000..11986f8e31
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js
@@ -0,0 +1,87 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ URICountListener: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
+});
+
+add_task(async function test_uniqueDomainsVisitedInPast24Hours() {
+ // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though:
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.proxy.allow_hijacking_localhost", true]],
+ });
+ registerCleanupFunction(async () => {
+ info("Cleaning up");
+ URICountListener.resetUniqueDomainsVisitedInPast24Hours();
+ });
+
+ URICountListener.resetUniqueDomainsVisitedInPast24Hours();
+ let startingCount = URICountListener.uniqueDomainsVisitedInPast24Hours;
+ is(
+ startingCount,
+ 0,
+ "We should have no domains recorded in the history right after resetting"
+ );
+
+ // Add a new window and then some tabs in it.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://example.com"
+ );
+
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://test1.example.com"
+ );
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 1,
+ "test1.example.com should only count as a unique visit if example.com wasn't visited before"
+ );
+
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "http://127.0.0.1");
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 1,
+ "127.0.0.1 should not count as a unique visit"
+ );
+
+ // Set the expiry time to 4 seconds. The value should be reasonably short
+ // for testing, but long enough so that waiting for openNewForegroundTab
+ // does not cause the expiry timeout to run.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.engagement.recent_visited_origins.expiry", 4]],
+ });
+
+ // http://www.exämple.test
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://xn--exmple-cua.test"
+ );
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 2,
+ "www.exämple.test should count as a unique visit"
+ );
+
+ let countBefore = URICountListener.uniqueDomainsVisitedInPast24Hours;
+
+ // If expiration does not work correctly, the following will time out.
+ await BrowserTestUtils.waitForCondition(() => {
+ return (
+ URICountListener.uniqueDomainsVisitedInPast24Hours == countBefore - 1
+ );
+ }, 250);
+
+ let countAfter = URICountListener.uniqueDomainsVisitedInPast24Hours;
+ is(countAfter, countBefore - 1, "The expiry should work correctly");
+
+ BrowserTestUtils.removeTab(win.gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(win.gBrowser.selectedTab);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/modules/test/browser/browser_preloading_tab_moving.js b/browser/modules/test/browser/browser_preloading_tab_moving.js
new file mode 100644
index 0000000000..ce7cba9e85
--- /dev/null
+++ b/browser/modules/test/browser/browser_preloading_tab_moving.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gOldCount = NewTabPagePreloading.MAX_COUNT;
+registerCleanupFunction(() => {
+ NewTabPagePreloading.MAX_COUNT = gOldCount;
+});
+
+async function openWinWithPreloadBrowser(options = {}) {
+ let idleFinishedPromise = TestUtils.topicObserved(
+ "browser-idle-startup-tasks-finished",
+ w => {
+ return w != window;
+ }
+ );
+ let newWin = await BrowserTestUtils.openNewBrowserWindow(options);
+ await idleFinishedPromise;
+ await TestUtils.waitForCondition(() => newWin.gBrowser.preloadedBrowser);
+ return newWin;
+}
+
+async function promiseNewTabLoadedInBrowser(browser) {
+ let url = browser.ownerGlobal.BROWSER_NEW_TAB_URL;
+ if (browser.currentURI.spec != url) {
+ info(`Waiting for ${url} to be the location for the browser.`);
+ await new Promise(resolve => {
+ let progressListener = {
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (!url || aLocationURI.spec == url) {
+ browser.removeProgressListener(progressListener);
+ resolve();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsISupportsWeakReference,
+ Ci.nsIWebProgressListener2,
+ Ci.nsIWebProgressListener,
+ ]),
+ };
+ browser.addProgressListener(
+ progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ });
+ } else {
+ info(`${url} already the current URI for the browser.`);
+ }
+
+ info(`Waiting for readyState complete in the browser`);
+ await SpecialPowers.spawn(browser, [], function () {
+ return ContentTaskUtils.waitForCondition(() => {
+ return content.document.readyState == "complete";
+ });
+ });
+}
+
+/**
+ * Verify that moving a preloaded browser's content from one window to the next
+ * works correctly.
+ */
+add_task(async function moving_works() {
+ NewTabPagePreloading.MAX_COUNT = 1;
+
+ NewTabPagePreloading.removePreloadedBrowser(window);
+
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ isnot(gBrowser.preloadedBrowser, null, "Should have preloaded browser");
+
+ let oldKey = gBrowser.preloadedBrowser.permanentKey;
+
+ let newWin = await openWinWithPreloadBrowser();
+ is(gBrowser.preloadedBrowser, null, "Preloaded browser should be gone");
+ isnot(
+ newWin.gBrowser.preloadedBrowser,
+ null,
+ "Should have moved the preload browser"
+ );
+ is(
+ newWin.gBrowser.preloadedBrowser.permanentKey,
+ oldKey,
+ "Should have the same permanent key"
+ );
+ let browser = newWin.gBrowser.preloadedBrowser;
+ let tab = BrowserTestUtils.addTab(
+ newWin.gBrowser,
+ newWin.BROWSER_NEW_TAB_URL
+ );
+ is(
+ tab.linkedBrowser,
+ browser,
+ "Preloaded browser is usable when opening a new tab."
+ );
+ await promiseNewTabLoadedInBrowser(browser);
+ ok(true, "Successfully loaded the tab.");
+
+ tab = browser = null;
+ await BrowserTestUtils.closeWindow(newWin);
+
+ tab = BrowserTestUtils.addTab(gBrowser, BROWSER_NEW_TAB_URL);
+ await promiseNewTabLoadedInBrowser(tab.linkedBrowser);
+
+ ok(true, "Managed to open a tab in the original window still.");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function moving_shouldnt_move_across_private_state() {
+ NewTabPagePreloading.MAX_COUNT = 1;
+
+ NewTabPagePreloading.removePreloadedBrowser(window);
+
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ isnot(gBrowser.preloadedBrowser, null, "Should have preloaded browser");
+
+ let oldKey = gBrowser.preloadedBrowser.permanentKey;
+ let newWin = await openWinWithPreloadBrowser({ private: true });
+
+ isnot(
+ gBrowser.preloadedBrowser,
+ null,
+ "Preloaded browser in original window should persist"
+ );
+ isnot(
+ newWin.gBrowser.preloadedBrowser,
+ null,
+ "Should have created another preload browser"
+ );
+ isnot(
+ newWin.gBrowser.preloadedBrowser.permanentKey,
+ oldKey,
+ "Should not have the same permanent key"
+ );
+ let browser = newWin.gBrowser.preloadedBrowser;
+ let tab = BrowserTestUtils.addTab(
+ newWin.gBrowser,
+ newWin.BROWSER_NEW_TAB_URL
+ );
+ is(
+ tab.linkedBrowser,
+ browser,
+ "Preloaded browser is usable when opening a new tab."
+ );
+ await promiseNewTabLoadedInBrowser(browser);
+ ok(true, "Successfully loaded the tab.");
+
+ tab = browser = null;
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/modules/test/browser/browser_taskbar_preview.js b/browser/modules/test/browser/browser_taskbar_preview.js
new file mode 100644
index 0000000000..921a9eb1bc
--- /dev/null
+++ b/browser/modules/test/browser/browser_taskbar_preview.js
@@ -0,0 +1,129 @@
+function test() {
+ var isWin7OrHigher = false;
+ try {
+ let version = Services.sysinfo.getProperty("version");
+ isWin7OrHigher = parseFloat(version) >= 6.1;
+ } catch (ex) {}
+
+ is(
+ !!Win7Features,
+ isWin7OrHigher,
+ "Win7Features available when it should be"
+ );
+ if (!isWin7OrHigher) {
+ return;
+ }
+
+ const ENABLE_PREF_NAME = "browser.taskbar.previews.enable";
+
+ let { AeroPeek } = ChromeUtils.importESModule(
+ "resource:///modules/WindowsPreviewPerTab.sys.mjs"
+ );
+
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, true);
+
+ is(1, AeroPeek.windows.length, "Got the expected number of windows");
+
+ checkPreviews(1, "Browser starts with one preview");
+
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+
+ checkPreviews(4, "Correct number of previews after adding");
+
+ for (let preview of AeroPeek.previews) {
+ ok(preview.visible, "Preview is shown as expected");
+ }
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, false);
+ is(0, AeroPeek.previews.length, "Should have 0 previews when disabled");
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, true);
+ checkPreviews(4, "Previews are back when re-enabling");
+ for (let preview of AeroPeek.previews) {
+ ok(preview.visible, "Preview is shown as expected after re-enabling");
+ }
+
+ [1, 2, 3, 4].forEach(function (idx) {
+ gBrowser.selectedTab = gBrowser.tabs[idx];
+ ok(checkSelectedTab(), "Current tab is correctly selected");
+ });
+
+ // Close #4
+ getPreviewForTab(gBrowser.selectedTab).controller.onClose();
+ checkPreviews(
+ 3,
+ "Expected number of previews after closing selected tab via controller"
+ );
+ Assert.equal(gBrowser.tabs.length, 3, "Successfully closed a tab");
+
+ // Select #1
+ ok(
+ getPreviewForTab(gBrowser.tabs[0]).controller.onActivate(),
+ "Activation was accepted"
+ );
+ ok(gBrowser.tabs[0].selected, "Correct tab was selected");
+ checkSelectedTab();
+
+ // Remove #3 (non active)
+ gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ checkPreviews(
+ 2,
+ "Expected number of previews after closing unselected via browser"
+ );
+
+ // Remove #1 (active)
+ gBrowser.removeTab(gBrowser.tabs[0]);
+ checkPreviews(
+ 1,
+ "Expected number of previews after closing selected tab via browser"
+ );
+
+ // Add a new tab
+ BrowserTestUtils.addTab(gBrowser);
+ checkPreviews(2);
+ // Check default selection
+ checkSelectedTab();
+
+ // Change selection
+ gBrowser.selectedTab = gBrowser.tabs[0];
+ checkSelectedTab();
+ // Close nonselected tab via controller
+ getPreviewForTab(gBrowser.tabs[1]).controller.onClose();
+ checkPreviews(1);
+
+ if (Services.prefs.prefHasUserValue(ENABLE_PREF_NAME)) {
+ Services.prefs.setBoolPref(
+ ENABLE_PREF_NAME,
+ !Services.prefs.getBoolPref(ENABLE_PREF_NAME)
+ );
+ }
+
+ finish();
+
+ function checkPreviews(aPreviews, msg) {
+ let nPreviews = AeroPeek.previews.length;
+ is(
+ aPreviews,
+ gBrowser.tabs.length,
+ "Browser has expected number of tabs - " + msg
+ );
+ is(
+ nPreviews,
+ gBrowser.tabs.length,
+ "Browser has one preview per tab - " + msg
+ );
+ is(nPreviews, aPreviews, msg || "Got expected number of previews");
+ }
+
+ function getPreviewForTab(tab) {
+ return window.gTaskbarTabGroup.previewFromTab(tab);
+ }
+
+ function checkSelectedTab() {
+ return getPreviewForTab(gBrowser.selectedTab).active;
+ }
+}
diff --git a/browser/modules/test/browser/browser_urlBar_zoom.js b/browser/modules/test/browser/browser_urlBar_zoom.js
new file mode 100644
index 0000000000..21d8202a52
--- /dev/null
+++ b/browser/modules/test/browser/browser_urlBar_zoom.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var initialPageZoom = ZoomManager.zoom;
+const kTimeoutInMS = 20000;
+
+async function testZoomButtonAppearsAndDisappearsBasedOnZoomChanges(
+ zoomEventType
+) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com/",
+ waitForStateStop: true,
+ });
+
+ info("Running this test with " + zoomEventType.substring(0, 9));
+ info("Confirm whether the browser zoom is set to the default level");
+ is(initialPageZoom, 1, "Page zoom is set to default (100%)");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ is(zoomResetButton.hidden, true, "Zoom reset button is currently hidden");
+
+ info("Change zoom and confirm zoom button appears");
+ let labelUpdatePromise = BrowserTestUtils.waitForAttribute(
+ "label",
+ zoomResetButton
+ );
+ FullZoom.enlarge();
+ await labelUpdatePromise;
+ info("Zoom increased to " + Math.floor(ZoomManager.zoom * 100) + "%");
+ is(zoomResetButton.hidden, false, "Zoom reset button is now visible");
+ let pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ let expectedZoomLevel = 110;
+ let buttonZoomLevel = parseInt(zoomResetButton.getAttribute("label"), 10);
+ is(
+ buttonZoomLevel,
+ expectedZoomLevel,
+ "Button label updated successfully to " +
+ Math.floor(ZoomManager.zoom * 100) +
+ "%"
+ );
+
+ let zoomResetPromise = BrowserTestUtils.waitForEvent(window, zoomEventType);
+ zoomResetButton.click();
+ await zoomResetPromise;
+ pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ expectedZoomLevel = 100;
+ is(
+ pageZoomLevel,
+ expectedZoomLevel,
+ "Clicking zoom button successfully resets browser zoom to 100%"
+ );
+ is(zoomResetButton.hidden, true, "Zoom reset button returns to being hidden");
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function () {
+ await testZoomButtonAppearsAndDisappearsBasedOnZoomChanges("FullZoomChange");
+ await SpecialPowers.pushPrefEnv({ set: [["browser.zoom.full", false]] });
+ await testZoomButtonAppearsAndDisappearsBasedOnZoomChanges("TextZoomChange");
+ await SpecialPowers.pushPrefEnv({ set: [["browser.zoom.full", true]] });
+});
+
+add_task(async function () {
+ info(
+ "Confirm that URL bar zoom button doesn't appear when customizable zoom widget is added to toolbar"
+ );
+ CustomizableUI.addWidgetToArea("zoom-controls", CustomizableUI.AREA_NAVBAR);
+ let zoomCustomizableWidget = document.getElementById("zoom-reset-button");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ let zoomChangePromise = BrowserTestUtils.waitForEvent(
+ window,
+ "FullZoomChange"
+ );
+ FullZoom.enlarge();
+ await zoomChangePromise;
+ is(
+ zoomResetButton.hidden,
+ true,
+ "URL zoom button remains hidden despite zoom increase"
+ );
+ is(
+ parseInt(zoomCustomizableWidget.label, 10),
+ 110,
+ "Customizable zoom widget's label has updated to " +
+ zoomCustomizableWidget.label
+ );
+});
+
+add_task(async function asyncCleanup() {
+ // reset zoom level and customizable widget
+ ZoomManager.zoom = initialPageZoom;
+ is(ZoomManager.zoom, 1, "Zoom level was restored");
+ if (document.getElementById("zoom-controls")) {
+ CustomizableUI.removeWidgetFromArea(
+ "zoom-controls",
+ CustomizableUI.AREA_NAVBAR
+ );
+ ok(
+ !document.getElementById("zoom-controls"),
+ "Customizable zoom widget removed from toolbar"
+ );
+ }
+});
diff --git a/browser/modules/test/browser/contain_iframe.html b/browser/modules/test/browser/contain_iframe.html
new file mode 100644
index 0000000000..8cea71fae4
--- /dev/null
+++ b/browser/modules/test/browser/contain_iframe.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body><iframe src="http://example.com"></iframe></body>
+</html>
diff --git a/browser/modules/test/browser/file_webrtc.html b/browser/modules/test/browser/file_webrtc.html
new file mode 100644
index 0000000000..1c75f7c75b
--- /dev/null
+++ b/browser/modules/test/browser/file_webrtc.html
@@ -0,0 +1,11 @@
+<html>
+<body onload="start()">
+<script>
+let stream;
+async function start()
+{
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
+}
+</script>
+</body>
+</html>
diff --git a/browser/modules/test/browser/formValidation/browser.toml b/browser/modules/test/browser/formValidation/browser.toml
new file mode 100644
index 0000000000..c7106c356b
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser.toml
@@ -0,0 +1,13 @@
+[DEFAULT]
+
+["browser_form_validation.js"]
+skip-if = ["true"] # bug 1057615
+
+["browser_validation_iframe.js"]
+skip-if = ["true"] # bug 1057615
+
+["browser_validation_invisible.js"]
+
+["browser_validation_navigation.js"]
+
+["browser_validation_other_popups.js"]
diff --git a/browser/modules/test/browser/formValidation/browser_form_validation.js b/browser/modules/test/browser/formValidation/browser_form_validation.js
new file mode 100644
index 0000000000..6348546c80
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_form_validation.js
@@ -0,0 +1,522 @@
+/**
+ * COPIED FROM browser/base/content/test/general/head.js.
+ * This function should be removed and replaced with BTU withNewTab calls
+ *
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+);
+
+function isWithinHalfPixel(a, b) {
+ return Math.abs(a - b) <= 0.5;
+}
+
+function checkPopupShow(anchorRect) {
+ ok(
+ gInvalidFormPopup.state == "showing" || gInvalidFormPopup.state == "open",
+ "[Test " + testId + "] The invalid form popup should be shown"
+ );
+ // Just check the vertical position, as the horizontal position of an
+ // arrow panel will be offset.
+ is(
+ isWithinHalfPixel(gInvalidFormPopup.screenY),
+ isWithinHalfPixel(anchorRect.bottom),
+ "popup top"
+ );
+}
+
+function checkPopupHide() {
+ ok(
+ gInvalidFormPopup.state != "showing" && gInvalidFormPopup.state != "open",
+ "[Test " + testId + "] The invalid form popup should not be shown"
+ );
+}
+
+var testId = 0;
+
+function incrementTest() {
+ testId++;
+ info("Starting next part of test");
+}
+
+function getDocHeader() {
+ return "<html><head><meta charset='utf-8'></head><body>" + getEmptyFrame();
+}
+
+function getDocFooter() {
+ return "</body></html>";
+}
+
+function getEmptyFrame() {
+ return (
+ "<iframe style='width:100px; height:30px; margin:3px; border:1px solid lightgray;' " +
+ "name='t' srcdoc=\"<html><head><meta charset='utf-8'></head><body>form target</body></html>\"></iframe>"
+ );
+}
+
+async function openNewTab(uri, background) {
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ let browser = gBrowser.getBrowserForTab(tab);
+ if (!background) {
+ gBrowser.selectedTab = tab;
+ }
+ await promiseTabLoadEvent(tab, "data:text/html," + escape(uri));
+ return browser;
+}
+
+function clickChildElement(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ let element = content.document.getElementById("s");
+ element.click();
+ return {
+ bottom: content.mozInnerScreenY + element.getBoundingClientRect().bottom,
+ };
+ });
+}
+
+async function blurChildElement(browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.getElementById("i").blur();
+ });
+}
+
+async function checkChildFocus(browser, message) {
+ await SpecialPowers.spawn(
+ browser,
+ [[message, testId]],
+ async function (args) {
+ let [msg, id] = args;
+ var focused =
+ content.document.activeElement == content.document.getElementById("i");
+
+ var validMsg = true;
+ if (msg) {
+ validMsg =
+ msg == content.document.getElementById("i").validationMessage;
+ }
+
+ Assert.equal(
+ focused,
+ true,
+ "Test " + id + " First invalid element should be focused"
+ );
+ Assert.equal(
+ validMsg,
+ true,
+ "Test " +
+ id +
+ " The panel should show the message from validationMessage"
+ );
+ }
+ );
+}
+
+/**
+ * In this test, we check that no popup appears if the form is valid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ await clickChildElement(browser);
+
+ await new Promise((resolve, reject) => {
+ // XXXndeakin This isn't really going to work when the content is another process
+ executeSoon(function () {
+ checkPopupHide();
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, when an invalid form is submitted,
+ * the invalid element is focused and a popup appears.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input required id='i'><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, when an invalid form is submitted,
+ * the first invalid element is focused and a popup appears.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input><input id='i' required><input required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, we hide the popup by interacting with the
+ * invalid element if the element becomes valid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ EventUtils.sendString("a");
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, we don't hide the popup by interacting with the
+ * invalid element if the element is still invalid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input type='email' id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ await new Promise((resolve, reject) => {
+ EventUtils.sendString("a");
+ executeSoon(function () {
+ checkPopupShow(anchorRect);
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that we can hide the popup by blurring the invalid
+ * element.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ await blurChildElement(browser);
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that we can hide the popup by pressing TAB.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Tab");
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that the popup will hide if we move to another tab.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser1 = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser1);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser1,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+
+ let browser2 = await openNewTab("data:text/html,<html></html>");
+ await popupHiddenPromise;
+
+ gBrowser.removeTab(gBrowser.getTabForBrowser(browser1));
+ gBrowser.removeTab(gBrowser.getTabForBrowser(browser2));
+});
+
+/**
+ * In this test, we check that the popup will hide if we navigate to another
+ * page.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ BrowserTestUtils.startLoadingURIString(
+ browser,
+ "data:text/html,<div>hello!</div>"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that the message is correctly updated when it changes.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input type='email' required id='i'><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let inputPromise = BrowserTestUtils.waitForContentEvent(browser, "input");
+ EventUtils.sendString("f");
+ await inputPromise;
+
+ // Now, the element suffers from another error, the message should have
+ // been updated.
+ await new Promise((resolve, reject) => {
+ // XXXndeakin This isn't really going to work when the content is another process
+ executeSoon(function () {
+ checkChildFocus(browser, gInvalidFormPopup.firstElementChild.textContent);
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we reload the page while the form validation popup is visible. The validation
+ * popup should hide.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ BrowserReloadSkipCache();
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_iframe.js b/browser/modules/test/browser/formValidation/browser_validation_iframe.js
new file mode 100644
index 0000000000..454c972f32
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_iframe.js
@@ -0,0 +1,67 @@
+/**
+ * Make sure that the form validation error message shows even if the form is in an iframe.
+ */
+add_task(async function test_iframe() {
+ let uri =
+ "data:text/html;charset=utf-8," +
+ escape(
+ "<iframe src=\"data:text/html,<iframe name='t'></iframe><form target='t' action='data:text/html,'><input required id='i'><input id='s' type='submit'></form>\" height=\"600\"></iframe>"
+ );
+
+ var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ await BrowserTestUtils.withNewTab(uri, async function checkTab(browser) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document
+ .getElementsByTagName("iframe")[0]
+ .contentDocument.getElementById("s")
+ .click();
+ });
+ await popupShownPromise;
+
+ let anchorBottom = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let childdoc =
+ content.document.getElementsByTagName("iframe")[0].contentDocument;
+ Assert.equal(
+ childdoc.activeElement,
+ childdoc.getElementById("i"),
+ "First invalid element should be focused"
+ );
+ return (
+ childdoc.defaultView.mozInnerScreenY +
+ childdoc.getElementById("i").getBoundingClientRect().bottom
+ );
+ }
+ );
+
+ function isWithinHalfPixel(a, b) {
+ return Math.abs(a - b) <= 0.5;
+ }
+
+ is(
+ isWithinHalfPixel(gInvalidFormPopup.screenY),
+ isWithinHalfPixel(anchorBottom),
+ "popup top"
+ );
+
+ ok(
+ gInvalidFormPopup.state == "showing" || gInvalidFormPopup.state == "open",
+ "The invalid form popup should be shown"
+ );
+ });
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_invisible.js b/browser/modules/test/browser/formValidation/browser_validation_invisible.js
new file mode 100644
index 0000000000..9383ad773b
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_invisible.js
@@ -0,0 +1,67 @@
+"use strict";
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+
+function checkPopupHide() {
+ ok(
+ gInvalidFormPopup.state != "showing" && gInvalidFormPopup.state != "open",
+ "[Test " + testId + "] The invalid form popup should not be shown"
+ );
+}
+
+var testId = 0;
+
+function incrementTest() {
+ testId++;
+ info("Starting next part of test");
+}
+
+/**
+ * In this test, we check that no popup appears if the element display is none.
+ */
+add_task(async function test_display_none() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ incrementTest();
+ let testPage =
+ "data:text/html;charset=utf-8," +
+ '<form target="t"><input type="url" placeholder="url" value="http://" style="display: none;"><input id="s" type="button" value="check"></form>';
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+ await BrowserTestUtils.synthesizeMouse(
+ "#s",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ checkPopupHide();
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * In this test, we check that no popup appears if the element visibility is hidden.
+ */
+add_task(async function test_visibility_hidden() {
+ incrementTest();
+ let testPage =
+ "data:text/html;charset=utf-8," +
+ '<form target="t"><input type="url" placeholder="url" value="http://" style="visibility: hidden;"><input id="s" type="button" value="check"></form>';
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+ await BrowserTestUtils.synthesizeMouse(
+ "#s",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ checkPopupHide();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_navigation.js b/browser/modules/test/browser/formValidation/browser_validation_navigation.js
new file mode 100644
index 0000000000..4dd793b983
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_navigation.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Make sure that the form validation message disappears if we navigate
+ * immediately.
+ */
+add_task(async function test_navigate() {
+ var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ await BrowserTestUtils.withNewTab(
+ "data:text/html,<body contenteditable='true'><button>Click me",
+ async function checkTab(browser) {
+ let promiseExampleLoaded = BrowserTestUtils.waitForNewTab(
+ browser.getTabBrowser(),
+ "https://example.com/",
+ true
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("select");
+ input.style.opacity = 0;
+ doc.body.append(input);
+ input.setCustomValidity("This message should not show up.");
+ content.eval(
+ `document.querySelector("button").setAttribute("onmousedown", "document.querySelector('select').reportValidity();window.open('https://example.com/');")`
+ );
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("button", {}, browser);
+ let otherTab = await promiseExampleLoaded;
+ await BrowserTestUtils.waitForPopupEvent(gInvalidFormPopup, "hidden");
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Invalid form popup should go away."
+ );
+ BrowserTestUtils.removeTab(otherTab);
+ }
+ );
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_other_popups.js b/browser/modules/test/browser/formValidation/browser_validation_other_popups.js
new file mode 100644
index 0000000000..320dff0b59
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_other_popups.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+
+add_task(async function test_other_popup_closes() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/nothere",
+ async function checkTab(browser) {
+ let popupShown = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "shown"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("input");
+ doc.body.append(input);
+ input.setCustomValidity("This message should be hidden.");
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ let popupHidden = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "hidden"
+ );
+ await popupShown;
+ let notificationPopup = document.getElementById("notification-popup");
+ let notificationShown = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "shown"
+ );
+ let notificationHidden = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "hidden"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ content.navigator.geolocation.getCurrentPosition(function () {});
+ });
+ await notificationShown;
+ // Should already be hidden at this point.
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should have closed"
+ );
+ // Close just in case.
+ if (gInvalidFormPopup.state != "closed") {
+ gInvalidFormPopup.hidePopup();
+ }
+ await popupHidden;
+ notificationPopup.hidePopup();
+ await notificationHidden;
+ }
+ );
+});
+
+add_task(async function test_dont_open_while_other_popup_open() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+ await BrowserTestUtils.withNewTab(
+ "https://example.org/nothere",
+ async function checkTab(browser) {
+ let notificationPopup = document.getElementById("notification-popup");
+ let notificationShown = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "shown"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ content.navigator.geolocation.getCurrentPosition(function () {});
+ });
+ await notificationShown;
+ let popupShown = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "shown"
+ );
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should be closed."
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("input");
+ doc.body.append(input);
+ input.setCustomValidity("This message should be hidden.");
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should still be closed."
+ );
+ let notificationHidden = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "hidden"
+ );
+ notificationPopup
+ .querySelector(".popup-notification-secondary-button")
+ .click();
+ await notificationHidden;
+ await SpecialPowers.spawn(browser, [], () => {
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ await popupShown;
+ let popupHidden = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "hidden"
+ );
+ gInvalidFormPopup.hidePopup();
+ await popupHidden;
+ }
+ );
+});
diff --git a/browser/modules/test/browser/head.js b/browser/modules/test/browser/head.js
new file mode 100644
index 0000000000..f852cdd641
--- /dev/null
+++ b/browser/modules/test/browser/head.js
@@ -0,0 +1,331 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const SINGLE_TRY_TIMEOUT = 100;
+const NUMBER_OF_TRIES = 30;
+
+function waitForConditionPromise(
+ condition,
+ timeoutMsg,
+ tryCount = NUMBER_OF_TRIES
+) {
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ function checkCondition() {
+ if (tries >= tryCount) {
+ reject(timeoutMsg);
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ return reject(e);
+ }
+ if (conditionPassed) {
+ return resolve();
+ }
+ tries++;
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return undefined;
+ }
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ });
+}
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ waitForConditionPromise(condition, errorMsg).then(nextTest, reason => {
+ ok(false, reason + (reason.stack ? "\n" + reason.stack : ""));
+ });
+}
+
+/**
+ * An utility function to write some text in the search input box
+ * in a content page.
+ * @param {Object} browser
+ * The browser that contains the content.
+ * @param {String} text
+ * The string to write in the search field.
+ * @param {String} fieldName
+ * The name of the field to write to.
+ */
+let typeInSearchField = async function (browser, text, fieldName) {
+ await SpecialPowers.spawn(
+ browser,
+ [[fieldName, text]],
+ async function ([contentFieldName, contentText]) {
+ // Put the focus on the search box.
+ let searchInput = content.document.getElementById(contentFieldName);
+ searchInput.focus();
+ searchInput.value = contentText;
+ }
+ );
+};
+
+/**
+ * Given a <xul:browser> at some non-internal web page,
+ * return something that resembles an nsIContentPermissionRequest,
+ * using the browsers currently loaded document to get a principal.
+ *
+ * @param browser (<xul:browser>)
+ * The browser that we'll create a nsIContentPermissionRequest
+ * for.
+ * @returns A nsIContentPermissionRequest-ish object.
+ */
+function makeMockPermissionRequest(browser) {
+ let type = {
+ options: Cc["@mozilla.org/array;1"].createInstance(Ci.nsIArray),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionType"]),
+ };
+ let types = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ types.appendElement(type);
+ let principal = browser.contentPrincipal;
+ let result = {
+ types,
+ isHandlingUserInput: false,
+ principal,
+ topLevelPrincipal: principal,
+ requester: null,
+ _cancelled: false,
+ cancel() {
+ this._cancelled = true;
+ },
+ _allowed: false,
+ allow() {
+ this._allowed = true;
+ },
+ getDelegatePrincipal(aType) {
+ return principal;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionRequest"]),
+ };
+
+ // In the e10s-case, nsIContentPermissionRequest will have
+ // element defined. window is defined otherwise.
+ if (browser.isRemoteBrowser) {
+ result.element = browser;
+ } else {
+ result.window = browser.contentWindow;
+ }
+
+ return result;
+}
+
+/**
+ * For an opened PopupNotification, clicks on the main action,
+ * and waits for the panel to fully close.
+ *
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickMainAction() {
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.button.click();
+ return removePromise;
+}
+
+/**
+ * For an opened PopupNotification, clicks on the secondary action,
+ * and waits for the panel to fully close.
+ *
+ * @param actionIndex (Number)
+ * The index of the secondary action to be clicked. The default
+ * secondary action (the button shown directly in the panel) is
+ * treated as having index 0.
+ *
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickSecondaryAction(actionIndex) {
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ let popupNotification = getPopupNotificationNode();
+ if (!actionIndex) {
+ popupNotification.secondaryButton.click();
+ return removePromise;
+ }
+
+ return (async function () {
+ // Click the dropmarker arrow and wait for the menu to show up.
+ let dropdownPromise = BrowserTestUtils.waitForEvent(
+ popupNotification.menupopup,
+ "popupshown"
+ );
+ await EventUtils.synthesizeMouseAtCenter(popupNotification.menubutton, {});
+ await dropdownPromise;
+
+ // The menuitems in the dropdown are accessible as direct children of the panel,
+ // because they are injected into a <children> node in the XBL binding.
+ // The target action is the menuitem at index actionIndex - 1, because the first
+ // secondary action (index 0) is the button shown directly in the panel.
+ let actionMenuItem =
+ popupNotification.querySelectorAll("menuitem")[actionIndex - 1];
+ await EventUtils.synthesizeMouseAtCenter(actionMenuItem, {});
+ await removePromise;
+ })();
+}
+
+/**
+ * Makes sure that 1 (and only 1) <xul:popupnotification> is being displayed
+ * by PopupNotification, and then returns that <xul:popupnotification>.
+ *
+ * @return {<xul:popupnotification>}
+ */
+function getPopupNotificationNode() {
+ // PopupNotification is a bit overloaded here, so to be
+ // clear, popupNotifications is a list of <xul:popupnotification>
+ // nodes.
+ let popupNotifications = PopupNotifications.panel.childNodes;
+ Assert.equal(
+ popupNotifications.length,
+ 1,
+ "Should be showing a <xul:popupnotification>"
+ );
+ return popupNotifications[0];
+}
+
+/**
+ * Disable non-release page actions (that are tested elsewhere).
+ *
+ * @return void
+ */
+async function disableNonReleaseActions() {
+ if (!["release", "esr"].includes(AppConstants.MOZ_UPDATE_CHANNEL)) {
+ SpecialPowers.Services.prefs.setBoolPref(
+ "extensions.webcompat-reporter.enabled",
+ false
+ );
+ }
+}
+
+function assertActivatedPageActionPanelHidden() {
+ Assert.ok(
+ !document.getElementById(BrowserPageActions._activatedActionPanelID)
+ );
+}
+
+function promiseOpenPageActionPanel() {
+ let dwu = window.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ // Wait for the main page action button to become visible. It's hidden for
+ // some URIs, so depending on when this is called, it may not yet be quite
+ // visible. It's up to the caller to make sure it will be visible.
+ info("Waiting for main page action button to have non-0 size");
+ let bounds = dwu.getBoundsWithoutFlushing(
+ BrowserPageActions.mainButtonNode
+ );
+ return bounds.width > 0 && bounds.height > 0;
+ })
+ .then(() => {
+ // Wait for the panel to become open, by clicking the button if necessary.
+ info("Waiting for main page action panel to be open");
+ if (BrowserPageActions.panelNode.state == "open") {
+ return Promise.resolve();
+ }
+ let shownPromise = promisePageActionPanelShown();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ return shownPromise;
+ })
+ .then(() => {
+ // Wait for items in the panel to become visible.
+ return promisePageActionViewChildrenVisible(
+ BrowserPageActions.mainViewNode
+ );
+ });
+}
+
+function promisePageActionPanelShown() {
+ return promisePanelShown(BrowserPageActions.panelNode);
+}
+
+function promisePageActionPanelHidden() {
+ return promisePanelHidden(BrowserPageActions.panelNode);
+}
+
+function promisePanelShown(panelIDOrNode) {
+ return promisePanelEvent(panelIDOrNode, "popupshown");
+}
+
+function promisePanelHidden(panelIDOrNode) {
+ return promisePanelEvent(panelIDOrNode, "popuphidden");
+}
+
+function promisePanelEvent(panelIDOrNode, eventType) {
+ return new Promise(resolve => {
+ let panel = panelIDOrNode;
+ if (typeof panel == "string") {
+ panel = document.getElementById(panelIDOrNode);
+ if (!panel) {
+ throw new Error(`Panel with ID "${panelIDOrNode}" does not exist.`);
+ }
+ }
+ if (
+ (eventType == "popupshown" && panel.state == "open") ||
+ (eventType == "popuphidden" && panel.state == "closed")
+ ) {
+ executeSoon(resolve);
+ return;
+ }
+ panel.addEventListener(
+ eventType,
+ () => {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+}
+
+function promisePageActionViewShown() {
+ info("promisePageActionViewShown waiting for ViewShown");
+ return BrowserTestUtils.waitForEvent(
+ BrowserPageActions.panelNode,
+ "ViewShown"
+ ).then(async event => {
+ let panelViewNode = event.originalTarget;
+ await promisePageActionViewChildrenVisible(panelViewNode);
+ return panelViewNode;
+ });
+}
+
+async function promisePageActionViewChildrenVisible(panelViewNode) {
+ info(
+ "promisePageActionViewChildrenVisible waiting for a child node to be visible"
+ );
+ await new Promise(requestAnimationFrame);
+ let dwu = window.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ let bodyNode = panelViewNode.firstElementChild;
+ for (let childNode of bodyNode.children) {
+ let bounds = dwu.getBoundsWithoutFlushing(childNode);
+ if (bounds.width > 0 && bounds.height > 0) {
+ return true;
+ }
+ }
+ return false;
+ });
+}
+
+async function initPageActionsTest() {
+ await disableNonReleaseActions();
+
+ // Ensure screenshots is really disabled (bug 1498738)
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ await addon.disable({ allowSystemAddons: true });
+
+ // Make the main button visible. It's not unless the window is narrow. This
+ // test isn't concerned with that behavior. We have other tests for that.
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+}
diff --git a/browser/modules/test/unit/test_E10SUtils_nested_URIs.js b/browser/modules/test/unit/test_E10SUtils_nested_URIs.js
new file mode 100644
index 0000000000..5ebcac114f
--- /dev/null
+++ b/browser/modules/test/unit/test_E10SUtils_nested_URIs.js
@@ -0,0 +1,90 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+
+var TEST_PREFERRED_REMOTE_TYPES = [
+ E10SUtils.WEB_REMOTE_TYPE,
+ E10SUtils.NOT_REMOTE,
+ "fakeRemoteType",
+];
+
+// These test cases give a nestedURL and a plainURL that should always load in
+// the same remote type. By making these tests comparisons, they should work
+// with any pref combination.
+var TEST_CASES = [
+ {
+ nestedURL: "jar:file:///some.file!/",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "jar:jar:file:///some.file!/!/",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "jar:http://some.site/file!/",
+ plainURL: "http://some.site/file",
+ },
+ {
+ nestedURL: "view-source:http://some.site",
+ plainURL: "http://some.site",
+ },
+ {
+ nestedURL: "view-source:file:///some.file",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "view-source:about:home",
+ plainURL: "about:home",
+ },
+ {
+ nestedURL: "view-source:about:robots",
+ plainURL: "about:robots",
+ },
+ {
+ nestedURL: "view-source:pcast:http://some.site",
+ plainURL: "http://some.site",
+ },
+];
+
+function run_test() {
+ for (let testCase of TEST_CASES) {
+ for (let preferredRemoteType of TEST_PREFERRED_REMOTE_TYPES) {
+ let plainUri = Services.io.newURI(testCase.plainURL);
+ let plainRemoteType = E10SUtils.getRemoteTypeForURIObject(plainUri, {
+ multiProcess: true,
+ remoteSubFrames: false,
+ preferredRemoteType,
+ });
+
+ let nestedUri = Services.io.newURI(testCase.nestedURL);
+ let nestedRemoteType = E10SUtils.getRemoteTypeForURIObject(nestedUri, {
+ multiProcess: true,
+ remoteSubFrames: false,
+ preferredRemoteType,
+ });
+
+ let nestedStr = nestedUri.scheme + ":";
+ do {
+ nestedUri = nestedUri.QueryInterface(Ci.nsINestedURI).innerURI;
+ if (nestedUri.scheme == "about") {
+ nestedStr += nestedUri.spec;
+ break;
+ }
+
+ nestedStr += nestedUri.scheme + ":";
+ } while (nestedUri instanceof Ci.nsINestedURI);
+
+ let plainStr =
+ plainUri.scheme == "about" ? plainUri.spec : plainUri.scheme + ":";
+ equal(
+ nestedRemoteType,
+ plainRemoteType,
+ `Check that ${nestedStr} loads in same remote type as ${plainStr}` +
+ ` with preferred remote type: ${preferredRemoteType}`
+ );
+ }
+ }
+}
diff --git a/browser/modules/test/unit/test_HomePage.js b/browser/modules/test/unit/test_HomePage.js
new file mode 100644
index 0000000000..f2f4588329
--- /dev/null
+++ b/browser/modules/test/unit/test_HomePage.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+const HOMEPAGE_IGNORELIST = "homepage-urls";
+
+/**
+ * Provides a basic set of remote settings for use in tests.
+ */
+async function setupRemoteSettings() {
+ const settings = await RemoteSettings("hijack-blocklists");
+ sinon.stub(settings, "get").returns([
+ {
+ id: HOMEPAGE_IGNORELIST,
+ matches: ["ignore=me"],
+ _status: "synced",
+ },
+ ]);
+}
+
+add_task(async function setup() {
+ await setupRemoteSettings();
+});
+
+add_task(function test_HomePage() {
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by default."
+ );
+ let newvalue = "about:blank|about:newtab";
+ HomePage.safeSet(newvalue);
+ Assert.ok(HomePage.overridden, "Homepage should be overriden after set()");
+ Assert.equal(HomePage.get(), newvalue, "Homepage should be ${newvalue}");
+ Assert.notEqual(
+ HomePage.getDefault(),
+ newvalue,
+ "Homepage should be ${newvalue}"
+ );
+ HomePage.reset();
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by after reset."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Homepage and default should be equal after reset."
+ );
+});
+
+add_task(function test_readLocalizedHomepage() {
+ let newvalue = "data:text/plain,browser.startup.homepage%3Dabout%3Alocalized";
+ let complexvalue = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
+ Ci.nsIPrefLocalizedString
+ );
+ complexvalue.data = newvalue;
+ Services.prefs
+ .getDefaultBranch(null)
+ .setComplexValue(
+ "browser.startup.homepage",
+ Ci.nsIPrefLocalizedString,
+ complexvalue
+ );
+ Assert.ok(!HomePage.overridden, "Complex value only works as default");
+ Assert.equal(HomePage.get(), "about:localized", "Get value from bundle");
+});
+
+add_task(function test_recoverEmptyHomepage() {
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by default."
+ );
+ Services.prefs.setStringPref("browser.startup.homepage", "");
+ Assert.ok(HomePage.overridden, "Homepage is overriden with empty string.");
+ Assert.equal(HomePage.get(), HomePage.getDefault(), "Recover is default");
+ Assert.ok(!HomePage.overridden, "Recover should have set default");
+});
diff --git a/browser/modules/test/unit/test_HomePage_ignore.js b/browser/modules/test/unit/test_HomePage_ignore.js
new file mode 100644
index 0000000000..1369b661b6
--- /dev/null
+++ b/browser/modules/test/unit/test_HomePage_ignore.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+const HOMEPAGE_IGNORELIST = "homepage-urls";
+
+/**
+ * Provides a basic set of remote settings for use in tests.
+ */
+async function setupRemoteSettings() {
+ const settings = await RemoteSettings("hijack-blocklists");
+ sinon.stub(settings, "get").returns([
+ {
+ id: HOMEPAGE_IGNORELIST,
+ matches: ["ignore=me", "ignoreCASE=ME"],
+ _status: "synced",
+ },
+ ]);
+}
+
+add_task(async function setup() {
+ await setupRemoteSettings();
+});
+
+add_task(async function test_initWithIgnoredPageCausesReset() {
+ // Set the preference direct as the set() would block us.
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://bad/?ignore=me"
+ );
+ Assert.ok(HomePage.overridden, "Should have overriden the homepage");
+
+ await HomePage.delayedStartup();
+
+ Assert.ok(
+ !HomePage.overridden,
+ "Should no longer be overriding the homepage."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should have reset to the default preference"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "saved_reset" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+});
+
+add_task(async function test_updateIgnoreListCausesReset() {
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://bad/?new=ignore"
+ );
+ Assert.ok(HomePage.overridden, "Should have overriden the homepage");
+
+ // Simulate an ignore list update.
+ await RemoteSettings("hijack-blocklists").emit("sync", {
+ data: {
+ current: [
+ {
+ id: HOMEPAGE_IGNORELIST,
+ schema: 1553857697843,
+ last_modified: 1553859483588,
+ matches: ["ignore=me", "ignoreCASE=ME", "new=ignore"],
+ },
+ ],
+ },
+ });
+
+ Assert.ok(
+ !HomePage.overridden,
+ "Should no longer be overriding the homepage."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should have reset to the default preference"
+ );
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "saved_reset" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+});
+
+async function testSetIgnoredUrl(url) {
+ Assert.ok(!HomePage.overriden, "Should not be overriding the homepage");
+
+ await HomePage.set(url);
+
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should still have the default homepage."
+ );
+ Assert.ok(!HomePage.overriden, "Should not be overriding the homepage.");
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "set_blocked" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+}
+
+add_task(async function test_setIgnoredUrl() {
+ await testSetIgnoredUrl("http://bad/?ignore=me");
+});
+
+add_task(async function test_setIgnoredUrl_case() {
+ await testSetIgnoredUrl("http://bad/?Ignorecase=me");
+});
diff --git a/browser/modules/test/unit/test_InstallationTelemetry.js b/browser/modules/test/unit/test_InstallationTelemetry.js
new file mode 100644
index 0000000000..4bb9acbd75
--- /dev/null
+++ b/browser/modules/test/unit/test_InstallationTelemetry.js
@@ -0,0 +1,234 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { BrowserUsageTelemetry } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserUsageTelemetry.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+const TIMESTAMP_PREF = "app.installation.timestamp";
+
+function encodeUtf16(str) {
+ const buf = new ArrayBuffer(str.length * 2);
+ const utf16 = new Uint16Array(buf);
+ for (let i = 0; i < str.length; i++) {
+ utf16[i] = str.charCodeAt(i);
+ }
+ return new Uint8Array(buf);
+}
+
+// Returns Promise
+function writeJsonUtf16(fileName, obj) {
+ const str = JSON.stringify(obj);
+ return IOUtils.write(fileName, encodeUtf16(str));
+}
+
+async function runReport(
+ dataFile,
+ installType,
+ { clearTS, setTS, assertRejects, expectExtra, expectTS, msixPrefixes }
+) {
+ // Setup timestamp
+ if (clearTS) {
+ Services.prefs.clearUserPref(TIMESTAMP_PREF);
+ }
+ if (typeof setTS == "string") {
+ Services.prefs.setStringPref(TIMESTAMP_PREF, setTS);
+ }
+
+ // Init events
+ Services.telemetry.clearEvents();
+
+ // Exercise reportInstallationTelemetry
+ if (typeof assertRejects != "undefined") {
+ await Assert.rejects(
+ BrowserUsageTelemetry.reportInstallationTelemetry(dataFile),
+ assertRejects
+ );
+ } else if (!msixPrefixes) {
+ await BrowserUsageTelemetry.reportInstallationTelemetry(dataFile);
+ } else {
+ await BrowserUsageTelemetry.reportInstallationTelemetry(
+ dataFile,
+ msixPrefixes
+ );
+ }
+
+ // Check events
+ TelemetryTestUtils.assertEvents(
+ expectExtra
+ ? [{ object: installType, value: null, extra: expectExtra }]
+ : [],
+ { category: "installation", method: "first_seen" }
+ );
+
+ // Check timestamp
+ if (typeof expectTS == "string") {
+ Assert.equal(expectTS, Services.prefs.getStringPref(TIMESTAMP_PREF));
+ }
+}
+
+let condition = {
+ skip_if: () =>
+ AppConstants.platform !== "win" ||
+ !Services.sysinfo.getProperty("hasWinPackageId"),
+};
+add_task(condition, async function testInstallationTelemetryMSIX() {
+ // Unfortunately, we have no way to inject different installation ping data
+ // into the system in a way that doesn't just completely override the code
+ // under test - so other than a basic test of the happy path, there's
+ // nothing we can do here.
+ let msixExtra = {
+ version: AppConstants.MOZ_APP_VERSION,
+ build_id: AppConstants.MOZ_BULIDID,
+ admin_user: "false",
+ from_msi: "false",
+ silent: "false",
+ default_path: "true",
+ install_existed: "false",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "false",
+ };
+
+ await runReport("fake", "msix", {
+ expectExtra: msixExtra,
+ });
+});
+condition = {
+ skip_if: () =>
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId"),
+};
+add_task(condition, async function testInstallationTelemetry() {
+ let dataFilePath = await IOUtils.createUniqueFile(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "installation-telemetry-test-data" + Math.random() + ".json"
+ );
+ let dataFile = new FileUtils.File(dataFilePath);
+
+ registerCleanupFunction(async () => {
+ try {
+ await IOUtils.remove(dataFilePath);
+ } catch (ex) {
+ // Ignore remove failure, file may not exist by now
+ }
+
+ Services.prefs.clearUserPref(TIMESTAMP_PREF);
+ });
+
+ // Test with normal stub data
+ let stubData = {
+ version: "99.0abc",
+ build_id: "123",
+ installer_type: "stub",
+ admin_user: true,
+ install_existed: false,
+ profdir_existed: false,
+ install_timestamp: "0",
+ };
+ let stubExtra = {
+ version: "99.0abc",
+ build_id: "123",
+ admin_user: "true",
+ install_existed: "false",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "false",
+ };
+
+ await writeJsonUtf16(dataFilePath, stubData);
+ await runReport(dataFile, "stub", {
+ clearTS: true,
+ expectExtra: stubExtra,
+ expectTS: "0",
+ });
+
+ // Check that it doesn't generate another event when the timestamp is unchanged
+ await runReport(dataFile, "stub", { expectTS: "0" });
+
+ // New timestamp
+ stubData.install_timestamp = "1";
+ await writeJsonUtf16(dataFilePath, stubData);
+ await runReport(dataFile, "stub", {
+ expectExtra: stubExtra,
+ expectTS: "1",
+ });
+
+ // Test with normal full data
+ let fullData = {
+ version: "99.0abc",
+ build_id: "123",
+ installer_type: "full",
+ admin_user: false,
+ install_existed: true,
+ profdir_existed: true,
+ silent: false,
+ from_msi: false,
+ default_path: true,
+
+ install_timestamp: "1",
+ };
+ let fullExtra = {
+ version: "99.0abc",
+ build_id: "123",
+ admin_user: "false",
+ install_existed: "true",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "true",
+ silent: "false",
+ from_msi: "false",
+ default_path: "true",
+ };
+
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", {
+ clearTS: true,
+ expectExtra: fullExtra,
+ expectTS: "1",
+ });
+
+ // Check that it doesn't generate another event when the timestamp is unchanged
+ await runReport(dataFile, "full", { expectTS: "1" });
+
+ // New timestamp and a check to make sure we can find installed MSIX packages
+ // by overriding the prefixes a bit further down.
+ fullData.install_timestamp = "2";
+ // This check only works on Windows
+ if (AppConstants.platform == "win") {
+ fullExtra.other_msix_inst = "true";
+ }
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", {
+ expectExtra: fullExtra,
+ expectTS: "2",
+ msixPrefixes: ["Microsoft"],
+ });
+
+ // Missing field
+ delete fullData.install_existed;
+ fullData.install_timestamp = "3";
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", { assertRejects: /install_existed/ });
+
+ // Malformed JSON
+ await IOUtils.write(dataFilePath, encodeUtf16("hello"));
+ await runReport(dataFile, "stub", {
+ assertRejects: /unexpected character/,
+ });
+
+ // Missing file, should return with no exception
+ await IOUtils.remove(dataFilePath);
+ await runReport(dataFile, "stub", { setTS: "3", expectTS: "3" });
+});
diff --git a/browser/modules/test/unit/test_LaterRun.js b/browser/modules/test/unit/test_LaterRun.js
new file mode 100644
index 0000000000..6de572b868
--- /dev/null
+++ b/browser/modules/test/unit/test_LaterRun.js
@@ -0,0 +1,244 @@
+"use strict";
+
+const kEnabledPref = "browser.laterrun.enabled";
+const kPagePrefRoot = "browser.laterrun.pages.";
+const kSessionCountPref = "browser.laterrun.bookkeeping.sessionCount";
+const kProfileCreationTime = "browser.laterrun.bookkeeping.profileCreationTime";
+
+const { LaterRun } = ChromeUtils.importESModule(
+ "resource:///modules/LaterRun.sys.mjs"
+);
+
+Services.prefs.setBoolPref(kEnabledPref, true);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+updateAppInfo();
+
+add_task(async function test_page_applies() {
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "https://www.mozilla.org/%VENDOR%/%NAME%/%ID%/%VERSION%/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 1, "Got 1 page");
+ let page = pages[0];
+ Assert.equal(
+ page.pref,
+ kPagePrefRoot + "test_LaterRun_unittest.",
+ "Should know its own pref"
+ );
+ Assert.equal(
+ page.minimumHoursSinceInstall,
+ 10,
+ "Needs to have 10 hours since install"
+ );
+ Assert.equal(page.minimumSessionCount, 3, "Needs to have 3 sessions");
+ Assert.equal(page.requireBoth, false, "Either requirement is enough");
+ let expectedURL =
+ "https://www.mozilla.org/" +
+ Services.appinfo.vendor +
+ "/" +
+ Services.appinfo.name +
+ "/" +
+ Services.appinfo.ID +
+ "/" +
+ Services.appinfo.version +
+ "/";
+ Assert.equal(page.url, expectedURL, "URL is stored correctly");
+
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Applies when session count has been met."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Applies when session count has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Applies when total session time has been met."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Applies when total session time has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Applies when both time and session count have been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when neither time and session count have been met."
+ );
+
+ page.requireBoth = true;
+
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Does not apply when only session count has been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Does not apply when only session count has been exceeded."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Does not apply when only total session time has been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Does not apply when only total session time has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Applies when both time and session count have been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when neither time and session count have been met."
+ );
+
+ // Check that pages that have run never apply:
+ Services.prefs.setBoolPref(
+ kPagePrefRoot + "test_LaterRun_unittest.hasRun",
+ true
+ );
+ page.requireBoth = false;
+
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Does not apply when page has already run (sessionCount equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Does not apply when page has already run (sessionCount exceeding)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Does not apply when page has already run (hoursSinceInstall equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Does not apply when page has already run (hoursSinceInstall exceeding)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Does not apply when page has already run (both criteria equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when page has already run (both criteria insufficient anyway)."
+ );
+
+ clearAllPagePrefs();
+});
+
+add_task(async function test_get_URL() {
+ Services.prefs.setIntPref(
+ kProfileCreationTime,
+ Math.floor((Date.now() - 11 * 60 * 60 * 1000) / 1000)
+ );
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "https://www.mozilla.org/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 1, "Should only be 1 matching page");
+ let page = pages[0];
+ let url;
+ do {
+ url = LaterRun.getURL();
+ // We have to loop because it's possible Firefox ships with other URLs that get triggered by
+ // this test.
+ } while (url && url != "https://www.mozilla.org/");
+ Assert.equal(
+ url,
+ "https://www.mozilla.org/",
+ "URL should be as expected when prefs are set."
+ );
+ Assert.ok(
+ Services.prefs.prefHasUserValue(
+ kPagePrefRoot + "test_LaterRun_unittest.hasRun"
+ ),
+ "Should have set pref"
+ );
+ Assert.ok(
+ Services.prefs.getBoolPref(kPagePrefRoot + "test_LaterRun_unittest.hasRun"),
+ "Should have set pref to true"
+ );
+ Assert.ok(page.hasRun, "Other page objects should know it has run, too.");
+
+ clearAllPagePrefs();
+});
+
+add_task(async function test_insecure_urls() {
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "http://www.mozilla.org/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get triggered in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 0, "URL with non-https scheme should get ignored");
+ clearAllPagePrefs();
+});
+
+add_task(async function test_dynamic_pref_getter_setter() {
+ delete LaterRun._sessionCount;
+ Services.prefs.setIntPref(kSessionCountPref, 0);
+ Assert.equal(LaterRun.sessionCount, 0, "Should start at 0");
+
+ LaterRun.sessionCount++;
+ Assert.equal(LaterRun.sessionCount, 1, "Should increment.");
+ Assert.equal(
+ Services.prefs.getIntPref(kSessionCountPref),
+ 1,
+ "Should update pref"
+ );
+});
+
+function clearAllPagePrefs() {
+ let allChangedPrefs = Services.prefs.getChildList(kPagePrefRoot);
+ for (let pref of allChangedPrefs) {
+ Services.prefs.clearUserPref(pref);
+ }
+}
diff --git a/browser/modules/test/unit/test_ProfileCounter.js b/browser/modules/test/unit/test_ProfileCounter.js
new file mode 100644
index 0000000000..2a22d849ff
--- /dev/null
+++ b/browser/modules/test/unit/test_ProfileCounter.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { BrowserUsageTelemetry } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserUsageTelemetry.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const PROFILE_COUNT_SCALAR = "browser.engagement.profile_count";
+// Largest possible uint32_t value represents an error.
+const SCALAR_ERROR_VALUE = 0;
+
+const FILE_OPEN_OPERATION = "open";
+const ERROR_FILE_NOT_FOUND = "NotFoundError";
+const ERROR_ACCESS_DENIED = "NotAllowedError";
+
+// We will redirect I/O to/from the profile counter file to read/write this
+// variable instead. That makes it easier for us to:
+// - avoid interference from any pre-existing file
+// - read and change the values in the file.
+// - clean up changes made to the file
+// We will translate a null value stored here to a File Not Found error.
+var gFakeProfileCounterFile = null;
+// We will use this to check that the profile counter code doesn't try to write
+// to multiple files (since this test will malfunction in that case due to
+// gFakeProfileCounterFile only being setup to accommodate a single file).
+var gProfileCounterFilePath = null;
+
+// Storing a value here lets us test the behavior when we encounter an error
+// reading or writing to the file. A null value means that no error will
+// be simulated (other than possibly a NotFoundError).
+var gNextReadExceptionReason = null;
+var gNextWriteExceptionReason = null;
+
+// Nothing will actually be stored in this directory, so it's not important that
+// it be valid, but the leafname should be unique to this test in order to be
+// sure of preventing name conflicts with the pref:
+// `browser.engagement.profileCounted.${hash}`
+function getDummyUpdateDirectory() {
+ const testName = "test_ProfileCounter";
+ let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ dir.initWithPath(`C:\\foo\\bar\\${testName}`);
+ return dir;
+}
+
+// We aren't going to bother generating anything looking like a real client ID
+// for this. The only real requirements for client ids is that they not repeat
+// and that they be strings. So we'll just return an integer as a string and
+// increment it when we want a new client id.
+var gDummyTelemetryClientId = 0;
+function getDummyTelemetryClientId() {
+ return gDummyTelemetryClientId.toString();
+}
+function setNewDummyTelemetryClientId() {
+ ++gDummyTelemetryClientId;
+}
+
+// Returns null if the (fake) profile count file hasn't been created yet.
+function getProfileCount() {
+ // Strict equality to ensure distinguish properly between a non-existent
+ // file and an empty one.
+ if (gFakeProfileCounterFile === null) {
+ return null;
+ }
+ let saveData = JSON.parse(gFakeProfileCounterFile);
+ return saveData.profileTelemetryIds.length;
+}
+
+// Resets the state to the original state, before the profile count file has
+// even been written.
+// If resetFile is specified as false, this will reset everything except for the
+// file itself. This allows us to sort of pretend that another installation
+// wrote the file.
+function reset(resetFile = true) {
+ if (resetFile) {
+ gFakeProfileCounterFile = null;
+ }
+ gNextReadExceptionReason = null;
+ gNextWriteExceptionReason = null;
+ setNewDummyTelemetryClientId();
+}
+
+function setup() {
+ reset();
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // Initialize FOG so we can test the FOG version of profile count
+ Services.fog.initializeFOG();
+ Services.fog.testResetFOG();
+
+ BrowserUsageTelemetry.Policy.readProfileCountFile = async path => {
+ if (!gProfileCounterFilePath) {
+ gProfileCounterFilePath = path;
+ } else {
+ // We've only got one mock-file variable. Make sure we are always
+ // accessing the same file or this will cause problems.
+ Assert.equal(
+ gProfileCounterFilePath,
+ path,
+ "Only one file should be accessed"
+ );
+ }
+ // Strict equality to ensure distinguish properly between null and 0.
+ if (gNextReadExceptionReason !== null) {
+ let ex = new DOMException(FILE_OPEN_OPERATION, gNextReadExceptionReason);
+ gNextReadExceptionReason = null;
+ throw ex;
+ }
+ // Strict equality to ensure distinguish properly between a non-existent
+ // file and an empty one.
+ if (gFakeProfileCounterFile === null) {
+ throw new DOMException(FILE_OPEN_OPERATION, ERROR_FILE_NOT_FOUND);
+ }
+ return gFakeProfileCounterFile;
+ };
+ BrowserUsageTelemetry.Policy.writeProfileCountFile = async (path, data) => {
+ if (!gProfileCounterFilePath) {
+ gProfileCounterFilePath = path;
+ } else {
+ // We've only got one mock-file variable. Make sure we are always
+ // accessing the same file or this will cause problems.
+ Assert.equal(
+ gProfileCounterFilePath,
+ path,
+ "Only one file should be accessed"
+ );
+ }
+ // Strict equality to ensure distinguish properly between null and 0.
+ if (gNextWriteExceptionReason !== null) {
+ let ex = new DOMException(FILE_OPEN_OPERATION, gNextWriteExceptionReason);
+ gNextWriteExceptionReason = null;
+ throw ex;
+ }
+ gFakeProfileCounterFile = data;
+ };
+ BrowserUsageTelemetry.Policy.getUpdateDirectory = getDummyUpdateDirectory;
+ BrowserUsageTelemetry.Policy.getTelemetryClientId = getDummyTelemetryClientId;
+}
+
+// Checks that the number of profiles reported is the number expected. Because
+// of bucketing, the raw count may be different than the reported count.
+function checkSuccess(profilesReported, rawCount = profilesReported) {
+ Assert.equal(rawCount, getProfileCount());
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ PROFILE_COUNT_SCALAR,
+ profilesReported,
+ "The value reported to telemetry should be the expected profile count"
+ );
+ Assert.equal(
+ profilesReported,
+ Glean.browserEngagement.profileCount.testGetValue()
+ );
+}
+
+function checkError() {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ PROFILE_COUNT_SCALAR,
+ SCALAR_ERROR_VALUE,
+ "The value reported to telemetry should be the error value"
+ );
+}
+
+add_task(async function testProfileCounter() {
+ setup();
+
+ info("Testing basic functionality, single install");
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ // Fake another installation by resetting everything except for the profile
+ // count file.
+ reset(false);
+
+ info("Testing basic functionality, faking a second install");
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(2);
+
+ // Check if we properly handle the case where we cannot read from the file
+ // and we have already set its contents. This should report an error.
+ info("Testing read error after successful write");
+ gNextReadExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ reset();
+
+ // A read error should cause an error to be reported, but should also write
+ // to the file in an attempt to fix it. So the next (successful) read should
+ // result in the correct telemetry.
+ info("Testing read error self-correction");
+ gNextReadExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ reset();
+
+ // If the file is malformed. We should report an error and fix it, then report
+ // the correct profile count next time.
+ info("Testing with malformed profile count file");
+ gFakeProfileCounterFile = "<malformed file data>";
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ reset();
+
+ // If we haven't yet written to the file, a write error should cause an error
+ // to be reported.
+ info("Testing write error before the first write");
+ gNextWriteExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ reset();
+
+ info("Testing bucketing");
+ // Fake 15 installations to drive the raw profile count up to 15.
+ for (let i = 0; i < 15; i++) {
+ reset(false);
+ await BrowserUsageTelemetry.reportProfileCount();
+ }
+ // With bucketing, values from 10-99 should all be reported as 10.
+ checkSuccess(10, 15);
+});
diff --git a/browser/modules/test/unit/test_Sanitizer_interrupted.js b/browser/modules/test/unit/test_Sanitizer_interrupted.js
new file mode 100644
index 0000000000..c8e7130ac0
--- /dev/null
+++ b/browser/modules/test/unit/test_Sanitizer_interrupted.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+do_get_profile();
+
+// Test that interrupted sanitizations are properly tracked.
+
+add_task(async function () {
+ const { Sanitizer } = ChromeUtils.importESModule(
+ "resource:///modules/Sanitizer.sys.mjs"
+ );
+
+ Services.prefs.setBoolPref(Sanitizer.PREF_NEWTAB_SEGREGATION, false);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN);
+ Services.prefs.clearUserPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata");
+ Services.prefs.clearUserPref(Sanitizer.PREF_NEWTAB_SEGREGATION);
+ });
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true);
+ Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata", true);
+
+ await Sanitizer.onStartup();
+ Assert.ok(Sanitizer.shouldSanitizeOnShutdown, "Should sanitize on shutdown");
+
+ let pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+ Assert.ok(
+ pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pref has been setup"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].options.isShutdown,
+ "Shutdown option is not present"
+ );
+
+ // Check the preference listeners.
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, false);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 0,
+ "Should not have pending sanitizations"
+ );
+ Assert.ok(
+ !Sanitizer.shouldSanitizeOnShutdown,
+ "Should not sanitize on shutdown"
+ );
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+
+ Assert.ok(
+ pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pending sanitizations should include formdata"
+ );
+ Services.prefs.setBoolPref(
+ Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata",
+ false
+ );
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pending sanitizations should have been updated"
+ );
+
+ // Check a sanitization properly rebuilds the pref.
+ await Sanitizer.sanitize(["formdata"]);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+
+ // Startup should run the pending one and setup a new shutdown sanitization.
+ Services.prefs.setBoolPref(
+ Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata",
+ false
+ );
+ await Sanitizer.onStartup();
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pref has been setup"
+ );
+});
diff --git a/browser/modules/test/unit/test_SiteDataManager.js b/browser/modules/test/unit/test_SiteDataManager.js
new file mode 100644
index 0000000000..87bb511d1d
--- /dev/null
+++ b/browser/modules/test/unit/test_SiteDataManager.js
@@ -0,0 +1,278 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+// We intend to add tests that add real quota manager data to test
+// data size fetching in the new clear history dialog.
+// Bug 1874387 - Add a test to SiteDataManager to test data size display in the new clear
+// history dialog using real quota manager data
+
+ChromeUtils.defineESModuleGetters(this, {
+ SiteDataManager: "resource:///modules/SiteDataManager.sys.mjs",
+ SiteDataTestUtils: "resource://testing-common/SiteDataTestUtils.sys.mjs",
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+});
+
+const EXAMPLE_ORIGIN = "https://www.example.com";
+const EXAMPLE_ORIGIN_2 = "https://example.org";
+const EXAMPLE_ORIGIN_3 = "http://localhost:8000";
+
+let p =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ EXAMPLE_ORIGIN
+ );
+let partitionKey = `(${p.scheme},${p.baseDomain})`;
+let EXAMPLE_ORIGIN_2_PARTITIONED =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(EXAMPLE_ORIGIN_2),
+ {
+ partitionKey,
+ }
+ ).origin;
+
+add_task(function setup() {
+ do_get_profile();
+});
+
+add_task(async function testGetSites() {
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+
+ // Cookie of EXAMPLE_ORIGIN_2 partitioned under EXAMPLE_ORIGIN.
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ // IndexedDB storage of EXAMPLE_ORIGIN_2 partitioned under EXAMPLE_ORIGIN.
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ let site1 = sites.find(site => site.baseDomain == "example.com");
+ let site2 = sites.find(site => site.baseDomain == "example.org");
+
+ Assert.equal(
+ site1.baseDomain,
+ "example.com",
+ "Has the correct base domain for example.com"
+ );
+ // 4096 partitioned + 4096 unpartitioned.
+ Assert.greater(site1.usage, 4096 * 2, "Has correct usage for example.com");
+ Assert.equal(site1.persisted, false, "example.com is not persisted");
+ Assert.equal(
+ site1.cookies.length,
+ 3, // 2 top level, 1 partitioned.
+ "Has correct number of cookies for example.com"
+ );
+ Assert.ok(
+ typeof site1.lastAccessed.getDate == "function",
+ "lastAccessed for example.com is a Date"
+ );
+ Assert.ok(
+ site1.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.com happened recently"
+ );
+
+ Assert.equal(
+ site2.baseDomain,
+ "example.org",
+ "Has the correct base domain for example.org"
+ );
+ Assert.greater(site2.usage, 2048, "Has correct usage for example.org");
+ Assert.equal(site2.persisted, true, "example.org is persisted");
+ Assert.equal(
+ site2.cookies.length,
+ 1,
+ "Has correct number of cookies for example.org"
+ );
+ Assert.ok(
+ typeof site2.lastAccessed.getDate == "function",
+ "lastAccessed for example.org is a Date"
+ );
+ Assert.ok(
+ site2.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.org happened recently"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function testGetTotalUsage() {
+ await SiteDataManager.updateSites();
+ let sites = await SiteDataManager.getSites();
+ Assert.equal(sites.length, 0, "SiteDataManager is empty");
+
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+
+ await SiteDataManager.updateSites();
+
+ let usage = await SiteDataManager.getTotalUsage();
+
+ Assert.greater(usage, 4096 + 2048, "Has the correct total usage.");
+
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function testRemove() {
+ await SiteDataManager.updateSites();
+
+ let uri = Services.io.newURI(EXAMPLE_ORIGIN);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_3, 2048);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 3, "Has three sites.");
+
+ await SiteDataManager.remove("localhost");
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 2, "Has two sites.");
+
+ await SiteDataManager.remove(["www.example.com"]);
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 1, "Has one site.");
+ Assert.equal(
+ sites[0].baseDomain,
+ "example.org",
+ "Has not cleared data for example.org"
+ );
+
+ let usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.com");
+
+ let cookies = Services.cookies.countCookiesFromHost("example.com");
+ Assert.equal(cookies, 0, "Has cleared cookies for example.com");
+
+ let perm = PermissionTestUtils.testPermission(uri, "persistent-storage");
+ Assert.equal(
+ perm,
+ Services.perms.UNKNOWN_ACTION,
+ "Cleared the persistent-storage permission."
+ );
+ perm = PermissionTestUtils.testPermission(uri, "camera");
+ Assert.equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Did not clear other permissions."
+ );
+
+ PermissionTestUtils.remove(uri, "camera");
+});
+
+add_task(async function testRemoveSiteData() {
+ let uri = Services.io.newURI(EXAMPLE_ORIGIN);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 2, "Has two sites.");
+
+ await SiteDataManager.removeSiteData();
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 0, "Has no sites.");
+
+ let usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.com");
+
+ usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN_2);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.org");
+
+ let cookies = Services.cookies.countCookiesFromHost("example.org");
+ Assert.equal(cookies, 0, "Has cleared cookies for example.org");
+
+ let perm = PermissionTestUtils.testPermission(uri, "persistent-storage");
+ Assert.equal(
+ perm,
+ Services.perms.UNKNOWN_ACTION,
+ "Cleared the persistent-storage permission."
+ );
+ perm = PermissionTestUtils.testPermission(uri, "camera");
+ Assert.equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Did not clear other permissions."
+ );
+
+ PermissionTestUtils.remove(uri, "camera");
+});
diff --git a/browser/modules/test/unit/test_SiteDataManagerContainers.js b/browser/modules/test/unit/test_SiteDataManagerContainers.js
new file mode 100644
index 0000000000..18bbb23262
--- /dev/null
+++ b/browser/modules/test/unit/test_SiteDataManagerContainers.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SiteDataManager } = ChromeUtils.importESModule(
+ "resource:///modules/SiteDataManager.sys.mjs"
+);
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+const EXAMPLE_ORIGIN = "https://www.example.com";
+const EXAMPLE_ORIGIN_2 = "https://example.org";
+
+add_task(function setup() {
+ do_get_profile();
+});
+
+add_task(async function testGetSitesByContainers() {
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ originAttributes: { userContextId: "1" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ originAttributes: { userContextId: "2" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo3",
+ value: "bar3",
+ originAttributes: { userContextId: "2" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ originAttributes: { userContextId: "3" },
+ });
+
+ await SiteDataTestUtils.addToIndexedDB(
+ EXAMPLE_ORIGIN + "^userContextId=1",
+ 4096
+ );
+ await SiteDataTestUtils.addToIndexedDB(
+ EXAMPLE_ORIGIN_2 + "^userContextId=3",
+ 2048
+ );
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ let site1Container1 = sites
+ .find(site => site.baseDomain == "example.com")
+ .containersData.get(1);
+
+ let site1Container2 = sites
+ .find(site => site.baseDomain == "example.com")
+ .containersData.get(2);
+
+ let site2Container3 = sites
+ .find(site => site.baseDomain == "example.org")
+ .containersData.get(3);
+
+ Assert.equal(
+ sites.reduce(
+ (accumulator, site) => accumulator + site.containersData.size,
+ 0
+ ),
+ 3,
+ "Has the correct number of sites by containers"
+ );
+
+ Assert.equal(
+ site1Container1.cookiesBlocked,
+ 1,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.greater(
+ site1Container1.quotaUsage,
+ 4096,
+ "Has correct usage for example.com^userContextId=1"
+ );
+
+ Assert.ok(
+ typeof site1Container1.lastAccessed.getDate == "function",
+ "lastAccessed for example.com^userContextId=1 is a Date"
+ );
+ Assert.ok(
+ site1Container1.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.com^userContextId=1 happened recently"
+ );
+
+ Assert.equal(
+ site1Container2.cookiesBlocked,
+ 2,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.equal(
+ site1Container2.quotaUsage,
+ 0,
+ "Has correct usage for example.org^userContextId=2"
+ );
+
+ Assert.ok(
+ typeof site1Container2.lastAccessed.getDate == "function",
+ "lastAccessed for example.com^userContextId=2 is a Date"
+ );
+
+ Assert.equal(
+ site2Container3.cookiesBlocked,
+ 1,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.greater(
+ site2Container3.quotaUsage,
+ 2048,
+ "Has correct usage for example.org^userContextId=3"
+ );
+
+ Assert.ok(
+ typeof site2Container3.lastAccessed.getDate == "function",
+ "lastAccessed for example.org^userContextId=3 is a Date"
+ );
+ Assert.ok(
+ site2Container3.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.org^userContextId=3 happened recently"
+ );
+
+ await SiteDataTestUtils.clear();
+});
diff --git a/browser/modules/test/unit/test_SitePermissions.js b/browser/modules/test/unit/test_SitePermissions.js
new file mode 100644
index 0000000000..b5acfbb6f6
--- /dev/null
+++ b/browser/modules/test/unit/test_SitePermissions.js
@@ -0,0 +1,403 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SitePermissions } = ChromeUtils.importESModule(
+ "resource:///modules/SitePermissions.sys.mjs"
+);
+
+const RESIST_FINGERPRINTING_ENABLED = Services.prefs.getBoolPref(
+ "privacy.resistFingerprinting"
+);
+const MIDI_ENABLED = Services.prefs.getBoolPref("dom.webmidi.enabled");
+
+const EXT_PROTOCOL_ENABLED = Services.prefs.getBoolPref(
+ "security.external_protocol_requires_permission"
+);
+
+const SPEAKER_SELECTION_ENABLED = Services.prefs.getBoolPref(
+ "media.setsinkid.enabled"
+);
+
+add_task(async function testPermissionsListing() {
+ let expectedPermissions = [
+ "autoplay-media",
+ "camera",
+ "cookie",
+ "desktop-notification",
+ "focus-tab-by-prompt",
+ "geo",
+ "install",
+ "microphone",
+ "popup",
+ "screen",
+ "shortcuts",
+ "persistent-storage",
+ "storage-access",
+ "xr",
+ "3rdPartyStorage",
+ "3rdPartyFrameStorage",
+ ];
+ if (RESIST_FINGERPRINTING_ENABLED) {
+ // Canvas permission should be hidden unless privacy.resistFingerprinting
+ // is true.
+ expectedPermissions.push("canvas");
+ }
+ if (MIDI_ENABLED) {
+ // Should remove this checking and add it as default after it is fully pref'd-on.
+ expectedPermissions.push("midi");
+ expectedPermissions.push("midi-sysex");
+ }
+ if (EXT_PROTOCOL_ENABLED) {
+ expectedPermissions.push("open-protocol-handler");
+ }
+ if (SPEAKER_SELECTION_ENABLED) {
+ expectedPermissions.push("speaker");
+ }
+ Assert.deepEqual(
+ SitePermissions.listPermissions().sort(),
+ expectedPermissions.sort(),
+ "Correct list of all permissions"
+ );
+});
+
+add_task(async function testGetAllByPrincipal() {
+ // check that it returns an empty array on an invalid principal
+ // like a principal with an about URI, which doesn't support site permissions
+ let wrongPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "about:config"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(wrongPrincipal), []);
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ "microphone",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_SESSION
+ );
+ SitePermissions.setForPrincipal(
+ principal,
+ "desktop-notification",
+ SitePermissions.BLOCK
+ );
+
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ {
+ id: "microphone",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ {
+ id: "desktop-notification",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "microphone");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ {
+ id: "desktop-notification",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ SitePermissions.removeFromPrincipal(principal, "desktop-notification");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ Assert.equal(Services.prefs.getIntPref("permissions.default.shortcuts"), 0);
+ SitePermissions.setForPrincipal(
+ principal,
+ "shortcuts",
+ SitePermissions.BLOCK
+ );
+
+ // Customized preference should have been enabled, but the default should not.
+ Assert.equal(Services.prefs.getIntPref("permissions.default.shortcuts"), 0);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "shortcuts",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "shortcuts");
+ Services.prefs.clearUserPref("permissions.default.shortcuts");
+});
+
+add_task(async function testGetAvailableStates() {
+ Assert.deepEqual(SitePermissions.getAvailableStates("camera"), [
+ SitePermissions.UNKNOWN,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+
+ // Test available states with a default permission set.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.ALLOW
+ );
+ Assert.deepEqual(SitePermissions.getAvailableStates("camera"), [
+ SitePermissions.PROMPT,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+ Services.prefs.clearUserPref("permissions.default.camera");
+
+ Assert.deepEqual(SitePermissions.getAvailableStates("cookie"), [
+ SitePermissions.ALLOW,
+ SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+ SitePermissions.BLOCK,
+ ]);
+
+ Assert.deepEqual(SitePermissions.getAvailableStates("popup"), [
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+});
+
+add_task(async function testExactHostMatch() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ let subPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://test1.example.com"
+ );
+
+ let exactHostMatched = [
+ "autoplay-media",
+ "desktop-notification",
+ "focus-tab-by-prompt",
+ "camera",
+ "microphone",
+ "screen",
+ "geo",
+ "xr",
+ "persistent-storage",
+ ];
+ if (RESIST_FINGERPRINTING_ENABLED) {
+ // Canvas permission should be hidden unless privacy.resistFingerprinting
+ // is true.
+ exactHostMatched.push("canvas");
+ }
+ if (MIDI_ENABLED) {
+ // WebMIDI is only pref'd on in nightly.
+ // Should remove this checking and add it as default after it is fully pref-on.
+ exactHostMatched.push("midi");
+ exactHostMatched.push("midi-sysex");
+ }
+ if (EXT_PROTOCOL_ENABLED) {
+ exactHostMatched.push("open-protocol-handler");
+ }
+ if (SPEAKER_SELECTION_ENABLED) {
+ exactHostMatched.push("speaker");
+ }
+ let nonExactHostMatched = [
+ "cookie",
+ "popup",
+ "install",
+ "shortcuts",
+ "storage-access",
+ "3rdPartyStorage",
+ "3rdPartyFrameStorage",
+ ];
+
+ let permissions = SitePermissions.listPermissions();
+ for (let permission of permissions) {
+ SitePermissions.setForPrincipal(
+ principal,
+ permission,
+ SitePermissions.ALLOW
+ );
+
+ if (exactHostMatched.includes(permission)) {
+ // Check that the sub-origin does not inherit the permission from its parent.
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.getDefault(permission),
+ `${permission} should exact-host match`
+ );
+ } else if (nonExactHostMatched.includes(permission)) {
+ // Check that the sub-origin does inherit the permission from its parent.
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.ALLOW,
+ `${permission} should not exact-host match`
+ );
+ } else {
+ Assert.ok(
+ false,
+ `Found an unknown permission ${permission} in exact host match test.` +
+ "Please add new permissions from SitePermissions.sys.mjs to this test."
+ );
+ }
+
+ // Check that the permission can be made specific to the sub-origin.
+ SitePermissions.setForPrincipal(
+ subPrincipal,
+ permission,
+ SitePermissions.PROMPT
+ );
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.PROMPT
+ );
+ Assert.equal(
+ SitePermissions.getForPrincipal(principal, permission).state,
+ SitePermissions.ALLOW
+ );
+
+ SitePermissions.removeFromPrincipal(subPrincipal, permission);
+ SitePermissions.removeFromPrincipal(principal, permission);
+ }
+});
+
+add_task(async function testDefaultPrefs() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ // Check that without a pref the default return value is UNKNOWN.
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the default return value changed after setting the pref.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.BLOCK
+ );
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that other permissions still return UNKNOWN.
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "microphone"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the default return value changed after changing the pref.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.ALLOW
+ );
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the preference is ignored if there is a value.
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.BLOCK);
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // The preference should be honored again, after resetting the permissions.
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Should be UNKNOWN after clearing the pref.
+ Services.prefs.clearUserPref("permissions.default.camera");
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+});
+
+add_task(async function testCanvasPermission() {
+ let resistFingerprinting = Services.prefs.getBoolPref(
+ "privacy.resistFingerprinting",
+ false
+ );
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ SitePermissions.setForPrincipal(principal, "canvas", SitePermissions.ALLOW);
+
+ // Canvas permission is hidden when privacy.resistFingerprinting is false.
+ Services.prefs.setBoolPref("privacy.resistFingerprinting", false);
+ Assert.equal(SitePermissions.listPermissions().indexOf("canvas"), -1);
+ Assert.equal(
+ SitePermissions.getAllByPrincipal(principal).filter(
+ permission => permission.id === "canvas"
+ ).length,
+ 0
+ );
+
+ // Canvas permission is visible when privacy.resistFingerprinting is true.
+ Services.prefs.setBoolPref("privacy.resistFingerprinting", true);
+ Assert.notEqual(SitePermissions.listPermissions().indexOf("canvas"), -1);
+ Assert.notEqual(
+ SitePermissions.getAllByPrincipal(principal).filter(
+ permission => permission.id === "canvas"
+ ).length,
+ 0
+ );
+
+ SitePermissions.removeFromPrincipal(principal, "canvas");
+ Services.prefs.setBoolPref(
+ "privacy.resistFingerprinting",
+ resistFingerprinting
+ );
+});
+
+add_task(async function testFilePermissions() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "file:///example.js"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+});
diff --git a/browser/modules/test/unit/test_SitePermissions_temporary.js b/browser/modules/test/unit/test_SitePermissions_temporary.js
new file mode 100644
index 0000000000..a91b1b8bd8
--- /dev/null
+++ b/browser/modules/test/unit/test_SitePermissions_temporary.js
@@ -0,0 +1,710 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SitePermissions } = ChromeUtils.importESModule(
+ "resource:///modules/SitePermissions.sys.mjs"
+);
+
+const TemporaryPermissions = SitePermissions._temporaryPermissions;
+
+const PERM_A = "foo";
+const PERM_B = "bar";
+const PERM_C = "foobar";
+
+const BROWSER_A = createDummyBrowser("https://example.com/foo");
+const BROWSER_B = createDummyBrowser("https://example.org/foo");
+
+const EXPIRY_MS_A = 1000000;
+const EXPIRY_MS_B = 1000001;
+
+function createDummyBrowser(spec) {
+ let uri = Services.io.newURI(spec);
+ return {
+ currentURI: uri,
+ contentPrincipal: Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ ),
+ dispatchEvent: () => {},
+ ownerGlobal: {
+ CustomEvent: class CustomEvent {},
+ },
+ };
+}
+
+function navigateDummyBrowser(browser, uri) {
+ // Callers may pass in either uri strings or nsIURI objects.
+ if (typeof uri == "string") {
+ uri = Services.io.newURI(uri);
+ }
+ browser.currentURI = uri;
+ browser.contentPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ browser.currentURI,
+ {}
+ );
+}
+
+/**
+ * Tests that temporary permissions with different block states are stored
+ * (set, overwrite, delete) correctly.
+ */
+add_task(async function testAllowBlock() {
+ // Set two temporary permissions on the same browser.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ // Test that the permissions have been set correctly.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns expected permission state for perm A."
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns expected permission state for perm B."
+ );
+
+ Assert.deepEqual(
+ TemporaryPermissions.get(BROWSER_A, PERM_A),
+ {
+ id: PERM_A,
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "TemporaryPermissions returns expected permission state for perm A."
+ );
+
+ Assert.deepEqual(
+ TemporaryPermissions.get(BROWSER_A, PERM_B),
+ {
+ id: PERM_B,
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "TemporaryPermissions returns expected permission state for perm B."
+ );
+
+ // Test internal data structure of TemporaryPermissions.
+ let entry = TemporaryPermissions._stateByBrowser.get(BROWSER_A);
+ ok(entry, "Should have an entry for browser A");
+ ok(
+ !TemporaryPermissions._stateByBrowser.has(BROWSER_B),
+ "Should have no entry for browser B"
+ );
+
+ let { browser, uriToPerm } = entry;
+ Assert.equal(
+ browser?.get(),
+ BROWSER_A,
+ "Entry should have a weak reference to the browser."
+ );
+
+ ok(uriToPerm, "Entry should have uriToPerm object.");
+ Assert.equal(Object.keys(uriToPerm).length, 2, "uriToPerm has 2 entries.");
+
+ let permissionsA = uriToPerm[BROWSER_A.contentPrincipal.origin];
+ let permissionsB =
+ uriToPerm[Services.eTLD.getBaseDomain(BROWSER_A.currentURI)];
+
+ ok(permissionsA, "Allow should be keyed under origin");
+ ok(permissionsB, "Block should be keyed under baseDomain");
+
+ let permissionA = permissionsA[PERM_A];
+ let permissionB = permissionsB[PERM_B];
+
+ Assert.equal(
+ permissionA.state,
+ SitePermissions.ALLOW,
+ "Should have correct state"
+ );
+ let expireTimeoutA = permissionA.expireTimeout;
+ Assert.ok(
+ Number.isInteger(expireTimeoutA),
+ "Should have valid expire timeout"
+ );
+
+ Assert.equal(
+ permissionB.state,
+ SitePermissions.BLOCK,
+ "Should have correct state"
+ );
+ let expireTimeoutB = permissionB.expireTimeout;
+ Assert.ok(
+ Number.isInteger(expireTimeoutB),
+ "Should have valid expire timeout"
+ );
+
+ // Overwrite permission A.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_B
+ );
+
+ Assert.ok(
+ permissionsA[PERM_A].expireTimeout != expireTimeoutA,
+ "Overwritten permission A should have new timer"
+ );
+
+ // Overwrite permission B - this time with a non-block state which means it
+ // should be keyed by origin now.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ let baseDomainEntry =
+ uriToPerm[Services.eTLD.getBaseDomain(BROWSER_A.currentURI)];
+ Assert.ok(
+ !baseDomainEntry || !baseDomainEntry[PERM_B],
+ "Should not longer have baseDomain permission entry"
+ );
+
+ permissionsB = uriToPerm[BROWSER_A.contentPrincipal.origin];
+ permissionB = permissionsB[PERM_B];
+ Assert.ok(
+ permissionsB && permissionB,
+ "Overwritten permission should be keyed under origin"
+ );
+ Assert.equal(
+ permissionB.state,
+ SitePermissions.ALLOW,
+ "Should have correct updated state"
+ );
+ Assert.ok(
+ permissionB.expireTimeout != expireTimeoutB,
+ "Overwritten permission B should have new timer"
+ );
+
+ // Remove permissions
+ SitePermissions.removeFromPrincipal(null, PERM_A, BROWSER_A);
+ SitePermissions.removeFromPrincipal(null, PERM_B, BROWSER_A);
+
+ // Test that permissions have been removed correctly
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for A."
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for B."
+ );
+
+ Assert.equal(
+ TemporaryPermissions.get(BROWSER_A, PERM_A),
+ null,
+ "TemporaryPermissions returns null for perm A."
+ );
+
+ Assert.equal(
+ TemporaryPermissions.get(BROWSER_A, PERM_B),
+ null,
+ "TemporaryPermissions returns null for perm B."
+ );
+});
+
+/**
+ * Tests TemporaryPermissions#getAll.
+ */
+add_task(async function testGetAll() {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_C,
+ SitePermissions.PROMPT,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+
+ Assert.deepEqual(TemporaryPermissions.getAll(BROWSER_A), [
+ {
+ id: PERM_A,
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ ]);
+
+ let permsBrowserB = TemporaryPermissions.getAll(BROWSER_B);
+ Assert.equal(
+ permsBrowserB.length,
+ 2,
+ "There should be 2 permissions set for BROWSER_B"
+ );
+
+ let permB;
+ let permC;
+
+ if (permsBrowserB[0].id == PERM_B) {
+ permB = permsBrowserB[0];
+ permC = permsBrowserB[1];
+ } else {
+ permB = permsBrowserB[1];
+ permC = permsBrowserB[0];
+ }
+
+ Assert.deepEqual(permB, {
+ id: PERM_B,
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+ Assert.deepEqual(permC, {
+ id: PERM_C,
+ state: SitePermissions.PROMPT,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+});
+
+/**
+ * Tests SitePermissions#clearTemporaryBlockPermissions and
+ * TemporaryPermissions#clear.
+ */
+add_task(async function testClear() {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_C,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+
+ let stateByBrowser = SitePermissions._temporaryPermissions._stateByBrowser;
+
+ Assert.ok(stateByBrowser.has(BROWSER_A), "Browser map should have BROWSER_A");
+ Assert.ok(stateByBrowser.has(BROWSER_B), "Browser map should have BROWSER_B");
+
+ SitePermissions.clearTemporaryBlockPermissions(BROWSER_A);
+
+ // We only clear block permissions, so we should still see PERM_A.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns ALLOW state for PERM_A."
+ );
+ // We don't clear BROWSER_B so it should still be there.
+ Assert.ok(stateByBrowser.has(BROWSER_B), "Should still have BROWSER_B.");
+
+ // Now clear allow permissions for A explicitly.
+ SitePermissions._temporaryPermissions.clear(BROWSER_A, SitePermissions.ALLOW);
+
+ Assert.ok(!stateByBrowser.has(BROWSER_A), "Should no longer have BROWSER_A.");
+ let browser = stateByBrowser.get(BROWSER_B);
+ Assert.ok(browser, "Should still have BROWSER_B");
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_A."
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_B."
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_C, BROWSER_B),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns BLOCK state for PERM_C."
+ );
+
+ SitePermissions._temporaryPermissions.clear(BROWSER_B);
+
+ Assert.ok(!stateByBrowser.has(BROWSER_B), "Should no longer have BROWSER_B.");
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_C, BROWSER_B),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_C."
+ );
+});
+
+/**
+ * Tests that the temporary permissions setter calls the callback on permission
+ * expire with the associated browser.
+ */
+add_task(async function testCallbackOnExpiry() {
+ let promiseExpireA = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_A,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 100,
+ undefined,
+ resolve
+ );
+ });
+ let promiseExpireB = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_B,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 100,
+ BROWSER_B.contentPrincipal,
+ resolve
+ );
+ });
+
+ let [browserA, browserB] = await Promise.all([
+ promiseExpireA,
+ promiseExpireB,
+ ]);
+ Assert.equal(
+ browserA,
+ BROWSER_A,
+ "Should get callback with browser on expiry for A"
+ );
+ Assert.equal(
+ browserB,
+ BROWSER_B,
+ "Should get callback with browser on expiry for B"
+ );
+});
+
+/**
+ * Tests that the temporary permissions setter calls the callback on permission
+ * expire with the associated browser if the browser associated browser has
+ * changed after setting the permission.
+ */
+add_task(async function testCallbackOnExpiryUpdatedBrowser() {
+ let promiseExpire = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_A,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 200,
+ undefined,
+ resolve
+ );
+ });
+
+ TemporaryPermissions.copy(BROWSER_A, BROWSER_B);
+
+ let browser = await promiseExpire;
+ Assert.equal(
+ browser,
+ BROWSER_B,
+ "Should get callback with updated browser on expiry."
+ );
+});
+
+/**
+ * Tests that the permission setter throws an exception if an invalid expiry
+ * time is passed.
+ */
+add_task(async function testInvalidExpiryTime() {
+ let expectedError = /expireTime must be a positive integer/;
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ null
+ );
+ }, expectedError);
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ 0
+ );
+ }, expectedError);
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ -100
+ );
+ }, expectedError);
+});
+
+/**
+ * Tests that we block by base domain but allow by origin.
+ */
+add_task(async function testTemporaryPermissionScope() {
+ let states = {
+ strict: {
+ same: [
+ "https://example.com",
+ "https://example.com/sub/path",
+ "https://example.com:443",
+ "https://name:password@example.com",
+ ],
+ different: [
+ "https://example.com",
+ "https://test1.example.com",
+ "http://example.com",
+ "http://example.org",
+ "file:///tmp/localPageA.html",
+ "file:///tmp/localPageB.html",
+ ],
+ },
+ nonStrict: {
+ same: [
+ "https://example.com",
+ "https://example.com/sub/path",
+ "https://example.com:443",
+ "https://test1.example.com",
+ "http://test2.test1.example.com",
+ "https://name:password@example.com",
+ "http://example.com",
+ ],
+ different: [
+ "https://example.com",
+ "https://example.org",
+ "http://example.net",
+ ],
+ },
+ };
+
+ for (let state of [SitePermissions.BLOCK, SitePermissions.ALLOW]) {
+ let matchStrict = state != SitePermissions.BLOCK;
+
+ let lists = matchStrict ? states.strict : states.nonStrict;
+
+ Object.entries(lists).forEach(([type, list]) => {
+ let expectSet = type == "same";
+
+ for (let uri of list) {
+ let browser = createDummyBrowser(uri);
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ state,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser,
+ EXPIRY_MS_A
+ );
+
+ ok(true, "origin:" + browser.contentPrincipal.origin);
+
+ for (let otherUri of list) {
+ if (uri == otherUri) {
+ continue;
+ }
+ navigateDummyBrowser(browser, otherUri);
+ ok(true, "new origin:" + browser.contentPrincipal.origin);
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, browser),
+ {
+ state: expectSet ? state : SitePermissions.UNKNOWN,
+ scope: expectSet
+ ? SitePermissions.SCOPE_TEMPORARY
+ : SitePermissions.SCOPE_PERSISTENT,
+ },
+ `${
+ state == SitePermissions.BLOCK ? "Block" : "Allow"
+ } Permission originally set for ${uri} should ${
+ expectSet ? "not" : "also"
+ } be set for ${otherUri}.`
+ );
+ }
+
+ SitePermissions._temporaryPermissions.clear(browser);
+ }
+ });
+ }
+});
+
+/**
+ * Tests that we can override the principal to use for keying temporary
+ * permissions.
+ */
+add_task(async function testOverrideBrowserURI() {
+ let testBrowser = createDummyBrowser("https://old.example.com/foo");
+ let overrideURI = Services.io.newURI("https://test.example.org/test/path");
+ SitePermissions.setForPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(overrideURI, {}),
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ testBrowser,
+ EXPIRY_MS_A
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, testBrowser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Permission should not be set for old URI."
+ );
+
+ // "Navigate" to new URI
+ navigateDummyBrowser(testBrowser, overrideURI);
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, testBrowser),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Permission should be set for new URI."
+ );
+
+ SitePermissions._temporaryPermissions.clear(testBrowser);
+});
+
+/**
+ * Tests that TemporaryPermissions does not throw for incompatible URI or
+ * browser.currentURI.
+ */
+add_task(async function testPermissionUnsupportedScheme() {
+ let aboutURI = Services.io.newURI("about:blank");
+
+ // Incompatible override URI should not throw or store any permissions.
+ SitePermissions.setForPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(aboutURI, {}),
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_B
+ );
+ Assert.ok(
+ SitePermissions._temporaryPermissions._stateByBrowser.has(BROWSER_A),
+ "Should not have stored permission for unsupported URI scheme."
+ );
+
+ let browser = createDummyBrowser("https://example.com/");
+ // Set a permission so we get an entry in the browser map.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ // Change browser URI to about:blank.
+ navigateDummyBrowser(browser, aboutURI);
+
+ // Setting permission for browser with unsupported URI should not throw.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+ Assert.ok(true, "Set should not throw for unsupported URI");
+
+ SitePermissions.removeFromPrincipal(null, PERM_A, browser);
+ Assert.ok(true, "Remove should not throw for unsupported URI");
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Should return no permission set for unsupported URI."
+ );
+ Assert.ok(true, "Get should not throw for unsupported URI");
+
+ // getAll should not throw, but return empty permissions array.
+ let permissions = SitePermissions.getAllForBrowser(browser);
+ Assert.ok(
+ Array.isArray(permissions) && !permissions.length,
+ "Should return empty array for browser on about:blank"
+ );
+
+ SitePermissions._temporaryPermissions.clear(browser);
+});
diff --git a/browser/modules/test/unit/test_TabUnloader.js b/browser/modules/test/unit/test_TabUnloader.js
new file mode 100644
index 0000000000..3d125828bb
--- /dev/null
+++ b/browser/modules/test/unit/test_TabUnloader.js
@@ -0,0 +1,449 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { TabUnloader } = ChromeUtils.importESModule(
+ "resource:///modules/TabUnloader.sys.mjs"
+);
+
+let TestTabUnloaderMethods = {
+ isNonDiscardable(tab, weight) {
+ return /\bselected\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isParentProcess(tab, weight) {
+ return /\bparent\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isPinned(tab, weight) {
+ return /\bpinned\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isLoading(tab, weight) {
+ return /\bloading\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ usingPictureInPicture(tab, weight) {
+ return /\bpictureinpicture\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ playingMedia(tab, weight) {
+ return /\bmedia\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ usingWebRTC(tab, weight) {
+ return /\bwebrtc\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isPrivate(tab, weight) {
+ return /\bprivate\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ getMinTabCount() {
+ // Use a low number for testing.
+ return 3;
+ },
+
+ getNow() {
+ return 100;
+ },
+
+ *iterateProcesses(tab) {
+ for (let process of tab.process.split(",")) {
+ yield Number(process);
+ }
+ },
+
+ async calculateMemoryUsage(processMap, tabs) {
+ let memory = tabs[0].memory;
+ for (let pid of processMap.keys()) {
+ processMap.get(pid).memory = memory ? memory[pid - 1] : 1;
+ }
+ },
+};
+
+let unloadTests = [
+ // Each item in the array represents one test. The test is a subarray
+ // containing an element per tab. This is a string of keywords that
+ // identify which criteria apply. The first part of the string may contain
+ // a number that represents the last visit time, where higher numbers
+ // are later. The last element in the subarray is special and identifies
+ // the expected order of the tabs sorted by weight. The first tab in
+ // this list is the one that is expected to selected to be discarded.
+ { tabs: ["1 selected", "2", "3"], result: "1,2,0" },
+ { tabs: ["1", "2 selected", "3"], result: "0,2,1" },
+ { tabs: ["1 selected", "2", "3"], process: ["1", "2", "3"], result: "1,2,0" },
+ {
+ tabs: ["1 selected", "2 selected", "3 selected"],
+ process: ["1", "2", "3"],
+ result: "0,1,2",
+ },
+ {
+ tabs: ["1 selected", "2", "3"],
+ process: ["1,2,3", "2", "3"],
+ result: "1,2,0",
+ },
+ {
+ tabs: ["9", "8", "6", "5 selected", "2", "3", "4", "1"],
+ result: "7,4,5,6,2,1,0,3",
+ },
+ {
+ tabs: ["9", "8 pinned", "6", "5 selected", "2", "3 pinned", "4", "1"],
+ result: "7,4,6,2,0,5,1,3",
+ },
+ {
+ tabs: [
+ "9",
+ "8 pinned",
+ "6",
+ "5 selected pinned",
+ "2",
+ "3 pinned",
+ "4",
+ "1",
+ ],
+ result: "7,4,6,2,0,5,1,3",
+ },
+ {
+ tabs: [
+ "9",
+ "8 pinned",
+ "6",
+ "5 selected pinned",
+ "2",
+ "3 selected pinned",
+ "4",
+ "1",
+ ],
+ result: "7,4,6,2,0,1,5,3",
+ },
+ {
+ tabs: ["1", "2 selected", "3", "4 media", "5", "6"],
+ result: "0,2,4,5,1,3",
+ },
+ {
+ tabs: ["1 media", "2 selected media", "3", "4 media", "5", "6"],
+ result: "2,4,5,0,3,1",
+ },
+ {
+ tabs: ["1 media", "2 media pinned", "3", "4 media", "5 pinned", "6"],
+ result: "2,5,4,0,3,1",
+ },
+ {
+ tabs: [
+ "1 media",
+ "2 media pinned",
+ "3",
+ "4 media",
+ "5 media pinned",
+ "6 selected",
+ ],
+ result: "2,0,3,5,1,4",
+ },
+ {
+ tabs: [
+ "10 selected",
+ "20 private",
+ "30 webrtc",
+ "40 pictureinpicture",
+ "50 loading pinned",
+ "60",
+ ],
+ result: "5,4,0,1,2,3",
+ },
+ {
+ // Since TestTabUnloaderMethods.getNow() returns 100 and the test
+ // passes minInactiveDuration = 0 to TabUnloader.getSortedTabs(),
+ // tab 200 and 300 are excluded from the result.
+ tabs: ["300", "10", "50", "100", "200"],
+ result: "1,2,3",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "1", "1", "1", "1"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2 selected", "3", "4", "5", "6"],
+ process: ["1", "2", "1", "1", "1", "1"],
+ result: "0,2,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "2", "1", "1", "1"],
+ result: "0,1,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1", "1"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1", "1"],
+ result: "2,0,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1,2,3", "1"],
+ result: "0,2,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1,4,5", "1"],
+ result: "2,0,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "2", "3", "1", "1,4,5", "1"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "1", "3", "1", "1,4,5", "1"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "2", "3", "4", "1,4,5", "5"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "1", "3", "4", "1,4,5", "5"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1", "1", "1"],
+ result: "4,0,3,1,2,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 selected", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,5,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,5,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1", "1", "1"],
+ result: "0,3,1,2,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1,3,4,5,6,7,8", "1", "1", "1", "1", "1", "1"],
+ result: "1,0,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,3,4,5,6,7,8", "1", "1", "1", "1", "1"],
+ result: "2,0,1,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,1,1,1,1,1,1", "1", "1", "1", "1,1,1,1,1", "1"],
+ result: "0,1,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,2,3,4,5", "1", "1", "1", "1,2,3,4,5", "1"],
+ result: "0,1,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,6", "1", "1", "1", "1,2,3,4,5", "1"],
+ result: "0,2,1,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,6", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "2,3,0,5,1,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1,10,11", "1", "1,2", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "0,3,1,5,2,6,7,4",
+ },
+ {
+ tabs: [
+ "1 media",
+ "2 media",
+ "3 media",
+ "4 media",
+ "5 media",
+ "6",
+ "7",
+ "8",
+ ],
+ process: ["1,10,11", "1", "1,2", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "6,5,7,0,1,2,3,4",
+ },
+ {
+ tabs: ["1", "2", "3"],
+ process: ["1", "2", "3"],
+ memory: ["100", "200", "300"],
+ result: "0,1,2",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "100",
+ "200",
+ "300",
+ "400",
+ "500",
+ "600",
+ "700",
+ "800",
+ "900",
+ "1000",
+ ],
+ result: "0,1,2,3,4,5,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "100",
+ "900",
+ "300",
+ "500",
+ "400",
+ "700",
+ "600",
+ "1000",
+ "200",
+ "200",
+ ],
+ result: "1,0,2,3,5,4,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "1000",
+ "900",
+ "300",
+ "500",
+ "400",
+ "1000",
+ "600",
+ "1000",
+ "200",
+ "200",
+ ],
+ result: "0,1,2,3,5,4,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2,7", "3", "4", "5", "6"],
+ memory: ["100", "200", "300", "400", "500", "600", "700"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1,6", "2,7", "3,8", "4,1,2", "5", "6", "7", "8"],
+ memory: ["100", "200", "300", "400", "500", "600", "700", "800"],
+ result: "2,3,0,1,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1", "1", "1", "1"],
+ memory: ["700", "1000"],
+ result: "0,3,1,2,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "1", "2,1", "2,1", "3", "3"],
+ memory: ["1000", "2000", "3000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["2", "2", "2", "2", "2,1", "2,1", "3", "3"],
+ memory: ["1000", "600", "1000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "2,1,1,1", "2,1", "3", "3"],
+ memory: ["1000", "1800", "1000"],
+ result: "0,1,3,2,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "2,1,1,1", "2,1", "3", "3"],
+ memory: ["4000", "1800", "1000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ // The tab "1" contains 4 frames, but its uniqueCount is 1 because
+ // all of those frames are backed by the process "1". As a result,
+ // TabUnloader puts the tab "1" first based on the last access time.
+ tabs: ["1", "2", "3", "4", "5"],
+ process: ["1,1,1,1", "2", "3", "3", "3"],
+ memory: ["100", "100", "100"],
+ result: "0,1,2,3,4",
+ },
+ {
+ // The uniqueCount of the tab "1", "2", and "3" is 1, 2, and 3,
+ // respectively. As a result the first three tabs are sorted as 2,1,0.
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1,7,1,7,1,1,7,1", "7,3,7,2", "4,5,7,4,6,7", "7", "7", "7"],
+ memory: ["100", "100", "100", "100", "100", "100", "100"],
+ result: "2,1,0,3,4,5",
+ },
+];
+
+let globalBrowser = {
+ discardBrowser() {
+ return true;
+ },
+};
+
+add_task(async function doTests() {
+ for (let test of unloadTests) {
+ function* iterateTabs() {
+ let tabs = test.tabs;
+ for (let t = 0; t < tabs.length; t++) {
+ let tab = {
+ tab: {
+ originalIndex: t,
+ lastAccessed: Number(/^[0-9]+/.exec(tabs[t])[0]),
+ keywords: tabs[t],
+ process: "process" in test ? test.process[t] : "1",
+ },
+ memory: test.memory,
+ gBrowser: globalBrowser,
+ };
+ yield tab;
+ }
+ }
+ TestTabUnloaderMethods.iterateTabs = iterateTabs;
+
+ let expectedOrder = "";
+ const sortedTabs = await TabUnloader.getSortedTabs(
+ 0,
+ TestTabUnloaderMethods
+ );
+ for (let tab of sortedTabs) {
+ if (expectedOrder) {
+ expectedOrder += ",";
+ }
+ expectedOrder += tab.tab.originalIndex;
+ }
+
+ Assert.equal(expectedOrder, test.result);
+ }
+});
diff --git a/browser/modules/test/unit/test_discovery.js b/browser/modules/test/unit/test_discovery.js
new file mode 100644
index 0000000000..08f67273a6
--- /dev/null
+++ b/browser/modules/test/unit/test_discovery.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+// ClientID fails without...
+do_get_profile();
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+const { Discovery } = ChromeUtils.importESModule(
+ "resource:///modules/Discovery.sys.mjs"
+);
+const { ContextualIdentityService } = ChromeUtils.importESModule(
+ "resource://gre/modules/ContextualIdentityService.sys.mjs"
+);
+
+const TAAR_COOKIE_NAME = "taarId";
+
+add_task(async function test_discovery() {
+ let uri = Services.io.newURI("https://example.com/foobar");
+
+ // Ensure the prefs we need
+ Services.prefs.setBoolPref("browser.discovery.enabled", true);
+ Services.prefs.setBoolPref("browser.discovery.containers.enabled", true);
+ Services.prefs.setBoolPref("datareporting.healthreport.uploadEnabled", true);
+ Services.prefs.setCharPref("browser.discovery.sites", uri.host);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.discovery.enabled");
+ Services.prefs.clearUserPref("browser.discovery.containers.enabled");
+ Services.prefs.clearUserPref("browser.discovery.sites");
+ Services.prefs.clearUserPref("datareporting.healthreport.uploadEnabled");
+ });
+
+ // This is normally initialized by telemetry, force id creation. This results
+ // in Discovery setting the cookie.
+ await ClientID.getClientID();
+ await Discovery.update();
+
+ ok(
+ Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {}),
+ "cookie exists"
+ );
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ privateBrowsingId: 1,
+ }),
+ "no private cookie exists"
+ );
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ equal(
+ Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ identity.public,
+ "cookie exists"
+ );
+ });
+
+ // Test the addition of a new container.
+ let changed = TestUtils.topicObserved("cookie-changed", subject => {
+ let cookie = subject
+ .QueryInterface(Ci.nsICookieNotification)
+ .cookie.QueryInterface(Ci.nsICookie);
+ equal(cookie.name, TAAR_COOKIE_NAME, "taar cookie exists");
+ equal(cookie.host, uri.host, "cookie exists for host");
+ equal(
+ cookie.originAttributes.userContextId,
+ container.userContextId,
+ "cookie userContextId is correct"
+ );
+ return true;
+ });
+ let container = ContextualIdentityService.create(
+ "New Container",
+ "Icon",
+ "Color"
+ );
+ await changed;
+
+ // Test disabling
+ Discovery.enabled = false;
+ // Wait for the update to remove the cookie.
+ await TestUtils.waitForCondition(() => {
+ return !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ "no cookie exists"
+ );
+ });
+
+ // turn off containers
+ Services.prefs.setBoolPref("browser.discovery.containers.enabled", false);
+
+ Discovery.enabled = true;
+ await TestUtils.waitForCondition(() => {
+ return Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+ // make sure we did not set cookies on containers
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ "no cookie exists"
+ );
+ });
+
+ // Make sure clientId changes update discovery
+ changed = TestUtils.topicObserved("cookie-changed", subject => {
+ let notification = subject.QueryInterface(Ci.nsICookieNotification);
+ if (notification.action != Ci.nsICookieNotification.COOKIE_ADDED) {
+ return false;
+ }
+ let cookie = notification.cookie.QueryInterface(Ci.nsICookie);
+ equal(cookie.name, TAAR_COOKIE_NAME, "taar cookie exists");
+ equal(cookie.host, uri.host, "cookie exists for host");
+ return true;
+ });
+ await ClientID.removeClientID();
+ await ClientID.getClientID();
+ await changed;
+
+ // Make sure disabling telemetry disables discovery.
+ Services.prefs.setBoolPref("datareporting.healthreport.uploadEnabled", false);
+ await TestUtils.waitForCondition(() => {
+ return !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+});
diff --git a/browser/modules/test/unit/xpcshell.toml b/browser/modules/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..1738e92194
--- /dev/null
+++ b/browser/modules/test/unit/xpcshell.toml
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = ''
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"] # bug 1730213
+
+["test_E10SUtils_nested_URIs.js"]
+
+["test_HomePage.js"]
+
+["test_HomePage_ignore.js"]
+
+["test_InstallationTelemetry.js"]
+run-if = ["os == 'win'"] # Test of a Windows-specific feature
+
+["test_LaterRun.js"]
+
+["test_ProfileCounter.js"]
+run-if = ["os == 'win'"] # Test of a Windows-specific feature
+
+["test_Sanitizer_interrupted.js"]
+
+["test_SiteDataManager.js"]
+
+["test_SiteDataManagerContainers.js"]
+
+["test_SitePermissions.js"]
+
+["test_SitePermissions_temporary.js"]
+
+["test_TabUnloader.js"]
+
+["test_discovery.js"]