summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview/tests/browser
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/firefoxview/tests/browser')
-rw-r--r--browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs177
-rw-r--r--browser/components/firefoxview/tests/browser/browser.toml74
-rw-r--r--browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js120
-rw-r--r--browser/components/firefoxview/tests/browser/browser_entrypoint_management.js67
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout.js746
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_position.js445
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js178
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js175
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js80
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview.js87
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js368
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js96
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js407
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js629
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js370
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js85
-rw-r--r--browser/components/firefoxview/tests/browser/browser_history_firefoxview.js544
-rw-r--r--browser/components/firefoxview/tests/browser/browser_notification_dot.js392
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_cards.js628
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_changes.js541
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js423
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_recency.js408
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js207
-rw-r--r--browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js600
-rw-r--r--browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js36
-rw-r--r--browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js141
-rw-r--r--browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js747
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js43
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js60
-rw-r--r--browser/components/firefoxview/tests/browser/head.js708
30 files changed, 9582 insertions, 0 deletions
diff --git a/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs
new file mode 100644
index 0000000000..3fd2bf95e3
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
+import { Assert } from "resource://testing-common/Assert.sys.mjs";
+import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";
+
+var testScope;
+
+/**
+ * Module consumers can optionally initialize the module
+ *
+ * @param {object} scope
+ * object with SimpleTest and info properties.
+ */
+function init(scope) {
+ testScope = scope;
+}
+
+function getFirefoxViewURL() {
+ return "about:firefoxview";
+}
+
+function assertFirefoxViewTab(win) {
+ Assert.ok(win.FirefoxViewHandler.tab, "Firefox View tab exists");
+ Assert.ok(win.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden");
+ Assert.equal(
+ win.gBrowser.visibleTabs.indexOf(win.FirefoxViewHandler.tab),
+ -1,
+ "Firefox View tab is not in the list of visible tabs"
+ );
+}
+
+async function assertFirefoxViewTabSelected(win) {
+ assertFirefoxViewTab(win);
+ Assert.ok(
+ win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is selected"
+ );
+ await BrowserTestUtils.browserLoaded(
+ win.FirefoxViewHandler.tab.linkedBrowser
+ );
+}
+
+async function openFirefoxViewTab(win) {
+ if (!testScope?.SimpleTest) {
+ throw new Error(
+ "Must initialize FirefoxViewTestUtils with a test scope which has a SimpleTest property"
+ );
+ }
+ await testScope.SimpleTest.promiseFocus(win);
+ let fxviewTab = win.FirefoxViewHandler.tab;
+ let alreadyLoaded =
+ fxviewTab?.linkedBrowser.currentURI.spec.includes(getFirefoxViewURL()) &&
+ fxviewTab?.linkedBrowser?.contentDocument?.readyState == "complete";
+ let enteredPromise = alreadyLoaded
+ ? Promise.resolve()
+ : TestUtils.topicObserved("firefoxview-entered");
+
+ if (!fxviewTab?.selected) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+ await TestUtils.waitForTick();
+ }
+
+ fxviewTab = win.FirefoxViewHandler.tab;
+ assertFirefoxViewTab(win);
+ Assert.ok(
+ win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is selected"
+ );
+
+ testScope.info(
+ "openFirefoxViewTab, waiting for complete readyState, visible and firefoxview-entered"
+ );
+ await Promise.all([
+ TestUtils.waitForCondition(() => {
+ const document = fxviewTab.linkedBrowser.contentDocument;
+ return (
+ document.readyState == "complete" &&
+ document.visibilityState == "visible"
+ );
+ }),
+ enteredPromise,
+ ]);
+ testScope.info("openFirefoxViewTab, ready resolved");
+ return fxviewTab;
+}
+
+function closeFirefoxViewTab(win) {
+ if (win.FirefoxViewHandler.tab) {
+ win.gBrowser.removeTab(win.FirefoxViewHandler.tab);
+ }
+ Assert.ok(
+ !win.FirefoxViewHandler.tab,
+ "Reference to Firefox View tab got removed when closing the tab"
+ );
+}
+
+/**
+ * Run a task with Firefox View open.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {boolean} [options.openNewWindow]
+ * Whether to run the task in a new window. If false, the current window will
+ * be used.
+ * @param {boolean} [options.resetFlowManager]
+ * Whether to reset the internal state of TabsSetupFlowManager before running
+ * the task.
+ * @param {Window} [options.win]
+ * The window in which to run the task.
+ * @param {(MozBrowser) => any} taskFn
+ * The task to run. It can be asynchronous.
+ * @returns {any}
+ * The value returned by the task.
+ */
+async function withFirefoxView(
+ { openNewWindow = false, resetFlowManager = true, win = null },
+ taskFn
+) {
+ if (!win) {
+ win = openNewWindow
+ ? await BrowserTestUtils.openNewBrowserWindow()
+ : Services.wm.getMostRecentBrowserWindow();
+ }
+ if (resetFlowManager) {
+ const { TabsSetupFlowManager } = ChromeUtils.importESModule(
+ "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs"
+ );
+ // reset internal state so we aren't reacting to whatever state the last invocation left behind
+ TabsSetupFlowManager.resetInternalState();
+ }
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await win.SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+ let tab = await openFirefoxViewTab(win);
+ let originalWindow = tab.ownerGlobal;
+ let result = await taskFn(tab.linkedBrowser);
+ let finalWindow = tab.ownerGlobal;
+ if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) {
+ // taskFn may resolve within a tick after opening a new tab.
+ // We shouldn't remove the newly opened tab in the same tick.
+ // Wait for the next tick here.
+ await TestUtils.waitForTick();
+ BrowserTestUtils.removeTab(tab);
+ } else {
+ Services.console.logStringMessage(
+ "withFirefoxView: Tab was already closed before " +
+ "removeTab would have been called"
+ );
+ }
+ await win.SpecialPowers.popPrefEnv();
+ if (openNewWindow) {
+ await BrowserTestUtils.closeWindow(win);
+ }
+ return result;
+}
+
+function isFirefoxViewTabSelectedInWindow(win) {
+ return win.gBrowser.selectedBrowser.currentURI.spec == getFirefoxViewURL();
+}
+
+export {
+ init,
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+ getFirefoxViewURL,
+};
diff --git a/browser/components/firefoxview/tests/browser/browser.toml b/browser/components/firefoxview/tests/browser/browser.toml
new file mode 100644
index 0000000000..8e2005760b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser.toml
@@ -0,0 +1,74 @@
+[DEFAULT]
+support-files = ["head.js"]
+prefs = [
+ "browser.sessionstore.closedTabsFromAllWindows=true",
+ "browser.sessionstore.closedTabsFromClosedWindows=true",
+ "browser.tabs.firefox-view.logLevel=All",
+]
+
+["browser_dragDrop_after_opening_fxViewTab.js"]
+
+["browser_entrypoint_management.js"]
+
+["browser_feature_callout.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_position.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_resize.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_targeting.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_theme.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_firefoxview.js"]
+
+["browser_firefoxview_tab.js"]
+
+["browser_notification_dot.js"]
+skip-if = ["true"] # Bug 1851453
+
+["browser_opentabs_changes.js"]
+
+["browser_reload_firefoxview.js"]
+
+["browser_tab_close_last_tab.js"]
+
+["browser_tab_on_close_warning.js"]
+
+["browser_firefoxview_paused.js"]
+
+["browser_firefoxview_general_telemetry.js"]
+
+["browser_firefoxview_navigation.js"]
+
+["browser_firefoxview_search_telemetry.js"]
+
+["browser_firefoxview_virtual_list.js"]
+
+["browser_history_firefoxview.js"]
+skip-if = ["true"] # Bug 1877594
+
+["browser_opentabs_firefoxview.js"]
+
+["browser_opentabs_cards.js"]
+fail-if = ["a11y_checks"] # Bugs 1858041, 1854625, and 1872174 clicked Show all link is not accessible because it is "hidden" when clicked
+
+["browser_opentabs_recency.js"]
+skip-if = [
+ "os == 'win'",
+ "os == 'mac' && verify",
+ "os == 'linux'"
+] # macos times out, see bug 1857293, skipped for windows, see bug 1858460, skipped for linux, see bug 1875877
+
+["browser_opentabs_tab_indicators.js"]
+
+["browser_recentlyclosed_firefoxview.js"]
+
+["browser_syncedtabs_errors_firefoxview.js"]
+
+["browser_syncedtabs_firefoxview.js"]
diff --git a/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js
new file mode 100644
index 0000000000..9ce547238a
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that dragging and dropping tabs into tabbrowser works as intended
+ * after opening the Firefox View tab for RTL builds. There was an issue where
+ * tabs from dragged links were not dropped in the correct tab indexes
+ * for RTL builds because logic for RTL builds did not take into consideration
+ * hidden tabs like the Firefox View tab. This test makes sure that this behavior does not reoccur.
+ */
+add_task(async function () {
+ info("Setting browser to RTL locale");
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+
+ // window.RTL_UI doesn't update in existing windows when this pref is changed,
+ // so we need to test in a new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+ let newTab = win.gBrowser.tabs[0];
+
+ let waitForTestTabPromise = BrowserTestUtils.waitForNewTab(
+ win.gBrowser,
+ TEST_ROOT + "file_new_tab_page.html"
+ );
+ let testTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TEST_ROOT + "file_new_tab_page.html"
+ );
+ await waitForTestTabPromise;
+
+ let linkSrcEl = win.document.querySelector("a");
+ ok(linkSrcEl, "Link exists");
+
+ let dropPromise = BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "drop"
+ );
+
+ /**
+ * There should be 2 tabs:
+ * 1. new tab (auto-generated)
+ * 2. test tab
+ */
+ is(win.gBrowser.visibleTabs.length, 2, "There should be 2 tabs");
+
+ // Now open Firefox View tab
+ info("Opening Firefox View tab");
+ await openFirefoxViewTab(win);
+
+ /**
+ * There should be 2 visible tabs:
+ * 1. new tab (auto-generated)
+ * 2. test tab
+ * Firefox View tab is hidden.
+ */
+ is(
+ win.gBrowser.visibleTabs.length,
+ 2,
+ "There should still be 2 visible tabs after opening Firefox View tab"
+ );
+
+ info("Switching to test tab");
+ await BrowserTestUtils.switchTab(win.gBrowser, testTab);
+
+ let waitForDraggedTabPromise = BrowserTestUtils.waitForNewTab(
+ win.gBrowser,
+ "https://example.com/#test"
+ );
+
+ info("Dragging link between test tab and new tab");
+ EventUtils.synthesizeDrop(
+ linkSrcEl,
+ testTab,
+ [[{ type: "text/plain", data: "https://example.com/#test" }]],
+ "link",
+ win,
+ win,
+ {
+ clientX: testTab.getBoundingClientRect().right,
+ }
+ );
+
+ info("Waiting for drop event");
+ await dropPromise;
+ info("Waiting for dragged tab to be created");
+ let draggedTab = await waitForDraggedTabPromise;
+
+ /**
+ * There should be 3 visible tabs:
+ * 1. new tab (auto-generated)
+ * 2. new tab from dragged link
+ * 3. test tab
+ *
+ * In RTL build, it should appear in the following order:
+ * <test tab> <link dragged tab> <new tab> | <FxView tab>
+ */
+ is(win.gBrowser.visibleTabs.length, 3, "There should be 3 tabs");
+ is(
+ win.gBrowser.visibleTabs.indexOf(newTab),
+ 0,
+ "New tab should still be rightmost visible tab"
+ );
+ is(
+ win.gBrowser.visibleTabs.indexOf(draggedTab),
+ 1,
+ "Dragged link should positioned at new index"
+ );
+ is(
+ win.gBrowser.visibleTabs.indexOf(testTab),
+ 2,
+ "Test tab should be to the left of dragged tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js
new file mode 100644
index 0000000000..ef6b0c99f5
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_removing_button_should_close_tab() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ let tab = browser.getTabBrowser().getTabForBrowser(browser);
+ let button = win.document.getElementById("firefox-view-button");
+ await win.gCustomizeMode.removeFromArea(button, "toolbar-context-menu");
+ ok(!tab.isConnected, "Tab should have been removed.");
+ isnot(win.gBrowser.selectedTab, tab, "A different tab should be selected.");
+ });
+ CustomizableUI.reset();
+});
+
+add_task(async function test_button_auto_readd() {
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+ ok(
+ !CustomizableUI.getPlacementOfWidget("firefox-view-button"),
+ "Button has no placement"
+ );
+ ok(!FirefoxViewHandler.tab, "Shouldn't have tab reference");
+ ok(!FirefoxViewHandler.button, "Shouldn't have button reference");
+
+ FirefoxViewHandler.openTab();
+ ok(FirefoxViewHandler.tab, "Tab re-opened");
+ ok(FirefoxViewHandler.button, "Button re-added");
+ let placement = CustomizableUI.getPlacementOfWidget("firefox-view-button");
+ is(
+ placement.area,
+ CustomizableUI.AREA_TABSTRIP,
+ "Button re-added to the tabs toolbar"
+ );
+ is(placement.position, 0, "Button re-added as the first toolbar element");
+ });
+ CustomizableUI.reset();
+});
+
+add_task(async function test_button_moved() {
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+ CustomizableUI.addWidgetToArea(
+ "firefox-view-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ is(
+ FirefoxViewHandler.button.closest("toolbar").id,
+ "nav-bar",
+ "Button is in the navigation toolbar"
+ );
+ });
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+ is(
+ FirefoxViewHandler.button.closest("toolbar").id,
+ "nav-bar",
+ "Button remains in the navigation toolbar"
+ );
+ });
+ CustomizableUI.reset();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout.js b/browser/components/firefoxview/tests/browser/browser_feature_callout.js
new file mode 100644
index 0000000000..3fd2ee517d
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js
@@ -0,0 +1,746 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const { MessageLoaderUtils } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+const defaultPrefValue = getPrefValueByScreen(1);
+
+add_setup(async function () {
+ requestLongerTimeout(3);
+ registerCleanupFunction(() => ASRouter.resetMessageState());
+});
+
+add_task(async function feature_callout_renders_in_firefox_view() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ }
+ );
+});
+
+add_task(async function feature_callout_is_not_shown_twice() {
+ // Third comma-separated value of the pref is set to a string value once a user completes the tour
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, '{"screen":"","complete":true}']],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ ok(
+ !document.querySelector(calloutSelector),
+ "Feature Callout tour does not render if the user finished it previously"
+ );
+ }
+ );
+ Services.prefs.clearUserPref(featureTourPref);
+});
+
+add_task(async function feature_callout_syncs_across_visits_and_tabs() {
+ // Second comma-separated value of the pref is the id
+ // of the last viewed screen of the feature tour
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_1","complete":false}']],
+ });
+ // Open an about:firefoxview tab
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:firefoxview"
+ );
+ let tab1Doc = tab1.linkedBrowser.contentWindow.document;
+ launchFeatureTourIn(tab1.linkedBrowser.contentWindow);
+ await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_1");
+
+ ok(
+ tab1Doc.querySelector(".FEATURE_CALLOUT_1"),
+ "First tab's Feature Callout shows the tour screen saved in the user pref"
+ );
+
+ // Open a second about:firefoxview tab
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:firefoxview"
+ );
+ let tab2Doc = tab2.linkedBrowser.contentWindow.document;
+ launchFeatureTourIn(tab2.linkedBrowser.contentWindow);
+ await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_1");
+
+ ok(
+ tab2Doc.querySelector(".FEATURE_CALLOUT_1"),
+ "Second tab's Feature Callout shows the tour screen saved in the user pref"
+ );
+
+ await clickCTA(tab2Doc);
+ await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_2");
+
+ gBrowser.selectedTab = tab1;
+ tab1.focus();
+ await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_2");
+ ok(
+ tab1Doc.querySelector(".FEATURE_CALLOUT_2"),
+ "First tab's Feature Callout advances to the next screen when the tour is advanced in second tab"
+ );
+
+ await clickCTA(tab1Doc);
+ gBrowser.selectedTab = tab1;
+ await waitForCalloutRemoved(tab1Doc);
+
+ ok(
+ !tab1Doc.body.querySelector(calloutSelector),
+ "Feature Callout is removed in first tab after being dismissed in first tab"
+ );
+
+ gBrowser.selectedTab = tab2;
+ tab2.focus();
+ await waitForCalloutRemoved(tab2Doc);
+
+ ok(
+ !tab2Doc.body.querySelector(calloutSelector),
+ "Feature Callout is removed in second tab after tour was dismissed in first tab"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ Services.prefs.clearUserPref(featureTourPref);
+});
+
+add_task(async function feature_callout_closes_on_dismiss() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_2","complete":false}']],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const spy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+
+ document.querySelector(".dismiss-button").click();
+ await waitForCalloutRemoved(document);
+
+ ok(
+ !document.querySelector(calloutSelector),
+ "Callout is removed from screen on dismiss"
+ );
+
+ let tourComplete = JSON.parse(
+ Services.prefs.getStringPref(featureTourPref)
+ ).complete;
+ ok(
+ tourComplete,
+ `Tour is recorded as complete in ${featureTourPref} preference value`
+ );
+
+ // Test that appropriate telemetry is sent
+ spy.assertCalledWith({
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: "dismiss_button",
+ page: "about:firefoxview",
+ },
+ message_id: sinon.match("FEATURE_CALLOUT_2"),
+ });
+ spy.assertCalledWith({
+ event: "DISMISS",
+ event_context: {
+ source: "dismiss_button",
+ page: "about:firefoxview",
+ },
+ message_id: sinon.match("FEATURE_CALLOUT_2"),
+ });
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_arrow_position_attribute_exists() {
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ const callout = await BrowserTestUtils.waitForCondition(
+ () =>
+ document.querySelector(`${calloutSelector}[arrow-position="top"]`),
+ "Waiting for callout to render"
+ );
+ is(
+ callout.getAttribute("arrow-position"),
+ "top",
+ "Arrow position attribute exists on parent container"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() {
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position = "start";
+ testMessage.message.content.screens[0].anchors[0].selector =
+ "span.brand-feature-name";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ const callout = await BrowserTestUtils.waitForCondition(
+ () =>
+ document.querySelector(
+ `${calloutSelector}[arrow-position="inline-start"]:not(.hidden)`
+ ),
+ "Waiting for callout to render"
+ );
+ is(
+ callout.getAttribute("arrow-position"),
+ "inline-start",
+ "Feature callout has inline-start arrow position when arrow_position is set to 'start'"
+ );
+ ok(
+ !callout.classList.contains("hidden"),
+ "Feature Callout is not hidden"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_respects_cfr_features_pref() {
+ async function toggleCFRFeaturesPref(value) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ value,
+ ],
+ ],
+ });
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+
+ await toggleCFRFeaturesPref(true);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ await toggleCFRFeaturesPref(false);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ ok(
+ !document.querySelector(calloutSelector),
+ "Feature Callout element was not created because CFR pref was disabled"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(
+ async function feature_callout_tab_pickup_reminder_primary_click_elm() {
+ Services.prefs.setBoolPref("identity.fxaccounts.enabled", false);
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ const expectedUrl =
+ await fxAccounts.constructor.config.promiseConnectAccountURI("fx-view");
+ info(`Expected FxA URL: ${expectedUrl}`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ let tabOpened = new Promise(resolve => {
+ gBrowser.tabContainer.addEventListener(
+ "TabOpen",
+ event => {
+ let newTab = event.target;
+ let newBrowser = newTab.linkedBrowser;
+ let result = newTab;
+ BrowserTestUtils.waitForDocLoadAndStopIt(
+ expectedUrl,
+ newBrowser
+ ).then(() => resolve(result));
+ },
+ { once: true }
+ );
+ });
+
+ info("Waiting for callout to render");
+ await waitForCalloutScreen(
+ document,
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+
+ info("Clicking primary button");
+ let calloutRemoved = waitForCalloutRemoved(document);
+ await clickCTA(document);
+ let openedTab = await tabOpened;
+ ok(openedTab, "FxA sign in page opened");
+ // The callout should be removed when primary CTA is clicked
+ await calloutRemoved;
+ BrowserTestUtils.removeTab(openedTab);
+ }
+ );
+ Services.prefs.clearUserPref("identity.fxaccounts.enabled");
+ sandbox.restore();
+ }
+);
+
+add_task(async function feature_callout_dismiss_on_timeout() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"screen":"","complete":true}`]],
+ });
+ const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
+ let testMessage = getCalloutMessageById(screenId);
+ // Configure message with a dismiss action on tab container click
+ testMessage.message.content.screens[0].content.page_event_listeners = [
+ {
+ params: { type: "timeout", options: { once: true, interval: 5000 } },
+ action: { dismiss: true, type: "CANCEL" },
+ },
+ ];
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const telemetrySpy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ let onInterval;
+ let startedInterval = new Promise(resolve => {
+ sandbox
+ .stub(browser.contentWindow, "setInterval")
+ .callsFake((fn, ms) => {
+ Assert.strictEqual(
+ ms,
+ 5000,
+ "setInterval called with 5 second interval"
+ );
+ onInterval = fn;
+ resolve();
+ return 1;
+ });
+ });
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ info("Waiting for callout to render");
+ await startedInterval;
+ await waitForCalloutScreen(document, screenId);
+
+ info("Ending timeout");
+ onInterval();
+ await waitForCalloutRemoved(document);
+
+ // Test that appropriate telemetry is sent
+ telemetrySpy.assertCalledWith({
+ event: "PAGE_EVENT",
+ event_context: {
+ action: "CANCEL",
+ reason: "TIMEOUT",
+ source: "timeout",
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ telemetrySpy.assertCalledWith({
+ event: "DISMISS",
+ event_context: {
+ source: "PAGE_EVENT:timeout",
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ }
+ );
+ Services.prefs.clearUserPref("browser.firefox-view.view-count");
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_advance_tour_on_page_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ featureTourPref,
+ JSON.stringify({
+ screen: "FEATURE_CALLOUT_1",
+ complete: false,
+ }),
+ ],
+ ],
+ });
+
+ // Add page action listeners to the built-in messages.
+ let testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ // Configure message with a dismiss action on tab container click
+ testMessage.message.content.screens.forEach(screen => {
+ screen.content.page_event_listeners = [
+ {
+ params: { type: "click", selectors: ".brand-logo" },
+ action: JSON.parse(
+ JSON.stringify(screen.content.primary_button.action)
+ ),
+ },
+ ];
+ });
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ info("Clicking page container");
+ // We intentionally turn off a11y_checks, because the following click
+ // is send to dismiss the feature callout using an alternative way of
+ // the callout dismissal, where other ways are accessible, therefore
+ // this test can be ignored.
+ AccessibilityUtils.setEnv({
+ mustHaveAccessibleRule: false,
+ });
+ document.querySelector(".brand-logo").click();
+ AccessibilityUtils.resetEnv();
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ info("Clicking page container");
+ // We intentionally turn off a11y_checks, because the following click
+ // is send to dismiss the feature callout using an alternative way of
+ // the callout dismissal, where other ways are accessible, therefore
+ // this test can be ignored.
+ AccessibilityUtils.setEnv({
+ mustHaveAccessibleRule: false,
+ });
+ document.querySelector(".brand-logo").click();
+ AccessibilityUtils.resetEnv();
+
+ await waitForCalloutRemoved(document);
+ let tourComplete = JSON.parse(
+ Services.prefs.getStringPref(featureTourPref)
+ ).complete;
+ ok(
+ tourComplete,
+ `Tour is recorded as complete in ${featureTourPref} preference value`
+ );
+ }
+ );
+
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_dismiss_on_escape() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"screen":"","complete":true}`]],
+ });
+ const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
+ let testMessage = getCalloutMessageById(screenId);
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const spy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ info("Waiting for callout to render");
+ await waitForCalloutScreen(document, screenId);
+
+ info("Pressing escape");
+ // Press Escape to close
+ EventUtils.synthesizeKey("KEY_Escape", {}, browser.contentWindow);
+ await waitForCalloutRemoved(document);
+
+ // Test that appropriate telemetry is sent
+ spy.assertCalledWith({
+ event: "DISMISS",
+ event_context: {
+ source: "KEY_Escape",
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ }
+ );
+ Services.prefs.clearUserPref("browser.firefox-view.view-count");
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function test_firefox_view_spotlight_promo() {
+ // Prevent attempts to fetch CFR messages remotely.
+ const sandbox = sinon.createSandbox();
+ let remoteSettingsStub = sandbox.stub(
+ MessageLoaderUtils,
+ "_remoteSettingsLoader"
+ );
+ remoteSettingsStub.resolves([]);
+
+ await SpecialPowers.pushPrefEnv({
+ clear: [
+ [featureTourPref],
+ ["browser.newtabpage.activity-stream.asrouter.providers.cfr"],
+ ],
+ });
+ ASRouter.resetMessageState();
+
+ let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://browser/content/spotlight.html",
+ { isSubDialog: true }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ launchFeatureTourIn(browser.contentWindow);
+
+ info("Waiting for the Fx View Spotlight promo to open");
+ let dialogBrowser = await dialogOpenPromise;
+ let primaryBtnSelector = ".action-buttons button.primary";
+ await TestUtils.waitForCondition(
+ () => dialogBrowser.document.querySelector("main.DEFAULT_MODAL_UI"),
+ `Should render main.DEFAULT_MODAL_UI`
+ );
+
+ dialogBrowser.document.querySelector(primaryBtnSelector).click();
+ info("Fx View Spotlight promo clicked");
+
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ info("Feature tour started");
+ await clickCTA(document);
+ }
+ );
+
+ ok(remoteSettingsStub.called, "Tried to load CFR messages");
+ sandbox.restore();
+ await SpecialPowers.popPrefEnv();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+
+ document.querySelector(".dismiss-button").click();
+ await waitForCalloutRemoved(document);
+
+ Assert.strictEqual(
+ document.activeElement.localName,
+ "body",
+ "by default focus returns to the document body after callout closes"
+ );
+ }
+ );
+ sandbox.restore();
+ await SpecialPowers.popPrefEnv();
+ ASRouter.resetMessageState();
+});
+
+add_task(
+ async function feature_callout_returns_moved_fxview_focus_to_previous() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(
+ document,
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+
+ // change focus to recently-closed-tabs-container
+ let recentlyClosedHeaderSection = document.querySelector(
+ "#recently-closed-tabs-header-section"
+ );
+ recentlyClosedHeaderSection.focus();
+
+ // close the callout dialog
+ document.querySelector(".dismiss-button").click();
+ await waitForCalloutRemoved(document);
+
+ // verify that the focus landed in the right place
+ Assert.strictEqual(
+ document.activeElement.id,
+ "recently-closed-tabs-header-section",
+ "when focus changes away from callout it reverts after callout closes"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(async function feature_callout_does_not_display_arrow_if_hidden() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].hide_arrow = true;
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ is(
+ getComputedStyle(
+ document.querySelector(`${calloutSelector} .arrow-box`)
+ ).getPropertyValue("display"),
+ "none",
+ "callout arrow is not visible"
+ );
+ }
+ );
+ sandbox.restore();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js
new file mode 100644
index 0000000000..fcb66719d9
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js
@@ -0,0 +1,445 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const defaultPrefValue = getPrefValueByScreen(1);
+
+const squareWidth = 24;
+const arrowWidth = Math.hypot(squareWidth, squareWidth);
+const arrowHeight = arrowWidth / 2;
+let overlap = 5 - arrowHeight;
+
+add_task(
+ async function feature_callout_first_screen_positioned_below_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parentBottom = document
+ .querySelector("#tab-pickup-container")
+ .getBoundingClientRect().bottom;
+ let containerTop = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().top;
+
+ isfuzzy(
+ parentBottom - containerTop,
+ overlap,
+ 1, // add 1px fuzziness to account for possible subpixel rounding
+ "Feature Callout is positioned below parent element with the arrow overlapping by 5px"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_right_of_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, getPrefValueByScreen(2)]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[1].anchors = [
+ { selector: ".brand-logo", arrow_position: "start" },
+ ];
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+
+ let parentRight = document
+ .querySelector(".brand-logo")
+ .getBoundingClientRect().right;
+ let containerLeft = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().left;
+ isfuzzy(
+ parentRight - containerLeft,
+ overlap,
+ 1,
+ "Feature Callout is positioned right of parent element with the arrow overlapping by 5px"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_above_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, getPrefValueByScreen(2)]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ let parentTop = document
+ .querySelector("#recently-closed-tabs-container")
+ .getBoundingClientRect().top;
+ let containerBottom = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().bottom;
+
+ Assert.greaterOrEqual(
+ parentTop,
+ containerBottom - 5 - 1,
+ "Feature Callout is positioned above parent element with 5px overlap"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_third_screen_position_respects_RTL_layouts() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Set layout direction to right to left
+ ["intl.l10n.pseudo", "bidi"],
+ [featureTourPref, getPrefValueByScreen(2)],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ const parent = document.querySelector(
+ "#recently-closed-tabs-container"
+ );
+ parent.style.gridArea = "1/2";
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ let parentRight = parent.getBoundingClientRect().right;
+ let containerLeft = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().left;
+
+ Assert.lessOrEqual(
+ parentRight,
+ containerLeft + 5 + 1,
+ "Feature Callout is positioned right of parent element when callout is set to 'end' in RTL layouts"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_is_repositioned_if_parent_container_is_toggled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ const parentEl = document.querySelector("#tab-pickup-container");
+ const calloutStartingTopPosition =
+ document.querySelector(calloutSelector).style.top;
+
+ //container has been toggled/minimized
+ parentEl.removeAttribute("open", "");
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributes: true },
+ () =>
+ document.querySelector(calloutSelector).style.top !=
+ calloutStartingTopPosition
+ );
+ isnot(
+ document.querySelector(calloutSelector).style.top,
+ calloutStartingTopPosition,
+ "Feature Callout position is recalculated when parent element is toggled"
+ );
+ await closeCallout(document);
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+// This test should be moved into a surface agnostic test suite with bug 1793656.
+add_task(async function feature_callout_top_end_positioning() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position = "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector("#tab-pickup-container");
+ let container = document.querySelector(calloutSelector);
+ let parentLeft = parent.getBoundingClientRect().left;
+ let containerLeft = container.getBoundingClientRect().left;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-end",
+ "Feature Callout container has the expected top-end arrow-position attribute"
+ );
+ isfuzzy(
+ containerLeft - parent.clientWidth + container.offsetWidth,
+ parentLeft,
+ 1, // Display scaling can cause up to 1px difference in layout
+ "Feature Callout's right edge is approximately aligned with parent element's right edge"
+ );
+
+ await closeCallout(document);
+ }
+ );
+ sandbox.restore();
+});
+
+// This test should be moved into a surface agnostic test suite with bug 1793656.
+add_task(async function feature_callout_top_start_positioning() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position =
+ "top-start";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector("#tab-pickup-container");
+ let container = document.querySelector(calloutSelector);
+ let parentLeft = parent.getBoundingClientRect().left;
+ let containerLeft = container.getBoundingClientRect().left;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-start",
+ "Feature Callout container has the expected top-start arrow-position attribute"
+ );
+ isfuzzy(
+ containerLeft,
+ parentLeft,
+ 1, // Display scaling can cause up to 1px difference in layout
+ "Feature Callout's left edge is approximately aligned with parent element's left edge"
+ );
+
+ await closeCallout(document);
+ }
+ );
+ sandbox.restore();
+});
+
+// This test should be moved into a surface agnostic test suite with bug 1793656.
+add_task(
+ async function feature_callout_top_end_position_respects_RTL_layouts() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Set layout direction to right to left
+ ["intl.l10n.pseudo", "bidi"],
+ [featureTourPref, defaultPrefValue],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position =
+ "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector("#tab-pickup-container");
+ let container = document.querySelector(calloutSelector);
+ let parentLeft = parent.getBoundingClientRect().left;
+ let containerLeft = container.getBoundingClientRect().left;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-start",
+ "In RTL mode, the feature callout container has the expected top-start arrow-position attribute"
+ );
+ is(
+ containerLeft,
+ parentLeft,
+ "In RTL mode, the feature Callout's left edge is aligned with parent element's left edge"
+ );
+
+ await closeCallout(document);
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ sandbox.restore();
+ }
+);
+
+add_task(async function feature_callout_is_larger_than_its_parent() {
+ let testMessage = {
+ message: {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ template: "feature_callout",
+ content: {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ anchors: [
+ { selector: ".brand-feature-name", arrow_position: "end" },
+ ],
+ content: {
+ position: "callout",
+ title: "callout-firefox-view-tab-pickup-title",
+ subtitle: {
+ string_id: "callout-firefox-view-tab-pickup-subtitle",
+ },
+ logo: {
+ imageURL: "chrome://browser/content/callout-tab-pickup.svg",
+ darkModeImageURL:
+ "chrome://browser/content/callout-tab-pickup-dark.svg",
+ height: "128px", // .brand-feature-name has a height of 32px
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector(".brand-feature-name");
+ let container = document.querySelector(calloutSelector);
+ let parentHeight = parent.offsetHeight;
+ let containerHeight = container.offsetHeight;
+
+ let parentPositionTop =
+ parent.getBoundingClientRect().top + window.scrollY;
+ let containerPositionTop =
+ container.getBoundingClientRect().top + window.scrollY;
+ Assert.greater(
+ containerHeight,
+ parentHeight,
+ "Feature Callout is height is larger than parent element when callout is configured at end of callout"
+ );
+ Assert.less(
+ containerPositionTop,
+ parentPositionTop,
+ "Feature Callout is positioned higher that parent element when callout is configured at end of callout"
+ );
+ isfuzzy(
+ containerHeight / 2 + containerPositionTop,
+ parentHeight / 2 + parentPositionTop,
+ 1, // Display scaling can cause up to 1px difference in layout
+ "Feature Callout is centered equally to parent element when callout is configured at end of callout"
+ );
+ await ASRouter.resetMessageState();
+ }
+ );
+ sandbox.restore();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js
new file mode 100644
index 0000000000..cbc0547717
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function getArrowPosition(doc) {
+ let callout = doc.querySelector(calloutSelector);
+ return callout.getAttribute("arrow-position");
+}
+
+add_setup(async function setup() {
+ let originalWidth = window.outerWidth;
+ let originalHeight = window.outerHeight;
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => window.FullZoom.reset(browser)
+ );
+ window.resizeTo(originalWidth, originalHeight);
+ });
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => window.FullZoom.setZoom(0.5, browser)
+ );
+});
+
+add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.sessionstore.max_tabs_undo", 1]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1550, 1000);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1800, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "inline-start") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1800, 400);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "inline-start",
+ "On first screen at 1800x400, the callout is positioned to the right of the parent element"
+ );
+
+ startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1100, 600);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1100, 600);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1100x600, the callout is positioned below the parent element"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_is_repositioned_rtl() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Set layout direction to right to left
+ ["intl.l10n.pseudo", "bidi"],
+ ["browser.sessionstore.max_tabs_undo", 1],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1550, 1000);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1800, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "inline-end") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1800, 400);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "inline-end",
+ "On first screen at 1800x400, the callout is positioned to the right of the parent element"
+ );
+
+ startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1100, 600);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1100, 600);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1100x600, the callout is positioned below the parent element"
+ );
+ }
+ );
+ sandbox.restore();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js
new file mode 100644
index 0000000000..a4f9c6b65e
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js
@@ -0,0 +1,175 @@
+"use strict";
+
+add_task(
+ async function test_firefox_view_tab_pick_up_not_signed_in_targeting() {
+ ASRouter.resetMessageState();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`],
+ ],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", 3]],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.enabled", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(
+ document,
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+ ok(
+ document.querySelector(".featureCallout"),
+ "Firefox:View Tab Pickup should be displayed."
+ );
+
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_firefox_view_tab_pick_up_sync_not_enabled_targeting() {
+ ASRouter.resetMessageState();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`],
+ ],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", 3]],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.enabled", true]],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.tabs", false]],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.username", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(
+ document,
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+ ok(
+ document.querySelector(".featureCallout"),
+ "Firefox:View Tab Pickup should be displayed."
+ );
+
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_firefox_view_tab_pick_up_wait_24_hours_after_spotlight() {
+ const TWENTY_FIVE_HOURS_IN_MS = 25 * 60 * 60 * 1000;
+
+ ASRouter.resetMessageState();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`],
+ ],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", 3]],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.enabled", false]],
+ });
+
+ ASRouter.setState({
+ messageImpressions: { FIREFOX_VIEW_SPOTLIGHT: [Date.now()] },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ ok(
+ !document.querySelector(".featureCallout"),
+ "Tab Pickup reminder should not be displayed when the Spotlight message introducing the tour was viewed less than 24 hours ago."
+ );
+ }
+ );
+
+ ASRouter.setState({
+ messageImpressions: {
+ FIREFOX_VIEW_SPOTLIGHT: [Date.now() - TWENTY_FIVE_HOURS_IN_MS],
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(
+ document,
+ "FIREFOX_VIEW_TAB_PICKUP_REMINDER"
+ );
+ ok(
+ document.querySelector(".featureCallout"),
+ "Tab Pickup reminder can be displayed when the Spotlight message introducing the tour was viewed over 24 hours ago."
+ );
+
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ SpecialPowers.popPrefEnv();
+ }
+ );
+ }
+);
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js
new file mode 100644
index 0000000000..f5fd77e4ad
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCallout.sys.mjs"
+);
+
+async function testCallout(config) {
+ const featureCallout = new FeatureCallout(config);
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const screen = testMessage.message.content.screens[1];
+ screen.anchors[0].selector = "body";
+ testMessage.message.content.screens = [screen];
+ featureCallout.showFeatureCallout(testMessage.message);
+ await waitForCalloutScreen(config.win.document, screen.id);
+ testStyles(config);
+ return { featureCallout };
+}
+
+function testStyles({ win, theme }) {
+ const calloutEl = win.document.querySelector(calloutSelector);
+ const calloutStyle = win.getComputedStyle(calloutEl);
+ for (const type of ["light", "dark", "hcm"]) {
+ const appliedTheme = Object.assign(
+ {},
+ FeatureCallout.themePresets[theme.preset],
+ theme
+ );
+ const scheme = appliedTheme[type];
+ for (const name of FeatureCallout.themePropNames) {
+ Assert.equal(
+ !!calloutStyle.getPropertyValue(`--fc-${name}-${type}`),
+ !!(scheme?.[name] || appliedTheme.all?.[name]),
+ `Theme property --fc-${name}-${type} is set`
+ );
+ }
+ }
+}
+
+add_task(async function feature_callout_chrome_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await testCallout({
+ win,
+ location: "chrome",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "chrome" },
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function feature_callout_pdfjs_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await testCallout({
+ win,
+ location: "pdfjs",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "pdfjs", simulateContent: true },
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function feature_callout_content_theme() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ browser =>
+ testCallout({
+ win: browser.contentWindow,
+ location: "about:firefoxview",
+ context: "content",
+ theme: { preset: "themed-content" },
+ })
+ );
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_firefoxview.js
new file mode 100644
index 0000000000..33467941a4
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function about_firefoxview_smoke_test() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // sanity check the important regions exist on this page
+ ok(
+ document.querySelector("fxview-category-navigation"),
+ "fxview-category-navigation element exists"
+ );
+ ok(document.querySelector("named-deck"), "named-deck element exists");
+ });
+});
+
+add_task(async function test_aria_roles() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, "about:firefoxview");
+
+ is(
+ document.querySelector("main").getAttribute("role"),
+ "application",
+ "The main element has role='application'"
+ );
+ // Purge session history to ensure recently closed empty state is shown
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed[slot=recentlyclosed]"
+ );
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ let recentlyClosedEmptyState = recentlyClosedComponent.emptyState;
+ let descriptionEls = recentlyClosedEmptyState.descriptionEls;
+ is(
+ descriptionEls[1].querySelector("a").getAttribute("aria-details"),
+ "card-container",
+ "The link within the recently closed empty state has the expected 'aria-details' attribute."
+ );
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs[slot=syncedtabs]"
+ );
+ let syncedTabsEmptyState = syncedTabsComponent.emptyState;
+ is(
+ syncedTabsEmptyState.querySelector("button").getAttribute("aria-details"),
+ "empty-container",
+ "The button within the synced tabs empty state has the expected 'aria-details' attribute."
+ );
+
+ // Test keyboard navigation from card-container summary
+ // elements to links/buttons in empty states
+ const tab = async shiftKey => {
+ info(`Tab${shiftKey ? " + Shift" : ""}`);
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ recentlyClosedComponent.cardEl.summaryEl.focus();
+ ok(
+ recentlyClosedComponent.cardEl.summaryEl.matches(":focus"),
+ "Focus should be on the summary element within the recently closed card-container"
+ );
+ // Purge session history to ensure recently closed empty state is shown
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ await tab();
+ ok(
+ descriptionEls[1].querySelector("a").matches(":focus"),
+ "Focus should be on the link within the recently closed empty state"
+ );
+ await tab();
+ const shadowRoot =
+ SpecialPowers.wrap(syncedTabsComponent).openOrClosedShadowRoot;
+ ok(
+ shadowRoot.querySelector("card-container").summaryEl.matches(":focus"),
+ "Focus should be on summary element of the synced tabs card-container"
+ );
+ await tab();
+ ok(
+ syncedTabsEmptyState.querySelector("button").matches(":focus"),
+ "Focus should be on button element of the synced tabs empty state"
+ );
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js
new file mode 100644
index 0000000000..51d5caa032
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js
@@ -0,0 +1,368 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const CARD_COLLAPSED_EVENT = [
+ ["firefoxview_next", "card_collapsed", "card_container", undefined],
+];
+const CARD_EXPANDED_EVENT = [
+ ["firefoxview_next", "card_expanded", "card_container", undefined],
+];
+let tabSelectedTelemetry = [
+ "firefoxview_next",
+ "tab_selected",
+ "toolbarbutton",
+ undefined,
+ {},
+];
+let enteredTelemetry = [
+ "firefoxview_next",
+ "entered",
+ "firefoxview",
+ undefined,
+ { page: "recentbrowsing" },
+];
+
+add_setup(async () => {
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+add_task(async function firefox_view_entered_telemetry() {
+ await clearAllParentTelemetryEvents();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry];
+ await telemetryEvent(enteredAndTabSelectedEvents);
+
+ enteredTelemetry[4] = { page: "recentlyclosed" };
+ enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry];
+
+ navigateToCategory(document, "recentlyclosed");
+ await clearAllParentTelemetryEvents();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:robots");
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:robots",
+ "The selected tab is about:robots"
+ );
+ await switchToFxViewTab(browser.ownerGlobal);
+ await telemetryEvent(enteredAndTabSelectedEvents);
+ await SpecialPowers.popPrefEnv();
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_collapse_and_expand_card() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // Test using Recently Closed card on Recent Browsing page
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed[slot=recentlyclosed]"
+ );
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ let cardContainer = recentlyClosedComponent.cardEl;
+ is(
+ cardContainer.isExpanded,
+ true,
+ "The card-container is expanded initially"
+ );
+ await clearAllParentTelemetryEvents();
+ // Click the summary to collapse the details disclosure
+ EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content);
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+ await telemetryEvent(CARD_COLLAPSED_EVENT);
+ // Click the summary again to expand the details disclosure
+ EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content);
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+ await telemetryEvent(CARD_EXPANDED_EVENT);
+ });
+});
+
+add_task(async function test_change_page_telemetry() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let changePageEvent = [
+ [
+ "firefoxview_next",
+ "change_page",
+ "navigation",
+ undefined,
+ { page: "recentlyclosed", source: "category-navigation" },
+ ],
+ ];
+ await clearAllParentTelemetryEvents();
+ navigateToCategory(document, "recentlyclosed");
+ await telemetryEvent(changePageEvent);
+ navigateToCategory(document, "recentbrowsing");
+
+ let openTabsComponent = document.querySelector(
+ "view-opentabs[slot=opentabs]"
+ );
+ let cardContainer =
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").cardEl;
+ let viewAllLink = cardContainer.viewAllLink;
+ changePageEvent = [
+ [
+ "firefoxview_next",
+ "change_page",
+ "navigation",
+ undefined,
+ { page: "opentabs", source: "view-all" },
+ ],
+ ];
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(viewAllLink, {}, content);
+ await telemetryEvent(changePageEvent);
+ });
+});
+
+add_task(async function test_browser_context_menu_telemetry() {
+ const menu = document.getElementById("contentAreaContextMenu");
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+
+ // Test browser context menu options
+ const openTabsComponent = document.querySelector("view-opentabs");
+ await TestUtils.waitForCondition(
+ () =>
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList
+ .rowEls.length
+ );
+ const [openTabsRow] =
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList
+ .rowEls;
+ const promisePopup = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ openTabsRow,
+ { type: "contextmenu" },
+ content
+ );
+ await promisePopup;
+ const promiseNewWindow = BrowserTestUtils.waitForNewWindow();
+ menu.activateItem(menu.querySelector("#context-openlink"));
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "browser_context_menu",
+ "tabs",
+ null,
+ { menu_action: "context-openlink", page: "recentbrowsing" },
+ ],
+ ]);
+
+ // Clean up extra window
+ const win = await promiseNewWindow;
+ await BrowserTestUtils.closeWindow(win);
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_context_menu_new_window_telemetry() {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+ let openInNewWindowOption = panelItems[1];
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "open-in-new-window", data_type: "history" },
+ ],
+ ];
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: URLs[0],
+ });
+ EventUtils.synthesizeMouseAtCenter(openInNewWindowOption, {}, content);
+ let win = await newWindowPromise;
+ await telemetryEvent(contextMenuEvent);
+ await BrowserTestUtils.closeWindow(win);
+ info("New window closed.");
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_context_menu_private_window_telemetry() {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ info("Context menu button clicked.");
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ info("Context menu shown.");
+ await clearAllParentTelemetryEvents();
+ let openInPrivateWindowOption = panelItems[2];
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "open-in-private-window", data_type: "history" },
+ ],
+ ];
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: URLs[0],
+ });
+ EventUtils.synthesizeMouseAtCenter(openInPrivateWindowOption, {}, content);
+ info("Open in private window context menu option clicked.");
+ let win = await newWindowPromise;
+ info("New private window opened.");
+ await telemetryEvent(contextMenuEvent);
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(win),
+ "Should have opened a private window."
+ );
+ await BrowserTestUtils.closeWindow(win);
+ info("New private window closed.");
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_context_menu_delete_from_history_telemetry() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ info("Context menu button clicked.");
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ info("Context menu shown.");
+ await clearAllParentTelemetryEvents();
+ let deleteFromHistoryOption = panelItems[0];
+ ok(
+ deleteFromHistoryOption.textContent.includes("Delete"),
+ "Delete from history button is present in the context menu."
+ );
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "delete-from-history", data_type: "history" },
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(deleteFromHistoryOption, {}, content);
+ info("Delete from history context menu option clicked.");
+
+ await TestUtils.waitForCondition(
+ () =>
+ !historyComponent.paused &&
+ historyComponent.fullyUpdated &&
+ !historyComponent.lists.length
+ );
+ await telemetryEvent(contextMenuEvent);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js
new file mode 100644
index 0000000000..80206dd945
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL_BASE = `${getFirefoxViewURL()}#`;
+
+function assertCorrectPage(document, name, event) {
+ is(
+ document.location.hash,
+ `#${name}`,
+ `Navigation button for ${name} navigates to ${URL_BASE + name} on ${event}.`
+ );
+ is(
+ document.querySelector("named-deck").selectedViewName,
+ name,
+ "The correct deck child is selected"
+ );
+}
+
+add_task(async function test_side_component_navigation_by_click() {
+ await withFirefoxView({}, async browser => {
+ await SimpleTest.promiseFocus(browser);
+
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+
+ for (let element of categoryButtons) {
+ const name = element.name;
+ let buttonClicked = BrowserTestUtils.waitForEvent(
+ element.buttonEl,
+ "click",
+ win
+ );
+
+ info(`Clicking navigation button for ${name}`);
+ EventUtils.synthesizeMouseAtCenter(element.buttonEl, {}, content);
+ await buttonClicked;
+
+ assertCorrectPage(document, name, "click");
+ }
+ });
+});
+
+add_task(async function test_side_component_navigation_by_keyboard() {
+ await withFirefoxView({}, async browser => {
+ await SimpleTest.promiseFocus(browser);
+
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+ const firstButton = categoryButtons[0];
+
+ firstButton.focus();
+ is(
+ document.activeElement,
+ firstButton,
+ "The first category button has focus"
+ );
+
+ for (let element of Array.from(categoryButtons).slice(1)) {
+ const name = element.name;
+ let buttonFocused = BrowserTestUtils.waitForEvent(element, "focus", win);
+
+ info(`Focus is on ${document.activeElement.name}`);
+ info(`Arrow down on navigation to ${name}`);
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, win);
+ await buttonFocused;
+
+ assertCorrectPage(document, name, "key press");
+ }
+ });
+});
+
+add_task(async function test_direct_navigation_to_correct_category() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+ const namedDeck = document.querySelector("named-deck");
+
+ for (let element of categoryButtons) {
+ const name = element.name;
+
+ info(`Navigating to ${URL_BASE + name}`);
+ document.location.assign(URL_BASE + name);
+ await BrowserTestUtils.waitForCondition(() => {
+ return namedDeck.selectedViewName === name;
+ }, "Wait for navigation to complete");
+
+ is(
+ namedDeck.selectedViewName,
+ name,
+ `The correct deck child for category ${name} is selected`
+ );
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js
new file mode 100644
index 0000000000..c95ac4fcf5
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js
@@ -0,0 +1,407 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const tabURL1 = "data:,Tab1";
+const tabURL2 = "data:,Tab2";
+const tabURL3 = "data:,Tab3";
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+const TestTabs = {};
+
+function getTopLevelViewElements(document) {
+ return {
+ recentBrowsingView: document.querySelector(
+ "named-deck > view-recentbrowsing"
+ ),
+ recentlyClosedView: document.querySelector(
+ "named-deck > view-recentlyclosed"
+ ),
+ openTabsView: document.querySelector("named-deck > view-opentabs"),
+ };
+}
+
+async function getElements(document) {
+ let { recentBrowsingView, recentlyClosedView, openTabsView } =
+ getTopLevelViewElements(document);
+ let recentBrowsingOpenTabsView =
+ recentBrowsingView.querySelector("view-opentabs");
+ let recentBrowsingOpenTabsList =
+ recentBrowsingOpenTabsView?.viewCards[0]?.tabList;
+ let recentBrowsingRecentlyClosedTabsView = recentBrowsingView.querySelector(
+ "view-recentlyclosed"
+ );
+ await TestUtils.waitForCondition(
+ () => recentBrowsingRecentlyClosedTabsView.fullyUpdated
+ );
+ let recentBrowsingRecentlyClosedTabsList =
+ recentBrowsingRecentlyClosedTabsView?.tabList;
+ if (recentlyClosedView.firstUpdateComplete) {
+ await TestUtils.waitForCondition(() => recentlyClosedView.fullyUpdated);
+ }
+ let recentlyClosedList = recentlyClosedView.tabList;
+ await openTabsView.openTabsTarget.readyWindowsPromise;
+ await openTabsView.updateComplete;
+ let openTabsList =
+ openTabsView.shadowRoot.querySelector("view-opentabs-card")?.tabList;
+
+ return {
+ // recentbrowsing
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+
+ // recentlyclosed
+ recentlyClosedView,
+ recentlyClosedList,
+
+ // opentabs
+ openTabsView,
+ openTabsList,
+ };
+}
+
+async function nextFrame(global = window) {
+ await new Promise(resolve => {
+ global.requestAnimationFrame(() => {
+ global.requestAnimationFrame(resolve);
+ });
+ });
+}
+
+async function setupOpenAndClosedTabs() {
+ TestTabs.tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL1
+ );
+ TestTabs.tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL2
+ );
+ TestTabs.tab3 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL3
+ );
+ // close a tab so we have recently-closed tabs content
+ await SessionStoreTestUtils.closeTab(TestTabs.tab3);
+}
+
+function assertSpiesCalled(spiesMap, expectCalled) {
+ let message = expectCalled ? "to be called" : "to not be called";
+ for (let [elem, renderSpy] of spiesMap.entries()) {
+ is(
+ expectCalled,
+ renderSpy.called,
+ `Expected the render method spy on element ${elem.localName} ${message}`
+ );
+ }
+}
+
+async function checkFxRenderCalls(browser, elements, selectedView) {
+ const sandbox = sinon.createSandbox();
+ const topLevelViews = getTopLevelViewElements(browser.contentDocument);
+
+ // sanity-check the selectedView we were given
+ ok(
+ Object.values(topLevelViews).find(view => view == selectedView),
+ `The selected view is in the topLevelViews`
+ );
+
+ const elementSpies = new Map();
+ const viewSpies = new Map();
+
+ for (let [elemName, elem] of Object.entries(topLevelViews)) {
+ let spy;
+ if (elem.render.isSinonProxy) {
+ spy = elem.render;
+ } else {
+ info(`Creating spy for render on element: ${elemName}`);
+ spy = sandbox.spy(elem, "render");
+ }
+ viewSpies.set(elem, spy);
+ }
+ for (let [elemName, elem] of Object.entries(elements)) {
+ let spy;
+ if (elem.render.isSinonProxy) {
+ spy = elem.render;
+ } else {
+ info(`Creating spy for render on element: ${elemName}`);
+ spy = sandbox.spy(elem, "render");
+ }
+ elementSpies.set(elem, spy);
+ }
+
+ info("test switches to tab2");
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, TestTabs.tab2);
+ await tabChangeRaised;
+ info(
+ "TabRecencyChange event was raised, check no render() methods were called"
+ );
+ assertSpiesCalled(viewSpies, false);
+ assertSpiesCalled(elementSpies, false);
+ for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) {
+ renderSpy.resetHistory();
+ }
+
+ // check all the top-level views are paused
+ ok(
+ topLevelViews.recentBrowsingView.paused,
+ "The recent-browsing view is paused"
+ );
+ ok(
+ topLevelViews.recentlyClosedView.paused,
+ "The recently-closed tabs view is paused"
+ );
+ ok(topLevelViews.openTabsView.paused, "The open tabs view is paused");
+
+ await nextFrame();
+ info("test removes tab1");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.removeTab(TestTabs.tab1);
+ await tabChangeRaised;
+
+ assertSpiesCalled(viewSpies, false);
+ assertSpiesCalled(elementSpies, false);
+
+ for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) {
+ renderSpy.resetHistory();
+ }
+
+ info("test will re-open fxview");
+ await openFirefoxViewTab(window);
+ await nextFrame();
+
+ assertSpiesCalled(elementSpies, true);
+ ok(
+ selectedView.render.called,
+ `Render was called on the selected top-level view: ${selectedView.localName}`
+ );
+
+ // check all the other views did not render
+ viewSpies.delete(selectedView);
+ assertSpiesCalled(viewSpies, false);
+
+ sandbox.restore();
+}
+
+add_task(async function test_recentbrowsing() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ is(document.querySelector("named-deck").selectedViewName, "recentbrowsing");
+
+ const {
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+ } = await getElements(document);
+
+ ok(recentBrowsingView, "Found the recent-browsing view");
+ ok(recentBrowsingOpenTabsView, "Found the recent-browsing open tabs view");
+ ok(recentBrowsingOpenTabsList, "Found the recent-browsing open tabs list");
+ ok(
+ recentBrowsingRecentlyClosedTabsView,
+ "Found the recent-browsing recently-closed tabs view"
+ );
+ ok(
+ recentBrowsingRecentlyClosedTabsList,
+ "Found the recent-browsing recently-closed tabs list"
+ );
+
+ // Collapse the Open Tabs card
+ let cardContainer = recentBrowsingOpenTabsView.viewCards[0]?.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ recentBrowsingOpenTabsList.updatesPaused,
+ "The Open Tabs list is paused after its card is collapsed."
+ );
+ ok(
+ !recentBrowsingOpenTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Open Tabs card
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !recentBrowsingOpenTabsList.updatesPaused,
+ "The Open Tabs list is unpaused after its card is expanded."
+ );
+ ok(
+ recentBrowsingOpenTabsList.intervalID,
+ "The intervalID for the Open Tabs list is defined while updates are unpaused."
+ );
+
+ // Collapse the Recently Closed card
+ let recentlyClosedCardContainer =
+ recentBrowsingRecentlyClosedTabsView.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ recentlyClosedCardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !recentlyClosedCardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ recentBrowsingRecentlyClosedTabsList.updatesPaused,
+ "The Recently Closed list is paused after its card is collapsed."
+ );
+ ok(
+ !recentBrowsingRecentlyClosedTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Recently Closed card
+ await EventUtils.synthesizeMouseAtCenter(
+ recentlyClosedCardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ recentlyClosedCardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !recentBrowsingRecentlyClosedTabsList.updatesPaused,
+ "The Recently Closed list is unpaused after its card is expanded."
+ );
+ ok(
+ recentBrowsingRecentlyClosedTabsList.intervalID,
+ "The intervalID for the Recently Closed list is defined while updates are unpaused."
+ );
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+ },
+ recentBrowsingView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
+
+add_task(async function test_opentabs() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ const { openTabsView } = getTopLevelViewElements(document);
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ const { openTabsList } = await getElements(document);
+ ok(openTabsView, "Found the open tabs view");
+ ok(openTabsList, "Found the first open tabs list");
+ ok(!openTabsView.paused, "The open tabs view is un-paused");
+ is(openTabsView.slot, "selected", "The open tabs view is selected");
+
+ // Collapse the Open Tabs card
+ let cardContainer = openTabsView.viewCards[0]?.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ openTabsList.updatesPaused,
+ "The Open Tabs list is paused after its card is collapsed."
+ );
+ ok(
+ !openTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Open Tabs card
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !openTabsList.updatesPaused,
+ "The Open Tabs list is unpaused after its card is expanded."
+ );
+ ok(
+ openTabsList.intervalID,
+ "The intervalID for the Open Tabs list is defined while updates are unpaused."
+ );
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ openTabsView,
+ openTabsList,
+ },
+ openTabsView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
+
+add_task(async function test_recentlyclosed() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ const { recentlyClosedView } = getTopLevelViewElements(document);
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+
+ const { recentlyClosedList } = await getElements(document);
+ ok(recentlyClosedView, "Found the recently-closed view");
+ ok(recentlyClosedList, "Found the recently-closed list");
+ ok(!recentlyClosedView.paused, "The recently-closed view is un-paused");
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ recentlyClosedView,
+ recentlyClosedList,
+ },
+ recentlyClosedView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js
new file mode 100644
index 0000000000..2ea2429c15
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js
@@ -0,0 +1,629 @@
+let gInitialTab;
+let gInitialTabURL;
+
+const NUMBER_OF_TABS = 6;
+
+const syncedTabsData = [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: Array(NUMBER_OF_TABS)
+ .fill({
+ type: "tab",
+ title: "Internet for people, not profits - Mozilla",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ client: 1,
+ })
+ .map((tab, i) => ({ ...tab, url: URLs[i] })),
+ },
+];
+
+const searchEvent = page => {
+ return [
+ ["firefoxview_next", "search_initiated", "search", undefined, { page }],
+ ];
+};
+
+const cleanUp = () => {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+};
+
+add_setup(async () => {
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+ registerCleanupFunction(async () => {
+ clearHistory();
+ });
+});
+
+add_task(async function test_search_initiated_telemetry() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+
+ is(document.location.hash, "", "Searching within recent browsing.");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("recentbrowsing"));
+
+ await navigateToCategoryAndWait(document, "opentabs");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#opentabs", "Searching within open tabs.");
+ const openTabs = document.querySelector("named-deck > view-opentabs");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("opentabs"));
+
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ await clearAllParentTelemetryEvents();
+ is(
+ document.location.hash,
+ "#recentlyclosed",
+ "Searching within recently closed."
+ );
+ const recentlyClosed = document.querySelector(
+ "named-deck > view-recentlyclosed"
+ );
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentlyClosed.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("recentlyclosed"));
+
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#syncedtabs", "Searching within synced tabs.");
+ const syncedTabs = document.querySelector("named-deck > view-syncedtabs");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("syncedtabs"));
+
+ await navigateToCategoryAndWait(document, "history");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#history", "Searching within history.");
+ const history = document.querySelector("named-deck > view-history");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(history.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("history"));
+
+ await clearAllParentTelemetryEvents();
+ });
+});
+
+add_task(async function test_show_all_recentlyclosed_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[1]);
+ }
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ const recentlyclosedSlot = recentBrowsing.querySelector(
+ "[slot='recentlyclosed']"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ recentlyclosedSlot.tabList.rowEls.length === 5 &&
+ recentlyclosedSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ ),
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ info("Click the Show All link.");
+ const showAllButton = recentlyclosedSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (recentlyclosedSlot.tabList.rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "recentlyclosed" },
+ ],
+ ]);
+ });
+});
+
+add_task(async function test_show_all_opentabs_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]);
+ }
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(URLs[1], content);
+ const opentabsSlot = recentBrowsing.querySelector("[slot='opentabs']");
+ await TestUtils.waitForCondition(
+ () => opentabsSlot.viewCards[0].tabList.rowEls.length === 5,
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ info("Click the Show All link.");
+ const showAllButton = opentabsSlot.viewCards[0].shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (opentabsSlot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_initiated",
+ "search",
+ null,
+ { page: "recentbrowsing" },
+ ],
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "opentabs" },
+ ],
+ ]);
+ });
+
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ await BrowserTestUtils.switchTab(gBrowser, gInitialTab);
+ await closeFirefoxViewTab(window);
+
+ cleanUp();
+});
+
+add_task(async function test_show_all_syncedtabs_telemetry() {
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("mozilla", content);
+ const syncedtabsSlot = recentBrowsing.querySelector("[slot='syncedtabs']");
+ await TestUtils.waitForCondition(
+ () =>
+ syncedtabsSlot.fullyUpdated &&
+ syncedtabsSlot.tabLists.length === 1 &&
+ Promise.all(
+ Array.from(syncedtabsSlot.tabLists).map(
+ tabList => tabList.updateComplete
+ )
+ ),
+ "Synced Tabs component is done updating."
+ );
+ syncedtabsSlot.tabLists[0].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () => syncedtabsSlot.tabLists[0].rowEls.length === 5,
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ const showAllButton = await TestUtils.waitForCondition(() =>
+ syncedtabsSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ )
+ );
+ info("Scroll show all button into view.");
+ showAllButton.scrollIntoView();
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ info("Click the Show All link.");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (syncedtabsSlot.tabLists[0].rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_initiated",
+ "search",
+ null,
+ { page: "recentbrowsing" },
+ ],
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "syncedtabs" },
+ ],
+ ]);
+ });
+
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sort_history_search_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[i]);
+ }
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+
+ const searchTextbox = await TestUtils.waitForCondition(
+ () => historyComponent.searchTextbox,
+ "The search textbox is displayed."
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await TestUtils.waitForCondition(() => {
+ const { rowEls } = historyComponent.lists[0];
+ return rowEls.length === 1;
+ }, "There is one matching search result.");
+ await clearAllParentTelemetryEvents();
+ // Select sort by site option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[1],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ null,
+ { sort_type: "site", search_start: "true" },
+ ],
+ ]);
+ await clearAllParentTelemetryEvents();
+
+ // Select sort by date option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[0],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ null,
+ { sort_type: "date", search_start: "true" },
+ ],
+ ]);
+ });
+});
+
+add_task(async function test_cumulative_searches_recent_browsing_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ is(document.location.hash, "", "Searching within recent browsing.");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(URLs[0], content);
+ const recentlyclosedSlot = recentBrowsing.querySelector(
+ "[slot='recentlyclosed']"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ recentlyclosedSlot?.tabList?.rowEls?.length &&
+ recentlyclosedSlot?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ recentlyclosedSlot.tabList.rowEls[0].mainEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => "recentbrowsing" in cumulativeSearchesHistogram.snapshot(),
+ `recentbrowsing key not found in cumulativeSearchesHistogram snapshot: ${JSON.stringify(
+ cumulativeSearchesHistogram.snapshot()
+ )}`
+ );
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "recentbrowsing",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_recently_closed_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ is(
+ document.location.hash,
+ "#recentlyclosed",
+ "Searching within recently closed."
+ );
+ const recentlyClosed = document.querySelector(
+ "named-deck > view-recentlyclosed"
+ );
+ const searchTextbox = await TestUtils.waitForCondition(() => {
+ return recentlyClosed.searchTextbox;
+ });
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ // eslint-disable-next-line no-unused-vars
+ const [recentlyclosedSlot, tabList] = await waitForRecentlyClosedTabsList(
+ document
+ );
+ await TestUtils.waitForCondition(() => recentlyclosedSlot?.searchQuery);
+
+ await click_recently_closed_tab_item(tabList[0]);
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "recentlyclosed",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_open_tabs_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "opentabs");
+ is(document.location.hash, "#opentabs", "Searching within open tabs.");
+ const openTabs = document.querySelector("named-deck > view-opentabs");
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ let cards;
+ await TestUtils.waitForCondition(() => {
+ cards = getOpenTabsCards(openTabs);
+ return cards.length == 1;
+ });
+ await TestUtils.waitForCondition(
+ () => cards[0].tabList.rowEls.length === 1 && openTabs?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ cards[0].tabList.rowEls[0].mainEl,
+ {},
+ content
+ );
+ });
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "opentabs",
+ 1,
+ 1
+ );
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_history_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+ is(document.location.hash, "#history", "Searching within history.");
+ const history = document.querySelector("named-deck > view-history");
+ const searchTextbox = await TestUtils.waitForCondition(() => {
+ return history.searchTextbox;
+ });
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ await TestUtils.waitForCondition(
+ () =>
+ history.fullyUpdated &&
+ history?.lists[0].rowEls?.length === 1 &&
+ history?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ history.lists[0].rowEls[0].mainEl,
+ {},
+ content
+ );
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "history",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_syncedtabs_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ is(document.location.hash, "#syncedtabs", "Searching within synced tabs.");
+ let syncedTabs = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabs.fullyUpdated &&
+ syncedTabs.tabLists.length === 1 &&
+ Promise.all(
+ Array.from(syncedTabs.tabLists).map(tabList => tabList.updateComplete)
+ ),
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabs.tabLists[0].rowEls.length === 1 && syncedTabs?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabs.tabLists[0].rowEls[0].mainEl,
+ {},
+ content
+ );
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "syncedtabs",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js
new file mode 100644
index 0000000000..f1ac7d6742
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js
@@ -0,0 +1,370 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function expectFocusAfterKey(
+ aKey,
+ aFocus,
+ aAncestorOk = false,
+ aWindow = window
+) {
+ let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/);
+ let shift = Boolean(res[1]);
+ let key;
+ if (res[2]) {
+ key = res[2]; // Character.
+ } else {
+ key = "KEY_" + res[3]; // Tab, ArrowRight, etc.
+ }
+ let expected;
+ let friendlyExpected;
+ if (typeof aFocus == "string") {
+ expected = aWindow.document.getElementById(aFocus);
+ friendlyExpected = aFocus;
+ } else {
+ expected = aFocus;
+ if (aFocus == aWindow.gURLBar.inputField) {
+ friendlyExpected = "URL bar input";
+ } else if (aFocus == aWindow.gBrowser.selectedBrowser) {
+ friendlyExpected = "Web document";
+ }
+ }
+ info("Listening on item " + (expected.id || expected.className));
+ let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk);
+ EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow);
+ let receivedEvent = await focused;
+ info(
+ "Got focus on item: " +
+ (receivedEvent.target.id || receivedEvent.target.className)
+ );
+ ok(true, friendlyExpected + " focused after " + aKey + " pressed");
+}
+
+function forceFocus(aElem) {
+ aElem.setAttribute("tabindex", "-1");
+ aElem.focus();
+ aElem.removeAttribute("tabindex");
+}
+
+function triggerClickOn(target, options) {
+ let promise = BrowserTestUtils.waitForEvent(target, "click");
+ if (AppConstants.platform == "macosx") {
+ options.metaKey = options.ctrlKey;
+ delete options.ctrlKey;
+ }
+ EventUtils.synthesizeMouseAtCenter(target, options);
+ return promise;
+}
+
+async function add_new_tab(URL) {
+ let tab = BrowserTestUtils.addTab(gBrowser, URL);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ return tab;
+}
+
+add_task(async function aria_attributes() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ is(
+ win.FirefoxViewHandler.button.getAttribute("role"),
+ "button",
+ "Firefox View button should have the 'button' ARIA role"
+ );
+ await openFirefoxViewTab(win);
+ isnot(
+ win.FirefoxViewHandler.button.getAttribute("aria-controls"),
+ "",
+ "Firefox View button should have non-empty `aria-controls` attribute"
+ );
+ is(
+ win.FirefoxViewHandler.button.getAttribute("aria-controls"),
+ win.FirefoxViewHandler.tab.linkedPanel,
+ "Firefox View button should refence the hidden tab's linked panel via `aria-controls`"
+ );
+ is(
+ win.FirefoxViewHandler.button.getAttribute("aria-pressed"),
+ "true",
+ 'Firefox View button should have `aria-pressed="true"` upon selecting it'
+ );
+ win.BrowserOpenTab();
+ is(
+ win.FirefoxViewHandler.button.getAttribute("aria-pressed"),
+ "false",
+ 'Firefox View button should have `aria-pressed="false"` upon selecting a different tab'
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function load_opens_new_tab() {
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ let win = browser.ownerGlobal;
+ ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected");
+ win.gURLBar.focus();
+ win.gURLBar.value = "https://example.com";
+ let newTabOpened = BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ info(
+ "Waiting for new tab to open from the address bar in the Firefox View tab"
+ );
+ await newTabOpened;
+ assertFirefoxViewTab(win);
+ ok(
+ !win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is not selected anymore (new tab opened in the foreground)"
+ );
+ });
+});
+
+add_task(async function homepage_new_tab() {
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ let win = browser.ownerGlobal;
+ ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected");
+ let newTabOpened = BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "TabOpen"
+ );
+ win.BrowserHome();
+ info("Waiting for BrowserHome() to open a new tab");
+ await newTabOpened;
+ assertFirefoxViewTab(win);
+ ok(
+ !win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is not selected anymore (home page opened in the foreground)"
+ );
+ });
+});
+
+add_task(async function number_tab_select_shortcut() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ EventUtils.synthesizeKey(
+ "1",
+ AppConstants.MOZ_WIDGET_GTK ? { altKey: true } : { accelKey: true },
+ win
+ );
+ ok(
+ !win.FirefoxViewHandler.tab.selected,
+ "Number shortcut to select the first tab skipped the Firefox View tab"
+ );
+ });
+});
+
+add_task(async function accel_w_behavior() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await openFirefoxViewTab(win);
+ EventUtils.synthesizeKey("w", { accelKey: true }, win);
+ ok(!win.FirefoxViewHandler.tab, "Accel+w closed the Firefox View tab");
+ await openFirefoxViewTab(win);
+ win.gBrowser.selectedTab = win.gBrowser.visibleTabs[0];
+ info(
+ "Waiting for Accel+W in the only visible tab to close the window, ignoring the presence of the hidden Firefox View tab"
+ );
+ let windowClosed = BrowserTestUtils.windowClosed(win);
+ EventUtils.synthesizeKey("w", { accelKey: true }, win);
+ await windowClosed;
+});
+
+add_task(async function undo_close_tab() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(win),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:about"
+ );
+ await TestUtils.waitForTick();
+
+ let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ win.gBrowser.removeTab(tab);
+ await sessionUpdatePromise;
+ is(
+ SessionStore.getClosedTabCountForWindow(win),
+ 1,
+ "Closing about:about added to the closed tab count"
+ );
+
+ let viewTab = await openFirefoxViewTab(win);
+ await TestUtils.waitForTick();
+ sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(viewTab);
+ closeFirefoxViewTab(win);
+ await sessionUpdatePromise;
+ is(
+ SessionStore.getClosedTabCountForWindow(win),
+ 1,
+ "Closing the Firefox View tab did not add to the closed tab count"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_firefoxview_view_count() {
+ const startViews = 2;
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", startViews]],
+ });
+
+ let tab = await openFirefoxViewTab(window);
+
+ Assert.strictEqual(
+ SpecialPowers.getIntPref("browser.firefox-view.view-count"),
+ startViews + 1,
+ "View count pref value is incremented when tab is selected"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_add_ons_cant_unhide_fx_view() {
+ // Test that add-ons can't unhide the Firefox View tab by calling
+ // browser.tabs.show(). See bug 1791770 for details.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:about"
+ );
+ let viewTab = await openFirefoxViewTab(win);
+ win.gBrowser.hideTab(tab);
+
+ ok(tab.hidden, "Regular tab is hidden");
+ ok(viewTab.hidden, "Firefox View tab is hidden");
+
+ win.gBrowser.showTab(tab);
+ win.gBrowser.showTab(viewTab);
+
+ ok(!tab.hidden, "Add-on showed regular hidden tab");
+ ok(viewTab.hidden, "Add-on did not show Firefox View tab");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+// Test navigation to first visible tab when the
+// Firefox View button is present and active.
+add_task(async function testFirstTabFocusableWhenFxViewOpen() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected");
+ let fxViewBtn = win.document.getElementById("firefox-view-button");
+ forceFocus(fxViewBtn);
+ is(
+ win.document.activeElement,
+ fxViewBtn,
+ "Firefox View button focused for start of test"
+ );
+ let firstVisibleTab = win.gBrowser.visibleTabs[0];
+ await expectFocusAfterKey("Tab", firstVisibleTab, false, win);
+ let activeElement = win.document.activeElement;
+ let expectedElement = firstVisibleTab;
+ is(activeElement, expectedElement, "First visible tab should be focused");
+ });
+});
+
+// Test that Firefox View tab is not multiselectable
+add_task(async function testFxViewNotMultiselect() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ Assert.ok(
+ win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is selected"
+ );
+ let tab2 = await add_new_tab("https://www.mozilla.org");
+ let fxViewBtn = win.document.getElementById("firefox-view-button");
+
+ info("We multi-select a visible tab with ctrl key down");
+ await triggerClickOn(tab2, { ctrlKey: true });
+ Assert.ok(
+ tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2),
+ "Second visible tab is (multi) selected"
+ );
+ Assert.equal(gBrowser.multiSelectedTabsCount, 1, "One tab is selected.");
+ Assert.notEqual(
+ fxViewBtn,
+ gBrowser.selectedTab,
+ "Fx View tab doesn't have focus"
+ );
+
+ // Ctrl/Cmd click tab2 again to deselect it
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ info("We multi-select visible tabs with shift key down");
+ await triggerClickOn(tab2, { shiftKey: true });
+ Assert.ok(
+ tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2),
+ "Second visible tab is (multi) selected"
+ );
+ Assert.equal(gBrowser.multiSelectedTabsCount, 2, "Two tabs are selected.");
+ Assert.notEqual(
+ fxViewBtn,
+ gBrowser.selectedTab,
+ "Fx View tab doesn't have focus"
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+ });
+});
+
+add_task(async function testFxViewEntryPointsInPrivateBrowsing() {
+ async function checkMenu(win, expectedEnabled) {
+ await SimpleTest.promiseFocus(win);
+ const toolsMenu = win.document.getElementById("tools-menu");
+ const fxViewMenuItem = toolsMenu.querySelector("#menu_openFirefoxView");
+ const menuShown = BrowserTestUtils.waitForEvent(toolsMenu, "popupshown");
+
+ toolsMenu.openMenu(true);
+ await menuShown;
+ Assert.equal(
+ BrowserTestUtils.isVisible(fxViewMenuItem),
+ expectedEnabled,
+ `Firefox view menu item is ${expectedEnabled ? "enabled" : "hidden"}`
+ );
+ const menuHidden = BrowserTestUtils.waitForEvent(toolsMenu, "popuphidden");
+ toolsMenu.menupopup.hidePopup();
+ await menuHidden;
+ }
+
+ async function checkEntryPointsInWindow(win, expectedVisible) {
+ const fxViewBtn = win.document.getElementById("firefox-view-button");
+
+ if (AppConstants.platform != "macosx") {
+ await checkMenu(win, expectedVisible);
+ }
+ // check the tab button
+ Assert.equal(
+ BrowserTestUtils.isVisible(fxViewBtn),
+ expectedVisible,
+ `#${fxViewBtn.id} is ${
+ expectedVisible ? "visible" : "hidden"
+ } as expected`
+ );
+ }
+
+ info("Check permanent private browsing");
+ // Setting permanent private browsing normally requires a restart.
+ // We'll emulate by manually setting the attribute it controls manually
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.privatebrowsing.autostart", true]],
+ });
+ const newWin = await BrowserTestUtils.openNewBrowserWindow();
+ newWin.document.documentElement.setAttribute(
+ "privatebrowsingmode",
+ "permanent"
+ );
+ await checkEntryPointsInWindow(newWin, false);
+ await BrowserTestUtils.closeWindow(newWin);
+ await SpecialPowers.popPrefEnv();
+
+ info("Check defaults (non-private)");
+ await SimpleTest.promiseFocus(window);
+ await checkEntryPointsInWindow(window, true);
+
+ info("Check private (temporary) browsing");
+ const privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await checkEntryPointsInWindow(privateWin, false);
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js
new file mode 100644
index 0000000000..501deb8e68
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const VIRTUAL_LIST_ENABLED_PREF = "browser.firefox-view.virtual-list.enabled";
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [[VIRTUAL_LIST_ENABLED_PREF, true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+add_task(async function test_max_render_count_on_win_resize() {
+ const now = new Date();
+ await PlacesUtils.history.insertMany([
+ {
+ url: "https://example.net/",
+ visits: [{ date: now }],
+ },
+ ]);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ getFirefoxViewURL(),
+ "Firefox View is loaded to the Recent Browsing page."
+ );
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ let tabList = historyComponent.lists[0];
+ let rootVirtualList = tabList.rootVirtualListEl;
+
+ const initialHeight = window.outerHeight;
+ const initialWidth = window.outerWidth;
+ const initialMaxRenderCount = rootVirtualList.maxRenderCountEstimate;
+ info(`The initial maxRenderCountEstimate is ${initialMaxRenderCount}`);
+ info(`The initial innerHeight is ${window.innerHeight}`);
+
+ // Resize window with new height value
+ const newHeight = 540;
+ window.resizeTo(initialWidth, newHeight);
+ await TestUtils.waitForCondition(
+ () => window.outerHeight >= newHeight,
+ `The window has been resized with outer height of ${window.outerHeight} instead of ${newHeight}.`
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ rootVirtualList.updateComplete &&
+ rootVirtualList.maxRenderCountEstimate < initialMaxRenderCount,
+ `Max render count ${rootVirtualList.maxRenderCountEstimate} is not less than initial max render count ${initialMaxRenderCount}`
+ );
+ const newMaxRenderCount = rootVirtualList.maxRenderCountEstimate;
+
+ Assert.strictEqual(
+ rootVirtualList.maxRenderCountEstimate,
+ newMaxRenderCount,
+ `The maxRenderCountEstimate on the virtual-list is now ${newMaxRenderCount}`
+ );
+
+ // Restore initial window size
+ resizeTo(initialWidth, initialHeight);
+ await TestUtils.waitForCondition(
+ () =>
+ window.outerWidth >= initialHeight && window.outerWidth >= initialWidth,
+ `The window has been resized with outer height of ${window.outerHeight} instead of ${initialHeight}.`
+ );
+ info(`The final innerHeight is ${window.innerHeight}`);
+ await TestUtils.waitForCondition(
+ () =>
+ rootVirtualList.updateComplete &&
+ rootVirtualList.maxRenderCountEstimate > newMaxRenderCount,
+ `Max render count ${rootVirtualList.maxRenderCountEstimate} is not greater than new max render count ${newMaxRenderCount}`
+ );
+
+ info(
+ `The maxRenderCountEstimate on the virtual-list is greater than ${newMaxRenderCount} after window resize`
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js
new file mode 100644
index 0000000000..a6c697e398
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js
@@ -0,0 +1,544 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+const { ProfileAge } = ChromeUtils.importESModule(
+ "resource://gre/modules/ProfileAge.sys.mjs"
+);
+
+const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history";
+const IMPORT_HISTORY_DISMISSED_PREF =
+ "browser.tabs.firefox-view.importHistory.dismissed";
+const HISTORY_EVENT = [["firefoxview_next", "history", "visits", undefined]];
+const SHOW_ALL_HISTORY_EVENT = [
+ ["firefoxview_next", "show_all_history", "tabs", undefined],
+];
+
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const DAY_MS = 24 * 60 * 60 * 1000;
+const today = new Date();
+const yesterday = new Date(Date.now() - DAY_MS);
+const twoDaysAgo = new Date(Date.now() - DAY_MS * 2);
+const threeDaysAgo = new Date(Date.now() - DAY_MS * 3);
+const fourDaysAgo = new Date(Date.now() - DAY_MS * 4);
+const oneMonthAgo = new Date(today);
+
+// Set the date for the first day of the last month
+oneMonthAgo.setDate(1);
+if (oneMonthAgo.getMonth() === 0) {
+ // If today's date is in January, use first day in December from the previous year
+ oneMonthAgo.setMonth(11);
+ oneMonthAgo.setFullYear(oneMonthAgo.getFullYear() - 1);
+} else {
+ oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
+}
+
+function isElInViewport(element) {
+ const boundingRect = element.getBoundingClientRect();
+ return (
+ boundingRect.top >= 0 &&
+ boundingRect.left >= 0 &&
+ boundingRect.bottom <=
+ (window.innerHeight || document.documentElement.clientHeight) &&
+ boundingRect.right <=
+ (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+async function historyComponentReady(historyComponent) {
+ await TestUtils.waitForCondition(
+ () =>
+ [...historyComponent.allHistoryItems.values()].reduce(
+ (acc, { length }) => acc + length,
+ 0
+ ) === 24
+ );
+
+ let expected = historyComponent.historyMapByDate.length;
+ let actual = historyComponent.cards.length;
+
+ is(expected, actual, `Total number of cards should be ${expected}`);
+}
+
+async function historyTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ HISTORY_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function sortHistoryTelemetry(sortHistoryEvent) {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for sort_history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ sortHistoryEvent,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function showAllHistoryTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for show_all_history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ SHOW_ALL_HISTORY_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function addHistoryItems(dateAdded) {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[1],
+ title: "Example Domain 2",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[2],
+ title: "Example Domain 3",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[3],
+ title: "Example Domain 4",
+ visits: [{ date: dateAdded }],
+ });
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test_list_ordering() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await addHistoryItems(yesterday);
+ await addHistoryItems(twoDaysAgo);
+ await addHistoryItems(threeDaysAgo);
+ await addHistoryItems(fourDaysAgo);
+ await addHistoryItems(oneMonthAgo);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+
+ await historyComponentReady(historyComponent);
+
+ let firstCard = historyComponent.cards[0];
+
+ info("The first card should have a header for 'Today'.");
+ await BrowserTestUtils.waitForMutationCondition(
+ firstCard.querySelector("[slot=header]"),
+ { attributes: true },
+ () =>
+ document.l10n.getAttributes(firstCard.querySelector("[slot=header]"))
+ .id === "firefoxview-history-date-today"
+ );
+
+ // Select first history item in first card
+ await clearAllParentTelemetryEvents();
+ await TestUtils.waitForCondition(() => {
+ return historyComponent.lists[0].rowEls.length;
+ });
+ let firstHistoryLink = historyComponent.lists[0].rowEls[0].mainEl;
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ document,
+ "visibilitychange"
+ );
+ await EventUtils.synthesizeMouseAtCenter(firstHistoryLink, {}, content);
+ await historyTelemetry();
+ await promiseHidden;
+ await openFirefoxViewTab(browser.ownerGlobal);
+
+ // Test number of cards when sorted by site/domain
+ await clearAllParentTelemetryEvents();
+ let sortHistoryEvent = [
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ undefined,
+ { sort_type: "site", search_start: "false" },
+ ],
+ ];
+ // Select sort by site option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[1],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await sortHistoryTelemetry(sortHistoryEvent);
+
+ let expectedNumOfCards = historyComponent.historyMapBySite.length;
+
+ info(`Total number of cards should be ${expectedNumOfCards}`);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () => expectedNumOfCards === historyComponent.cards.length
+ );
+
+ await clearAllParentTelemetryEvents();
+ sortHistoryEvent = [
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ undefined,
+ { sort_type: "date", search_start: "false" },
+ ],
+ ];
+ // Select sort by date option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[0],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await sortHistoryTelemetry(sortHistoryEvent);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_empty_states() {
+ await PlacesUtils.history.clear();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await TestUtils.waitForCondition(() => historyComponent.emptyState);
+ let emptyStateCard = historyComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes(
+ "Get back to where you’ve been"
+ ),
+ "Initial empty state header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[0].textContent.includes(
+ "As you browse, the pages you visit will be listed here."
+ ),
+ "Initial empty state description has the expected text."
+ );
+
+ // Test empty state when History mode is set to never remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true);
+ // Manually update the history component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ historyComponent.requestUpdate();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ emptyStateCard = historyComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Nothing to show"),
+ "Empty state with never remember history header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[1].textContent.includes(
+ "remember your activity as you browse. To change that"
+ ),
+ "Empty state with never remember history description has the expected text."
+ );
+ // Reset History mode to Remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false);
+ // Manually update the history component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ historyComponent.requestUpdate();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+
+ // Test import history banner shows if profile age is 7 days or less and
+ // user hasn't already imported history from another browser
+ Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false);
+ Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, true);
+ ok(!historyComponent.cards.length, "Import history banner not shown yet");
+ historyComponent.profileAge = 0;
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ !historyComponent.cards.length,
+ "Import history banner still not shown yet"
+ );
+ Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, false);
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ historyComponent.cards[0].textContent.includes(
+ "Import history from another browser"
+ ),
+ "Import history banner is shown"
+ );
+ let importHistoryCloseButton =
+ historyComponent.cards[0].querySelector("button.close");
+ importHistoryCloseButton.click();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ Services.prefs.getBoolPref(IMPORT_HISTORY_DISMISSED_PREF, true) &&
+ !historyComponent.cards.length,
+ "Import history banner has been dismissed."
+ );
+ // Reset profileAge to greater than 7 to avoid affecting other tests
+ historyComponent.profileAge = 8;
+ Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false);
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_observers_removed_when_view_is_hidden() {
+ await PlacesUtils.history.clear();
+ const NEW_TAB_URL = "https://example.com";
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ NEW_TAB_URL
+ );
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ let visitList = await TestUtils.waitForCondition(() =>
+ historyComponent.cards?.[0]?.querySelector("fxview-tab-list")
+ );
+ info("The list should show a visit from the new tab.");
+ await TestUtils.waitForCondition(() => visitList.rowEls.length === 1);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ document,
+ "visibilitychange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await promiseHidden;
+ const { date } = await PlacesUtils.history
+ .fetch(NEW_TAB_URL, {
+ includeVisits: true,
+ })
+ .then(({ visits }) => visits[0]);
+ await addHistoryItems(date);
+ is(
+ visitList.rowEls.length,
+ 1,
+ "The list does not update when Firefox View is hidden."
+ );
+
+ info("The list should update when Firefox View is visible.");
+ await openFirefoxViewTab(browser.ownerGlobal);
+ visitList = await TestUtils.waitForCondition(() =>
+ historyComponent.cards?.[0]?.querySelector("fxview-tab-list")
+ );
+ await TestUtils.waitForCondition(() => visitList.rowEls.length > 1);
+
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+add_task(async function test_show_all_history_telemetry() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await addHistoryItems(yesterday);
+ await addHistoryItems(twoDaysAgo);
+ await addHistoryItems(threeDaysAgo);
+ await addHistoryItems(fourDaysAgo);
+ await addHistoryItems(oneMonthAgo);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await historyComponentReady(historyComponent);
+
+ await clearAllParentTelemetryEvents();
+ let showAllHistoryBtn = historyComponent.showAllHistoryBtn;
+ showAllHistoryBtn.scrollIntoView();
+ await EventUtils.synthesizeMouseAtCenter(showAllHistoryBtn, {}, content);
+ await showAllHistoryTelemetry();
+
+ // Make sure library window is shown
+ await TestUtils.waitForCondition(() =>
+ Services.wm.getMostRecentWindow("Places:Organizer")
+ );
+ let library = Services.wm.getMostRecentWindow("Places:Organizer");
+ await BrowserTestUtils.closeWindow(library);
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_search_history() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await historyComponentReady(historyComponent);
+ const searchTextbox = await TestUtils.waitForCondition(
+ () => historyComponent.searchTextbox,
+ "The search textbox is displayed."
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("Example Domain 1", content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length === 1 &&
+ document.l10n.getAttributes(
+ historyComponent.cards[0].querySelector("[slot=header]")
+ ).id === "firefoxview-search-results-header"
+ );
+ await TestUtils.waitForCondition(() => {
+ const { rowEls } = historyComponent.lists[0];
+ return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[0];
+ }, "There is one matching search result.");
+
+ info("Input a bogus search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(() => {
+ const tabList = historyComponent.lists[0];
+ return tabList?.shadowRoot.querySelector("fxview-empty-state");
+ }, "There are no matching search results.");
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length ===
+ historyComponent.historyMapByDate.length
+ );
+ searchTextbox.blur();
+
+ info("Input a bogus search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(() => {
+ const tabList = historyComponent.lists[0];
+ return tabList?.shadowRoot.querySelector("fxview-empty-state");
+ }, "There are no matching search results.");
+
+ info("Clear the search query with keyboard.");
+ is(
+ historyComponent.shadowRoot.activeElement,
+ searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length ===
+ historyComponent.historyMapByDate.length
+ );
+ });
+});
+
+add_task(async function test_persist_collapse_card_after_view_change() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await TestUtils.waitForCondition(
+ () =>
+ [...historyComponent.allHistoryItems.values()].reduce(
+ (acc, { length }) => acc + length,
+ 0
+ ) === 4
+ );
+ let firstHistoryCard = historyComponent.cards[0];
+ ok(
+ firstHistoryCard.isExpanded,
+ "The first history card is expanded initially."
+ );
+
+ // Collapse history card
+ EventUtils.synthesizeMouseAtCenter(firstHistoryCard.summaryEl, {}, content);
+ is(
+ firstHistoryCard.detailsEl.hasAttribute("open"),
+ false,
+ "The first history card is now collapsed."
+ );
+
+ // Switch to a new view and then back to History
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ await navigateToCategoryAndWait(document, "history");
+
+ // Check that first history card is still collapsed after changing view
+ ok(
+ !firstHistoryCard.isExpanded,
+ "The first history card is still collapsed after changing view."
+ );
+
+ await PlacesUtils.history.clear();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js
new file mode 100644
index 0000000000..0fa747d40f
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js
@@ -0,0 +1,392 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const tabsList1 = syncedTabsData1[0].tabs;
+const tabsList2 = syncedTabsData1[1].tabs;
+const BADGE_TOP_RIGHT = "75% 25%";
+
+const { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+
+function setupRecentDeviceListMocks() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [
+ {
+ id: 1,
+ name: "My desktop",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "My iphone",
+ type: "mobile",
+ tabs: [],
+ },
+ ]);
+
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ });
+
+ return sandbox;
+}
+
+function waitForWindowActive(win, active) {
+ info("Waiting for window activation");
+ return Promise.all([
+ BrowserTestUtils.waitForEvent(win, active ? "focus" : "blur"),
+ BrowserTestUtils.waitForEvent(win, active ? "activate" : "deactivate"),
+ ]);
+}
+
+async function waitForNotificationBadgeToBeShowing(fxViewButton) {
+ info("Waiting for attention attribute to be set");
+ await BrowserTestUtils.waitForMutationCondition(
+ fxViewButton,
+ { attributes: true },
+ () => fxViewButton.hasAttribute("attention")
+ );
+ return fxViewButton.hasAttribute("attention");
+}
+
+async function waitForNotificationBadgeToBeHidden(fxViewButton) {
+ info("Waiting for attention attribute to be removed");
+ await BrowserTestUtils.waitForMutationCondition(
+ fxViewButton,
+ { attributes: true },
+ () => !fxViewButton.hasAttribute("attention")
+ );
+ return !fxViewButton.hasAttribute("attention");
+}
+
+async function clickFirefoxViewButton(win) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+}
+
+function getBackgroundPositionForElement(ele) {
+ let style = ele.ownerGlobal.getComputedStyle(ele);
+ return style.getPropertyValue("background-position");
+}
+
+let previousFetchTime = 0;
+
+async function resetSyncedTabsLastFetched() {
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+ previousFetchTime = 0;
+ await TestUtils.waitForTick();
+}
+
+async function initTabSync() {
+ let recentFetchTime = Math.floor(Date.now() / 1000);
+ // ensure we don't try to set the pref with the same value, which will not produce
+ // the expected pref change effects
+ while (recentFetchTime == previousFetchTime) {
+ await TestUtils.waitForTick();
+ recentFetchTime = Math.floor(Date.now() / 1000);
+ }
+ Assert.greater(
+ recentFetchTime,
+ previousFetchTime,
+ "The new lastTabFetch value is greater than the previous"
+ );
+
+ info("initTabSync, updating lastFetch:" + recentFetchTime);
+ Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime);
+ previousFetchTime = recentFetchTime;
+ await TestUtils.waitForTick();
+}
+
+add_setup(async function () {
+ await resetSyncedTabsLastFetched();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.notify-for-tabs", true]],
+ });
+
+ // Clear any synced tabs from previous tests
+ FirefoxViewNotificationManager.syncedTabs = null;
+ Services.obs.notifyObservers(
+ null,
+ "firefoxview-notification-dot-update",
+ "false"
+ );
+});
+
+/**
+ * Test that the notification badge will show and hide in the correct cases
+ */
+add_task(async function testNotificationDot() {
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ sandbox.spy(SyncedTabs, "syncTabs");
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let fxViewBtn = win.document.getElementById("firefox-view-button");
+ ok(fxViewBtn, "Got the Firefox View button");
+
+ // Initiate a synced tabs update with new tabs
+ syncedTabsMock.returns(tabsList1);
+ await initTabSync();
+
+ ok(
+ BrowserTestUtils.isVisible(fxViewBtn),
+ "The Firefox View button is showing"
+ );
+
+ info(
+ "testNotificationDot, button is showing, badge should be initially hidden"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing initially"
+ );
+
+ // Initiate a synced tabs update with new tabs
+ syncedTabsMock.returns(tabsList2);
+ await initTabSync();
+
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn),
+ "The notification badge is showing after first tab sync"
+ );
+
+ // check that switching to the firefoxviewtab removes the badge
+ await clickFirefoxViewButton(win);
+
+ info(
+ "testNotificationDot, after clicking the button, badge should become hidden"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing after going to Firefox View"
+ );
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return SyncedTabs.syncTabs.calledOnce;
+ });
+
+ ok(SyncedTabs.syncTabs.calledOnce, "SyncedTabs.syncTabs() was called once");
+
+ syncedTabsMock.returns(tabsList1);
+ // Initiate a synced tabs update with new tabs
+ await initTabSync();
+
+ // The noti badge would show but we are on a Firefox View page so no need to show the noti badge
+ info(
+ "testNotificationDot, after updating the recent tabs, badge should be hidden"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing after tab sync while Firefox View is focused"
+ );
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser);
+ syncedTabsMock.returns(tabsList2);
+ await initTabSync();
+
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn),
+ "The notification badge is showing after navigation to a new tab"
+ );
+
+ // check that switching back to the Firefox View tab removes the badge
+ await clickFirefoxViewButton(win);
+
+ info(
+ "testNotificationDot, after switching back to fxview, badge should be hidden"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing after focusing the Firefox View tab"
+ );
+
+ await BrowserTestUtils.switchTab(win.gBrowser, newTab);
+
+ // Initiate a synced tabs update with no new tabs
+ await initTabSync();
+
+ info(
+ "testNotificationDot, after switching back to fxview with no new tabs, badge should be hidden"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing after a tab sync with the same tabs"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+
+ sandbox.restore();
+});
+
+/**
+ * Tests the notification badge with multiple windows
+ */
+add_task(async function testNotificationDotOnMultipleWindows() {
+ const sandbox = setupRecentDeviceListMocks();
+
+ await resetSyncedTabsLastFetched();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+
+ // Create a new window
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ await win1.delayedStartupPromise;
+ let fxViewBtn = win1.document.getElementById("firefox-view-button");
+ ok(fxViewBtn, "Got the Firefox View button");
+
+ syncedTabsMock.returns(tabsList1);
+ // Initiate a synced tabs update
+ await initTabSync();
+
+ // Create another window
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await win2.delayedStartupPromise;
+ let fxViewBtn2 = win2.document.getElementById("firefox-view-button");
+
+ await clickFirefoxViewButton(win2);
+
+ // Make sure the badge doesn't show on any window
+ info(
+ "testNotificationDotOnMultipleWindows, badge is initially hidden on window 1"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn),
+ "The notification badge is not showing in the inital window"
+ );
+ info(
+ "testNotificationDotOnMultipleWindows, badge is initially hidden on window 2"
+ );
+ ok(
+ await waitForNotificationBadgeToBeHidden(fxViewBtn2),
+ "The notification badge is not showing in the second window"
+ );
+
+ // Minimize the window.
+ win2.minimize();
+
+ await TestUtils.waitForCondition(
+ () => !win2.gBrowser.selectedBrowser.docShellIsActive,
+ "Waiting for docshell to be marked as inactive after minimizing the window"
+ );
+
+ syncedTabsMock.returns(tabsList2);
+ info("Initiate a synced tabs update with new tabs");
+ await initTabSync();
+
+ // The badge will show because the View tab is minimized
+ // Make sure the badge shows on all windows
+ info(
+ "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 1"
+ );
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn),
+ "The notification badge is showing in the initial window"
+ );
+ info(
+ "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 2"
+ );
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn2),
+ "The notification badge is showing in the second window"
+ );
+
+ win2.restore();
+ await TestUtils.waitForCondition(
+ () => win2.gBrowser.selectedBrowser.docShellIsActive,
+ "Waiting for docshell to be marked as active after restoring the window"
+ );
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+
+ sandbox.restore();
+});
+
+/**
+ * Tests the notification badge is in the correct spot and that the badge shows when opening a new window
+ * if another window is showing the badge
+ */
+add_task(async function testNotificationDotLocation() {
+ const sandbox = setupRecentDeviceListMocks();
+ await resetSyncedTabsLastFetched();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+
+ syncedTabsMock.returns(tabsList1);
+
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let fxViewBtn = win1.document.getElementById("firefox-view-button");
+ ok(fxViewBtn, "Got the Firefox View button");
+
+ // Initiate a synced tabs update
+ await initTabSync();
+ syncedTabsMock.returns(tabsList2);
+ // Initiate another synced tabs update
+ await initTabSync();
+
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn),
+ "The notification badge is showing initially"
+ );
+
+ // Create a new window
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await win2.delayedStartupPromise;
+
+ // Make sure the badge is showing on the new window
+ let fxViewBtn2 = win2.document.getElementById("firefox-view-button");
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn2),
+ "The notification badge is showing in the second window after opening"
+ );
+
+ // Make sure the badge is below and center now
+ isnot(
+ getBackgroundPositionForElement(fxViewBtn),
+ BADGE_TOP_RIGHT,
+ "The notification badge is not showing in the top right in the initial window"
+ );
+ isnot(
+ getBackgroundPositionForElement(fxViewBtn2),
+ BADGE_TOP_RIGHT,
+ "The notification badge is not showing in the top right in the second window"
+ );
+
+ CustomizableUI.addWidgetToArea(
+ "firefox-view-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+
+ // Make sure both windows still have the notification badge
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn),
+ "The notification badge is showing in the initial window"
+ );
+ ok(
+ await waitForNotificationBadgeToBeShowing(fxViewBtn2),
+ "The notification badge is showing in the second window"
+ );
+
+ // Make sure the badge is in the top right now
+ is(
+ getBackgroundPositionForElement(fxViewBtn),
+ BADGE_TOP_RIGHT,
+ "The notification badge is showing in the top right in the initial window"
+ );
+ is(
+ getBackgroundPositionForElement(fxViewBtn2),
+ BADGE_TOP_RIGHT,
+ "The notification badge is showing in the top right in the second window"
+ );
+
+ CustomizableUI.reset();
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+
+ sandbox.restore();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js
new file mode 100644
index 0000000000..d57aa3cad1
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js
@@ -0,0 +1,628 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URL = "about:robots";
+const ROW_URL_ID = "fxview-tab-row-url";
+const ROW_DATE_ID = "fxview-tab-row-date";
+
+let gInitialTab;
+let gInitialTabURL;
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+add_setup(function () {
+ // This test opens a lot of windows and tabs and might run long on slower configurations
+ requestLongerTimeout(2);
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+});
+
+async function navigateToOpenTabs(browser) {
+ const document = browser.contentDocument;
+ if (document.querySelector("named-deck").selectedViewName != "opentabs") {
+ await navigateToCategoryAndWait(browser.contentDocument, "opentabs");
+ }
+}
+
+function getOpenTabsComponent(browser) {
+ return browser.contentDocument.querySelector("named-deck > view-opentabs");
+}
+
+function getCards(browser) {
+ return getOpenTabsComponent(browser).shadowRoot.querySelectorAll(
+ "view-opentabs-card"
+ );
+}
+
+async function cleanup() {
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ await BrowserTestUtils.switchTab(gBrowser, gInitialTab);
+ await closeFirefoxViewTab(window);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+
+ is(
+ BrowserWindowTracker.orderedWindows.length,
+ 1,
+ "One window at the end of test cleanup"
+ );
+ Assert.deepEqual(
+ gBrowser.tabs.map(tab => tab.linkedBrowser.currentURI.spec),
+ [gInitialTabURL],
+ "One about:blank tab open at the end up test cleanup"
+ );
+}
+
+async function getRowsForCard(card) {
+ await TestUtils.waitForCondition(() => card.tabList.rowEls.length);
+ return card.tabList.rowEls;
+}
+
+/**
+ * Verify that there are the expected number of cards, and that each card has
+ * the expected URLs in order.
+ *
+ * @param {tabbrowser} browser
+ * The browser to verify in.
+ * @param {string[][]} expected
+ * The expected URLs for each card.
+ */
+async function checkTabLists(browser, expected) {
+ const cards = getCards(browser);
+ is(cards.length, expected.length, `There are ${expected.length} windows.`);
+ for (let i = 0; i < cards.length; i++) {
+ const tabItems = await getRowsForCard(cards[i]);
+ const actual = Array.from(tabItems).map(({ url }) => url);
+ Assert.deepEqual(
+ actual,
+ expected[i],
+ "Tab list has items with URLs in the expected order"
+ );
+ }
+}
+
+add_task(async function open_tab_same_window() {
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [[gInitialTabURL]]);
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ const [originalTab, newTab] = gBrowser.visibleTabs;
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [[gInitialTabURL, TEST_URL]]);
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ const cards = getCards(browser);
+ const tabItems = await getRowsForCard(cards[0]);
+ tabItems[0].mainEl.click();
+ await promiseHidden;
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => originalTab.selected,
+ "The original tab is selected."
+ );
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const cards = getCards(browser);
+ let tabItems = await getRowsForCard(cards[0]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+
+ tabItems[1].mainEl.click();
+ await promiseHidden;
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => newTab.selected,
+ "The new tab is selected."
+ );
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ info("Bring the new tab to the front.");
+ gBrowser.moveTabTo(newTab, 0);
+
+ await tabChangeRaised;
+ await checkTabLists(browser, [[TEST_URL, gInitialTabURL]]);
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.removeTab(newTab);
+ await tabChangeRaised;
+
+ await checkTabLists(browser, [[gInitialTabURL]]);
+ const [card] = getCards(browser);
+ const [row] = await getRowsForCard(card);
+ ok(
+ !row.shadowRoot.getElementById("fxview-tab-row-url").hidden,
+ "The URL is displayed, since we have one window."
+ );
+ ok(
+ !row.shadowRoot.getElementById("fxview-tab-row-date").hidden,
+ "The date is displayed, since we have one window."
+ );
+ });
+
+ await cleanup();
+});
+
+add_task(async function open_tab_new_window() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ let winFocused;
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+
+ info("Open fxview in new window");
+ await openFirefoxViewTab(win).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [
+ [gInitialTabURL, TEST_URL],
+ [gInitialTabURL],
+ ]);
+ const cards = getCards(browser);
+ const originalWinRows = await getRowsForCard(cards[1]);
+ const [row] = originalWinRows;
+ ok(
+ row.shadowRoot.getElementById("fxview-tab-row-url").hidden,
+ "The URL is hidden, since we have two windows."
+ );
+ ok(
+ row.shadowRoot.getElementById("fxview-tab-row-date").hidden,
+ "The date is hidden, since we have two windows."
+ );
+ info("Select a tab from the original window.");
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ winFocused = BrowserTestUtils.waitForEvent(window, "focus", true);
+ originalWinRows[0].mainEl.click();
+ await tabChangeRaised;
+ });
+
+ info("Wait for the original window to be focused");
+ await winFocused;
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 2, "There are two windows.");
+ const newWinRows = await getRowsForCard(cards[1]);
+
+ info("Select a tab from the new window.");
+ winFocused = BrowserTestUtils.waitForEvent(win, "focus", true);
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ newWinRows[0].mainEl.click();
+ await tabChangeRaised;
+ });
+ info("Wait for the new window to be focused");
+ await winFocused;
+ await cleanup();
+});
+
+add_task(async function open_tab_new_private_window() {
+ await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ await SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 1, "The private window is not displayed.");
+ });
+ await cleanup();
+});
+
+add_task(async function open_tab_new_window_sort_by_recency() {
+ info("Open new tabs in a new window.");
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const tabs = [
+ newWindow.gBrowser.selectedTab,
+ await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[0]),
+ await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[1]),
+ ];
+
+ info("Open Firefox View in the original window.");
+ await openFirefoxViewTab(window).then(async ({ linkedBrowser }) => {
+ await navigateToOpenTabs(linkedBrowser);
+ const openTabs = getOpenTabsComponent(linkedBrowser);
+ setSortOption(openTabs, "recency");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(linkedBrowser, [
+ [gInitialTabURL],
+ [URLs[1], URLs[0], gInitialTabURL],
+ ]);
+ info("Select tabs in the new window to trigger recency changes.");
+ await SimpleTest.promiseFocus(newWindow);
+ await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[1]);
+ await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[0]);
+ await SimpleTest.promiseFocus(window);
+ await TestUtils.waitForCondition(async () => {
+ const [, secondCard] = getCards(linkedBrowser);
+ const tabItems = await getRowsForCard(secondCard);
+ return tabItems[0].url === gInitialTabURL;
+ });
+ await checkTabLists(linkedBrowser, [
+ [gInitialTabURL],
+ [gInitialTabURL, URLs[0], URLs[1]],
+ ]);
+ });
+ await cleanup();
+});
+
+add_task(async function styling_for_multiple_windows() {
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=one]"),
+ "The container shows one column when one window is open."
+ );
+ });
+
+ await BrowserTestUtils.openNewBrowserWindow();
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await NonPrivateTabs.readyWindowsPromise;
+ await tabChangeRaised;
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 2,
+ "NonPrivateTabs now has 2 currentWindows"
+ );
+
+ info("switch to firefox view in the first window");
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+ is(
+ openTabs.openTabsTarget.currentWindows.length,
+ 2,
+ "There should be 2 current windows"
+ );
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=two]"),
+ "The container shows two columns when two windows are open."
+ );
+ });
+ await BrowserTestUtils.openNewBrowserWindow();
+ tabChangeRaised = BrowserTestUtils.waitForEvent(NonPrivateTabs, "TabChange");
+ await NonPrivateTabs.readyWindowsPromise;
+ await tabChangeRaised;
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 3,
+ "NonPrivateTabs now has 2 currentWindows"
+ );
+
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=three-or-more]"),
+ "The container shows three columns when three windows are open."
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function toggle_show_more_link() {
+ const tabEntry = url => ({
+ entries: [{ url, triggeringPrincipal_base64 }],
+ });
+ const NUMBER_OF_WINDOWS = 4;
+ const NUMBER_OF_TABS = 42;
+ const browserState = { windows: [] };
+ for (let windowIndex = 0; windowIndex < NUMBER_OF_WINDOWS; windowIndex++) {
+ const winData = { tabs: [] };
+ let tabCount = windowIndex == NUMBER_OF_WINDOWS - 1 ? NUMBER_OF_TABS : 1;
+ for (let i = 0; i < tabCount; i++) {
+ winData.tabs.push(tabEntry(`data:,Window${windowIndex}-Tab${i}`));
+ }
+ winData.selected = winData.tabs.length;
+ browserState.windows.push(winData);
+ }
+ // use Session restore to batch-open windows and tabs
+ await SessionStoreTestUtils.promiseBrowserState(browserState);
+ // restoring this state requires an update to the initial tab globals
+ // so cleanup expects the right thing
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+
+ const windows = Array.from(Services.wm.getEnumerator("navigator:browser"));
+ is(windows.length, NUMBER_OF_WINDOWS, "There are four browser windows.");
+
+ const tab = (win = window) => {
+ info("Tab");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ };
+
+ const enter = (win = window) => {
+ info("Enter");
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ };
+
+ let lastCard;
+
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, NUMBER_OF_WINDOWS, "There are four windows.");
+ lastCard = cards[NUMBER_OF_WINDOWS - 1];
+ });
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+ Assert.less(
+ (await getRowsForCard(lastCard)).length,
+ NUMBER_OF_TABS,
+ "Not all tabs are shown yet."
+ );
+ info("Toggle the Show More link.");
+ lastCard.shadowRoot.querySelector("div[slot=footer]").click();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS
+ );
+
+ info("Toggle the Show Less link.");
+ lastCard.shadowRoot.querySelector("div[slot=footer]").click();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS
+ );
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ info("Toggle the Show More link with keyboard.");
+ lastCard.shadowRoot.querySelector("card-container").summaryEl.focus();
+ // Tab to first item in the list
+ tab();
+ // Tab to the footer
+ tab();
+ enter();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS
+ );
+
+ info("Toggle the Show Less link with keyboard.");
+ lastCard.shadowRoot.querySelector("card-container").summaryEl.focus();
+ // Tab to first item in the list
+ tab();
+ // Tab to the footer
+ tab();
+ enter();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+ await cleanup();
+});
+
+add_task(async function search_open_tabs() {
+ // Open a new window and navigate to TEST_URL. Then, when we search for
+ // TEST_URL, it should show a search result in the new window's card.
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 2, "There are two windows.");
+ const winTabs = await getRowsForCard(cards[0]);
+ const newWinTabs = await getRowsForCard(cards[1]);
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString(TEST_URL, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === 0,
+ "There are no matching search results in the original window."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === 1,
+ "There is one matching search result in the new window."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ openTabs.searchTextbox.clearButton,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length,
+ "The original window's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length,
+ "The new window's list is restored."
+ );
+ openTabs.searchTextbox.blur();
+
+ info("Input a search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString(TEST_URL, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === 0,
+ "There are no matching search results in the original window."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === 1,
+ "There is one matching search result in the new window."
+ );
+
+ info("Clear the search query with keyboard.");
+ is(
+ openTabs.shadowRoot.activeElement,
+ openTabs.searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ openTabs.searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length,
+ "The original window's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length,
+ "The new window's list is restored."
+ );
+ });
+
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
+
+add_task(async function search_open_tabs_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+ }
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToCategoryAndWait(browser.contentDocument, "recentbrowsing");
+ const recentBrowsing = browser.contentDocument.querySelector(
+ "view-recentbrowsing"
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(TEST_URL, content);
+ const slot = recentBrowsing.querySelector("[slot='opentabs']");
+ await TestUtils.waitForCondition(
+ () => slot.viewCards[0].tabList.rowEls.length === 5,
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = await TestUtils.waitForCondition(() => {
+ const elt = slot.viewCards[0].shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ EventUtils.synthesizeMouseAtCenter(elt, {}, content);
+ if (slot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) {
+ return elt;
+ }
+ return false;
+ }, "All search results are shown.");
+ is(showAllLink.role, "link", "The show all control is a link.");
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js
new file mode 100644
index 0000000000..c293afa8cd
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js
@@ -0,0 +1,541 @@
+const { NonPrivateTabs, getTabsTargetForWindow } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+let privateTabsChanges;
+
+const tabURL1 = "data:text/html,<title>Tab1</title>Tab1";
+const tabURL2 = "data:text/html,<title>Tab2</title>Tab2";
+const tabURL3 = "data:text/html,<title>Tab3</title>Tab3";
+const tabURL4 = "data:text/html,<title>Tab4</title>Tab4";
+
+const nonPrivateListener = sinon.stub();
+const privateListener = sinon.stub();
+
+function tabUrl(tab) {
+ return tab.linkedBrowser.currentURI?.spec;
+}
+
+function getWindowId(win) {
+ return win.windowGlobalChild.innerWindowId;
+}
+
+async function setup(tabChangeEventName) {
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ NonPrivateTabs.addEventListener(tabChangeEventName, nonPrivateListener);
+
+ await TestUtils.waitForTick();
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 1,
+ "NonPrivateTabs has 1 window a tick after adding the event listener"
+ );
+
+ info("Opening new windows");
+ let win0 = window,
+ win1 = await BrowserTestUtils.openNewBrowserWindow(),
+ privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ BrowserTestUtils.startLoadingURIString(
+ win1.gBrowser.selectedBrowser,
+ tabURL1
+ );
+ await BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser);
+
+ // load a tab with a title/label we can easily verify
+ BrowserTestUtils.startLoadingURIString(
+ privateWin.gBrowser.selectedBrowser,
+ tabURL2
+ );
+ await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser);
+
+ is(
+ win1.gBrowser.selectedTab.label,
+ "Tab1",
+ "Check the tab label in the new non-private window"
+ );
+ is(
+ privateWin.gBrowser.selectedTab.label,
+ "Tab2",
+ "Check the tab label in the new private window"
+ );
+
+ privateTabsChanges = getTabsTargetForWindow(privateWin);
+ privateTabsChanges.addEventListener(tabChangeEventName, privateListener);
+ is(
+ privateTabsChanges,
+ getTabsTargetForWindow(privateWin),
+ "getTabsTargetForWindow reuses a single instance per exclusive window"
+ );
+
+ await TestUtils.waitForTick();
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 2,
+ "NonPrivateTabs has 2 windows once openNewBrowserWindow resolves"
+ );
+ is(
+ privateTabsChanges.currentWindows.length,
+ 1,
+ "privateTabsChanges has 1 window once openNewBrowserWindow resolves"
+ );
+
+ await SimpleTest.promiseFocus(win0);
+ info("setup, win0 has id: " + getWindowId(win0));
+ info("setup, win1 has id: " + getWindowId(win1));
+ info("setup, privateWin has id: " + getWindowId(privateWin));
+ info("setup,waiting for both private and nonPrivateListener to be called");
+ await TestUtils.waitForCondition(() => {
+ return nonPrivateListener.called && privateListener.called;
+ });
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ const cleanup = async eventName => {
+ NonPrivateTabs.removeEventListener(eventName, nonPrivateListener);
+ privateTabsChanges.removeEventListener(eventName, privateListener);
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ };
+ return { windows: [win0, win1, privateWin], cleanup };
+}
+
+add_task(async function test_TabChanges() {
+ const { windows, cleanup } = await setup("TabChange");
+ const [win0, win1, privateWin] = windows;
+ let tabChangeRaised;
+ let changeEvent;
+
+ info(
+ "Verify that manipulating tabs in a non-private window dispatches events on the correct target"
+ );
+ for (let win of [win0, win1]) {
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ tabURL1
+ );
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ const navigateUrl = "https://example.org/";
+ BrowserTestUtils.startLoadingURIString(newTab.linkedBrowser, navigateUrl);
+ await BrowserTestUtils.browserLoaded(
+ newTab.linkedBrowser,
+ null,
+ navigateUrl
+ );
+ // navigation in a tab changes the label which should produce a change event
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ BrowserTestUtils.removeTab(newTab);
+ // navigation in a tab changes the label which should produce a change event
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+ }
+
+ info(
+ "make sure a change to a private window doesnt dispatch on a nonprivate target"
+ );
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabChange"
+ );
+ BrowserTestUtils.addTab(privateWin.gBrowser, tabURL1);
+ changeEvent = await tabChangeRaised;
+ info(
+ `Check windowIds adding tab to private window: ${getWindowId(
+ privateWin
+ )}: ${JSON.stringify(changeEvent.detail.windowIds)}`
+ );
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ await TestUtils.waitForTick();
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "A private tab change shouldnt raise a tab change event on the non-private target"
+ );
+
+ info("testTabChanges complete");
+ await cleanup("TabChange");
+});
+
+add_task(async function test_TabRecencyChange() {
+ const { windows, cleanup } = await setup("TabRecencyChange");
+ const [win0, win1, privateWin] = windows;
+
+ let tabChangeRaised;
+ let changeEvent;
+ let sortedTabs;
+
+ info("Open some tabs in the non-private windows");
+ for (let win of [win0, win1]) {
+ for (let url of [tabURL1, tabURL2]) {
+ let tab = BrowserTestUtils.addTab(win.gBrowser, url);
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await tabChangeRaised;
+ }
+ }
+
+ info("Verify switching tabs produces the expected event and result");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ BrowserTestUtils.switchTab(win0.gBrowser, win0.gBrowser.tabs.at(-1));
+ changeEvent = await tabChangeRaised;
+
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win0)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity check that the non-private tabs listener was called"
+ );
+ Assert.ok(
+ privateListener.notCalled,
+ "The private tabs listener was not called"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win0.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab"
+ );
+
+ info("Verify switching window produces the expected event and result");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win1)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity check that the non-private tabs listener was called"
+ );
+ Assert.ok(
+ privateListener.notCalled,
+ "The private tabs listener was not called"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the current window"
+ );
+
+ info("Verify behavior with private window changes");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(privateWin);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ Assert.ok(
+ privateListener.called,
+ "Sanity check the private tabs listener was called"
+ );
+
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the current window"
+ );
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent non-private tab is still the selected tab in the previous non-private window"
+ );
+
+ info("Verify adding a tab to a private window does the right thing");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, tabURL3);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ tabUrl(sortedTabs[0]),
+ tabURL3,
+ "The most-recent tab is the tab we just opened in the private window"
+ );
+
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ BrowserTestUtils.switchTab(privateWin.gBrowser, privateWin.gBrowser.tabs[0]);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the private window"
+ );
+
+ info("Verify switching back to a non-private does the right thing");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+ if (privateListener.called) {
+ info(`The private listener was called ${privateListener.callCount} times`);
+ }
+ Assert.ok(
+ privateListener.notCalled,
+ "The private listener got no recency-change events for the non-private window"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity-check the non-private listener got a recency-change event for the non-private window"
+ );
+
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent private tab is unchanged"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent non-private tab is the selected tab in the current window"
+ );
+
+ await cleanup("TabRecencyChange");
+ while (win0.gBrowser.tabs.length > 1) {
+ info(
+ "Removing last tab:" +
+ win0.gBrowser.tabs.at(-1).linkedBrowser.currentURI.spec
+ );
+ BrowserTestUtils.removeTab(win0.gBrowser.tabs.at(-1));
+ info("Removed, tabs.length:" + win0.gBrowser.tabs.length);
+ }
+});
+
+add_task(async function test_tabNavigations() {
+ const { windows, cleanup } = await setup("TabChange");
+ const [, win1, privateWin] = windows;
+
+ // also listen for TabRecencyChange events
+ const nonPrivateRecencyListener = sinon.stub();
+ const privateRecencyListener = sinon.stub();
+ privateTabsChanges.addEventListener(
+ "TabRecencyChange",
+ privateRecencyListener
+ );
+ NonPrivateTabs.addEventListener(
+ "TabRecencyChange",
+ nonPrivateRecencyListener
+ );
+
+ info(
+ `Verify navigating in tab generates TabChange & TabRecencyChange events`
+ );
+ let loaded = BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser);
+ win1.gBrowser.selectedBrowser.loadURI(Services.io.newURI(tabURL4), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ info("waiting for the load into win1 tab to complete");
+ await loaded;
+ info("waiting for listeners to be called");
+ await BrowserTestUtils.waitForCondition(() => {
+ return nonPrivateListener.called && nonPrivateRecencyListener.called;
+ });
+ ok(!privateListener.called, "The private TabChange listener was not called");
+ ok(
+ !privateRecencyListener.called,
+ "The private TabRecencyChange listener was not called"
+ );
+
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+ nonPrivateRecencyListener.resetHistory();
+ privateRecencyListener.resetHistory();
+
+ // Now verify the same with a private window
+ info(
+ `Verify navigating in private tab generates TabChange & TabRecencyChange events`
+ );
+ ok(
+ !nonPrivateListener.called,
+ "The non-private TabChange listener is not yet called"
+ );
+
+ loaded = BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser);
+ privateWin.gBrowser.selectedBrowser.loadURI(
+ Services.io.newURI("about:robots"),
+ {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ }
+ );
+ info("waiting for the load into privateWin tab to complete");
+ await loaded;
+ info("waiting for the privateListeners to be called");
+ await BrowserTestUtils.waitForCondition(() => {
+ return privateListener.called && privateRecencyListener.called;
+ });
+ ok(
+ !nonPrivateListener.called,
+ "The non-private TabChange listener was not called"
+ );
+ ok(
+ !nonPrivateRecencyListener.called,
+ "The non-private TabRecencyChange listener was not called"
+ );
+
+ // cleanup
+ privateTabsChanges.removeEventListener(
+ "TabRecencyChange",
+ privateRecencyListener
+ );
+ NonPrivateTabs.removeEventListener(
+ "TabRecencyChange",
+ nonPrivateRecencyListener
+ );
+
+ await cleanup();
+});
+
+add_task(async function test_tabsFromPrivateWindows() {
+ const { cleanup } = await setup("TabChange");
+ const private2Listener = sinon.stub();
+
+ const private2Win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ const private2TabsChanges = getTabsTargetForWindow(private2Win);
+ private2TabsChanges.addEventListener("TabChange", private2Listener);
+ ok(
+ privateTabsChanges !== getTabsTargetForWindow(private2Win),
+ "getTabsTargetForWindow creates a distinct instance for a different private window"
+ );
+
+ await BrowserTestUtils.waitForCondition(() => private2Listener.called);
+
+ ok(
+ !privateListener.called,
+ "No TabChange event was raised by opening a different private window"
+ );
+ privateListener.resetHistory();
+ private2Listener.resetHistory();
+
+ BrowserTestUtils.addTab(private2Win.gBrowser, tabURL1);
+ await BrowserTestUtils.waitForCondition(() => private2Listener.called);
+ ok(
+ !privateListener.called,
+ "No TabChange event was raised by adding tab to a different private window"
+ );
+
+ is(
+ privateTabsChanges.getRecentTabs().length,
+ 1,
+ "The recent tab count for the first private window tab target only reports the tabs for its associated windodw"
+ );
+ is(
+ private2TabsChanges.getRecentTabs().length,
+ 2,
+ "The recent tab count for a 2nd private window tab target only reports the tabs for its associated windodw"
+ );
+
+ await cleanup("TabChange");
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js
new file mode 100644
index 0000000000..57d0f8d031
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js
@@ -0,0 +1,423 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URL1 = "about:robots";
+const TEST_URL2 = "https://example.org/";
+const TEST_URL3 = "about:mozilla";
+
+const fxaDevicesWithCommands = [
+ {
+ id: 1,
+ name: "My desktop device",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "test" },
+ lastAccessTime: Date.now(),
+ },
+ {
+ id: 2,
+ name: "My mobile device",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" },
+ lastAccessTime: Date.now() + 60000, // add 30min
+ },
+];
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+async function getRowsForCard(card) {
+ await TestUtils.waitForCondition(() => card.tabList.rowEls.length);
+ return card.tabList.rowEls;
+}
+
+async function add_new_tab(URL) {
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ let tab = BrowserTestUtils.addTab(gBrowser, URL);
+ // wait so we can reliably compare the tab URL
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await tabChangeRaised;
+ return tab;
+}
+
+function getVisibleTabURLs(win = window) {
+ return win.gBrowser.visibleTabs.map(tab => tab.linkedBrowser.currentURI.spec);
+}
+
+function getTabRowURLs(rows) {
+ return Array.from(rows).map(row => row.url);
+}
+
+async function waitUntilRowsMatch(openTabs, cardIndex, expectedURLs) {
+ let card;
+
+ info(
+ "moreMenuSetup: openTabs has openTabsTarget?:" + !!openTabs?.openTabsTarget
+ );
+ //await openTabs.openTabsTarget.readyWindowsPromise;
+ info(
+ `waitUntilRowsMatch, wait for there to be at least ${cardIndex + 1} cards`
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (!openTabs.initialWindowsReady) {
+ info("openTabs.initialWindowsReady isn't true");
+ return false;
+ }
+ try {
+ card = getOpenTabsCards(openTabs)[cardIndex];
+ } catch (ex) {
+ info("Calling getOpenTabsCards produced exception: " + ex.message);
+ }
+ return !!card;
+ }, "Waiting for openTabs to be ready and to get the cards");
+
+ const expectedURLsAsString = JSON.stringify(expectedURLs);
+ info(`Waiting for row URLs to match ${expectedURLs.join(", ")}`);
+ await BrowserTestUtils.waitForMutationCondition(
+ card.shadowRoot,
+ { characterData: true, childList: true, subtree: true },
+ async () => {
+ let rows = await getRowsForCard(card);
+ return (
+ rows.length == expectedURLs.length &&
+ JSON.stringify(getTabRowURLs(rows)) == expectedURLsAsString
+ );
+ }
+ );
+}
+
+async function getContextMenuPanelListForCard(card) {
+ let menuContainer = card.shadowRoot.querySelector(
+ "view-opentabs-contextmenu"
+ );
+ ok(menuContainer, "Found the menuContainer for card");
+ await TestUtils.waitForCondition(
+ () => menuContainer.panelList,
+ "Waiting for the context menu's panel-list to be rendered"
+ );
+ ok(
+ menuContainer.panelList,
+ "Found the panelList in the card's view-opentabs-contextmenu"
+ );
+ return menuContainer.panelList;
+}
+
+async function openContextMenuForItem(tabItem, card) {
+ // click on the item's button element (more menu)
+ // and wait for the panel list to be shown
+ tabItem.buttonEl.click();
+ // NOTE: menu must populate with devices data before it can be rendered
+ // so the creation of the panel-list can be async
+ let panelList = await getContextMenuPanelListForCard(card);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ return panelList;
+}
+
+async function moreMenuSetup() {
+ await add_new_tab(TEST_URL2);
+ await add_new_tab(TEST_URL3);
+
+ // once we've opened a few tabs, navigate to the open tabs section in firefox view
+ await clickFirefoxViewButton(window);
+ const document = window.FirefoxViewHandler.tab.linkedBrowser.contentDocument;
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+
+ info("waiting for openTabs' first card rows");
+ await waitUntilRowsMatch(openTabs, 0, getVisibleTabURLs());
+
+ let cards = getOpenTabsCards(openTabs);
+ is(cards.length, 1, "There is one open window.");
+
+ let rows = await getRowsForCard(cards[0]);
+
+ let firstTab = rows[0];
+
+ firstTab.scrollIntoView();
+ is(
+ isElInViewport(firstTab),
+ true,
+ "first tab list item is visible in viewport"
+ );
+
+ return [cards, rows];
+}
+
+add_task(async function test_more_menus() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ let shown, menuHidden;
+
+ gBrowser.selectedTab = gBrowser.visibleTabs[0];
+ Assert.equal(
+ gBrowser.selectedTab.linkedBrowser.currentURI.spec,
+ "about:blank",
+ "Selected tab is about:blank"
+ );
+
+ info(`Loading ${TEST_URL1} into the selected about:blank tab`);
+ let tabLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ win.gURLBar.focus();
+ win.gURLBar.value = TEST_URL1;
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ await tabLoaded;
+
+ info("Waiting for moreMenuSetup to resolve");
+ let [cards, rows] = await moreMenuSetup();
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL1, TEST_URL2, TEST_URL3],
+ "Prepared 3 open tabs"
+ );
+
+ let firstTab = rows[0];
+ // Open the panel list (more menu) from the first list item
+ let panelList = await openContextMenuForItem(firstTab, cards[0]);
+
+ // Close Tab menu item
+ info("Panel list shown. Clicking on panel-item");
+ let panelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-close-tab]"
+ );
+ let panelItemButton = panelItem.shadowRoot.querySelector(
+ "button[role=menuitem]"
+ );
+ ok(panelItem, "Close Tab panel item exists");
+ ok(
+ panelItemButton,
+ "Close Tab panel item button with role=menuitem exists"
+ );
+
+ await clearAllParentTelemetryEvents();
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "close-tab", data_type: "opentabs" },
+ ],
+ ];
+
+ // close a tab via the menu
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ panelItemButton.click();
+ info("Waiting for result of closing a tab via the menu");
+ await tabChangeRaised;
+ await cards[0].getUpdateComplete();
+ await menuHidden;
+ await telemetryEvent(contextMenuEvent);
+
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL2, TEST_URL3],
+ "Got the expected 2 open tabs"
+ );
+
+ let openTabs = cards[0].ownerDocument.querySelector(
+ "view-opentabs[name=opentabs]"
+ );
+ await waitUntilRowsMatch(openTabs, 0, [TEST_URL2, TEST_URL3]);
+
+ // Move Tab submenu item
+ firstTab = rows[0];
+ is(firstTab.url, TEST_URL2, `First tab list item is ${TEST_URL2}`);
+
+ panelList = await openContextMenuForItem(firstTab, cards[0]);
+ let moveTabsPanelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-move-tab]"
+ );
+
+ let moveTabsSubmenuList = moveTabsPanelItem.shadowRoot.querySelector(
+ "panel-list[id=move-tab-menu]"
+ );
+ ok(moveTabsSubmenuList, "Move tabs submenu panel list exists");
+
+ // navigate down to the "Move tabs" submenu option, and
+ // open it with the right arrow key
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ shown = BrowserTestUtils.waitForEvent(moveTabsSubmenuList, "shown");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ await shown;
+
+ await clearAllParentTelemetryEvents();
+ contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "move-tab-end", data_type: "opentabs" },
+ ],
+ ];
+
+ // click on the first option, which should be "Move to the end" since
+ // this is the first tab
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {});
+ info("Waiting for result of moving a tab via the menu");
+ await telemetryEvent(contextMenuEvent);
+ await menuHidden;
+ await tabChangeRaised;
+
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL3, TEST_URL2],
+ "The last tab became the first tab"
+ );
+
+ // this entire "move tabs" submenu test can be reordered above
+ // closing a tab since it very clearly reveals the issues
+ // outlined in bug 1852622 when there are 3 or more tabs open
+ // and one is moved via the more menus.
+ await waitUntilRowsMatch(openTabs, 0, [TEST_URL3, TEST_URL2]);
+
+ // Copy Link menu item (copyLink function that's called is a member of Viewpage.mjs)
+ panelList = await openContextMenuForItem(firstTab, cards[0]);
+ firstTab = rows[0];
+ panelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-copy-link]"
+ );
+ panelItemButton = panelItem.shadowRoot.querySelector(
+ "button[role=menuitem]"
+ );
+ ok(panelItem, "Copy link panel item exists");
+ ok(
+ panelItemButton,
+ "Copy link panel item button with role=menuitem exists"
+ );
+
+ await clearAllParentTelemetryEvents();
+ contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "copy-link", data_type: "opentabs" },
+ ],
+ ];
+
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ panelItemButton.click();
+ info("Waiting for menuHidden");
+ await menuHidden;
+ info("Waiting for telemetryEvent");
+ await telemetryEvent(contextMenuEvent);
+
+ let copiedText = SpecialPowers.getClipboardData(
+ "text/plain",
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ is(copiedText, TEST_URL3, "The correct url has been copied and pasted");
+
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+ });
+});
+
+add_task(async function test_send_device_submenu() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+ sandbox
+ .stub(gSync, "getSendTabTargets")
+ .callsFake(() => fxaDevicesWithCommands);
+
+ await withFirefoxView({}, async browser => {
+ // TEST_URL2 is our only tab, left over from previous test
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL2],
+ `We initially have a single ${TEST_URL2} tab`
+ );
+ let shown;
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ let [cards, rows] = await moreMenuSetup(document);
+
+ let firstTab = rows[0];
+ let panelList = await openContextMenuForItem(firstTab, cards[0]);
+
+ let sendTabPanelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-send-tab]"
+ );
+
+ ok(sendTabPanelItem, "Send tabs to device submenu panel item exists");
+
+ let sendTabSubmenuList = sendTabPanelItem.shadowRoot.querySelector(
+ "panel-list[id=send-tab-menu]"
+ );
+ ok(sendTabSubmenuList, "Send tabs to device submenu panel list exists");
+
+ // navigate down to the "Send tabs" submenu option, and
+ // open it with the right arrow key
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+
+ shown = BrowserTestUtils.waitForEvent(sendTabSubmenuList, "shown");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ await shown;
+
+ let expectation = sandbox
+ .mock(gSync)
+ .expects("sendTabToDevice")
+ .once()
+ .withExactArgs(
+ TEST_URL2,
+ [fxaDevicesWithCommands[0]],
+ "mochitest index /"
+ )
+ .returns(true);
+
+ await clearAllParentTelemetryEvents();
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "send-tab-device", data_type: "opentabs" },
+ ],
+ ];
+
+ // click on the first device and verify it was "sent"
+ let menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ EventUtils.synthesizeKey("KEY_Enter", {});
+
+ expectation.verify();
+ await telemetryEvent(contextMenuEvent);
+ await menuHidden;
+
+ sandbox.restore();
+ TabsSetupFlowManager.resetInternalState();
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js
new file mode 100644
index 0000000000..e5beb4700a
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js
@@ -0,0 +1,408 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ This test checks the recent-browsing view of open tabs in about:firefoxview next
+ presents the correct tab data in the correct order.
+*/
+
+const tabURL1 = "data:,Tab1";
+const tabURL2 = "data:,Tab2";
+const tabURL3 = "data:,Tab3";
+const tabURL4 = "data:,Tab4";
+
+let gInitialTab;
+let gInitialTabURL;
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+add_setup(function () {
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = tabUrl(gInitialTab);
+});
+
+function tabUrl(tab) {
+ return tab.linkedBrowser.currentURI?.spec;
+}
+
+async function minimizeWindow(win) {
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ win,
+ "sizemodechange"
+ );
+ win.minimize();
+ await promiseSizeModeChange;
+ ok(
+ !win.gBrowser.selectedTab.linkedBrowser.docShellIsActive,
+ "Docshell should be Inactive"
+ );
+ ok(win.document.hidden, "Top level window should be hidden");
+}
+
+async function restoreWindow(win) {
+ ok(win.document.hidden, "Top level window should be hidden");
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ win,
+ "sizemodechange"
+ );
+
+ // Check if we also need to wait for occlusion to be updated.
+ let promiseOcclusion;
+ let willWaitForOcclusion = win.isFullyOccluded;
+ if (willWaitForOcclusion) {
+ // Not only do we need to wait for the occlusionstatechange event,
+ // we also have to wait *one more event loop* to ensure that the
+ // other listeners to the occlusionstatechange events have fired.
+ // Otherwise, our browsing context might not have become active
+ // at the point where we receive the occlusionstatechange event.
+ promiseOcclusion = BrowserTestUtils.waitForEvent(
+ win,
+ "occlusionstatechange"
+ ).then(() => new Promise(resolve => SimpleTest.executeSoon(resolve)));
+ } else {
+ promiseOcclusion = Promise.resolve();
+ }
+
+ info("Calling window.restore");
+ win.restore();
+ // From browser/base/content/test/general/browser_minimize.js:
+ // On Ubuntu `window.restore` doesn't seem to work, use a timer to make the
+ // test fail faster and more cleanly than with a test timeout.
+ info(
+ `Waiting for sizemodechange ${
+ willWaitForOcclusion ? "and occlusionstatechange " : ""
+ }event`
+ );
+ let timer;
+ await Promise.race([
+ Promise.all([promiseSizeModeChange, promiseOcclusion]),
+ new Promise((resolve, reject) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timer = setTimeout(() => {
+ reject(
+ `timed out waiting for sizemodechange sizemodechange ${
+ willWaitForOcclusion ? "and occlusionstatechange " : ""
+ }event`
+ );
+ }, 5000);
+ }),
+ ]);
+ clearTimeout(timer);
+ ok(
+ win.gBrowser.selectedTab.linkedBrowser.docShellIsActive,
+ "Docshell should be active again"
+ );
+ ok(!win.document.hidden, "Top level window should be visible");
+}
+
+async function prepareOpenTabs(urls, win = window) {
+ const reusableTabURLs = ["about:newtab", "about:blank"];
+ const gBrowser = win.gBrowser;
+
+ for (let url of urls) {
+ if (
+ gBrowser.visibleTabs.length == 1 &&
+ reusableTabURLs.includes(gBrowser.selectedBrowser.currentURI.spec)
+ ) {
+ // we'll load into this tab rather than opening a new one
+ info(
+ `Loading ${url} into blank tab: ${gBrowser.selectedBrowser.currentURI.spec}`
+ );
+ BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, null, url);
+ } else {
+ info(`Loading ${url} into new tab`);
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ }
+ await new Promise(res => win.requestAnimationFrame(res));
+ }
+ Assert.equal(
+ gBrowser.visibleTabs.length,
+ urls.length,
+ `Prepared ${urls.length} tabs as expected`
+ );
+ Assert.equal(
+ tabUrl(gBrowser.selectedTab),
+ urls[urls.length - 1],
+ "The selectedTab is the last of the URLs given as expected"
+ );
+}
+
+async function cleanup(...windowsToClose) {
+ await Promise.all(
+ windowsToClose.map(win => BrowserTestUtils.closeWindow(win))
+ );
+
+ while (gBrowser.visibleTabs.length > 1) {
+ await SessionStoreTestUtils.closeTab(gBrowser.tabs.at(-1));
+ }
+ if (gBrowser.selectedBrowser.currentURI.spec !== gInitialTabURL) {
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ gInitialTabURL
+ );
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ null,
+ gInitialTabURL
+ );
+ }
+}
+
+function getOpenTabsComponent(browser) {
+ return browser.contentDocument.querySelector(
+ "view-recentbrowsing view-opentabs"
+ );
+}
+
+async function checkTabList(browser, expected) {
+ const tabsView = getOpenTabsComponent(browser);
+ const openTabsCard = tabsView.shadowRoot.querySelector("view-opentabs-card");
+ await tabsView.getUpdateComplete();
+ const tabList = openTabsCard.shadowRoot.querySelector("fxview-tab-list");
+ Assert.ok(tabList, "Found the tab list element");
+ await TestUtils.waitForCondition(() => tabList.rowEls.length);
+ let actual = Array.from(tabList.rowEls).map(row => row.url);
+ Assert.deepEqual(
+ actual,
+ expected,
+ "Tab list has items with URLs in the expected order"
+ );
+}
+
+add_task(async function test_single_window_tabs() {
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL2, tabURL1]);
+
+ // switch to the first tab
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.visibleTabs[0]);
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // and check the results in the open tabs section of Recent Browsing
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL1, tabURL2]);
+ });
+ await cleanup();
+});
+
+add_task(async function test_multiple_window_tabs() {
+ const fxViewURL = getFirefoxViewURL();
+ const win1 = window;
+ let tabChangeRaised;
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3, tabURL4], win2);
+
+ // to avoid confusing the results by activating different windows,
+ // check fxview in the current window - which is win2
+ info("Switching to fxview tab in win2");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]);
+
+ Assert.equal(
+ tabUrl(win2.gBrowser.selectedTab),
+ fxViewURL,
+ `The selected tab in window 2 is ${fxViewURL}`
+ );
+
+ info("Switching to first tab (tab3) in win2");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ await BrowserTestUtils.switchTab(
+ win2.gBrowser,
+ win2.gBrowser.visibleTabs[0]
+ );
+ Assert.equal(
+ tabUrl(win2.gBrowser.selectedTab),
+ tabURL3,
+ `The selected tab in window 2 is ${tabURL3}`
+ );
+ await tabChangeRaised;
+ await promiseHidden;
+ });
+
+ info("Opening fxview in win2 to confirm tab3 is most recent");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info("Check result of selecting 1ist tab in window 2");
+ await checkTabList(browser, [tabURL3, tabURL4, tabURL2, tabURL1]);
+ });
+
+ info("Focusing win1, where tab2 should be selected");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+ Assert.equal(
+ tabUrl(win1.gBrowser.selectedTab),
+ tabURL2,
+ `The selected tab in window 1 is ${tabURL2}`
+ );
+
+ info("Opening fxview in win1 to confirm tab2 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info(
+ "In fxview, check result of activating window 1, where tab 2 is selected"
+ );
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ info("Switching to first visible tab (tab1) in win1");
+ await BrowserTestUtils.switchTab(
+ win1.gBrowser,
+ win1.gBrowser.visibleTabs[0]
+ );
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // check result in the fxview in the 1st window
+ info("Opening fxview in win1 to confirm tab1 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info("Check result of selecting 1st tab in win1");
+ await checkTabList(browser, [tabURL1, tabURL2, tabURL3, tabURL4]);
+ });
+
+ await cleanup(win2);
+});
+
+add_task(async function test_windows_activation() {
+ const win1 = window;
+ await prepareOpenTabs([tabURL1], win1);
+ let fxViewTab;
+ let tabChangeRaised;
+ info("switch to firefox-view and leave it selected");
+ await openFirefoxViewTab(win1).then(tab => (fxViewTab = tab));
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL2], win2);
+
+ const win3 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3], win3);
+ await tabChangeRaised;
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+
+ const browser = fxViewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL3, tabURL2, tabURL1]);
+
+ info("switch to win2 and confirm its selected tab becomes most recent");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win2);
+ await tabChangeRaised;
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL1]);
+ await cleanup(win2, win3);
+});
+
+add_task(async function test_minimize_restore_windows() {
+ const win1 = window;
+ let tabChangeRaised;
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3, tabURL4], win2);
+
+ // to avoid confusing the results by activating different windows,
+ // check fxview in the current window - which is win2
+ info("Opening fxview in win2 to confirm tab4 is most recent");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ info("Switching to the first tab (tab3) in 2nd window");
+ await BrowserTestUtils.switchTab(
+ win2.gBrowser,
+ win2.gBrowser.visibleTabs[0]
+ );
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // then minimize the window, focusing the 1st window
+ info("Minimizing win2, leaving tab 3 selected");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await minimizeWindow(win2);
+ info("Focusing win1, where tab2 is selected - making it most recent");
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+
+ Assert.equal(
+ tabUrl(win1.gBrowser.selectedTab),
+ tabURL2,
+ `The selected tab in window 1 is ${tabURL2}`
+ );
+
+ info("Opening fxview in win1 to confirm tab2 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]);
+ info(
+ "Restoring win2 and focusing it - which should make its selected tab most recent"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await restoreWindow(win2);
+ await SimpleTest.promiseFocus(win2);
+ await tabChangeRaised;
+
+ info(
+ "Checking tab order in fxview in win1, to confirm tab3 is most recent"
+ );
+ await checkTabList(browser, [tabURL3, tabURL2, tabURL4, tabURL1]);
+ });
+
+ await cleanup(win2);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js
new file mode 100644
index 0000000000..1375052125
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js
@@ -0,0 +1,207 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+let pageWithAlert =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/tabPrompts/openPromptOffTimeout.html";
+let pageWithSound =
+ "http://mochi.test:8888/browser/dom/base/test/file_audioLoop.html";
+
+function cleanup() {
+ // Cleanup
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+}
+
+add_task(async function test_notification_dot_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ await navigateToCategoryAndWait(document, "opentabs");
+ // load page that opens prompt when page is hidden
+ let openedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithAlert,
+ true
+ );
+ let openedTabGotAttentionPromise = BrowserTestUtils.waitForAttribute(
+ "attention",
+ openedTab
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ await switchToFxViewTab();
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await openedTabGotAttentionPromise;
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls[1].attention,
+ "The opened tab doesn't have the attention property, so no notification dot is shown."
+ );
+
+ info("The newly opened tab has a notification dot.");
+
+ // Switch back to other tab to close prompt before cleanup
+ await BrowserTestUtils.switchTab(gBrowser, openedTab);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+
+ cleanup();
+ });
+});
+
+add_task(async function test_container_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+
+ // Load a page in a container tab
+ let userContextId = 1;
+ let containerTab = BrowserTestUtils.addTab(win.gBrowser, URLs[0], {
+ userContextId,
+ });
+
+ await BrowserTestUtils.browserLoaded(
+ containerTab.linkedBrowser,
+ false,
+ URLs[0]
+ );
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await TestUtils.waitForCondition(
+ () =>
+ containerTab.getAttribute("usercontextid") === userContextId.toString(),
+ "The container tab doesn't have the usercontextid attribute."
+ );
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList?.rowEls.length,
+ "The tab list hasn't rendered."
+ );
+ info("openTabs component has finished updating.");
+
+ let containerTabElem = openTabs.viewCards[0].tabList.rowEls[1];
+
+ await TestUtils.waitForCondition(
+ () => containerTabElem.containerObj,
+ "The container tab element isn't marked in Fx View."
+ );
+
+ ok(
+ containerTabElem.shadowRoot
+ .querySelector(".fxview-tab-row-container-indicator")
+ .classList.contains("identity-color-blue"),
+ "The container color is blue."
+ );
+
+ info("The newly opened tab is marked as a container tab.");
+
+ cleanup();
+ });
+});
+
+add_task(async function test_sound_playing_muted_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ // Load a page in a container tab
+ let soundTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithSound,
+ true
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ await switchToFxViewTab();
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await TestUtils.waitForCondition(() =>
+ soundTab.hasAttribute("soundplaying")
+ );
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList?.rowEls.length,
+ "The tab list hasn't rendered."
+ );
+
+ let soundPlayingTabElem = openTabs.viewCards[0].tabList.rowEls[1];
+
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the mute button showing."
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ // Mute the tab
+ EventUtils.synthesizeMouseAtCenter(
+ soundPlayingTabElem.mediaButtonEl,
+ {},
+ content
+ );
+
+ await TestUtils.waitForCondition(
+ () => soundTab.hasAttribute("muted"),
+ "The tab doesn't have the muted attribute."
+ );
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.muted);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the unmute button showing."
+ );
+
+ // Mute and unmute the tab and make sure the element in Fx View updates
+ soundTab.toggleMuteAudio();
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the mute button showing."
+ );
+
+ soundTab.toggleMuteAudio();
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.muted);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the unmute button showing."
+ );
+
+ cleanup();
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js
new file mode 100644
index 0000000000..313d86416e
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js
@@ -0,0 +1,600 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+requestLongerTimeout(2);
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const SEARCH_ENABLED_PREF = "browser.firefox-view.search.enabled";
+const RECENTLY_CLOSED_EVENT = [
+ ["firefoxview_next", "recently_closed", "tabs", undefined],
+];
+const DISMISS_CLOSED_TAB_EVENT = [
+ ["firefoxview_next", "dismiss_closed_tab", "tabs", undefined],
+];
+const initialTab = gBrowser.selectedTab;
+
+async function restore_tab(itemElem, browser, expectedURL) {
+ info(`Restoring tab ${itemElem.url}`);
+ let tabRestored = BrowserTestUtils.waitForNewTab(
+ browser.getTabBrowser(),
+ expectedURL
+ );
+ await click_recently_closed_tab_item(itemElem, "main");
+ await tabRestored;
+}
+
+async function dismiss_tab(itemElem) {
+ info(`Dismissing tab ${itemElem.url}`);
+ return click_recently_closed_tab_item(itemElem, "dismiss");
+}
+
+async function tabTestCleanup() {
+ await promiseAllButPrimaryWindowClosed();
+ for (let tab of gBrowser.visibleTabs) {
+ if (tab == initialTab) {
+ continue;
+ }
+ await TabStateFlusher.flush(tab.linkedBrowser);
+ let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ BrowserTestUtils.removeTab(tab);
+ await sessionUpdatePromise;
+ }
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+}
+
+async function prepareSingleClosedTab() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ await open_then_close(URLs[0]);
+ return {
+ cleanup: tabTestCleanup,
+ };
+}
+
+async function prepareClosedTabs() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ is(
+ SessionStore.getClosedTabCountFromClosedWindows(),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+
+ // create 1 recently-closed tabs in a 2nd window
+ info("Opening win2 and open/closing tabs in it");
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // open a non-transitory, worth-keeping tab to ensure window data is saved on close
+ await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, "about:mozilla");
+ await open_then_close(URLs[2], win2);
+
+ info("Opening win3 and open/closing a tab in it");
+ const win3 = await BrowserTestUtils.openNewBrowserWindow();
+ // open a non-transitory, worth-keeping tab to ensure window data is saved on close
+ await BrowserTestUtils.openNewForegroundTab(win3.gBrowser, "about:mozilla");
+ await open_then_close(URLs[3], win3);
+
+ // close the 3rd window with its 1 recently-closed tab
+ info("closing win3 and waiting for sessionstore-closed-objects-changed");
+ await BrowserTestUtils.closeWindow(win3);
+
+ // refocus and bring the initial window to the foreground
+ await SimpleTest.promiseFocus(window);
+
+ // this is the order we expect for all the recently-closed tabs
+ const expectedURLs = [
+ "https://example.org/", // URLS[3]
+ "https://example.net/", // URLS[2]
+ "https://www.example.com/", // URLS[1]
+ "http://mochi.test:8888/browser/", // URLS[0]
+ ];
+ const preparedClosedTabCount = expectedURLs.length;
+
+ const closedTabsFromClosedWindowsCount =
+ SessionStore.getClosedTabCountFromClosedWindows();
+ is(
+ closedTabsFromClosedWindowsCount,
+ 1,
+ "Expected 1 closed tab from a closed window"
+ );
+
+ const closedTabsFromOpenWindowsCount = SessionStore.getClosedTabCount({
+ sourceWindow: window,
+ closedTabsFromClosedWindows: false,
+ });
+ const actualClosedTabCount = SessionStore.getClosedTabCount();
+ is(
+ closedTabsFromOpenWindowsCount,
+ 3,
+ "Expected 3 closed tabs currently-open windows"
+ );
+
+ is(
+ actualClosedTabCount,
+ preparedClosedTabCount,
+ `SessionStore reported the expected number (${actualClosedTabCount}) of closed tabs`
+ );
+
+ return {
+ cleanup: tabTestCleanup,
+ // return a list of the tab urls we closed in the order we closed them
+ closedTabURLs: [...URLs.slice(0, 4)],
+ expectedURLs,
+ };
+}
+
+async function recentlyClosedTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for recently_closed firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ RECENTLY_CLOSED_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function recentlyClosedDismissTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for dismiss_closed_tab firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ DISMISS_CLOSED_TAB_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SEARCH_ENABLED_PREF, true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+/**
+ * Asserts that we get the expected initial recently-closed tab list item
+ */
+add_task(async function test_initial_closed_tab() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, getFirefoxViewURL());
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ let { cleanup } = await prepareSingleClosedTab();
+ await switchToFxViewTab(window);
+ let [listItems] = await waitForRecentlyClosedTabsList(document);
+
+ Assert.strictEqual(
+ listItems.rowEls.length,
+ 1,
+ "Initial list item is rendered."
+ );
+
+ await cleanup();
+ });
+});
+
+/**
+ * Asserts that we get the expected order recently-closed tab list items given a known
+ * sequence of tab closures
+ */
+add_task(async function test_list_ordering() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+ navigateToCategory(document, "recentlyclosed");
+ let [cardMainSlotNode, listItems] = await waitForRecentlyClosedTabsList(
+ document
+ );
+
+ is(
+ cardMainSlotNode.tagName.toLowerCase(),
+ "fxview-tab-list",
+ "The tab list component is rendered."
+ );
+
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+/**
+ * Asserts that an out-of-band update to recently-closed tabs results in the correct update to the tab list
+ */
+add_task(async function test_list_updates() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+
+ // the first tab we opened and closed is the last in the list
+ let closedTabItem = listItems[listItems.length - 1];
+ is(
+ closedTabItem.url,
+ "http://mochi.test:8888/browser/",
+ "Sanity-check the least-recently closed tab is https://example.org/"
+ );
+ info(
+ `Restore the last (least-recently) closed tab ${closedTabItem.url}, closedId: ${closedTabItem.closedId} and wait for sessionstore-closed-objects-changed`
+ );
+ let promiseClosedObjectsChanged = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ SessionStore.undoCloseById(closedTabItem.closedId);
+ await promiseClosedObjectsChanged;
+ await clickFirefoxViewButton(window);
+
+ // we expect the last item to be removed
+ expectedURLs.pop();
+ listItems = listElem.rowEls;
+
+ is(
+ listItems.length,
+ 3,
+ `Three tabs are shown in the list: ${Array.from(listItems).map(
+ el => el.url
+ )}, of ${expectedURLs.length} expectedURLs: ${expectedURLs}`
+ );
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The updated list has rendered the expected tab items in the right order"
+ );
+
+ // forget the window the most-recently closed tab was in and verify the list is correctly updated
+ closedTabItem = listItems[0];
+ promiseClosedObjectsChanged = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ SessionStore.forgetClosedWindowById(closedTabItem.sourceClosedId);
+ await promiseClosedObjectsChanged;
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ expectedURLs.shift(); // we expect to have removed the firsts URL from the list
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After forgetting the closed window that owned the last recent tab, we have expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+/**
+ * Asserts that tabs that have been recently closed can be
+ * restored by clicking on the list item
+ */
+add_task(async function test_restore_tab() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+ let closeTabItem = listItems[0];
+ info(
+ `Restoring the first closed tab ${closeTabItem.url}, closedId: ${closeTabItem.closedId}, sourceClosedId: ${closeTabItem.sourceClosedId} and waiting for sessionstore-closed-objects-changed`
+ );
+ await clearAllParentTelemetryEvents();
+ await restore_tab(closeTabItem, browser, closeTabItem.url);
+ await recentlyClosedTelemetry();
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 3, "Three tabs are shown in the list.");
+
+ closeTabItem = listItems[listItems.length - 1];
+ await clearAllParentTelemetryEvents();
+ await restore_tab(closeTabItem, browser, closeTabItem.url);
+ await recentlyClosedTelemetry();
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+ });
+ await cleanup();
+});
+
+/**
+ * Asserts that tabs that have been recently closed can be
+ * dismissed by clicking on their respective dismiss buttons.
+ */
+add_task(async function test_dismiss_tab() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ await clearAllParentTelemetryEvents();
+
+ info("calling dismiss_tab on the top, most-recently closed tab");
+ let closedTabItem = listItems[0];
+
+ // dismiss the first tab and verify the list is correctly updated
+ await dismiss_tab(closedTabItem);
+ await listElem.getUpdateComplete;
+
+ info("check telemetry results");
+ await recentlyClosedDismissTelemetry();
+
+ listItems = listElem.rowEls;
+ expectedURLs.shift(); // we expect to have removed the first URL from the list
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After dismissing the most-recent tab we have expected tab items in the right order"
+ );
+
+ // dismiss the last tab and verify the list is correctly updated
+ closedTabItem = listItems[listItems.length - 1];
+ await dismiss_tab(closedTabItem);
+ await listElem.getUpdateComplete;
+
+ listItems = listElem.rowEls;
+ expectedURLs.pop(); // we expect to have removed the last URL from the list
+ let actualClosedTabCount =
+ SessionStore.getClosedTabCount(window) +
+ SessionStore.getClosedTabCountFromClosedWindows();
+ Assert.equal(
+ actualClosedTabCount,
+ 2,
+ "After dismissing the least-recent tab, SessionStore has 2 left"
+ );
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After dismissing the least-recent tab we have expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function test_empty_states() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ is(
+ SessionStore.getClosedTabCountFromClosedWindows(),
+ 0,
+ "Closed tabs-from-closed-windows count after purging session history"
+ );
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, "about:firefoxview");
+
+ navigateToCategory(document, "recentlyclosed");
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+
+ await TestUtils.waitForCondition(() => recentlyClosedComponent.emptyState);
+ let emptyStateCard = recentlyClosedComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Closed a tab too soon"),
+ "Initial empty state header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[0].textContent.includes(
+ "Here you’ll find the tabs you recently closed"
+ ),
+ "Initial empty state description has the expected text."
+ );
+
+ // Test empty state when History mode is set to never remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true);
+ // Manually update the recentlyclosed component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ recentlyClosedComponent.requestUpdate();
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ emptyStateCard = recentlyClosedComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Nothing to show"),
+ "Empty state with never remember history header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[1].textContent.includes(
+ "remember your activity as you browse. To change that"
+ ),
+ "Empty state with never remember history description has the expected text."
+ );
+ // Reset History mode to Remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false);
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_observers_removed_when_view_is_hidden() {
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+ const [listElem] = await waitForRecentlyClosedTabsList(document);
+ is(listElem.rowEls.length, 1);
+
+ const gBrowser = browser.getTabBrowser();
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]);
+ await open_then_close(URLs[2]);
+ await open_then_close(URLs[3]);
+ await open_then_close(URLs[4]);
+ is(
+ listElem.rowEls.length,
+ 1,
+ "The list does not update when Firefox View is hidden."
+ );
+
+ await switchToFxViewTab(browser.ownerGlobal);
+ info("The list should update when Firefox View is visible.");
+ await BrowserTestUtils.waitForMutationCondition(
+ listElem,
+ { childList: true },
+ () => listElem.rowEls.length === 4
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+add_task(async function test_search() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+ const [listElem] = await waitForRecentlyClosedTabsList(document);
+ const recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+ const { searchTextbox, tabList } = recentlyClosedComponent;
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === 1,
+ "There is one matching search result."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === expectedURLs.length,
+ "The original list is restored."
+ );
+ searchTextbox.blur();
+
+ info("Input a bogus search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(
+ () => tabList.shadowRoot.querySelector("fxview-empty-state"),
+ "There are no matching search results."
+ );
+
+ info("Clear the search query with keyboard.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+
+ is(
+ recentlyClosedComponent.shadowRoot.activeElement,
+ searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === expectedURLs.length,
+ "The original list is restored."
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function test_search_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ clearHistory();
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[1]);
+ }
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+
+ info("Input a search query.");
+ await navigateToCategoryAndWait(document, "recentbrowsing");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ const slot = recentBrowsing.querySelector("[slot='recentlyclosed']");
+ await TestUtils.waitForCondition(
+ () =>
+ slot.tabList.rowEls.length === 5 &&
+ slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']"),
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = slot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ is(showAllLink.role, "link", "The show all control is a link.");
+ EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content);
+ await TestUtils.waitForCondition(
+ () => slot.tabList.rowEls.length === NUMBER_OF_TABS,
+ "All search results are shown."
+ );
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js
new file mode 100644
index 0000000000..f9a226bbf2
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ Ensures that the Firefox View tab can be reloaded via:
+ - Clicking the Refresh button in the toolbar
+ - Using the various keyboard shortcuts
+*/
+add_task(async function test_reload_firefoxview() {
+ await withFirefoxView({}, async browser => {
+ let reloadButton = document.getElementById("reload-button");
+ let tabLoaded = BrowserTestUtils.browserLoaded(browser);
+ EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal);
+ await tabLoaded;
+ ok(true, "Firefox View loaded after clicking the Reload button");
+
+ let keys = [
+ ["R", { accelKey: true }],
+ ["R", { accelKey: true, shift: true }],
+ ["VK_F5", {}],
+ ];
+
+ if (AppConstants.platform != "macosx") {
+ keys.push(["VK_F5", { accelKey: true }]);
+ }
+
+ for (let key of keys) {
+ tabLoaded = BrowserTestUtils.browserLoaded(browser);
+ EventUtils.synthesizeKey(key[0], key[1], browser.ownerGlobal);
+ await tabLoaded;
+ ok(true, `Firefox view loaded after using ${key}`);
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js
new file mode 100644
index 0000000000..15dba68551
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+
+async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) {
+ const sandbox = setupSyncFxAMocks({
+ state,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+ return sandbox;
+}
+
+async function tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+ Services.prefs.clearUserPref("identity.fxaccounts.enabled");
+}
+
+add_setup(async function () {
+ // gSync.init() is called in a requestIdleCallback. Force its initialization.
+ gSync.init();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["services.sync.engine.tabs", true],
+ ["identity.fxaccounts.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async function () {
+ // reset internal state so it doesn't affect the next tests
+ TabsSetupFlowManager.resetInternalState();
+ await tearDown(gSandbox);
+ });
+});
+
+add_task(async function test_network_offline() {
+ const sandbox = await setupWithDesktopDevices();
+ sandbox.spy(TabsSetupFlowManager, "tryToClearError");
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "offline"
+ );
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsComponent.shadowRoot.querySelector(".cards-container"),
+ { childList: true },
+ () => syncedTabsComponent.shadowRoot.innerHTML.includes("network-offline")
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("network-offline"),
+ "Network offline message is shown"
+ );
+ emptyState.querySelector("button[data-action='network-offline']").click();
+
+ await BrowserTestUtils.waitForCondition(
+ () => TabsSetupFlowManager.tryToClearError.calledOnce
+ );
+
+ ok(
+ TabsSetupFlowManager.tryToClearError.calledOnce,
+ "TabsSetupFlowManager.tryToClearError() was called once"
+ );
+
+ emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("network-offline"),
+ "Network offline message is still shown"
+ );
+
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "online"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sync_error() {
+ const sandbox = await setupWithDesktopDevices();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsComponent.shadowRoot.querySelector(".cards-container"),
+ { childList: true },
+ () => syncedTabsComponent.shadowRoot.innerHTML.includes("sync-error")
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("sync-error"),
+ "Correct message should show when there's a sync service error"
+ );
+
+ // Clear the error.
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ });
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js
new file mode 100644
index 0000000000..8a3c63985b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js
@@ -0,0 +1,747 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ // reset internal state so it doesn't affect the next tests
+ TabsSetupFlowManager.resetInternalState();
+ });
+
+ // gSync.init() is called in a requestIdleCallback. Force its initialization.
+ gSync.init();
+
+ registerCleanupFunction(async function () {
+ await tearDown(gSandbox);
+ });
+});
+
+async function promiseTabListsUpdated({ tabLists }) {
+ for (const tabList of tabLists) {
+ await tabList.updateComplete;
+ }
+ await TestUtils.waitForTick();
+}
+
+add_task(async function test_unconfigured_initial_state() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: false,
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-signin"),
+ "Signin message is shown"
+ );
+
+ // Test telemetry for signing into Firefox Accounts.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(
+ emptyState.querySelector(`button[data-action="sign-in"]`),
+ {},
+ browser.contentWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for fxa_continue firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [["firefoxview_next", "fxa_continue", "sync"]],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_signed_in() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-adddevice"),
+ "Add device message is shown"
+ );
+
+ // Test telemetry for adding a device.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(
+ emptyState.querySelector(`button[data-action="add-device"]`),
+ {},
+ browser.contentWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for fxa_mobile firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [["firefoxview_next", "fxa_mobile", "sync"]],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_no_synced_tabs() {
+ Services.prefs.setBoolPref("services.sync.engine.tabs", false);
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-synctabs"),
+ "Enable synced tabs message is shown"
+ );
+ });
+ await tearDown(sandbox);
+ Services.prefs.setBoolPref("services.sync.engine.tabs", true);
+});
+
+add_task(async function test_no_error_for_two_desktop() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ is(emptyState, null, "No empty state should be shown");
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 1, "Should be 1 empty device");
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_state() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Desktop",
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 3,
+ name: "Other Mobile",
+ type: "phone",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 2, "Should be 2 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("Other Desktop"),
+ "Text is correct (Desktop)"
+ );
+ ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop");
+ ok(
+ headers[1].textContent.includes("Other Mobile"),
+ "Text is correct (Mobile)"
+ );
+ ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone");
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_tabs() {
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData1);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData1);
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("My desktop"),
+ "Text is correct (My desktop)"
+ );
+ ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop");
+ ok(
+ headers[1].textContent.includes("My iphone"),
+ "Text is correct (My iphone)"
+ );
+ ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone");
+
+ let tabLists = syncedTabsComponent.tabLists;
+ await TestUtils.waitForCondition(() => {
+ return tabLists[0].rowEls.length;
+ });
+ let tabRow1 = tabLists[0].rowEls;
+ ok(
+ tabRow1[0].shadowRoot.textContent.includes,
+ "Internet for people, not profits - Mozilla"
+ );
+ ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS");
+ is(tabRow1.length, 2, "Correct number of rows are displayed.");
+ let tabRow2 = tabLists[1].shadowRoot.querySelectorAll("fxview-tab-row");
+ is(tabRow2.length, 2, "Correct number of rows are dispayed.");
+ ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian");
+ ok(tabRow1[1].shadowRoot.textContent.includes, "The Times");
+
+ // Test telemetry for opening a tab.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(tabRow1[0], {}, browser.contentWindow);
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for synced_tabs firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [
+ [
+ "firefoxview_next",
+ "synced_tabs",
+ "tabs",
+ null,
+ { page: "syncedtabs" },
+ ],
+ ],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_desktop_same_name() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "A Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 1, "Should be 1 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_desktop_same_name_three() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "A Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 3,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 2, "Should be 2 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ ok(
+ headers[1].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function search_synced_tabs() {
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData1);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData1);
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+
+ is(syncedTabsComponent.cardEls.length, 2, "There are two device cards.");
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ let deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ let deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabsComponent.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first card."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === 1,
+ "There is one matching search result for the first device."
+ );
+ await TestUtils.waitForCondition(
+ () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"),
+ "There are no matching search results for the second device."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabsComponent.searchTextbox.clearButton,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === deviceOneTabs.length,
+ "The original device's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length === deviceTwoTabs.length,
+ "The new devices's list is restored."
+ );
+ syncedTabsComponent.searchTextbox.blur();
+
+ info("Input a search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first card."
+ );
+ await TestUtils.waitForCondition(() => {
+ return (
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === 1
+ );
+ }, "There is one matching search result for the first device.");
+ await TestUtils.waitForCondition(
+ () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"),
+ "There are no matching search results for the second device."
+ );
+
+ info("Clear the search query with keyboard.");
+ is(
+ syncedTabsComponent.shadowRoot.activeElement,
+ syncedTabsComponent.searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ syncedTabsComponent.searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === deviceOneTabs.length,
+ "The original device's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length === deviceTwoTabs.length,
+ "The new devices's list is restored."
+ );
+ });
+ await SpecialPowers.popPrefEnv();
+ await tearDown(sandbox);
+});
+
+add_task(async function search_synced_tabs_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ TabsSetupFlowManager.resetInternalState();
+ const sandbox = setupRecentDeviceListMocks();
+ const tabClients = [
+ {
+ id: 1,
+ type: "client",
+ name: "My desktop",
+ clientType: "desktop",
+ tabs: Array(NUMBER_OF_TABS).fill({
+ type: "tab",
+ title: "Internet for people, not profits - Mozilla",
+ url: "https://www.mozilla.org/",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ client: 1,
+ }),
+ },
+ {
+ id: 2,
+ type: "client",
+ name: "My iphone",
+ clientType: "phone",
+ tabs: [
+ {
+ type: "tab",
+ title: "Mount Everest - Wikipedia",
+ url: "https://en.wikipedia.org/wiki/Mount_Everest",
+ icon: "https://www.wikipedia.org/static/favicon/wikipedia.ico",
+ client: 2,
+ },
+ ],
+ },
+ ];
+ sandbox
+ .stub(SyncedTabs, "getRecentTabs")
+ .resolves(getMockTabData(tabClients));
+ sandbox.stub(SyncedTabs, "getTabClients").resolves(tabClients);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "recentbrowsing");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ const slot = recentBrowsing.querySelector("[slot='syncedtabs']");
+
+ // Test that all tab lists repopulate when clearing out searched terms (Bug 1869895 & Bug 1873212)
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 1,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ info("Scroll first card into view.");
+ slot.tabLists[0].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === 5,
+ "The first card is populated."
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 });
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 2,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ info("Scroll second card into view.");
+ slot.tabLists[1].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () =>
+ slot.tabLists[0].rowEls.length === 5 &&
+ slot.tabLists[1].rowEls.length === 1,
+ "Both cards are populated."
+ );
+ info("Clear the search query.");
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 });
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 2,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === 5,
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = await TestUtils.waitForCondition(() =>
+ slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']")
+ );
+ is(showAllLink.role, "link", "The show all control is a link.");
+ EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content);
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === NUMBER_OF_TABS,
+ "All search results are shown."
+ );
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+ await SpecialPowers.popPrefEnv();
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js
new file mode 100644
index 0000000000..e7aed1c429
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const URL = "https://example.com/";
+
+add_task(async function closing_last_tab_should_not_switch_to_fx_view() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.closeWindowWithLastTab", false]],
+ });
+ info("Opening window...");
+ const win = await BrowserTestUtils.openNewBrowserWindow({
+ waitForTabURL: "about:newtab",
+ });
+ const firstTab = win.gBrowser.selectedTab;
+ info("Opening Firefox View tab...");
+ await openFirefoxViewTab(win);
+ info("Switch back to new tab...");
+ await BrowserTestUtils.switchTab(win.gBrowser, firstTab);
+ info("Load web page in new tab...");
+ const loaded = BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ URL
+ );
+ BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, URL);
+ await loaded;
+ info("Opening new browser tab...");
+ const secondTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ URL
+ );
+ info("Close all browser tabs...");
+ await BrowserTestUtils.removeTab(firstTab);
+ await BrowserTestUtils.removeTab(secondTab);
+ isnot(
+ win.gBrowser.selectedTab,
+ win.FirefoxViewHandler.tab,
+ "The selected tab should not be the Firefox View tab"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js
new file mode 100644
index 0000000000..9980980c29
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+class DialogObserver {
+ constructor() {
+ this.wasOpened = false;
+ Services.obs.addObserver(this, "common-dialog-loaded");
+ }
+ cleanup() {
+ Services.obs.removeObserver(this, "common-dialog-loaded");
+ }
+ observe(win, topic) {
+ if (topic == "common-dialog-loaded") {
+ this.wasOpened = true;
+ // Close dialog.
+ win.document.querySelector("dialog").getButton("cancel").click();
+ }
+ }
+}
+
+add_task(
+ async function on_close_warning_should_not_show_for_firefox_view_tab() {
+ const dialogObserver = new DialogObserver();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.warnOnClose", true]],
+ });
+ info("Opening window...");
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ info("Opening Firefox View tab...");
+ await openFirefoxViewTab(win);
+ info("Trigger warnAboutClosingWindow()");
+ win.BrowserTryToCloseWindow();
+ await BrowserTestUtils.closeWindow(win);
+ ok(!dialogObserver.wasOpened, "Dialog was not opened");
+ dialogObserver.cleanup();
+ }
+);
+
+add_task(
+ async function on_close_warning_should_not_show_for_firefox_view_tab_non_macos() {
+ let initialTab = gBrowser.selectedTab;
+ const dialogObserver = new DialogObserver();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.warnOnClose", true],
+ ["browser.warnOnQuit", true],
+ ],
+ });
+ info("Opening Firefox View tab...");
+ await openFirefoxViewTab(window);
+ info('Trigger "quit-application-requested"');
+ canQuitApplication("lastwindow", "close-button");
+ ok(!dialogObserver.wasOpened, "Dialog was not opened");
+ await BrowserTestUtils.switchTab(gBrowser, initialTab);
+ closeFirefoxViewTab(window);
+ dialogObserver.cleanup();
+ }
+);
diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js
new file mode 100644
index 0000000000..b0b41b759d
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/head.js
@@ -0,0 +1,708 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {
+ getFirefoxViewURL,
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+ init: FirefoxViewTestUtilsInit,
+} = ChromeUtils.importESModule(
+ "resource://testing-common/FirefoxViewTestUtils.sys.mjs"
+);
+
+/* exported testVisibility */
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { FeatureCalloutMessages } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+const { SessionStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SessionStoreTestUtils.sys.mjs"
+);
+SessionStoreTestUtils.init(this, window);
+FirefoxViewTestUtilsInit(this, window);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+ TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
+});
+
+const calloutId = "feature-callout";
+const calloutSelector = `#${calloutId}.featureCallout`;
+const CTASelector = `#${calloutId} :is(.primary, .secondary)`;
+
+/**
+ * URLs used for browser_recently_closed_tabs_keyboard and
+ * browser_firefoxview_accessibility
+ */
+const URLs = [
+ "http://mochi.test:8888/browser/",
+ "https://www.example.com/",
+ "https://example.net/",
+ "https://example.org/",
+ "about:robots",
+ "https://www.mozilla.org/",
+];
+
+const syncedTabsData1 = [
+ {
+ id: 1,
+ type: "client",
+ name: "My desktop",
+ clientType: "desktop",
+ lastModified: 1655730486760,
+ tabs: [
+ {
+ type: "tab",
+ title: "Sandboxes - Sinon.JS",
+ url: "https://sinonjs.org/releases/latest/sandbox/",
+ icon: "https://sinonjs.org/assets/images/favicon.png",
+ lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000
+ client: 1,
+ },
+ {
+ type: "tab",
+ title: "Internet for people, not profits - Mozilla",
+ url: "https://www.mozilla.org/",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000
+ client: 1,
+ },
+ ],
+ },
+ {
+ id: 2,
+ type: "client",
+ name: "My iphone",
+ clientType: "phone",
+ lastModified: 1655727832930,
+ tabs: [
+ {
+ type: "tab",
+ title: "The Guardian",
+ url: "https://www.theguardian.com/",
+ icon: "page-icon:https://www.theguardian.com/",
+ lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000
+ client: 2,
+ },
+ {
+ type: "tab",
+ title: "The Times",
+ url: "https://www.thetimes.co.uk/",
+ icon: "page-icon:https://www.thetimes.co.uk/",
+ lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000
+ client: 2,
+ },
+ ],
+ },
+];
+
+async function clearAllParentTelemetryEvents() {
+ // Clear everything.
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ return !events || !events.length;
+ });
+}
+
+function testVisibility(browser, expected) {
+ const { document } = browser.contentWindow;
+ for (let [selector, shouldBeVisible] of Object.entries(
+ expected.expectedVisible
+ )) {
+ const elem = document.querySelector(selector);
+ if (shouldBeVisible) {
+ ok(
+ BrowserTestUtils.isVisible(elem),
+ `Expected ${selector} to be visible`
+ );
+ } else {
+ ok(BrowserTestUtils.isHidden(elem), `Expected ${selector} to be hidden`);
+ }
+ }
+}
+
+async function waitForElementVisible(browser, selector, isVisible = true) {
+ const { document } = browser.contentWindow;
+ const elem = document.querySelector(selector);
+ if (!isVisible && !elem) {
+ return;
+ }
+ ok(elem, `Got element with selector: ${selector}`);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ elem,
+ {
+ attributeFilter: ["hidden"],
+ },
+ () => {
+ return isVisible
+ ? BrowserTestUtils.isVisible(elem)
+ : BrowserTestUtils.isHidden(elem);
+ }
+ );
+}
+
+async function waitForVisibleSetupStep(browser, expected) {
+ const { document } = browser.contentWindow;
+
+ const deck = document.querySelector(".sync-setup-container");
+ const nextStepElem = deck.querySelector(expected.expectedVisible);
+ const stepElems = deck.querySelectorAll(".setup-step");
+
+ await BrowserTestUtils.waitForMutationCondition(
+ deck,
+ {
+ attributeFilter: ["selected-view"],
+ },
+ () => {
+ return BrowserTestUtils.isVisible(nextStepElem);
+ }
+ );
+
+ for (let elem of stepElems) {
+ if (elem == nextStepElem) {
+ ok(
+ BrowserTestUtils.isVisible(elem),
+ `Expected ${elem.id || elem.className} to be visible`
+ );
+ } else {
+ ok(
+ BrowserTestUtils.isHidden(elem),
+ `Expected ${elem.id || elem.className} to be hidden`
+ );
+ }
+ }
+}
+
+var gMockFxaDevices = null;
+var gUIStateStatus;
+var gSandbox;
+function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) {
+ gUIStateStatus = state || UIState.STATUS_SIGNED_IN;
+ if (gSandbox) {
+ gSandbox.restore();
+ }
+ const sandbox = (gSandbox = sinon.createSandbox());
+ gMockFxaDevices = fxaDevices;
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
+ sandbox.stub(UIState, "get").callsFake(() => {
+ return {
+ status: gUIStateStatus,
+ syncEnabled,
+ email:
+ gUIStateStatus === UIState.STATUS_NOT_CONFIGURED
+ ? undefined
+ : "email@example.com",
+ };
+ });
+
+ return sandbox;
+}
+
+function setupRecentDeviceListMocks() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [
+ {
+ id: 1,
+ name: "My desktop",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "My iphone",
+ type: "mobile",
+ },
+ ]);
+
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ return sandbox;
+}
+
+function getMockTabData(clients) {
+ return SyncedTabs._internal._createRecentTabsList(clients, 10);
+}
+
+async function setupListState(browser) {
+ // Skip the synced tabs sign up flow to get to a loaded list state
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.tabs", true]],
+ });
+
+ UIState.refresh();
+ const recentFetchTime = Math.floor(Date.now() / 1000);
+ info("updating lastFetch:" + recentFetchTime);
+ Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime);
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ await waitForElementVisible(browser, "#tabpickup-tabs-container", true);
+
+ const tabsContainer = browser.contentWindow.document.querySelector(
+ "#tabpickup-tabs-container"
+ );
+ await tabsContainer.tabListAdded;
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsContainer,
+ { attributeFilter: ["class"], attributes: true },
+ () => {
+ return !tabsContainer.classList.contains("loading");
+ }
+ );
+ info("tabsContainer isn't loading anymore, returning");
+}
+
+async function touchLastTabFetch() {
+ // lastTabFetch stores a timestamp in *seconds*.
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ info("updating lastFetch:" + nowSeconds);
+ Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds);
+ // wait so all pref observers can complete
+ await TestUtils.waitForTick();
+}
+
+let gUIStateSyncEnabled;
+function setupMocks({ fxaDevices = null, state, syncEnabled = true }) {
+ gUIStateStatus = state || UIState.STATUS_SIGNED_IN;
+ gUIStateSyncEnabled = syncEnabled;
+ if (gSandbox) {
+ gSandbox.restore();
+ }
+ const sandbox = (gSandbox = sinon.createSandbox());
+ gMockFxaDevices = fxaDevices;
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
+ sandbox.stub(UIState, "get").callsFake(() => {
+ return {
+ status: gUIStateStatus,
+ // Sometimes syncEnabled is not present on UIState, for example when the user signs
+ // out the state is just { status: "not_configured" }
+ ...(gUIStateSyncEnabled != undefined && {
+ syncEnabled: gUIStateSyncEnabled,
+ }),
+ };
+ });
+ // This is converting the device list to a client list.
+ // There are two primary differences:
+ // 1. The client list doesn't return the current device.
+ // 2. It uses clientType instead of type.
+ let tabClients = fxaDevices ? [...fxaDevices] : [];
+ for (let client of tabClients) {
+ client.clientType = client.type;
+ }
+ tabClients = tabClients.filter(device => !device.isCurrentDevice);
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(tabClients);
+ });
+ return sandbox;
+}
+
+async function tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+}
+
+const featureTourPref = "browser.firefox-view.feature-tour";
+const launchFeatureTourIn = win => {
+ const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCallout.sys.mjs"
+ );
+ let callout = new FeatureCallout({
+ win,
+ pref: { name: featureTourPref },
+ location: "about:firefoxview",
+ context: "content",
+ theme: { preset: "themed-content" },
+ });
+ callout.showFeatureCallout();
+ return callout;
+};
+
+/**
+ * Returns a value that can be used to set
+ * `browser.firefox-view.feature-tour` to change the feature tour's
+ * UI state.
+ *
+ * @see FeatureCalloutMessages.sys.mjs for valid values of "screen"
+ *
+ * @param {number} screen The full ID of the feature callout screen
+ * @returns {string} JSON string used to set
+ * `browser.firefox-view.feature-tour`
+ */
+const getPrefValueByScreen = screen => {
+ return JSON.stringify({
+ screen: `FEATURE_CALLOUT_${screen}`,
+ complete: false,
+ });
+};
+
+/**
+ * Wait for a feature callout screen of given parameters to be shown
+ *
+ * @param {Document} doc the document where the callout appears.
+ * @param {string} screenPostfix The full ID of the feature callout screen.
+ */
+const waitForCalloutScreen = async (doc, screenPostfix) => {
+ await BrowserTestUtils.waitForCondition(() =>
+ doc.querySelector(`${calloutSelector}:not(.hidden) .${screenPostfix}`)
+ );
+};
+
+/**
+ * Waits for the feature callout screen to be removed.
+ *
+ * @param {Document} doc The document where the callout appears.
+ */
+const waitForCalloutRemoved = async doc => {
+ await BrowserTestUtils.waitForCondition(() => {
+ return !doc.body.querySelector(calloutSelector);
+ });
+};
+
+/**
+ * NOTE: Should be replaced with synthesizeMouseAtCenter for
+ * simulating user input. See Bug 1798322
+ *
+ * Clicks the primary button in the feature callout dialog
+ *
+ * @param {document} doc Firefox View document
+ */
+const clickCTA = async doc => {
+ doc.querySelector(CTASelector).click();
+};
+
+/**
+ * Closes a feature callout via a click to the dismiss button.
+ *
+ * @param {Document} doc The document where the callout appears.
+ */
+const closeCallout = async doc => {
+ // close the callout dialog
+ const dismissBtn = doc.querySelector(`${calloutSelector} .dismiss-button`);
+ if (!dismissBtn) {
+ return;
+ }
+ doc.querySelector(`${calloutSelector} .dismiss-button`).click();
+ await BrowserTestUtils.waitForCondition(() => {
+ return !document.querySelector(calloutSelector);
+ });
+};
+
+/**
+ * Get a Feature Callout message by id.
+ *
+ * @param {string} id
+ * The message id.
+ */
+const getCalloutMessageById = id => {
+ return {
+ message: FeatureCalloutMessages.getMessages().find(m => m.id === id),
+ };
+};
+
+/**
+ * Create a sinon sandbox with `sendTriggerMessage` stubbed
+ * to return a specified test message for featureCalloutCheck.
+ *
+ * @param {object} testMessage
+ * @param {string} [source="about:firefoxview"]
+ */
+const createSandboxWithCalloutTriggerStub = (
+ testMessage,
+ source = "about:firefoxview"
+) => {
+ const firefoxViewMatch = sinon.match({
+ id: "featureCalloutCheck",
+ context: { source },
+ });
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(firefoxViewMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+ return sandbox;
+};
+
+/**
+ * A helper to check that correct telemetry was sent by AWSendEventTelemetry.
+ * This is a wrapper around sinon's spy functionality.
+ *
+ * @example
+ * let spy = new TelemetrySpy();
+ * element.click();
+ * spy.assertCalledWith({ event: "CLICK" });
+ * spy.restore();
+ */
+class TelemetrySpy {
+ /**
+ * @param {object} [sandbox] A pre-existing sinon sandbox to build the spy in.
+ * If not provided, a new sandbox will be created.
+ */
+ constructor(sandbox = sinon.createSandbox()) {
+ this.sandbox = sandbox;
+ this.spy = this.sandbox
+ .spy(AboutWelcomeParent.prototype, "onContentMessage")
+ .withArgs("AWPage:TELEMETRY_EVENT");
+ registerCleanupFunction(() => this.restore());
+ }
+ /**
+ * Assert that AWSendEventTelemetry sent the expected telemetry object.
+ *
+ * @param {object} expectedData
+ */
+ assertCalledWith(expectedData) {
+ let match = this.spy.calledWith("AWPage:TELEMETRY_EVENT", expectedData);
+ if (match) {
+ ok(true, "Expected telemetry sent");
+ } else if (this.spy.called) {
+ ok(
+ false,
+ "Wrong telemetry sent: " + JSON.stringify(this.spy.lastCall.args)
+ );
+ } else {
+ ok(false, "No telemetry sent");
+ }
+ }
+ reset() {
+ this.spy.resetHistory();
+ }
+ restore() {
+ this.sandbox.restore();
+ }
+}
+
+/**
+ * Helper function to open and close a tab so the recently
+ * closed tabs list can have data.
+ *
+ * @param {string} url
+ * @returns {Promise} Promise that resolves when the session store
+ * has been updated after closing the tab.
+ */
+async function open_then_close(url, win = window) {
+ return SessionStoreTestUtils.openAndCloseTab(win, url);
+}
+
+/**
+ * Clears session history. Used to clear out the recently closed tabs list.
+ *
+ */
+function clearHistory() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+}
+
+/**
+ * Cleanup function for tab pickup tests.
+ *
+ */
+function cleanup_tab_pickup() {
+ Services.prefs.clearUserPref("services.sync.engine.tabs");
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+}
+
+function isFirefoxViewTabSelected(win = window) {
+ return isFirefoxViewTabSelectedInWindow(win);
+}
+
+function promiseAllButPrimaryWindowClosed() {
+ let windows = [];
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ if (win != window) {
+ windows.push(win);
+ }
+ }
+ return Promise.all(windows.map(BrowserTestUtils.closeWindow));
+}
+
+registerCleanupFunction(() => {
+ // ensure all the stubs are restored, regardless of any exceptions
+ // that might have prevented it
+ gSandbox?.restore();
+});
+
+function navigateToCategory(document, category) {
+ const navigation = document.querySelector("fxview-category-navigation");
+ let navButton = Array.from(navigation.categoryButtons).filter(
+ categoryButton => {
+ return categoryButton.name === category;
+ }
+ )[0];
+ navButton.buttonEl.click();
+}
+
+async function navigateToCategoryAndWait(document, category) {
+ info(`navigateToCategoryAndWait, for ${category}`);
+ const navigation = document.querySelector("fxview-category-navigation");
+ const win = document.ownerGlobal;
+ SimpleTest.promiseFocus(win);
+ let navButton = Array.from(navigation.categoryButtons).find(
+ categoryButton => {
+ return categoryButton.name === category;
+ }
+ );
+ const namedDeck = document.querySelector("named-deck");
+
+ await BrowserTestUtils.waitForCondition(
+ () => navButton.getBoundingClientRect().height,
+ `Waiting for ${category} button to be clickable`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(navButton, {}, win);
+
+ await BrowserTestUtils.waitForCondition(() => {
+ let selectedView = Array.from(namedDeck.children).find(
+ child => child.slot == "selected"
+ );
+ return (
+ namedDeck.selectedViewName == category &&
+ selectedView?.getBoundingClientRect().height
+ );
+ }, `Waiting for ${category} to be visible`);
+}
+
+/**
+ * Switch to the Firefox View tab.
+ *
+ * @param {Window} [win]
+ * The window to use, if specified. Defaults to the global window instance.
+ * @returns {Promise<MozTabbrowserTab>}
+ * The tab switched to.
+ */
+async function switchToFxViewTab(win = window) {
+ return BrowserTestUtils.switchTab(win.gBrowser, win.FirefoxViewHandler.tab);
+}
+
+function isElInViewport(element) {
+ const boundingRect = element.getBoundingClientRect();
+ return (
+ boundingRect.top >= 0 &&
+ boundingRect.left >= 0 &&
+ boundingRect.bottom <=
+ (window.innerHeight || document.documentElement.clientHeight) &&
+ boundingRect.right <=
+ (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+// TODO once we port over old tests, helpers and cleanup old firefox view
+// we should decide whether to keep this or openFirefoxViewTab.
+async function clickFirefoxViewButton(win) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+}
+
+/**
+ * Wait for and assert telemetry events.
+ *
+ * @param {Array} eventDetails
+ * Nested array of event details
+ */
+async function telemetryEvent(eventDetails) {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for firefoxview_next telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ eventDetails,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+function setSortOption(component, value) {
+ info(`Sort by ${value}.`);
+ const el = component.optionsContainer.querySelector(
+ `input[value='${value}']`
+ );
+ EventUtils.synthesizeMouseAtCenter(el, {}, el.ownerGlobal);
+}
+
+function getOpenTabsCards(openTabs) {
+ return openTabs.shadowRoot.querySelectorAll("view-opentabs-card");
+}
+
+async function click_recently_closed_tab_item(itemElem, itemProperty = "") {
+ // Make sure the firefoxview tab still has focus
+ is(
+ itemElem.ownerDocument.location.href,
+ "about:firefoxview#recentlyclosed",
+ "about:firefoxview is the selected tab and showing the Recently closed view page"
+ );
+
+ // Scroll to the tab element to ensure dismiss button is visible
+ itemElem.scrollIntoView();
+ is(isElInViewport(itemElem), true, "Tab is visible in viewport");
+ let clickTarget;
+ switch (itemProperty) {
+ case "dismiss":
+ clickTarget = itemElem.buttonEl;
+ break;
+ default:
+ clickTarget = itemElem.mainEl;
+ break;
+ }
+
+ const closedObjectsChangePromise = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ EventUtils.synthesizeMouseAtCenter(clickTarget, {}, itemElem.ownerGlobal);
+ await closedObjectsChangePromise;
+}
+
+async function waitForRecentlyClosedTabsList(doc) {
+ let recentlyClosedComponent = doc.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+ // Check that the tabs list is rendered
+ await TestUtils.waitForCondition(() => {
+ return recentlyClosedComponent.cardEl;
+ });
+ let cardContainer = recentlyClosedComponent.cardEl;
+ let cardMainSlotNode = Array.from(
+ cardContainer?.mainSlot?.assignedNodes()
+ )[0];
+ await TestUtils.waitForCondition(() => {
+ return cardMainSlotNode.rowEls.length;
+ });
+ return [cardMainSlotNode, cardMainSlotNode.rowEls];
+}