diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/modules/test | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/modules/test')
63 files changed, 14936 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.ini b/browser/modules/test/browser/browser.ini new file mode 100644 index 0000000000..280d8bcb0d --- /dev/null +++ b/browser/modules/test/browser/browser.ini @@ -0,0 +1,73 @@ +[DEFAULT] +support-files = + head.js +prefs = + telemetry.number_of_site_origin.min_interval=0 + +[browser_BrowserWindowTracker.js] +skip-if = os == "win" && os_version == "6.1" # bug 1715860 +[browser_ContentSearch.js] +support-files = + contentSearchBadImage.xml + contentSearchSuggestions.sjs + contentSearchSuggestions.xml + !/browser/components/search/test/browser/testEngine.xml + !/browser/components/search/test/browser/testEngine_diacritics.xml + testEngine_chromeicon.xml +skip-if = (debug && os == "linux" && bits == 64 && os_version == "18.04") # Bug 1649755 +[browser_EveryWindow.js] +[browser_HomePage_add_button.js] +[browser_PageActions.js] +[browser_PageActions_contextMenus.js] +[browser_PageActions_newWindow.js] +[browser_PartnerLinkAttribution.js] +support-files = + search-engines/basic/manifest.json + search-engines/simple/manifest.json + search-engines/engines.json +[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" || (os == "win" && bits == 64) # bug 1456807 +[browser_urlBar_zoom.js] +skip-if = + (os == "mac") || (os == "linux" && bits == 64 && os_version == "18.04") || (os == "win" && os_version == '10.0' && bits == 64) # Bug 1528429, Bug 1619835 + os == 'win' && 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_ContentSearch.js b/browser/modules/test/browser/browser_ContentSearch.js new file mode 100644 index 0000000000..0e489a54a1 --- /dev/null +++ b/browser/modules/test/browser/browser_ContentSearch.js @@ -0,0 +1,519 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", +}); + +SearchTestUtils.init(this); + +const SERVICE_EVENT_TYPE = "ContentSearchService"; +const CLIENT_EVENT_TYPE = "ContentSearchClient"; + +var arrayBufferIconTested = false; +var plainURIIconTested = false; + +function sendEventToContent(browser, data) { + return SpecialPowers.spawn( + browser, + [CLIENT_EVENT_TYPE, data], + (eventName, eventData) => { + content.dispatchEvent( + new content.CustomEvent(eventName, { + detail: Cu.cloneInto(eventData, content), + }) + ); + } + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.newtab.preload", false], + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", true], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine.xml", + setAsDefault: true, + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine_diacritics.xml", + setAsDefaultPrivate: true, + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_chromeicon.xml", + }); +}); + +add_task(async function GetState() { + let { browser } = await addTab(); + let statePromise = await waitForTestMsg(browser, "State"); + sendEventToContent(browser, { + type: "GetState", + }); + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "State", + data: await currentStateObj(false), + }); + + ok(arrayBufferIconTested, "ArrayBuffer path for the iconData was tested"); + ok(plainURIIconTested, "Plain URI path for the iconData was tested"); +}); + +add_task(async function SetDefaultEngine() { + let { browser } = await addTab(); + let newDefaultEngine = await Services.search.getEngineByName("FooChromeIcon"); + let oldDefaultEngine = await Services.search.getDefault(); + let searchPromise = await waitForTestMsg(browser, "CurrentEngine"); + sendEventToContent(browser, { + type: "SetCurrentEngine", + data: newDefaultEngine.name, + }); + let deferredPromise = new Promise(resolve => { + Services.obs.addObserver(function obs(subj, topic, data) { + info("Test observed " + data); + if (data == "engine-default") { + ok(true, "Test observed engine-default"); + Services.obs.removeObserver(obs, "browser-search-engine-modified"); + resolve(); + } + }, "browser-search-engine-modified"); + }); + info("Waiting for test to observe engine-default..."); + await deferredPromise; + let msg = await searchPromise.donePromise; + checkMsg(msg, { + type: "CurrentEngine", + data: await constructEngineObj(newDefaultEngine), + }); + + let enginePromise = await waitForTestMsg(browser, "CurrentEngine"); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + msg = await enginePromise.donePromise; + checkMsg(msg, { + type: "CurrentEngine", + data: await constructEngineObj(oldDefaultEngine), + }); +}); + +// ContentSearchChild doesn't support setting the private engine at this time +// as it doesn't need to, so we just test updating the default here. +add_task(async function setDefaultEnginePrivate() { + const engine = await Services.search.getEngineByName("FooChromeIcon"); + const { browser } = await addTab(); + let enginePromise = await waitForTestMsg(browser, "CurrentPrivateEngine"); + await Services.search.setDefaultPrivate( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let msg = await enginePromise.donePromise; + checkMsg(msg, { + type: "CurrentPrivateEngine", + data: await constructEngineObj(engine), + }); +}); + +add_task(async function modifyEngine() { + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let oldAlias = engine.alias; + let statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.alias = "ContentSearchTest"; + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); + statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.alias = oldAlias; + msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); +}); + +add_task(async function test_hideEngine() { + let { browser } = await addTab(); + let engine = await Services.search.getEngineByName("Foo \u2661"); + let statePromise = await waitForTestMsg(browser, "CurrentState"); + Services.prefs.setStringPref("browser.search.hiddenOneOffs", engine.name); + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(undefined, "Foo \u2661"), + }); + statePromise = await waitForTestMsg(browser, "CurrentState"); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); +}); + +add_task(async function search() { + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let data = { + engineName: engine.name, + searchString: "ContentSearchTest", + healthReportKey: "ContentSearchTest", + searchPurpose: "ContentSearchTest", + }; + let submissionURL = engine.getSubmission(data.searchString, "", data.whence) + .uri.spec; + + await performSearch(browser, data, submissionURL); +}); + +add_task(async function searchInBackgroundTab() { + // This test is like search(), but it opens a new tab after starting a search + // in another. In other words, it performs a search in a background tab. The + // search page should be loaded in the same tab that performed the search, in + // the background tab. + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let data = { + engineName: engine.name, + searchString: "ContentSearchTest", + healthReportKey: "ContentSearchTest", + searchPurpose: "ContentSearchTest", + }; + let submissionURL = engine.getSubmission(data.searchString, "", data.whence) + .uri.spec; + + let searchPromise = performSearch(browser, data, submissionURL); + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + registerCleanupFunction(() => gBrowser.removeTab(newTab)); + + await searchPromise; +}); + +add_task(async function badImage() { + let { browser } = await addTab(); + // If the bad image URI caused an exception to be thrown within ContentSearch, + // then we'll hang waiting for the CurrentState responses triggered by the new + // engine. That's what we're testing, and obviously it shouldn't happen. + let vals = await waitForNewEngine(browser, "contentSearchBadImage.xml"); + let engine = vals[0]; + let finalCurrentStateMsg = vals[vals.length - 1]; + let expectedCurrentState = await currentStateObj(); + let expectedEngine = expectedCurrentState.engines.find( + e => e.name == engine.name + ); + ok(!!expectedEngine, "Sanity check: engine should be in expected state"); + ok( + expectedEngine.iconData === + "chrome://browser/skin/search-engine-placeholder.png", + "Sanity check: icon of engine in expected state should be the placeholder: " + + expectedEngine.iconData + ); + checkMsg(finalCurrentStateMsg, { + type: "CurrentState", + data: expectedCurrentState, + }); + // Removing the engine triggers a final CurrentState message. Wait for it so + // it doesn't trip up subsequent tests. + let statePromise = await waitForTestMsg(browser, "CurrentState"); + await Services.search.removeEngine(engine); + await statePromise.donePromise; +}); + +add_task( + async function GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() { + let { browser } = await addTab(); + + // Add the test engine that provides suggestions. + let vals = await waitForNewEngine(browser, "contentSearchSuggestions.xml"); + let engine = vals[0]; + + let searchStr = "browser_ContentSearch.js-suggestions-"; + + // Add a form history suggestion and wait for Satchel to notify about it. + sendEventToContent(browser, { + type: "AddFormHistoryEntry", + data: { + value: searchStr + "form", + engineName: engine.name, + }, + }); + await new Promise(resolve => { + Services.obs.addObserver(function onAdd(subj, topic, data) { + if (data == "formhistory-add") { + Services.obs.removeObserver(onAdd, "satchel-storage-changed"); + executeSoon(resolve); + } + }, "satchel-storage-changed"); + }); + + // Send GetSuggestions using the test engine. Its suggestions should appear + // in the remote suggestions in the Suggestions response below. + let suggestionsPromise = await waitForTestMsg(browser, "Suggestions"); + sendEventToContent(browser, { + type: "GetSuggestions", + data: { + engineName: engine.name, + searchString: searchStr, + }, + }); + + // Check the Suggestions response. + let msg = await suggestionsPromise.donePromise; + checkMsg(msg, { + type: "Suggestions", + data: { + engineName: engine.name, + searchString: searchStr, + formHistory: [searchStr + "form"], + remote: [searchStr + "foo", searchStr + "bar"], + }, + }); + + // Delete the form history suggestion and wait for Satchel to notify about it. + sendEventToContent(browser, { + type: "RemoveFormHistoryEntry", + data: searchStr + "form", + }); + + await new Promise(resolve => { + Services.obs.addObserver(function onRemove(subj, topic, data) { + if (data == "formhistory-remove") { + Services.obs.removeObserver(onRemove, "satchel-storage-changed"); + executeSoon(resolve); + } + }, "satchel-storage-changed"); + }); + + // Send GetSuggestions again. + suggestionsPromise = await waitForTestMsg(browser, "Suggestions"); + sendEventToContent(browser, { + type: "GetSuggestions", + data: { + engineName: engine.name, + searchString: searchStr, + }, + }); + + // The formHistory suggestions in the Suggestions response should be empty. + msg = await suggestionsPromise.donePromise; + checkMsg(msg, { + type: "Suggestions", + data: { + engineName: engine.name, + searchString: searchStr, + formHistory: [], + remote: [searchStr + "foo", searchStr + "bar"], + }, + }); + + // Finally, clean up by removing the test engine. + let statePromise = await waitForTestMsg(browser, "CurrentState"); + await Services.search.removeEngine(engine); + await statePromise.donePromise; + } +); + +async function performSearch(browser, data, expectedURL) { + let stoppedPromise = BrowserTestUtils.browserStopped(browser, expectedURL); + sendEventToContent(browser, { + type: "Search", + data, + expectedURL, + }); + + await stoppedPromise; + // BrowserTestUtils.browserStopped should ensure this, but let's + // be absolutely sure. + Assert.equal( + browser.currentURI.spec, + expectedURL, + "Correct search page loaded" + ); +} + +function buffersEqual(actualArrayBuffer, expectedArrayBuffer) { + let expectedView = new Int8Array(expectedArrayBuffer); + let actualView = new Int8Array(actualArrayBuffer); + for (let i = 0; i < expectedView.length; i++) { + if (actualView[i] != expectedView[i]) { + return false; + } + } + return true; +} + +function arrayBufferEqual(actualArrayBuffer, expectedArrayBuffer) { + ok(actualArrayBuffer instanceof ArrayBuffer, "Actual value is ArrayBuffer."); + ok( + expectedArrayBuffer instanceof ArrayBuffer, + "Expected value is ArrayBuffer." + ); + Assert.equal( + actualArrayBuffer.byteLength, + expectedArrayBuffer.byteLength, + "Array buffers have the same length." + ); + ok( + buffersEqual(actualArrayBuffer, expectedArrayBuffer), + "Buffers are equal." + ); +} + +function checkArrayBuffers(actual, expected) { + if (actual instanceof ArrayBuffer) { + arrayBufferEqual(actual, expected); + } + if (typeof actual == "object") { + for (let i in actual) { + checkArrayBuffers(actual[i], expected[i]); + } + } +} + +function checkMsg(actualMsg, expectedMsgData) { + SimpleTest.isDeeply(actualMsg, expectedMsgData, "Checking message"); + + // Engines contain ArrayBuffers which we have to compare byte by byte and + // not as Objects (like SimpleTest.isDeeply does). + checkArrayBuffers(actualMsg, expectedMsgData); +} + +async function waitForTestMsg(browser, type, count = 1) { + await SpecialPowers.spawn( + browser, + [SERVICE_EVENT_TYPE, type, count], + (childEvent, childType, childCount) => { + content.eventDetails = []; + function listener(event) { + if (event.detail.type != childType) { + return; + } + + content.eventDetails.push(event.detail); + + if (--childCount > 0) { + return; + } + + content.removeEventListener(childEvent, listener, true); + } + content.addEventListener(childEvent, listener, true); + } + ); + + let donePromise = SpecialPowers.spawn( + browser, + [type, count], + async (childType, childCount) => { + await ContentTaskUtils.waitForCondition(() => { + return content.eventDetails.length == childCount; + }, "Expected " + childType + " event"); + + return childCount > 1 ? content.eventDetails : content.eventDetails[0]; + } + ); + + return { donePromise }; +} + +async function waitForNewEngine(browser, basename) { + info("Waiting for engine to be added: " + basename); + + // Wait for the search events triggered by adding the new engine. + // There are two events triggerd by engine-added and engine-loaded + let statePromise = await waitForTestMsg(browser, "CurrentState", 2); + + // Wait for addOpenSearchEngine(). + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + basename, + }); + let results = await statePromise.donePromise; + return [engine, ...results]; +} + +async function addTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab" + ); + registerCleanupFunction(() => gBrowser.removeTab(tab)); + + return { browser: tab.linkedBrowser }; +} + +var currentStateObj = async function (isPrivateWindowValue, hiddenEngine = "") { + let state = { + engines: [], + currentEngine: await constructEngineObj(await Services.search.getDefault()), + currentPrivateEngine: await constructEngineObj( + await Services.search.getDefaultPrivate() + ), + }; + for (let engine of await Services.search.getVisibleEngines()) { + let uri = engine.getIconURLBySize(16, 16); + state.engines.push({ + name: engine.name, + iconData: await iconDataFromURI(uri), + hidden: engine.name == hiddenEngine, + isAppProvided: engine.isAppProvided, + }); + } + if (typeof isPrivateWindowValue == "boolean") { + state.isInPrivateBrowsingMode = isPrivateWindowValue; + state.isAboutPrivateBrowsing = isPrivateWindowValue; + } + return state; +}; + +async function constructEngineObj(engine) { + let uriFavicon = engine.getIconURLBySize(16, 16); + return { + name: engine.name, + iconData: await iconDataFromURI(uriFavicon), + isAppProvided: engine.isAppProvided, + }; +} + +function iconDataFromURI(uri) { + if (!uri) { + return Promise.resolve( + "chrome://browser/skin/search-engine-placeholder.png" + ); + } + + if (!uri.startsWith("data:")) { + plainURIIconTested = true; + return Promise.resolve(uri); + } + + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", uri, true); + xhr.responseType = "arraybuffer"; + xhr.onerror = () => { + resolve("chrome://browser/skin/search-engine-placeholder.png"); + }; + xhr.onload = () => { + arrayBufferIconTested = true; + resolve(xhr.response); + }; + try { + xhr.send(); + } catch (err) { + resolve("chrome://browser/skin/search-engine-placeholder.png"); + } + }); +} diff --git a/browser/modules/test/browser/browser_EveryWindow.js b/browser/modules/test/browser/browser_EveryWindow.js new file mode 100644 index 0000000000..7cadfaadad --- /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.import( + "resource:///modules/EveryWindow.jsm" +); + +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..045fed870e --- /dev/null +++ b/browser/modules/test/browser/browser_HomePage_add_button.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "HomePage", + "resource:///modules/HomePage.jsm" +); + +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..a0b6e72211 --- /dev/null +++ b/browser/modules/test/browser/browser_PageActions.js @@ -0,0 +1,1402 @@ +"use strict"; + +// This is a test for PageActions.jsm, 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..a76a5bcb16 --- /dev/null +++ b/browser/modules/test/browser/browser_PageActions_contextMenus.js @@ -0,0 +1,226 @@ +"use strict"; + +// This is a test for PageActions.jsm, 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..e351727bff --- /dev/null +++ b/browser/modules/test/browser/browser_PageActions_newWindow.js @@ -0,0 +1,377 @@ +"use strict"; + +// This is a test for PageActions.jsm, 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_PartnerLinkAttribution.js b/browser/modules/test/browser/browser_PartnerLinkAttribution.js new file mode 100644 index 0000000000..08e393694d --- /dev/null +++ b/browser/modules/test/browser/browser_PartnerLinkAttribution.js @@ -0,0 +1,428 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with search related actions. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +// The preference to enable suggestions in the urlbar. +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +// The name of the search engine used to generate suggestions. +const SUGGESTION_ENGINE_NAME = + "browser_UsageTelemetry usageTelemetrySearchSuggestions.xml"; + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", + + Region: "resource://gre/modules/Region.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +let gCUITestUtils = new CustomizableUITestUtils(window); +SearchTestUtils.init(this); + +var gHttpServer = null; +var gRequests = []; + +function submitHandler(request, response) { + gRequests.push(request); + response.setStatusLine(request.httpVersion, 200, "Ok"); +} + +add_setup(async function () { + // Ensure the initial init is complete. + await Services.search.init(); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + + await SearchTestUtils.useMochitestEngines(searchExtensions); + + SearchTestUtils.useMockIdleService(); + let response = await fetch(`resource://search-extensions/engines.json`); + let json = await response.json(); + await SearchTestUtils.updateRemoteSettingsConfig(json.data); + + let topsitesAttribution = Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ); + gHttpServer = new HttpServer(); + gHttpServer.registerPathHandler(`/cid/${topsitesAttribution}`, submitHandler); + gHttpServer.start(-1); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Enable search suggestions in the urlbar. + [SUGGEST_URLBAR_PREF, true], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + [ + "browser.partnerlink.attributionURL", + `http://localhost:${gHttpServer.identity.primaryPort}/cid/`, + ], + ], + }); + + await gCUITestUtils.addSearchBar(); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await gHttpServer.stop(); + gHttpServer = null; + await PlacesUtils.history.clear(); + gCUITestUtils.removeSearchBar(); + await settingsWritten; + }); +}); + +function searchInAwesomebar(value, win = window) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus, + value, + fireInputEvent: true, + }); +} + +async function searchInSearchbar(inputText) { + let win = window; + await new Promise(r => waitForFocus(r, win)); + let sb = BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = inputText; + sb.textbox.controller.startSearch(inputText); + // Wait for the popup to be shown and built. + let popup = sb.textbox.popup; + await Promise.all([ + BrowserTestUtils.waitForEvent(popup, "popupshown"), + BrowserTestUtils.waitForEvent(popup.oneOffButtons, "rebuild"), + ]); + // And then for the search to complete. + await BrowserTestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); +} + +add_task(async function test_simpleQuery_no_attribution() { + await Services.search.setDefault( + Services.search.getEngineByName("Simple Engine"), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Simulate entering a simple search."); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://example.com/?sourceId=Mozilla-search&search=simple+query", + tab + ); + await searchInAwesomebar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + await promiseLoad; + + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + Assert.equal(gRequests.length, 0, "Should not have submitted an attribution"); + + BrowserTestUtils.removeTab(tab); + + await Services.search.setDefault( + Services.search.getEngineByName("basic"), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); + +async function checkAttributionRecorded(actionFn, cleanupFn) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/plain;charset=utf8,simple%20query" + ); + + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1", + tab + ); + await actionFn(tab); + await promiseLoad; + + await BrowserTestUtils.waitForCondition( + () => gRequests.length == 1, + "Should have received an attribution submission" + ); + Assert.equal( + gRequests[0].getHeader("X-Region"), + Region.home, + "Should have set the region correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Source"), + "searchurl", + "Should have set the source correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Target-url"), + "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1", + "Should have set the target url correctly and stripped the search terms" + ); + if (cleanupFn) { + await cleanupFn(); + } + BrowserTestUtils.removeTab(tab); + gRequests = []; +} + +add_task(async function test_urlbar() { + await checkAttributionRecorded(async tab => { + await searchInAwesomebar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +add_task(async function test_searchbar() { + await checkAttributionRecorded(async tab => { + let sb = BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = "simple query"; + sb.textbox.controller.startSearch("simple query"); + // Wait for the popup to show. + await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown"); + // And then for the search to complete. + await BrowserTestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +add_task(async function test_context_menu() { + let contextMenu; + await checkAttributionRecorded( + async tab => { + info("Select all the text in the page."); + await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () { + return new Promise(resolve => { + content.document.addEventListener( + "selectionchange", + () => resolve(), + { + once: true, + } + ); + content.document + .getSelection() + .selectAllChildren(content.document.body); + }); + }); + + info("Open the context menu."); + contextMenu = document.getElementById("contentAreaContextMenu"); + let shownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await shownPromise; + + let hiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + + info("Click on search."); + let searchItem = contextMenu.querySelector("#context-searchselect"); + contextMenu.activateItem(searchItem); + await hiddenPromise; + }, + () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + ); +}); + +add_task(async function test_about_newtab() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab", + false + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => !content.document.hidden); + }); + + info("Trigger a simple search, just text + enter."); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1", + tab + ); + await typeInSearchField( + tab.linkedBrowser, + "simple query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await promiseLoad; + + await BrowserTestUtils.waitForCondition( + () => gRequests.length == 1, + "Should have received an attribution submission" + ); + Assert.equal( + gRequests[0].getHeader("X-Region"), + Region.home, + "Should have set the region correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Source"), + "searchurl", + "Should have set the source correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Target-url"), + "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1", + "Should have set the target url correctly and stripped the search terms" + ); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); + gRequests = []; +}); + +add_task(async function test_urlbar_oneOff_click() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=query&foo=1", + tab + ); + await searchInAwesomebar("query"); + info("Click the first one-off button."); + UrlbarTestUtils.getOneOffSearchButtons(window) + .getSelectableButtons(false)[0] + .click(); + EventUtils.synthesizeKey("KEY_Enter"); + await promiseLoad; + + await BrowserTestUtils.waitForCondition( + () => gRequests.length == 1, + "Should have received an attribution submission" + ); + Assert.equal( + gRequests[0].getHeader("X-Region"), + Region.home, + "Should have set the region correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Source"), + "searchurl", + "Should have set the source correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Target-url"), + "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1", + "Should have set the target url correctly and stripped the search terms" + ); + + BrowserTestUtils.removeTab(tab); + gRequests = []; +}); + +add_task(async function test_searchbar_oneOff_click() { + // For this test, set the other engine as default, so that we can select + // the attribution engine as the first one in the one-offs. + await Services.search.setDefault( + Services.search.getEngineByName("Simple Engine"), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=searchbar&foo=1", + tab + ); + await searchInSearchbar("searchbar"); + info("Click the first one-off button."); + BrowserSearch.searchBar.textbox.popup.oneOffButtons + .getSelectableButtons(false)[0] + .click(); + await promiseLoad; + + await BrowserTestUtils.waitForCondition( + () => gRequests.length == 1, + "Should have received an attribution submission" + ); + Assert.equal( + gRequests[0].getHeader("X-Region"), + Region.home, + "Should have set the region correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Source"), + "searchurl", + "Should have set the source correctly" + ); + Assert.equal( + gRequests[0].getHeader("X-Target-url"), + "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1", + "Should have set the target url correctly and stripped the search terms" + ); + + BrowserTestUtils.removeTab(tab); + // Set back the engine in case of other tests in this file. + await Services.search.setDefault( + Services.search.getEngineByName("basic"), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + gRequests = []; +}); 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..fd8116abfe --- /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.closeButton.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..36a17ddbe0 --- /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.loadURIString(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.loadURIString(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.loadURIString(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.loadURIString(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..a4af0dbdc8 --- /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.import( + "resource:///modules/TabUnloader.jsm" +); + +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..8caaa1ff38 --- /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.loadURIString(tab.linkedBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Navigate to another test page. + BrowserTestUtils.loadURIString(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..47684b1a5a --- /dev/null +++ b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js @@ -0,0 +1,819 @@ +"use strict"; + +/** + * This suite tests the "unsubmitted crash report" notification + * that is seen when we detect pending crash reports on startup. + */ + +const { UnsubmittedCrashHandler } = ChromeUtils.import( + "resource:///modules/ContentCrashHandlers.jsm" +); + +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"], + false + ); +} + +/** + * 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.closeButton.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.closeButton.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.closeButton.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..aa752f5b7b --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry.js @@ -0,0 +1,684 @@ +"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.defineModuleGetter( + this, + "MINIMUM_TAB_COUNT_INTERVAL_MS", + "resource:///modules/BrowserUsageTelemetry.jsm" +); + +const { ObjectUtils } = ChromeUtils.import( + "resource://gre/modules/ObjectUtils.jsm" +); + +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.loadURIString(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" + ); + ok( + 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" + ); + ok( + BrowserUsageTelemetry._lastRecordTabCount != oldLastRecordTabCount, + "TAB_COUNT telemetry - _lastRecordTabCount updated" + ); + ok( + 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.loadURIString(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" + ); + ok( + 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.loadURIString(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" + ); + ok( + BrowserUsageTelemetry._lastRecordTabCount != oldLastRecordTabCount, + "TAB_COUNT telemetry - _lastRecordTabCount updated" + ); + ok( + 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.loadURIString( + 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..359bfa9c69 --- /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.import( + "resource:///modules/ContentCrashHandlers.jsm" + ); + + // 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..d736809dc5 --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_domains.js @@ -0,0 +1,190 @@ +"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.loadURIString(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.loadURIString( + 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.loadURIString( + 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.loadURIString( + 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.loadURIString( + 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.loadURIString(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..50a3e08391 --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js @@ -0,0 +1,967 @@ +/* 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", + 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"); + }); + } + + click("stop-reload-button"); + click("back-button"); + click("back-button"); + + // 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 initialized = BrowserTestUtils.waitForEvent(gBrowser, "Initialized"); + await BrowserTestUtils.withNewTab("about:preferences", async browser => { + await initialized; + + Services.telemetry.getSnapshotForKeyedScalars("main", true); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#browserRestoreSession", + {}, + gBrowser.selectedBrowser.browsingContext + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#category-search", + {}, + gBrowser.selectedBrowser.browsingContext + ); + await BrowserTestUtils.waitForCondition(() => + gBrowser.selectedBrowser.contentDocument.getElementById( + "searchBarShownRadio" + ) + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#searchBarShownRadio", + {}, + gBrowser.selectedBrowser.browsingContext + ); + + gBrowser.selectedBrowser.contentDocument + .getElementById("openLocationBarPrivacyPreferences") + .scrollIntoView(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#openLocationBarPrivacyPreferences", + {}, + 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, + }, + preferences_paneSearch: { + searchBarShownRadio: 1, + openLocationBarPrivacyPreferences: 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..ab0c8651b6 --- /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.loadURIString( + 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..ec5da64882 --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js @@ -0,0 +1,89 @@ +/* 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.defineModuleGetter( + this, + "URICountListener", + "resource:///modules/BrowserUsageTelemetry.jsm" +); + +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..89892e38dd --- /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.import( + "resource:///modules/WindowsPreviewPerTab.jsm" + ); + + 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" + ); + ok(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/contentSearchBadImage.xml b/browser/modules/test/browser/contentSearchBadImage.xml new file mode 100644 index 0000000000..6e4cb60a58 --- /dev/null +++ b/browser/modules/test/browser/contentSearchBadImage.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_ContentSearch contentSearchBadImage.xml</ShortName> +<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchBadImage" rel="searchform"/> +<Image width="16" height="16">data:image/x-icon;base64,notbase64</Image> +</SearchPlugin> diff --git a/browser/modules/test/browser/contentSearchSuggestions.sjs b/browser/modules/test/browser/contentSearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/modules/test/browser/contentSearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/modules/test/browser/contentSearchSuggestions.xml b/browser/modules/test/browser/contentSearchSuggestions.xml new file mode 100644 index 0000000000..448a946e1b --- /dev/null +++ b/browser/modules/test/browser/contentSearchSuggestions.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/browser/contentSearchSuggestions.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/> +</SearchPlugin> 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.ini b/browser/modules/test/browser/formValidation/browser.ini new file mode 100644 index 0000000000..1c0b80d782 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser.ini @@ -0,0 +1,7 @@ +[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..a1cbc4d88d --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_form_validation.js @@ -0,0 +1,519 @@ +/** + * 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.loadURIString(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.loadURIString(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/browser/search-engines/basic/manifest.json b/browser/modules/test/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..96b29935cf --- /dev/null +++ b/browser/modules/test/browser/search-engines/basic/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "browser_specific_settings": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/modules/test/browser/search-engines/engines.json b/browser/modules/test/browser/search-engines/engines.json new file mode 100644 index 0000000000..0311630bba --- /dev/null +++ b/browser/modules/test/browser/search-engines/engines.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "webExtension": { + "id": "basic@search.mozilla.org" + }, + "telemetryId": "telemetry", + "appliesTo": [ + { + "included": { "everywhere": true }, + "default": "yes", + "sendAttributionRequest": true + } + ] + }, + { + "webExtension": { + "id": "simple@search.mozilla.org" + }, + "appliesTo": [ + { + "included": { "everywhere": true }, + "default": "yes" + } + ] + } + ] +} diff --git a/browser/modules/test/browser/search-engines/simple/manifest.json b/browser/modules/test/browser/search-engines/simple/manifest.json new file mode 100644 index 0000000000..67d2974753 --- /dev/null +++ b/browser/modules/test/browser/search-engines/simple/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "Simple Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Simple engine with a different name from the WebExtension id prefix", + "browser_specific_settings": { + "gecko": { + "id": "simple@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Simple Engine", + "search_url": "https://example.com", + "params": [ + { + "name": "sourceId", + "value": "Mozilla-search" + }, + { + "name": "search", + "value": "{searchTerms}" + } + ], + "suggest_url": "https://example.com?search={searchTerms}" + } + } +} diff --git a/browser/modules/test/browser/testEngine_chromeicon.xml b/browser/modules/test/browser/testEngine_chromeicon.xml new file mode 100644 index 0000000000..3ce80bcaea --- /dev/null +++ b/browser/modules/test/browser/testEngine_chromeicon.xml @@ -0,0 +1,12 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>FooChromeIcon</ShortName> + <Description>Foo Chrome Icon Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">chrome://browser/skin/info.svg</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm> + <moz:Alias>foochromeiconalias</moz:Alias> +</OpenSearchDescription> 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..0fb1030f1d --- /dev/null +++ b/browser/modules/test/unit/test_HomePage.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HomePage: "resource:///modules/HomePage.jsm", +}); + +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..26d384bea7 --- /dev/null +++ b/browser/modules/test/unit/test_HomePage_ignore.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HomePage: "resource:///modules/HomePage.jsm", +}); + +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..64d9964cbe --- /dev/null +++ b/browser/modules/test/unit/test_InstallationTelemetry.js @@ -0,0 +1,284 @@ +/* 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 { AttributionIOUtils } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { BrowserUsageTelemetry } = ChromeUtils.import( + "resource:///modules/BrowserUsageTelemetry.jsm" +); +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" }, + { clear: false } + ); + // Provenance Data is currently only supported on Windows. + if (AppConstants.platform == "win") { + let provenanceExtra = { + data_exists: "true", + file_system: "NTFS", + ads_exists: "true", + security_zone: "3", + refer_url_exist: "true", + refer_url_moz: "true", + host_url_exist: "true", + host_url_moz: "true", + }; + TelemetryTestUtils.assertEvents( + expectExtra + ? [{ object: installType, value: null, extra: provenanceExtra }] + : [], + { category: "installation", method: "first_seen_prov_ext" } + ); + } else { + TelemetryTestUtils.assertEvents( + expectExtra ? [{ object: installType, value: null, extra: {} }] : [], + { category: "installation", method: "first_seen_prov_ext" } + ); + } + + // Check timestamp + if (typeof expectTS == "string") { + Assert.equal(expectTS, Services.prefs.getStringPref(TIMESTAMP_PREF)); + } +} + +add_setup(function setup() { + let origReadUTF8 = AttributionIOUtils.readUTF8; + registerCleanupFunction(() => { + AttributionIOUtils.readUTF8 = origReadUTF8; + }); + AttributionIOUtils.readUTF8 = async path => { + return ` +[Mozilla] +fileSystem=NTFS +zoneIdFileSize=194 +zoneIdBufferLargeEnough=true +zoneIdTruncated=false + +[MozillaZoneIdentifierStartSentinel] +[ZoneTransfer] +ZoneId=3 +ReferrerUrl=https://mozilla.org/ +HostUrl=https://download-installer.cdn.mozilla.net/pub/firefox/nightly/latest-mozilla-central-l10n/Firefox%20Installer.en-US.exe +`; + }; +}); + +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 10 and above + if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) { + 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..d78fde2414 --- /dev/null +++ b/browser/modules/test/unit/test_LaterRun.js @@ -0,0 +1,242 @@ +"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.import("resource:///modules/LaterRun.jsm"); + +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_PartnerLinkAttribution.js b/browser/modules/test/unit/test_PartnerLinkAttribution.js new file mode 100644 index 0000000000..79053fbb07 --- /dev/null +++ b/browser/modules/test/unit/test_PartnerLinkAttribution.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { PartnerLinkAttribution, CONTEXTUAL_SERVICES_PING_TYPES } = + ChromeUtils.importESModule( + "resource:///modules/PartnerLinkAttribution.sys.mjs" + ); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const FAKE_PING = { tile_id: 1, position: 1 }; + +let sandbox; +let stub; + +add_task(function setup() { + sandbox = sinon.createSandbox(); + stub = sandbox.stub( + PartnerLinkAttribution._pingCentre, + "sendStructuredIngestionPing" + ); + stub.returns(200); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(function test_sendContextualService_success() { + for (const type of Object.values(CONTEXTUAL_SERVICES_PING_TYPES)) { + PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, type); + + Assert.ok(stub.calledOnce, `Should send the ping for ${type}`); + + const [payload, endpoint] = stub.firstCall.args; + Assert.ok(!!payload.context_id, "Should add context_id to the payload"); + Assert.ok( + endpoint.includes(type), + "Should include the ping type in the endpoint URL" + ); + stub.resetHistory(); + } +}); + +add_task(function test_rejectUnknownPingType() { + PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, "unknown-type"); + + Assert.ok(stub.notCalled, "Should not send the ping with unknown ping type"); +}); diff --git a/browser/modules/test/unit/test_PingCentre.js b/browser/modules/test/unit/test_PingCentre.js new file mode 100644 index 0000000000..b022030196 --- /dev/null +++ b/browser/modules/test/unit/test_PingCentre.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { PingCentre, PingCentreConstants } = ChromeUtils.import( + "resource:///modules/PingCentre.jsm" +); +const { TelemetryEnvironment } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryEnvironment.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { UpdateUtils } = ChromeUtils.importESModule( + "resource://gre/modules/UpdateUtils.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const FAKE_PING = { event: "fake_event", value: "fake_value", locale: "en-US" }; +const FAKE_ENDPOINT = "https://www.test.com"; + +let pingCentre; +let sandbox; +let fogInitd = false; + +function _setUp() { + Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, true); + Services.prefs.setBoolPref(PingCentreConstants.FHR_UPLOAD_ENABLED_PREF, true); + Services.prefs.setBoolPref(PingCentreConstants.LOGGING_PREF, true); + sandbox.restore(); + if (fogInitd) { + Services.fog.testResetFOG(); + } +} + +add_setup(function setup() { + sandbox = sinon.createSandbox(); + _setUp(); + pingCentre = new PingCentre({ topic: "test_topic" }); + + registerCleanupFunction(() => { + sandbox.restore(); + Services.prefs.clearUserPref(PingCentreConstants.TELEMETRY_PREF); + Services.prefs.clearUserPref(PingCentreConstants.FHR_UPLOAD_ENABLED_PREF); + Services.prefs.clearUserPref(PingCentreConstants.LOGGING_PREF); + }); + + // On Android, FOG is set up through head.js + if (AppConstants.platform != "android") { + do_get_profile(); + Services.fog.initializeFOG(); + fogInitd = true; + } +}); + +add_task(function test_enabled() { + _setUp(); + Assert.ok(pingCentre.enabled, "Telemetry should be on"); +}); + +add_task(function test_disabled_by_pingCentre() { + _setUp(); + Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, false); + + Assert.ok(!pingCentre.enabled, "Telemetry should be off"); +}); + +add_task(function test_disabled_by_FirefoxHealthReport() { + _setUp(); + Services.prefs.setBoolPref( + PingCentreConstants.FHR_UPLOAD_ENABLED_PREF, + false + ); + + Assert.ok(!pingCentre.enabled, "Telemetry should be off"); +}); + +add_task(function test_logging() { + _setUp(); + Assert.ok(pingCentre.logging, "Logging should be on"); + + Services.prefs.setBoolPref(PingCentreConstants.LOGGING_PREF, false); + + Assert.ok(!pingCentre.logging, "Logging should be off"); +}); + +add_task(function test_createExperimentsPayload() { + _setUp(); + const activeExperiments = { + exp1: { branch: "foo", enrollmentID: "SOME_RANDON_ID" }, + exp2: { branch: "bar", type: "PrefStudy" }, + exp3: {}, + }; + sandbox + .stub(TelemetryEnvironment, "getActiveExperiments") + .returns(activeExperiments); + const expected = { + exp1: { branch: "foo" }, + exp2: { branch: "bar" }, + }; + + const experiments = pingCentre._createExperimentsPayload(); + + Assert.deepEqual( + experiments, + expected, + "Should create experiments with all the required context" + ); +}); + +add_task(function test_createExperimentsPayload_without_active_experiments() { + _setUp(); + sandbox.stub(TelemetryEnvironment, "getActiveExperiments").returns({}); + const experiments = pingCentre._createExperimentsPayload({}); + + Assert.deepEqual(experiments, {}, "Should send an empty object"); +}); + +add_task(function test_createStructuredIngestionPing() { + _setUp(); + sandbox + .stub(TelemetryEnvironment, "getActiveExperiments") + .returns({ exp1: { branch: "foo" } }); + const ping = pingCentre._createStructuredIngestionPing(FAKE_PING); + const expected = { + experiments: { exp1: { branch: "foo" } }, + locale: "en-US", + version: AppConstants.MOZ_APP_VERSION, + release_channel: UpdateUtils.getUpdateChannel(false), + ...FAKE_PING, + }; + + Assert.deepEqual(ping, expected, "Should create a valid ping"); +}); + +add_task(function test_sendStructuredIngestionPing_disabled() { + _setUp(); + sandbox.stub(PingCentre, "_sendStandalonePing").resolves(); + Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, false); + pingCentre.sendStructuredIngestionPing(FAKE_PING, FAKE_ENDPOINT); + + Assert.ok(PingCentre._sendStandalonePing.notCalled, "Should not be sent"); +}); + +add_task(async function test_sendStructuredIngestionPing_success() { + _setUp(); + sandbox.stub(PingCentre, "_sendStandalonePing").resolves(); + await pingCentre.sendStructuredIngestionPing( + FAKE_PING, + FAKE_ENDPOINT, + "messaging-system" + ); + + Assert.equal(PingCentre._sendStandalonePing.callCount, 1, "Should be sent"); + Assert.equal( + 1, + Glean.pingCentre.sendSuccessesByNamespace.messaging_system.testGetValue() + ); + + // Test an unknown namespace + await pingCentre.sendStructuredIngestionPing( + FAKE_PING, + FAKE_ENDPOINT, + "different-system" + ); + + Assert.equal(PingCentre._sendStandalonePing.callCount, 2, "Should be sent"); + Assert.equal( + 1, + Glean.pingCentre.sendSuccessesByNamespace.__other__.testGetValue() + ); +}); + +add_task(async function test_sendStructuredIngestionPing_failure() { + _setUp(); + sandbox.stub(PingCentre, "_sendStandalonePing").rejects(); + Assert.equal(undefined, Glean.pingCentre.sendFailures.testGetValue()); + await pingCentre.sendStructuredIngestionPing( + FAKE_PING, + FAKE_ENDPOINT, + "activity-stream" + ); + + Assert.equal(1, Glean.pingCentre.sendFailures.testGetValue()); + Assert.equal( + 1, + Glean.pingCentre.sendFailuresByNamespace.activity_stream.testGetValue() + ); +}); diff --git a/browser/modules/test/unit/test_ProfileCounter.js b/browser/modules/test/unit/test_ProfileCounter.js new file mode 100644 index 0000000000..e8b3179f34 --- /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.import( + "resource:///modules/BrowserUsageTelemetry.jsm" +); +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..adceb64ca4 --- /dev/null +++ b/browser/modules/test/unit/test_SiteDataManager.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { SiteDataManager } = ChromeUtils.import( + "resource:///modules/SiteDataManager.jsm" +); +const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" +); +const { PermissionTestUtils } = ChromeUtils.importESModule( + "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..d083c41414 --- /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.import( + "resource:///modules/SiteDataManager.jsm" +); +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..e982cf6e99 --- /dev/null +++ b/browser/modules/test/unit/test_SitePermissions.js @@ -0,0 +1,401 @@ +/* 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", + ]; + 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", + ]; + + 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..2177fe14e2 --- /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.import( + "resource:///modules/TabUnloader.jsm" +); + +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..7237b78c20 --- /dev/null +++ b/browser/modules/test/unit/test_discovery.js @@ -0,0 +1,138 @@ +/* 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.import("resource:///modules/Discovery.jsm"); +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, data) => { + let cookie = subject.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, data) => { + if (data !== "added") { + return false; + } + let cookie = subject.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.ini b/browser/modules/test/unit/xpcshell.ini new file mode 100644 index 0000000000..d7bda83c77 --- /dev/null +++ b/browser/modules/test/unit/xpcshell.ini @@ -0,0 +1,23 @@ +[DEFAULT] +head = +firefox-appdir = browser +skip-if = toolkit == 'android' # bug 1730213 + +[test_E10SUtils_nested_URIs.js] +[test_HomePage.js] +[test_HomePage_ignore.js] +[test_Sanitizer_interrupted.js] +[test_SitePermissions.js] +[test_SitePermissions_temporary.js] +[test_SiteDataManager.js] +[test_SiteDataManagerContainers.js] +[test_TabUnloader.js] +[test_LaterRun.js] +[test_discovery.js] +[test_PingCentre.js] +[test_ProfileCounter.js] +skip-if = os != 'win' # Test of a Windows-specific feature +[test_InstallationTelemetry.js] +skip-if = + os != 'win' # Test of a Windows-specific feature +[test_PartnerLinkAttribution.js] |