summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/firefoxview/tests
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/firefoxview/tests')
-rw-r--r--browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs122
-rw-r--r--browser/components/firefoxview/tests/browser/browser.ini37
-rw-r--r--browser/components/firefoxview/tests/browser/browser_cfr_message.js67
-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.js771
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_position.js402
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js127
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js171
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js79
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview.js18
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js110
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js55
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js308
-rw-r--r--browser/components/firefoxview/tests/browser/browser_keyboard_focus.js91
-rw-r--r--browser/components/firefoxview/tests/browser/browser_notification_dot.js389
-rw-r--r--browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js886
-rw-r--r--browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js269
-rw-r--r--browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js36
-rw-r--r--browser/components/firefoxview/tests/browser/browser_setup_errors.js370
-rw-r--r--browser/components/firefoxview/tests/browser/browser_setup_primary_password.js145
-rw-r--r--browser/components/firefoxview/tests/browser/browser_setup_state.js769
-rw-r--r--browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js176
-rw-r--r--browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js68
-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/browser_tab_pickup_device_added_telemetry.js278
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js794
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js149
-rw-r--r--browser/components/firefoxview/tests/browser/browser_ui_state.js141
-rw-r--r--browser/components/firefoxview/tests/browser/head.js550
-rw-r--r--browser/components/firefoxview/tests/chrome/chrome.ini5
-rw-r--r--browser/components/firefoxview/tests/chrome/test_card_container.html122
-rw-r--r--browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html322
-rw-r--r--browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html465
35 files changed, 8582 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..f47bbc2436
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs
@@ -0,0 +1,122 @@
+/* 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";
+
+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) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+ assertFirefoxViewTab(win);
+ Assert.ok(
+ win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is selected"
+ );
+ await BrowserTestUtils.browserLoaded(
+ win.FirefoxViewHandler.tab.linkedBrowser
+ );
+ return win.FirefoxViewHandler.tab;
+}
+
+function closeFirefoxViewTab(win) {
+ 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 {function(MozBrowser)} taskFn
+ * The task to run. It can be asynchronous.
+ * @returns {any}
+ * The value returned by the task.
+ */
+async function withFirefoxView(
+ { openNewWindow = false, resetFlowManager = true },
+ taskFn
+) {
+ const 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 == "about:firefoxview";
+}
+
+export {
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+};
diff --git a/browser/components/firefoxview/tests/browser/browser.ini b/browser/components/firefoxview/tests/browser/browser.ini
new file mode 100644
index 0000000000..cd00e12504
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+support-files = head.js
+prefs =
+ browser.tabs.firefox-view.logLevel=All
+
+[browser_cfr_message.js]
+skip-if = true # Bug 1783684
+[browser_dragDrop_after_opening_fxViewTab.js]
+[browser_entrypoint_management.js]
+[browser_feature_callout.js]
+[browser_feature_callout_position.js]
+[browser_feature_callout_resize.js]
+[browser_feature_callout_targeting.js]
+[browser_feature_callout_theme.js]
+[browser_firefoxview.js]
+[browser_firefoxview_accessibility.js]
+[browser_firefoxview_feature_callout_a11y.js]
+[browser_firefoxview_tab.js]
+[browser_keyboard_focus.js]
+[browser_notification_dot.js]
+[browser_recently_closed_tabs.js]
+[browser_recently_closed_tabs_keyboard.js]
+[browser_reload_firefoxview.js]
+[browser_setup_errors.js]
+[browser_setup_primary_password.js]
+[browser_setup_state.js]
+[browser_setup_synced_tabs_loading.js]
+[browser_sync_admin_disabled.js]
+[browser_tab_close_last_tab.js]
+[browser_tab_on_close_warning.js]
+[browser_tab_pickup_device_added_telemetry.js]
+[browser_tab_pickup_list.js]
+skip-if =
+ os == "linux" # Bug 1824273
+ os == "win" # Bug 1824273
+[browser_tab_pickup_visibility.js]
+[browser_ui_state.js]
diff --git a/browser/components/firefoxview/tests/browser/browser_cfr_message.js b/browser/components/firefoxview/tests/browser/browser_cfr_message.js
new file mode 100644
index 0000000000..337f74c4b1
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_cfr_message.js
@@ -0,0 +1,67 @@
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { ASRouterTriggerListeners } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+
+add_task(async function cfr_firefoxview_should_show() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", 0]],
+ });
+
+ let cfrSpy = sinon.spy(ASRouter, "routeCFRMessage");
+ let specialMessageActionsSpy = sinon.spy(
+ SpecialMessageActions,
+ "handleAction"
+ );
+ registerCleanupFunction(() => {
+ cfrSpy.restore();
+ specialMessageActionsSpy.restore();
+ ASRouter.resetMessageState();
+ ASRouter.unblockMessageById("CFR_FIREFOX_VIEW");
+ ASRouterTriggerListeners.get("nthTabClosed").uninit();
+ });
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown",
+ target => {
+ return target;
+ }
+ );
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+
+ await showPanel;
+
+ Assert.equal(cfrSpy.lastCall.args[0].id, "CFR_FIREFOX_VIEW");
+
+ const notification = document.querySelector(
+ "#contextual-feature-recommendation-notification"
+ );
+
+ Assert.ok(notification);
+ Assert.ok(document.querySelector(".popup-notification-primary-button"));
+
+ Assert.ok(document.querySelector(".popup-notification-secondary-button"));
+
+ await notification.button.click();
+
+ Assert.equal(
+ specialMessageActionsSpy.firstCall.args[0].type,
+ "OPEN_FIREFOX_VIEW"
+ );
+ await SpecialPowers.popPrefEnv();
+
+ closeFirefoxViewTab(window);
+});
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..07223b0873
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js
@@ -0,0 +1,771 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const { MessageLoaderUtils } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+const featureTourPref = "browser.firefox-view.feature-tour";
+const defaultPrefValue = getPrefValueByScreen(1);
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+ 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;
+ 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, '{"message":"","screen":"","complete":true}']],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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;
+ 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;
+ 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 clickPrimaryButton(tab2Doc);
+
+ 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 clickPrimaryButton(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() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const spy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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_not_rendered_when_it_has_no_parent() {
+ Services.telemetry.clearEvents();
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].parent_selector = "#fake-selector";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ const CONTAINER_NOT_CREATED_EVENT = [
+ [
+ "messaging_experiments",
+ "feature_callout",
+ "create_failed",
+ `${testMessage.message.id}-${testMessage.message.content.screens[0].parent_selector}`,
+ ],
+ ];
+ await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 2;
+ }, "Waiting for container_not_created event");
+
+ TelemetryTestUtils.assertEvents(
+ CONTAINER_NOT_CREATED_EVENT,
+ { method: "feature_callout" },
+ { clear: true, process: "parent" }
+ );
+
+ ok(
+ !document.querySelector(`${calloutSelector}:not(.hidden)`),
+ "Feature Callout screen does not render if its parent element does not exist"
+ );
+ }
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_only_highlights_existing_elements() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].parent_selector = "#fake-selector";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ ok(
+ !document.querySelector(`${calloutSelector}:not(.hidden)`),
+ "Feature Callout screen does not render if its parent element does not exist"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_arrow_class_exists() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ const arrowParent = document.querySelector(".callout-arrow.arrow-top");
+ ok(arrowParent, "Arrow class 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_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].content.arrow_position = "start";
+ testMessage.message.content.screens[0].parent_selector = "span.brand-icon";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await BrowserTestUtils.waitForCondition(() => {
+ return document.querySelector(
+ `${calloutSelector}.arrow-inline-start:not(.hidden)`
+ );
+ });
+ ok(
+ true,
+ "Feature Callout arrow parent has arrow-start class when arrow direction is set to 'start'"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_respects_cfr_features_pref() {
+ async function toggleCFRFeaturesPref(value, extraPrefs = []) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ value,
+ ],
+ ...extraPrefs,
+ ],
+ });
+ }
+
+ await toggleCFRFeaturesPref(true, [[featureTourPref, defaultPrefValue]]);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+
+ await toggleCFRFeaturesPref(false);
+ await waitForCalloutRemoved(document);
+ ok(
+ !document.querySelector(calloutSelector),
+ "Feature Callout element was removed because CFR pref was disabled"
+ );
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ ok(
+ !document.querySelector(calloutSelector),
+ "Feature Callout element was not created because CFR pref was disabled"
+ );
+
+ await toggleCFRFeaturesPref(true);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element was created because CFR pref was enabled"
+ );
+ }
+ );
+});
+
+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;
+ 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 clickPrimaryButton(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_page_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]],
+ });
+ const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
+ const testClickSelector = "#recently-closed-tabs-container";
+ 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: "click",
+ selectors: testClickSelector,
+ },
+ action: {
+ dismiss: true,
+ },
+ },
+ ];
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const spy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ info("Waiting for callout to render");
+ await waitForCalloutScreen(document, screenId);
+
+ info("Clicking page element");
+ document.querySelector(testClickSelector).click();
+ await waitForCalloutRemoved(document);
+
+ // Test that appropriate telemetry is sent
+ spy.assertCalledWith({
+ event: "PAGE_EVENT",
+ event_context: {
+ action: "DISMISS",
+ reason: "CLICK",
+ source: sinon.match(testClickSelector),
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ spy.assertCalledWith({
+ event: "DISMISS",
+ event_context: {
+ source: sinon
+ .match("PAGE_EVENT:")
+ .and(sinon.match(testClickSelector)),
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+
+ browser.tabDialogBox
+ ?.getTabDialogManager()
+ .dialogs.forEach(dialog => dialog.close());
+ }
+ );
+ Services.prefs.clearUserPref("browser.firefox-view.view-count");
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_advance_tour_on_page_click() {
+ let sandbox = sinon.createSandbox();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ featureTourPref,
+ JSON.stringify({
+ message: "FIREFOX_VIEW_FEATURE_TOUR",
+ screen: "FEATURE_CALLOUT_1",
+ complete: false,
+ }),
+ ],
+ ],
+ });
+
+ // Add page action listeners to the built-in messages.
+ const TEST_MESSAGES = FeatureCalloutMessages.getMessages().filter(msg =>
+ [
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS",
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS",
+ ].includes(msg.id)
+ );
+ TEST_MESSAGES.forEach(msg => {
+ let { content } = msg.content.screens[msg.content.startScreen ?? 0];
+ content.page_event_listeners = [
+ {
+ params: {
+ type: "click",
+ selectors: ".brand-logo",
+ },
+ action: JSON.parse(JSON.stringify(content.primary_button.action)),
+ },
+ ];
+ });
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ info("Clicking page button");
+ document.querySelector(".brand-logo").click();
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ info("Clicking page button");
+ document.querySelector(".brand-logo").click();
+
+ 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();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+});
+
+add_task(async function feature_callout_dismiss_on_escape() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"message":"","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;
+
+ 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 => {
+ 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");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browser.contentWindow.performance.navigation.type ==
+ browser.contentWindow.performance.navigation.TYPE_RELOAD
+ );
+ info("Spotlight modal cleared, entering feature tour");
+
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ info("Feature tour started");
+ await clickPrimaryButton(document);
+ }
+ );
+
+ ok(remoteSettingsStub.called, "Tried to load CFR messages");
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+
+ document.querySelector(".dismiss-button").click();
+ await waitForCalloutRemoved(document);
+
+ ok(
+ document.activeElement.localName === "body",
+ "by default focus returns to the document body after callout closes"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+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;
+ 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
+ ok(
+ 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() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].content.hide_arrow = true;
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ ok(
+ getComputedStyle(
+ document.querySelector(".callout-arrow"),
+ ":before"
+ ).getPropertyValue("display") == "none" &&
+ getComputedStyle(
+ document.querySelector(".callout-arrow"),
+ ":after"
+ ).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..386fbbb91b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js
@@ -0,0 +1,402 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const featureTourPref = "browser.firefox-view.feature-tour";
+const defaultPrefValue = getPrefValueByScreen(1);
+
+add_task(
+ async function feature_callout_first_screen_positioned_below_element() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parentBottom = document
+ .querySelector("#tab-pickup-container")
+ .getBoundingClientRect().bottom;
+ let containerTop = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().top;
+
+ Assert.lessOrEqual(
+ parentBottom,
+ containerTop + 5 + 1, // Add 5px for overlap and 1px for fuzziness to account for possible subpixel rounding
+ "Feature Callout is positioned below parent element with 5px overlap"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_left_of_element() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
+ );
+ testMessage.message.content.screens[1].content.arrow_position = "end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ const parent = document.querySelector(
+ "#recently-closed-tabs-container"
+ );
+ parent.style.gridArea = "1/2";
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ let parentLeft = parent.getBoundingClientRect().left;
+ let containerRight = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().right;
+
+ Assert.greaterOrEqual(
+ parentLeft,
+ containerRight - 5 - 1, // Subtract 5px for overlap and 1px for fuzziness to account for possible subpixel rounding
+ "Feature Callout is positioned left of parent element with 5px overlap"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_above_element() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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"],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].content.arrow_position = "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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;
+
+ ok(
+ container.classList.contains("arrow-top-end"),
+ "Feature Callout container has the expected arrow-top-end class"
+ );
+ 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() {
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].content.arrow_position = "top-start";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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;
+
+ ok(
+ container.classList.contains("arrow-top-start"),
+ "Feature Callout container has the expected arrow-top-start class"
+ );
+ 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"],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
+ );
+ testMessage.message.content.screens[0].content.arrow_position = "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = 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;
+
+ ok(
+ container.classList.contains("arrow-top-start"),
+ "In RTL mode, the feature Callout container has the expected arrow-top-start class"
+ );
+ 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_1_NO_CWS",
+ template: "feature_callout",
+ content: {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ parent_selector: ".brand-icon",
+ content: {
+ position: "callout",
+ arrow_position: "end",
+ 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-icon has a height of 32px
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, getPrefValueByScreen(1)]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector(".brand-icon");
+ 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..1f9d00975a
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const featureTourPref = "browser.firefox-view.feature-tour";
+
+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_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-top`),
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1600, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-inline-start`),
+ "On first screen at 1600x400, 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
+ );
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-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_1_NO_CWS"
+ );
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-top`),
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1600, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-inline-end`),
+ "On first screen at 1600x400, 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
+ );
+ ok(
+ document.querySelector(`${calloutSelector}.arrow-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..82952b3adf
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js
@@ -0,0 +1,171 @@
+"use strict";
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+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;
+
+ 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;
+
+ 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;
+
+ 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;
+
+ 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..f56414145e
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/FeatureCallout.sys.mjs"
+);
+
+async function testCallout(config) {
+ const featureCallout = new FeatureCallout(config);
+ const testMessage = getCalloutMessageById(
+ "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
+ );
+ const screen = testMessage.message.content.screens.find(s => s.id);
+ screen.parent_selector = "body";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage, config.page);
+ featureCallout.showFeatureCallout();
+ await waitForCalloutScreen(config.win.document, screen.id);
+ testStyles(config.win);
+ return { featureCallout, sandbox };
+}
+
+function testStyles(win) {
+ const calloutEl = win.document.querySelector(calloutSelector);
+ const calloutStyle = win.getComputedStyle(calloutEl);
+ for (const type of ["light", "dark", "hcm"]) {
+ for (const name of FeatureCallout.themePropNames) {
+ ok(
+ calloutStyle.getPropertyValue(`--fc-${name}-${type}`),
+ `Theme property --fc-${name}-${type} is set`
+ );
+ }
+ }
+}
+
+add_task(async function feature_callout_chrome_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const { sandbox } = await testCallout({
+ win,
+ browser: win.gBrowser.selectedBrowser,
+ prefName: "fakepref",
+ page: "chrome",
+ theme: { preset: "chrome" },
+ });
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_pdfjs_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const { sandbox } = await testCallout({
+ win,
+ browser: win.gBrowser.selectedBrowser,
+ prefName: "fakepref",
+ page: "chrome",
+ theme: { preset: "pdfjs", simulateContent: true },
+ });
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_content_theme() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { sandbox } = await testCallout({
+ win: browser.contentWindow,
+ prefName: "fakepref",
+ page: "about:firefoxview",
+ theme: { preset: "themed-content" },
+ });
+ sandbox.restore();
+ }
+ );
+});
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..5ac9dd4c7b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js
@@ -0,0 +1,18 @@
+/* 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.getElementById("tab-pickup-container"),
+ "tab-pickup-container element exists"
+ );
+ ok(
+ document.getElementById("recently-closed-tabs-container"),
+ "recently-closed-tabs-container element exists"
+ );
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js
new file mode 100644
index 0000000000..e4b2e866a0
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that are related to the accessibility of the Firefox View
+ * document. These tasks tend to be privileged content, not browser
+ * chrome.
+ */
+
+add_setup(async function setup() {
+ // Make sure the prompt to connect FxA doesn't show
+ // Without resetting the view-count pref it gets surfaced after
+ // the third click on the fx view toolbar button.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.view-count", 0]],
+ });
+});
+
+add_task(async function test_keyboard_focus_after_tab_pickup_opened() {
+ // Reset various things touched by other tests in this file so that
+ // we have a sufficiently clean environment.
+
+ TabsSetupFlowManager.resetInternalState();
+
+ // Ensure that the tab-pickup section doesn't need to be opened.
+ Services.prefs.clearUserPref(
+ "browser.tabs.firefox-view.ui-state.tab-pickup.open"
+ );
+
+ // make sure the feature tour doesn't get in the way
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.firefox-view.feature-tour",
+ JSON.stringify({
+ screen: `FEATURE_CALLOUT_1`,
+ complete: true,
+ }),
+ ],
+ ],
+ });
+
+ // Let's be deterministic about the basic UI state!
+ const sandbox = setupMocks({
+ state: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: false,
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+
+ is(
+ document.activeElement.localName,
+ "body",
+ "document body element is initially focused"
+ );
+
+ const tab = () => {
+ info("Tab keypress synthesized");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ };
+
+ tab();
+
+ let tabPickupContainer = document.querySelector(
+ "#tab-pickup-container summary.page-section-header"
+ );
+ is(
+ document.activeElement,
+ tabPickupContainer,
+ "tab pickup container header has focus"
+ );
+
+ tab();
+
+ is(
+ document.activeElement.id,
+ "firefoxview-tabpickup-step-signin-primarybutton",
+ "tab pickup primary button has focus"
+ );
+ });
+
+ // cleanup time
+ await tearDown(sandbox);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_keyboard_accessibility_tab_pickup() {
+ await withFirefoxView({}, async browser => {
+ const win = browser.ownerGlobal;
+ const { document } = browser.contentWindow;
+ const enter = async () => {
+ info("Enter");
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ };
+ let details = document.getElementById("tab-pickup-container");
+ let summary = details.querySelector("summary");
+ ok(summary, "summary element should exist");
+ ok(details.open, "Tab pickup container should be initially open on load");
+ summary.focus();
+ await enter();
+ ok(!details.open, "Tab pickup container should be closed");
+ await enter();
+ ok(details.open, "Tab pickup container should be opened");
+ });
+ cleanup_tab_pickup();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js
new file mode 100644
index 0000000000..7386f109f5
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that are related to the accessibility of the feature callout
+ */
+
+/**
+ * Ensure feature tour is accessible using a screen reader and with
+ * keyboard navigation.
+ */
+add_task(async function feature_callout_is_accessible() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.feature-tour", getPrefValueByScreen(1)]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ await BrowserTestUtils.waitForCondition(
+ () => document.activeElement.value === "primary_button",
+ `Feature Callout primary button is focused on page load}`
+ );
+ ok(true, "Feature Callout primary button was focused on page load");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ document.querySelector(
+ `${calloutSelector}[aria-describedby="#${calloutId} .welcome-text"]`
+ ),
+ "The callout container has an aria-describedby value equal to the screen welcome text"
+ );
+ ok(true, "The callout container has the correct aria-describedby value");
+
+ // Advance to second screen
+ clickPrimaryButton(document);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+
+ ok(true, "FEATURE_CALLOUT_2 was successfully displayed");
+ await BrowserTestUtils.waitForCondition(
+ () => document.activeElement.value == "primary_button",
+ "Feature Callout primary button is focused after advancing screens"
+ );
+ ok(true, "Feature Callout primary button was successfully focused");
+ }
+ );
+});
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..b5a83d6335
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js
@@ -0,0 +1,308 @@
+/* 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);
+
+ ok(
+ 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);
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js
new file mode 100644
index 0000000000..254c315fdb
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+});
+
+const SYNCED_URI = syncedTabsData1[0].tabs[1].url;
+
+add_task(async function test_keyboard_focus() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData1);
+ syncedTabsMock.returns(mockTabs1);
+
+ await setupListState(browser);
+
+ testVisibility(browser, {
+ expectedVisible: {
+ "ol.synced-tabs-list": true,
+ },
+ });
+
+ let tabPickupEle = document.querySelector(".synced-tab-a");
+ document.querySelector(".page-section-header").focus();
+
+ EventUtils.synthesizeKey("KEY_Tab");
+
+ is(
+ tabPickupEle,
+ document.activeElement,
+ "The first tab pickup link is focused"
+ );
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI);
+ EventUtils.synthesizeKey("KEY_Enter");
+ await newTabPromise;
+
+ is(
+ SYNCED_URI,
+ gBrowser.selectedBrowser.currentURI.displaySpec,
+ "We opened the tab via keyboard"
+ );
+
+ let sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate(
+ gBrowser.selectedTab
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+ await sessionStorePromise;
+
+ window.FirefoxViewHandler.openTab();
+
+ let recentlyClosedEle = await TestUtils.waitForCondition(() =>
+ document.querySelector(".closed-tab-li-main")
+ );
+ document.querySelectorAll(".page-section-header")[1].focus();
+
+ EventUtils.synthesizeKey("KEY_Tab");
+
+ is(
+ recentlyClosedEle,
+ document.activeElement,
+ "The recently closed tab is focused"
+ );
+
+ newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI);
+ EventUtils.synthesizeKey("KEY_Enter");
+ await newTabPromise;
+ is(
+ SYNCED_URI,
+ gBrowser.selectedBrowser.currentURI.displaySpec,
+ "We opened the tab via keyboard"
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ sessionStorePromise = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ SessionStore.forgetClosedTab(window, 0);
+ await sessionStorePromise;
+
+ sandbox.restore();
+ });
+});
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..01411ee260
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js
@@ -0,0 +1,389 @@
+/* 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",
+ },
+ {
+ id: 2,
+ name: "My iphone",
+ type: "mobile",
+ },
+ ]);
+
+ 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);
+ }
+ ok(
+ 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.is_visible(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_recently_closed_tabs.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js
new file mode 100644
index 0000000000..22889a43eb
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js
@@ -0,0 +1,886 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(10);
+
+/**
+ * The recently closed tab list is populated on a per-window basis.
+ *
+ * By default, the withFirefoxView helper opens fx view in the current window.
+ * This ensures that the add_new_tab, close_tab,
+ * and open_then_close functions are creating sessionstore entries
+ * associated with the correct window where the tests are run.
+ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+const RECENTLY_CLOSED_EVENT = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "recently_closed", "tabs", undefined],
+];
+
+const CLOSED_TABS_OPEN_EVENT = [
+ ["firefoxview", "closed_tabs_open", "tabs", "false"],
+];
+
+const RECENTLY_CLOSED_DISMISS_EVENT = [
+ ["firefoxview", "dismiss_closed_tab", "tabs", undefined],
+];
+
+async function add_new_tab(URL) {
+ let tab = BrowserTestUtils.addTab(gBrowser, URL);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ return tab;
+}
+
+async function close_tab(tab) {
+ const sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ BrowserTestUtils.removeTab(tab);
+ await sessionStorePromise;
+}
+
+async function dismiss_tab(tab, content) {
+ info(`Dismissing tab ${tab.dataset.targeturi}`);
+ const closedObjectsChanged = () =>
+ TestUtils.topicObserved("sessionstore-closed-objects-changed");
+ let dismissButton = tab.querySelector(".closed-tab-li-dismiss");
+ EventUtils.synthesizeMouseAtCenter(dismissButton, {}, content);
+ await closedObjectsChanged();
+}
+
+add_setup(async function setup() {
+ // set updateTimeMs to 0 to prevent unexpected/unrelated DOM mutations during testing
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", 100000]],
+ });
+});
+
+add_task(async function test_empty_list() {
+ clearHistory();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let container = document.querySelector("#collapsible-tabs-container");
+ ok(
+ container.classList.contains("empty-container"),
+ "collapsible container should have correct styling when the list is empty"
+ );
+
+ Assert.ok(
+ document.getElementById("recently-closed-tabs-placeholder"),
+ "The empty message is displayed."
+ );
+
+ Assert.ok(
+ !document.querySelector("ol.closed-tabs-list"),
+ "The recently closed tabs list is not displayed."
+ );
+
+ const tab1 = await add_new_tab(URLs[0]);
+
+ await close_tab(tab1);
+
+ // The UI update happens asynchronously as we learn of the new closed tab.
+ await BrowserTestUtils.waitForMutationCondition(
+ container,
+ { attributeFilter: ["class"] },
+ () => !container.classList.contains("empty-container")
+ );
+ ok(
+ !container.classList.contains("empty-container"),
+ "collapsible container should have correct styling when the list is not empty"
+ );
+
+ Assert.ok(
+ !document.getElementById("recently-closed-tabs-placeholder"),
+ "The empty message is not displayed."
+ );
+
+ Assert.ok(
+ document.querySelector("ol.closed-tabs-list"),
+ "The recently closed tabs list is displayed."
+ );
+
+ is(
+ document.querySelector("ol.closed-tabs-list").children.length,
+ 1,
+ "recently-closed-tabs-list should have one list item"
+ );
+ });
+});
+
+add_task(async function test_list_ordering() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ await clearAllParentTelemetryEvents();
+
+ const closedObjectsChanged = () =>
+ TestUtils.topicObserved("sessionstore-closed-objects-changed");
+
+ const tab1 = await add_new_tab(URLs[0]);
+ const tab2 = await add_new_tab(URLs[1]);
+ const tab3 = await add_new_tab(URLs[2]);
+
+ gBrowser.selectedTab = tab3;
+
+ await close_tab(tab3);
+ await closedObjectsChanged();
+
+ await close_tab(tab2);
+ await closedObjectsChanged();
+
+ await close_tab(tab1);
+ await closedObjectsChanged();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsList,
+ { childList: true },
+ () => tabsList.children.length > 1
+ );
+
+ is(
+ document.querySelector("ol.closed-tabs-list").children.length,
+ 3,
+ "recently-closed-tabs-list should have three list items"
+ );
+
+ // check that the ordering is correct when user navigates to another tab, and then closes multiple tabs.
+ ok(
+ document
+ .querySelector("ol.closed-tabs-list")
+ .children[0].textContent.includes("mochi.test"),
+ "first list item in recently-closed-tabs-list is in the correct order"
+ );
+
+ ok(
+ document
+ .querySelector("ol.closed-tabs-list")
+ .children[2].textContent.includes("example.net"),
+ "last list item in recently-closed-tabs-list is in the correct order"
+ );
+
+ let ele = document.querySelector("ol.closed-tabs-list").firstElementChild;
+ let uri = ele.getAttribute("data-targeturi");
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, uri);
+ ele.querySelector(".closed-tab-li-main").click();
+ await newTabPromise;
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 2;
+ },
+ "Waiting for entered and recently_closed firefoxview telemetry events.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ RECENTLY_CLOSED_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ await clearAllParentTelemetryEvents();
+
+ await waitForElementVisible(
+ browser,
+ "#recently-closed-tabs-container > summary"
+ );
+ document.querySelector("#recently-closed-tabs-container > summary").click();
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for closed_tabs_open firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ CLOSED_TABS_OPEN_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+ });
+});
+
+add_task(async function test_max_list_items() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ // Seed the closed tabs count. We've assured that we've opened and
+ // closed at least three tabs because of the calls to open_then_close
+ // above.
+ let mockMaxTabsLength = 3;
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // override this value for testing purposes
+ document.querySelector("recently-closed-tabs-list").maxTabsLength =
+ mockMaxTabsLength;
+
+ ok(
+ !document
+ .querySelector("#collapsible-tabs-container")
+ .classList.contains("empty-container"),
+ "collapsible container should have correct styling when the list is not empty"
+ );
+
+ Assert.ok(
+ !document.getElementById("recently-closed-tabs-placeholder"),
+ "The empty message is not displayed."
+ );
+
+ Assert.ok(
+ document.querySelector("ol.closed-tabs-list"),
+ "The recently closed tabs list is displayed."
+ );
+
+ is(
+ document.querySelector("ol.closed-tabs-list").children.length,
+ mockMaxTabsLength,
+ `recently-closed-tabs-list should have ${mockMaxTabsLength} list items`
+ );
+
+ const closedObjectsChanged = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ // add another tab
+ const tab = await add_new_tab(URLs[3]);
+ await close_tab(tab);
+ await closedObjectsChanged;
+ let firstListItem = document.querySelector("ol.closed-tabs-list")
+ .children[0];
+ await BrowserTestUtils.waitForMutationCondition(
+ firstListItem,
+ { characterData: true, childList: true, subtree: true },
+ () => firstListItem.textContent.includes(".org")
+ );
+ ok(
+ firstListItem.textContent.includes("example.org"),
+ "first list item in recently-closed-tabs-list should have been updated"
+ );
+
+ is(
+ document.querySelector("ol.closed-tabs-list").children.length,
+ mockMaxTabsLength,
+ `recently-closed-tabs-list should still have ${mockMaxTabsLength} list items`
+ );
+ });
+});
+
+add_task(async function test_time_updates_correctly() {
+ clearHistory();
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ // Set the closed tabs state to include one tab that was closed 2 seconds ago.
+ // This is well below the initial threshold for displaying the 'Just now' timestamp.
+ // It is also much greater than the 5ms threshold we use for the updated pref value,
+ // which results in the timestamp text changing after the pref value is changed.
+ const TAB_CLOSED_AGO_MS = 2000;
+ const TAB_UPDATE_TIME_MS = 5;
+ const TAB_CLOSED_STATE = {
+ windows: [
+ {
+ tabs: [{ entries: [] }],
+ _closedTabs: [
+ {
+ state: { entries: [{ url: "https://www.example.com/" }] },
+ closedId: 0,
+ closedAt: Date.now() - TAB_CLOSED_AGO_MS,
+ image: null,
+ title: "Example",
+ },
+ ],
+ },
+ ],
+ };
+ await SessionStore.setBrowserState(JSON.stringify(TAB_CLOSED_STATE));
+
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 1,
+ "Closed tab count after setting browser state"
+ );
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+ const numOfListItems = tabsList.children.length;
+ const lastListItem = tabsList.children[numOfListItems - 1];
+ const timeLabel = lastListItem.querySelector("span.closed-tab-li-time");
+ let initialTimeText = timeLabel.textContent;
+ Assert.stringContains(
+ initialTimeText,
+ "Just now",
+ "recently-closed-tabs list item time is 'Just now'"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]],
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ timeLabel,
+ { childList: true },
+ () => !timeLabel.textContent.includes("now")
+ );
+
+ isnot(
+ timeLabel.textContent,
+ initialTimeText,
+ "recently-closed-tabs list item time has updated"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+ // Cleanup recently closed tab data.
+ clearHistory();
+});
+
+add_task(async function test_list_maintains_focus_when_restoring_tab() {
+ await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF);
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(true);
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await withFirefoxView({}, async browser => {
+ let gBrowser = browser.getTabBrowser();
+ const { document } = browser.contentWindow;
+ const list = document.querySelectorAll(".closed-tab-li");
+ list[0].querySelector(".closed-tab-li-main").focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View");
+ await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab);
+ Assert.ok(
+ document.activeElement.textContent.includes("mochitest index"),
+ "Focus should be on the first item in the recently closed list"
+ );
+ });
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+
+ clearHistory();
+ await open_then_close(URLs[2]);
+ await withFirefoxView({}, async browser => {
+ let gBrowser = browser.getTabBrowser();
+ const { document } = browser.contentWindow;
+ let expectedFocusedElement = document.getElementById(
+ "recently-closed-tabs-header-section"
+ );
+ const list = document.querySelectorAll(".closed-tab-li");
+ list[0].querySelector(".closed-tab-li-main").focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View");
+ await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab);
+ is(
+ document.activeElement,
+ expectedFocusedElement,
+ "Focus should be on the section header"
+ );
+ });
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+});
+
+add_task(async function test_switch_before_closing() {
+ clearHistory();
+
+ const INITIAL_URL = "https://example.org/iwilldisappear";
+ const FINAL_URL = "https://example.com/ishouldappear";
+ await withFirefoxView({}, async function (browser) {
+ let gBrowser = browser.getTabBrowser();
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ INITIAL_URL
+ );
+ // Switch back to FxView:
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ gBrowser.getTabForBrowser(browser)
+ );
+ // Update the tab we opened to a different site:
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ newTab.linkedBrowser,
+ null,
+ FINAL_URL
+ );
+ BrowserTestUtils.loadURIString(newTab.linkedBrowser, FINAL_URL);
+ await loadPromise;
+ // Close the added tab
+ BrowserTestUtils.removeTab(newTab);
+
+ const { document } = browser.contentWindow;
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector("recently-closed-tabs-list"),
+ { childList: true, subtree: true },
+ () => document.querySelector("ol.closed-tabs-list")
+ );
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+ info("A tab appeared in the list, ensure it has the right URL.");
+ let urlBit = tabsList.children[0].querySelector(".closed-tab-li-url");
+ await BrowserTestUtils.waitForMutationCondition(
+ urlBit,
+ { characterData: true, attributeFilter: ["title"] },
+ () => urlBit.textContent.includes(".com")
+ );
+ Assert.ok(
+ urlBit.textContent.includes("example.com"),
+ "Item should end up with the correct URL."
+ );
+ });
+});
+
+add_task(async function test_alt_click_no_launch() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ let gBrowser = browser.getTabBrowser();
+ let originalTabsLength = gBrowser.tabs.length;
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".closed-tab-li .closed-tab-li-main",
+ { altKey: true },
+ browser
+ );
+
+ is(
+ gBrowser.tabs.length,
+ originalTabsLength,
+ `Opened tabs length should still be ${originalTabsLength}`
+ );
+ });
+});
+
+/**
+ * Asserts that tabs that have been recently closed can be
+ * restored by clicking on them, using the Enter key,
+ * and using the Space bar.
+ */
+add_task(async function test_restore_recently_closed_tabs() {
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+ // Wait for Firefox View to be loaded before interacting
+ // with the page.
+ await BrowserTestUtils.browserLoaded(
+ window.FirefoxViewHandler.tab.linkedBrowser
+ );
+ let { document } = gBrowser.contentWindow;
+ let tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]);
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector(".closed-tab-li"),
+ {},
+ gBrowser.contentWindow
+ );
+
+ await tabRestored;
+ ok(true, "Tab was restored by mouse click");
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+
+ tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[1]);
+ document.querySelector(".closed-tab-li .closed-tab-li-main").focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, gBrowser.contentWindow);
+
+ await tabRestored;
+ ok(true, "Tab was restored by using the Enter key");
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+});
+
+/**
+ * Asserts that tabs are removed from Recently Closed tabs in
+ * Fx View when tabs are removed from latest closed tab data.
+ * Ex: Selecting "Reopen Closed Tab" from the tabs toolbar
+ * context menu
+ */
+add_task(async function test_reopen_recently_closed_tabs() {
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+ // Wait for Firefox View to be loaded before interacting
+ // with the page.
+ await BrowserTestUtils.browserLoaded(
+ window.FirefoxViewHandler.tab.linkedBrowser
+ );
+
+ let { document } = gBrowser.contentWindow;
+
+ let tabReopened = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]);
+ SessionStore.undoCloseTab(window);
+ await tabReopened;
+
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsList,
+ { childList: true },
+ () => tabsList.children.length === 2
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[1],
+ `First recently closed item should be ${URLs[1]}`
+ );
+
+ await close_tab(gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1]);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsList,
+ { childList: true },
+ () => tabsList.children.length === 3
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[2],
+ `First recently closed item should be ${URLs[2]}`
+ );
+
+ await dismiss_tab(tabsList.children[0], content);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsList,
+ { childList: true },
+ () => tabsList.children.length === 2
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[1],
+ `First recently closed item should be ${URLs[1]}`
+ );
+
+ const contextMenu = gBrowser.ownerDocument.getElementById(
+ "contentAreaContextMenu"
+ );
+ const promisePopup = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tabsList.querySelector(".closed-tab-li-title"),
+ {
+ button: 2,
+ type: "contextmenu",
+ },
+ gBrowser.contentWindow
+ );
+ await promisePopup;
+ const promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, URLs[1]);
+ contextMenu.activateItem(
+ gBrowser.ownerDocument.getElementById("context-openlinkintab")
+ );
+ await promiseNewTab;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsList,
+ { childList: true },
+ () => tabsList.children.length === 1
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[0],
+ `First recently closed item should be ${URLs[0]}`
+ );
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+});
+
+/**
+ * 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() {
+ const TAB_UPDATE_TIME_MS = 5;
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ Assert.equal(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ await clearAllParentTelemetryEvents();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ const closedObjectsChanged = () =>
+ TestUtils.topicObserved("sessionstore-closed-objects-changed");
+
+ const tab1 = await add_new_tab(URLs[0]);
+ const tab2 = await add_new_tab(URLs[1]);
+ const tab3 = await add_new_tab(URLs[2]);
+
+ await close_tab(tab3);
+ await closedObjectsChanged();
+
+ await close_tab(tab2);
+ await closedObjectsChanged();
+
+ await close_tab(tab1);
+ await closedObjectsChanged();
+
+ await clearAllParentTelemetryEvents();
+
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+ const numOfListItems = tabsList.children.length;
+ const lastListItem = tabsList.children[numOfListItems - 1];
+ const timeLabel = lastListItem.querySelector("span.closed-tab-li-time");
+ let initialTimeText = timeLabel.textContent;
+ Assert.stringContains(
+ initialTimeText,
+ "Just now",
+ "recently-closed-tabs list item time is 'Just now'"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]],
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ timeLabel,
+ { childList: true },
+ () => !timeLabel.textContent.includes("Just now")
+ );
+
+ isnot(
+ timeLabel.textContent,
+ initialTimeText,
+ "recently-closed-tabs list item time has updated"
+ );
+
+ await dismiss_tab(tabsList.children[0], content);
+
+ 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(
+ RECENTLY_CLOSED_DISMISS_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[1],
+ `First recently closed item should be ${URLs[1]}`
+ );
+
+ Assert.equal(
+ tabsList.children.length,
+ 2,
+ "recently-closed-tabs-list should have two list items"
+ );
+
+ await clearAllParentTelemetryEvents();
+
+ await dismiss_tab(tabsList.children[0], content);
+
+ 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(
+ RECENTLY_CLOSED_DISMISS_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[2],
+ `First recently closed item should be ${URLs[2]}`
+ );
+
+ Assert.equal(
+ tabsList.children.length,
+ 1,
+ "recently-closed-tabs-list should have one list item"
+ );
+
+ await clearAllParentTelemetryEvents();
+
+ await dismiss_tab(tabsList.children[0], content);
+
+ 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(
+ RECENTLY_CLOSED_DISMISS_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ Assert.ok(
+ document.getElementById("recently-closed-tabs-placeholder"),
+ "The empty message is displayed."
+ );
+
+ Assert.ok(
+ !document.querySelector("ol.closed-tabs-list"),
+ "The recently closed tabs list is not displayed."
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Asserts that the actionable part of each list item is role="button".
+ * Discussion on why we want a button role can be seen here:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1789875#c1
+ */
+add_task(async function test_button_role() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ Assert.equal(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+
+ Array.from(tabsList.children).forEach(tabItem => {
+ let actionableElement = tabItem.querySelector(".closed-tab-li-main");
+ Assert.ok(actionableElement.getAttribute("role"), "button");
+ });
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js
new file mode 100644
index 0000000000..8d82db2b93
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js
@@ -0,0 +1,269 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function dismiss_tab_keyboard(closedTab, document) {
+ const enter = () => {
+ info("Enter");
+ EventUtils.synthesizeKey("KEY_Enter");
+ };
+ const tab = (shiftKey = false) => {
+ info(`${shiftKey ? "Shift + Tab" : "Tab"}`);
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ const closedObjectsChanged = () =>
+ TestUtils.topicObserved("sessionstore-closed-objects-changed");
+ let firstTabMainContent = closedTab.querySelector(".closed-tab-li-main");
+ let dismissButton = closedTab.querySelector(".closed-tab-li-dismiss");
+ firstTabMainContent.focus();
+ tab();
+ Assert.equal(
+ document.activeElement,
+ dismissButton,
+ "Focus should be on the dismiss button for the first item in the recently closed list"
+ );
+ enter();
+ await closedObjectsChanged();
+}
+
+/**
+ * Tests keyboard navigation of the recently closed tabs component
+ */
+add_task(async function test_keyboard_navigation() {
+ const enter = () => {
+ info("Enter");
+ EventUtils.synthesizeKey("KEY_Enter");
+ };
+ const tab = (shiftKey = false) => {
+ info(`${shiftKey ? "Shift + Tab" : "Tab"}`);
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ /**
+ * Focus the summary element and asserts that:
+ * - The recently closed details should be initially opened
+ * - The recently closed details can be opened and closed via the Enter key
+ *
+ * @param {Document} document The currently used browser's content window document
+ * @param {HTMLElement} summary The header section element for recently closed tabs
+ */
+ const assertPreconditions = (document, summary) => {
+ let details = document.getElementById("recently-closed-tabs-container");
+ ok(
+ details.open,
+ "Recently closed details should be initially open on load"
+ );
+ summary.focus();
+ enter();
+ ok(!details.open, "Recently closed details should be closed");
+ enter();
+ ok(details.open, "Recently closed details should be opened");
+ };
+ await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF);
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(true);
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const list = document.querySelectorAll(".closed-tab-li");
+ let summary = document.getElementById(
+ "recently-closed-tabs-header-section"
+ );
+
+ assertPreconditions(document, summary);
+
+ tab();
+
+ Assert.equal(
+ list[0].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The first link is focused"
+ );
+
+ tab(true);
+ Assert.equal(
+ summary,
+ document.activeElement,
+ "The container is focused when using shift+tab in the list"
+ );
+ });
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const list = document.querySelectorAll(".closed-tab-li");
+ let summary = document.getElementById(
+ "recently-closed-tabs-header-section"
+ );
+ assertPreconditions(document, summary);
+
+ tab();
+
+ Assert.equal(
+ list[0].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The first link is focused"
+ );
+ tab();
+ tab();
+ Assert.equal(
+ list[1].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The second link is focused"
+ );
+ tab(true);
+ tab(true);
+ Assert.equal(
+ list[0].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The first link is focused again"
+ );
+
+ tab(true);
+ Assert.equal(
+ summary,
+ document.activeElement,
+ "The container is focused when using shift+tab in the list"
+ );
+ });
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const list = document.querySelectorAll(".closed-tab-li");
+ let summary = document.getElementById(
+ "recently-closed-tabs-header-section"
+ );
+ assertPreconditions(document, summary);
+
+ tab();
+
+ Assert.equal(
+ list[0].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The first link is focused"
+ );
+ tab();
+ tab();
+ Assert.equal(
+ list[1].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The second link is focused"
+ );
+ tab();
+ tab();
+ Assert.equal(
+ list[2].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The third link is focused"
+ );
+ tab(true);
+ tab(true);
+ Assert.equal(
+ list[1].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The second link is focused"
+ );
+ tab(true);
+ tab(true);
+ Assert.equal(
+ list[0].querySelector(".closed-tab-li-main"),
+ document.activeElement,
+ "The first link is focused"
+ );
+ });
+});
+
+add_task(async function test_dismiss_tab_keyboard() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ Assert.equal(
+ SessionStore.getClosedTabCountForWindow(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+ await open_then_close(URLs[2]);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ gBrowser.ownerDocument.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ window
+ );
+
+ const tabsList = document.querySelector("ol.closed-tabs-list");
+
+ await dismiss_tab_keyboard(tabsList.children[0], document);
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[1],
+ `First recently closed item should be ${URLs[1]}`
+ );
+
+ Assert.equal(
+ tabsList.children.length,
+ 2,
+ "recently-closed-tabs-list should have two list items"
+ );
+
+ await dismiss_tab_keyboard(tabsList.children[0], document);
+
+ Assert.equal(
+ tabsList.children[0].dataset.targeturi,
+ URLs[0],
+ `First recently closed item should be ${URLs[0]}`
+ );
+
+ Assert.equal(
+ tabsList.children.length,
+ 1,
+ "recently-closed-tabs-list should have one list item"
+ );
+
+ await dismiss_tab_keyboard(tabsList.children[0], document);
+
+ Assert.ok(
+ document.getElementById("recently-closed-tabs-placeholder"),
+ "The empty message is displayed."
+ );
+
+ Assert.ok(
+ !document.querySelector("ol.closed-tabs-list"),
+ "The recently clsoed tabs list is not displayed."
+ );
+ });
+});
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_setup_errors.js b/browser/components/firefoxview/tests/browser/browser_setup_errors.js
new file mode 100644
index 0000000000..e2733945a0
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_setup_errors.js
@@ -0,0 +1,370 @@
+/* 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",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ },
+ ],
+ });
+ 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();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "offline"
+ );
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("connection")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("network-offline"),
+ "Correct message should show when network connection is lost"
+ );
+
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "online"
+ );
+
+ await waitForElementVisible(browser, "#tabpickup-tabs-container", true);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sync_error() {
+ const sandbox = await setupWithDesktopDevices();
+ sandbox.spy(TabsSetupFlowManager, "tryToClearError");
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("trouble syncing")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"),
+ "Correct message should show when there's a sync service error"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#error-state-button",
+ {},
+ browser
+ );
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return TabsSetupFlowManager.tryToClearError.calledOnce;
+ });
+
+ ok(
+ TabsSetupFlowManager.tryToClearError.calledOnce,
+ "TabsSetupFlowManager.tryToClearError() was called once"
+ );
+
+ // Clear the error.
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ });
+
+ // Now reopen the tab and check that sending an error state does not
+ // start showing the error:
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ const recentFetchTime = Math.floor(Date.now() / 1000);
+ info("updating lastFetch:" + recentFetchTime);
+ Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime);
+ await withFirefoxView({ resetFlowManager: false }, async browser => {
+ const { document } = browser.contentWindow;
+
+ await waitForElementVisible(browser, "#synced-tabs-placeholder", true);
+
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+ await TestUtils.waitForTick();
+ ok(
+ BrowserTestUtils.is_visible(
+ document.getElementById("synced-tabs-placeholder")
+ ),
+ "Should still be showing the placeholder content."
+ );
+ let stepHeader = document.getElementById("tabpickup-steps-view0-header");
+ ok(
+ !stepHeader || BrowserTestUtils.is_hidden(stepHeader),
+ "Should not be showing an error state if we had previously synced successfully."
+ );
+
+ // Now drop a device:
+ let someDevice = gMockFxaDevices.pop();
+ Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
+ // This will trip a UI update where we decide we can't rely on
+ // previously synced tabs anymore (they may be from the device
+ // that was removed!), so we still show an error:
+
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("trouble syncing")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"),
+ "Correct message should show when there's an error and tab information is outdated."
+ );
+
+ // Sneak device back in so as not to break other tests:
+ gMockFxaDevices.push(someDevice);
+ // Clear the error.
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ });
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sync_error_signed_out() {
+ // sync error should not show if user is not signed in
+ let sandbox = await setupWithDesktopDevices(UIState.STATUS_NOT_CONFIGURED);
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view1",
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sync_disconnected_error() {
+ // it's possible for fxa to be enabled but sync not enabled.
+ const sandbox = setupSyncFxAMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ syncEnabled: false,
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // triggered when user disconnects sync in about:preferences
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ info("Waiting for the tabpickup error step to be visible");
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ info(
+ "Waiting for a mutation condition to ensure the right syncing error message"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("Turn on syncing to continue")
+ );
+
+ ok(
+ errorStateHeader
+ .getAttribute("data-l10n-id")
+ .includes("sync-disconnected"),
+ "Correct message should show when sync's been disconnected error"
+ );
+
+ let preferencesTabPromise = BrowserTestUtils.waitForNewTab(
+ browser.getTabBrowser(),
+ "about:preferences?action=choose-what-to-sync#sync",
+ true
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#error-state-button",
+ {},
+ browser
+ );
+ let preferencesTab = await preferencesTabPromise;
+ await BrowserTestUtils.removeTab(preferencesTab);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_password_change_disconnect_error() {
+ // When the user changes their password on another device, we get into a state
+ // where the user is signed out but sync is still enabled.
+ const sandbox = setupSyncFxAMocks({
+ state: UIState.STATUS_LOGIN_FAILED,
+ syncEnabled: true,
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // triggered by the user changing fxa password on another device
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("Sign in to reconnect")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("signed-out"),
+ "Correct message should show when user has been logged out due to external password change."
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_multiple_errors() {
+ let sandbox = await setupWithDesktopDevices();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ // Simulate conditions in which both the locked password and sync error
+ // messages could be shown
+ LoginTestUtils.primaryPassword.enable();
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+
+ info("Waiting for the primary password error message to be shown");
+ await waitForElementVisible(browser, "#tabpickup-steps", true);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("Enter your Primary Password")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"),
+ "Password locked error message is shown"
+ );
+
+ const errorLink = document.querySelector("#error-state-link");
+ ok(
+ errorLink && BrowserTestUtils.is_visible(errorLink),
+ "Error link is visible"
+ );
+
+ // Clear the primary password error message
+ LoginTestUtils.primaryPassword.disable();
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ info("Waiting for the sync error message to be shown");
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("trouble syncing")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"),
+ "Sync error message is now shown"
+ );
+
+ ok(
+ errorLink && BrowserTestUtils.is_hidden(errorLink),
+ "Error link is now hidden"
+ );
+
+ // Clear the sync error
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ });
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js
new file mode 100644
index 0000000000..7f8722d808
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js
@@ -0,0 +1,145 @@
+/* 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 tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+}
+
+function setupMocks() {
+ const sandbox = (gSandbox = setupRecentDeviceListMocks());
+ return sandbox;
+}
+
+add_setup(async function () {
+ registerCleanupFunction(async () => {
+ // reset internal state so it doesn't affect the next tests
+ TabsSetupFlowManager.resetInternalState();
+ LoginTestUtils.primaryPassword.disable();
+ await tearDown(gSandbox);
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.username", "username@example.com"]],
+ });
+});
+
+add_task(async function test_primary_password_locked() {
+ LoginTestUtils.primaryPassword.enable();
+ const sandbox = setupMocks();
+
+ await withFirefoxView({}, async browser => {
+ sandbox.stub(TabsSetupFlowManager, "syncTabs").resolves(null);
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ syncedTabsMock.resolves(getMockTabData(syncedTabsData1));
+
+ const { document } = browser.contentWindow;
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ info("waiting for the error setup step to be visible");
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("Enter your Primary Password")
+ );
+
+ ok(
+ errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"),
+ "Correct error message is shown"
+ );
+
+ const errorLink = document.querySelector("#error-state-link");
+ ok(
+ errorLink && BrowserTestUtils.is_visible(errorLink),
+ "Error link is visible"
+ );
+ ok(
+ errorLink.getAttribute("data-l10n-id").includes("password-locked-link"),
+ "Correct link text is shown"
+ );
+
+ const primaryButton = document.querySelector("#error-state-button");
+ ok(
+ primaryButton && BrowserTestUtils.is_visible(primaryButton),
+ "Error primary button is visible"
+ );
+
+ const clearErrorStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "tryToClearError"
+ );
+ info("Setup state:" + TabsSetupFlowManager.currentSetupState.name);
+
+ info("clicking the error panel button");
+ primaryButton.click();
+ ok(
+ clearErrorStub.called,
+ "tryToClearError was called when the try-again button was clicked"
+ );
+ TabsSetupFlowManager.tryToClearError.restore();
+
+ info("Clearing the primary password");
+ LoginTestUtils.primaryPassword.disable();
+ ok(
+ !TabsSetupFlowManager.isPrimaryPasswordLocked,
+ "primary password is unlocked"
+ );
+
+ info("notifying of the primary-password unlock");
+ const clearErrorSpy = sandbox.spy(TabsSetupFlowManager, "tryToClearError");
+ // we stubbed out sync, so pretend it ran.
+ info("notifying of sync:finish");
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+
+ const setupContainer = document.querySelector(".sync-setup-container");
+ // wait until the setup container gets hidden before checking if the tabs container is visible
+ // as it may not exist until then
+ let setupHiddenPromise = BrowserTestUtils.waitForMutationCondition(
+ setupContainer,
+ {
+ attributeFilter: ["hidden"],
+ },
+ () => {
+ return BrowserTestUtils.is_hidden(setupContainer);
+ }
+ );
+
+ Services.obs.notifyObservers(null, "passwordmgr-crypto-login");
+ await setupHiddenPromise;
+ ok(
+ clearErrorSpy.called,
+ "tryToClearError was called when the primary-password unlock notification was received"
+ );
+ // We expect the waiting state until we get a sync update/finished
+ info("Setup state:" + TabsSetupFlowManager.currentSetupState.name);
+
+ ok(TabsSetupFlowManager.waitingForTabs, "Now waiting for tabs");
+ ok(
+ document
+ .querySelector("#tabpickup-tabs-container")
+ .classList.contains("loading"),
+ "Synced tabs container has loading class"
+ );
+
+ info("notifying of sync:finish");
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ await TestUtils.waitForTick();
+ ok(
+ !document
+ .querySelector("#tabpickup-tabs-container")
+ .classList.contains("loading"),
+ "Synced tabs isn't loading any more"
+ );
+ });
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_setup_state.js b/browser/components/firefoxview/tests/browser/browser_setup_state.js
new file mode 100644
index 0000000000..589f16af26
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_setup_state.js
@@ -0,0 +1,769 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const FXA_CONTINUE_EVENT = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "fxa_continue", "sync", undefined],
+];
+
+const FXA_MOBILE_EVENT = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "fxa_mobile", "sync", undefined, { has_devices: "false" }],
+];
+
+var gMockFxaDevices = null;
+var gUIStateStatus;
+
+function promiseSyncReady() {
+ let service = Cc["@mozilla.org/weave/service;1"].getService(
+ Ci.nsISupports
+ ).wrappedJSObject;
+ return service.whenLoaded();
+}
+
+var gSandbox;
+
+async function setupWithDesktopDevices() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ },
+ ],
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.tabs", true]],
+ });
+ return sandbox;
+}
+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 () {
+ Services.prefs.clearUserPref("services.sync.engine.tabs");
+ await tearDown(gSandbox);
+ });
+ // set tab sync false so we don't skip setup states
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.tabs", false]],
+ });
+});
+
+add_task(async function test_unconfigured_initial_state() {
+ await clearAllParentTelemetryEvents();
+ // test with the pref set to show FEATURE TOUR CALLOUT
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.firefox-view.feature-tour",
+ JSON.stringify({
+ screen: `FEATURE_CALLOUT_1`,
+ complete: false,
+ }),
+ ],
+ ],
+ });
+ const sandbox = setupMocks({
+ state: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: false,
+ });
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view1",
+ });
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ 'button[data-action="view1-primary-action"]',
+ {},
+ browser
+ );
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 2;
+ },
+ "Waiting for entered and fxa_continue firefoxview telemetry events.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FXA_CONTINUE_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_signed_in() {
+ await clearAllParentTelemetryEvents();
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view2",
+ });
+ is(
+ fxAccounts.device.recentDeviceList?.length,
+ 1,
+ "Just 1 device connected"
+ );
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ 'button[data-action="view2-primary-action"]',
+ {},
+ browser
+ );
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 2;
+ },
+ "Waiting for entered and fxa_mobile firefoxview telemetry events.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FXA_MOBILE_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_support_links() {
+ await clearAllParentTelemetryEvents();
+ setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ ],
+ });
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view2",
+ });
+ const { document } = browser.contentWindow;
+ const container = document.getElementById("tab-pickup-container");
+ const supportLinks = Array.from(
+ container.querySelectorAll("a[href]")
+ ).filter(a => !!a.href);
+ is(supportLinks.length, 2, "Support links have non-empty hrefs");
+ });
+});
+
+add_task(async function test_2nd_desktop_connected() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ },
+ ],
+ });
+ await withFirefoxView({}, async browser => {
+ // ensure tab sync is false so we don't skip onto next step
+ ok(
+ !Services.prefs.getBoolPref("services.sync.engine.tabs", false),
+ "services.sync.engine.tabs is initially false"
+ );
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view3",
+ });
+
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+ ok(
+ fxAccounts.device.recentDeviceList?.every(
+ device => device.type !== "mobile" && device.type !== "tablet"
+ ),
+ "No connected device is type:mobile or type:tablet"
+ );
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_mobile_connected() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "mobile",
+ },
+ ],
+ });
+ await withFirefoxView({}, async browser => {
+ // ensure tab sync is false so we don't skip onto next step
+ ok(
+ !Services.prefs.getBoolPref("services.sync.engine.tabs", false),
+ "services.sync.engine.tabs is initially false"
+ );
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view3",
+ });
+
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+ ok(
+ fxAccounts.device.recentDeviceList?.some(
+ device => device.type == "mobile"
+ ),
+ "A connected device is type:mobile"
+ );
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_tablet_connected() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "tablet",
+ },
+ ],
+ });
+ await withFirefoxView({}, async browser => {
+ // ensure tab sync is false so we don't skip onto next step
+ ok(
+ !Services.prefs.getBoolPref("services.sync.engine.tabs", false),
+ "services.sync.engine.tabs is initially false"
+ );
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view3",
+ });
+
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+ ok(
+ fxAccounts.device.recentDeviceList?.some(
+ device => device.type == "tablet"
+ ),
+ "A connected device is type:tablet"
+ );
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_tab_sync_enabled() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "mobile",
+ },
+ ],
+ });
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ // test initial state, with the pref not enabled
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view3",
+ });
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ // test with the pref toggled on
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.tabs", true]],
+ });
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ // reset and test clicking the action button
+ await SpecialPowers.popPrefEnv();
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view3",
+ });
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ const actionButton = browser.contentWindow.document.querySelector(
+ "#tabpickup-steps-view3 button.primary"
+ );
+ actionButton.click();
+
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ await waitForElementVisible(browser, ".featureCallout .FEATURE_CALLOUT_1");
+ ok(true, "Tab pickup product tour screen renders when sync is enabled");
+ ok(
+ Services.prefs.getBoolPref("services.sync.engine.tabs", false),
+ "tab sync pref should be enabled after button click"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_mobile_promo() {
+ const sandbox = await setupWithDesktopDevices();
+ await withFirefoxView({}, async browser => {
+ // ensure last tab fetch was just now so we don't get the loading state
+ await touchLastTabFetch();
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForElementVisible(browser, ".synced-tabs-container");
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+
+ info("checking mobile promo, should be visible now");
+ checkMobilePromo(browser, {
+ mobilePromo: true,
+ mobileConfirmation: false,
+ });
+
+ gMockFxaDevices.push({
+ id: 3,
+ name: "Mobile Device",
+ type: "mobile",
+ });
+
+ Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
+
+ // Wait for the async refreshDeviceList(),
+ // which should result in the promo being hidden
+ await waitForElementVisible(
+ browser,
+ "#tab-pickup-container > .promo-box",
+ false
+ );
+ is(fxAccounts.device.recentDeviceList?.length, 3, "3 devices connected");
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: true,
+ });
+
+ info("checking mobile promo disappears on log out");
+ gMockFxaDevices.pop();
+ Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
+ await waitForElementVisible(
+ browser,
+ "#tab-pickup-container > .promo-box",
+ true
+ );
+ checkMobilePromo(browser, {
+ mobilePromo: true,
+ mobileConfirmation: false,
+ });
+
+ // Set the UIState to what we expect when the user signs out
+ gUIStateStatus = UIState.STATUS_NOT_CONFIGURED;
+ gUIStateSyncEnabled = undefined;
+
+ info(
+ "notifying that we've signed out of fxa, UIState.get().status:" +
+ UIState.get().status
+ );
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ info("waiting for setup card 1 to appear again");
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view1",
+ });
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_mobile_promo_pref() {
+ const sandbox = await setupWithDesktopDevices();
+ await SpecialPowers.pushPrefEnv({
+ set: [[MOBILE_PROMO_DISMISSED_PREF, true]],
+ });
+ await withFirefoxView({}, async browser => {
+ // ensure tab sync is false so we don't skip onto next step
+ info("starting test, will notify of UIState update");
+ // ensure last tab fetch was just now so we don't get the loading state
+ await touchLastTabFetch();
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForElementVisible(browser, ".synced-tabs-container");
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+
+ info("checking mobile promo, should be still hidden because of the pref");
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ // reset the dismissed pref, which should case the promo to get shown
+ await SpecialPowers.pushPrefEnv({
+ set: [[MOBILE_PROMO_DISMISSED_PREF, false]],
+ });
+ await waitForElementVisible(
+ browser,
+ "#tab-pickup-container > .promo-box",
+ true
+ );
+
+ const promoElem = browser.contentWindow.document.querySelector(
+ "#tab-pickup-container > .promo-box"
+ );
+ const promoElemClose = promoElem.querySelector(".close");
+ ok(promoElemClose.hasAttribute("aria-label"), "Button has an a11y name");
+ // check that dismissing the promo sets the pref
+ info("Clicking the promo close button: " + promoElemClose);
+ EventUtils.sendMouseEvent({ type: "click" }, promoElemClose);
+
+ info("Check the promo box got hidden");
+ BrowserTestUtils.is_hidden(promoElem);
+ ok(
+ SpecialPowers.getBoolPref(MOBILE_PROMO_DISMISSED_PREF),
+ "Promo pref is updated when close is clicked"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_mobile_promo_windows() {
+ // make sure interacting with the promo and success confirmation in one window
+ // also updates the others
+ const sandbox = await setupWithDesktopDevices();
+ await withFirefoxView({}, async browser => {
+ // ensure last tab fetch was just now so we don't get the loading state
+ await touchLastTabFetch();
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ await waitForElementVisible(browser, ".synced-tabs-container");
+ is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected");
+
+ info("checking mobile promo is visible");
+ checkMobilePromo(browser, {
+ mobilePromo: true,
+ mobileConfirmation: false,
+ });
+
+ info(
+ "opening new window, pref is: " +
+ SpecialPowers.getBoolPref("browser.tabs.firefox-view")
+ );
+
+ info("Got window, now opening Firefox View in it");
+ await withFirefoxView(
+ { openNewWindow: true, resetFlowManager: false },
+ async win2Browser => {
+ info("In withFirefoxView taskFn for win2");
+ // promo should be visible in the 2nd window too
+ info("check mobile promo is visible in the new window");
+ checkMobilePromo(win2Browser, {
+ mobilePromo: true,
+ mobileConfirmation: false,
+ });
+
+ // add the mobile device to get the success confirmation in both instances
+ info("add a mobile device and send device_connected notification");
+ gMockFxaDevices.push({
+ id: 3,
+ name: "Mobile Device",
+ type: "mobile",
+ });
+
+ Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
+ is(
+ fxAccounts.device.recentDeviceList?.length,
+ 3,
+ "3 devices connected"
+ );
+
+ // Wait for the async refreshDevices(),
+ // which should result in the promo being hidden
+ info("waiting for the confirmation box to be visible");
+ await waitForElementVisible(
+ win2Browser,
+ "#tab-pickup-container > .promo-box",
+ false
+ );
+
+ for (let fxviewBrowser of [browser, win2Browser]) {
+ info(
+ "checking promo is hidden and confirmation is visible in each window"
+ );
+ checkMobilePromo(fxviewBrowser, {
+ mobilePromo: false,
+ mobileConfirmation: true,
+ });
+ }
+
+ // dismiss the confirmation and check its gone from both instances
+ const confirmBox = win2Browser.contentWindow.document.querySelector(
+ "#tab-pickup-container > .confirmation-message-box"
+ );
+ const closeButton = confirmBox.querySelector(".close");
+ ok(closeButton.hasAttribute("aria-label"), "Button has an a11y name");
+ EventUtils.sendMouseEvent(
+ { type: "click" },
+ closeButton,
+ win2Browser.ownerGlobal
+ );
+ BrowserTestUtils.is_hidden(confirmBox);
+
+ for (let fxviewBrowser of [browser, win2Browser]) {
+ checkMobilePromo(fxviewBrowser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ }
+ }
+ );
+ });
+ await tearDown(sandbox);
+});
+
+async function mockFxaDeviceConnected(win) {
+ // We use an existing tab to navigate to the final "device connected" url
+ // in order to fake the fxa device sync process
+ const url = "https://example.org/pair/auth/complete";
+ is(win.gBrowser.tabs.length, 3, "Tabs strip should contain three tabs");
+
+ BrowserTestUtils.loadURIString(win.gBrowser.selectedTab.linkedBrowser, url);
+
+ await BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedTab.linkedBrowser,
+ null,
+ url
+ );
+
+ is(
+ win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath,
+ "/pair/auth/complete",
+ "/pair/auth/complete is the selected tab"
+ );
+}
+
+add_task(async function test_close_device_connected_tab() {
+ // test that when a device has been connected to sync we close
+ // that tab after the user is directed back to firefox view
+
+ // Ensure we are in the correct state to start the task.
+ TabsSetupFlowManager.resetInternalState();
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.remote.root", "https://example.org/"]],
+ });
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let fxViewTab = await openFirefoxViewTab(win);
+
+ await waitForVisibleSetupStep(win.gBrowser, {
+ expectedVisible: "#tabpickup-steps-view1",
+ });
+
+ let actionButton = win.gBrowser.contentWindow.document.querySelector(
+ "#tabpickup-steps-view1 button.primary"
+ );
+ // initiate the sign in flow from Firefox View, to check that didFxaTabOpen is set
+ let tabSwitched = BrowserTestUtils.waitForEvent(
+ win.gBrowser,
+ "TabSwitchDone"
+ );
+ actionButton.click();
+ await tabSwitched;
+
+ // fake the end point of the device syncing flow
+ await mockFxaDeviceConnected(win);
+ let deviceConnectedTab = win.gBrowser.tabs[2];
+
+ // remove the blank tab opened with the browser to check that we don't
+ // close the window when the "Device connected" tab is closed
+ const newTab = win.gBrowser.tabs.find(
+ tab => tab != deviceConnectedTab && tab != fxViewTab
+ );
+ let removedTab = BrowserTestUtils.waitForTabClosing(newTab);
+ BrowserTestUtils.removeTab(newTab);
+ await removedTab;
+
+ is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs");
+
+ is(
+ win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath,
+ "/pair/auth/complete",
+ "/pair/auth/complete is the selected tab"
+ );
+
+ // we use this instead of BrowserTestUtils.switchTab to get back to the firefox view tab
+ // because this more accurately reflects how this tab is selected - via a custom onmousedown
+ // and command that calls FirefoxViewHandler.openTab (both when the user manually clicks the tab
+ // and when navigating from the fxa Device Connected tab, which also calls FirefoxViewHandler.openTab)
+ await EventUtils.synthesizeMouseAtCenter(
+ win.document.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ win
+ );
+
+ is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs");
+
+ is(
+ win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath,
+ "firefoxview",
+ "First tab is Firefox view"
+ );
+
+ is(
+ win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath,
+ "newtab",
+ "Second tab is about:newtab"
+ );
+
+ // now simulate the signed-in state with the prompt to download
+ // and sync mobile
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ ],
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForVisibleSetupStep(win.gBrowser, {
+ expectedVisible: "#tabpickup-steps-view2",
+ });
+
+ actionButton = win.gBrowser.contentWindow.document.querySelector(
+ "#tabpickup-steps-view2 button.primary"
+ );
+ // initiate the connect device (mobile) flow from Firefox View, to check that didFxaTabOpen is set
+ tabSwitched = BrowserTestUtils.waitForEvent(win.gBrowser, "TabSwitchDone");
+ actionButton.click();
+ await tabSwitched;
+ // fake the end point of the device syncing flow
+ await mockFxaDeviceConnected(win);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ win.document.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ win
+ );
+ is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs");
+
+ is(
+ win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath,
+ "firefoxview",
+ "First tab is Firefox view"
+ );
+
+ is(
+ win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath,
+ "newtab",
+ "Second tab is about:newtab"
+ );
+
+ // cleanup time
+ await tearDown(sandbox);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js
new file mode 100644
index 0000000000..e302b3dee9
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js
@@ -0,0 +1,176 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+});
+
+async function tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+}
+
+function checkLoadingState(browser, isLoading = false) {
+ const { document } = browser.contentWindow;
+ const tabsContainer = document.querySelector("#tabpickup-tabs-container");
+ const tabsList = document.querySelector(
+ "#tabpickup-tabs-container tab-pickup-list"
+ );
+ const loadingElem = document.querySelector(
+ "#tabpickup-tabs-container .loading-content"
+ );
+ const setupElem = document.querySelector("#tabpickup-steps");
+
+ if (isLoading) {
+ ok(
+ tabsContainer.classList.contains("loading"),
+ "Tabs container has loading class"
+ );
+ BrowserTestUtils.is_visible(
+ loadingElem,
+ "Loading content is visible when loading"
+ );
+ !tabsList ||
+ BrowserTestUtils.is_hidden(
+ tabsList,
+ "Synced tabs list is not visible when loading"
+ );
+ !setupElem ||
+ BrowserTestUtils.is_hidden(
+ setupElem,
+ "Setup content is not visible when loading"
+ );
+ } else {
+ ok(
+ !tabsContainer.classList.contains("loading"),
+ "Tabs container has no loading class"
+ );
+ !loadingElem ||
+ BrowserTestUtils.is_hidden(
+ loadingElem,
+ "Loading content is not visible when tabs are loaded"
+ );
+ BrowserTestUtils.is_visible(
+ tabsList,
+ "Synced tabs list is visible when loaded"
+ );
+ !setupElem ||
+ BrowserTestUtils.is_hidden(
+ setupElem,
+ "Setup content is not visible when tabs are loaded"
+ );
+ }
+}
+
+function setupMocks(recentTabs, syncEnabled = true) {
+ const sandbox = (gSandbox = setupRecentDeviceListMocks());
+ sandbox.stub(SyncedTabs, "getRecentTabs").callsFake(() => {
+ info(
+ `SyncedTabs.getRecentTabs will return a promise resolving to ${recentTabs.length} tabs`
+ );
+ return Promise.resolve(recentTabs);
+ });
+ return sandbox;
+}
+
+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 () {
+ Services.prefs.clearUserPref("services.sync.engine.tabs");
+ await tearDown(gSandbox);
+ });
+});
+
+add_task(async function test_tab_sync_loading() {
+ // empty synced tabs, so we're relying on tabs.changed or sync:finish notifications to clear the waiting state
+ const recentTabsData = [];
+ const sandbox = setupMocks(recentTabsData);
+ // stub syncTabs so it resolves to true - meaning yes it will trigger a sync, which is the case
+ // we want to cover in this test.
+ sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true);
+
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ const { document } = browser.contentWindow;
+ const tabsContainer = document.querySelector("#tabpickup-tabs-container");
+
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ await waitForElementVisible(browser, "#tabpickup-tabs-container", true);
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+
+ ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true");
+ checkLoadingState(browser, true);
+
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabsContainer,
+ { attributeFilter: ["class"], attributes: true },
+ () => {
+ return !tabsContainer.classList.contains("loading");
+ }
+ );
+ checkLoadingState(browser, false);
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_tab_no_sync() {
+ // Ensure we take down the waiting message if SyncedTabs determines it doesnt need to sync
+ const recentTabsData = structuredClone(syncedTabsData1[0].tabs);
+ const sandbox = setupMocks(recentTabsData);
+ // stub syncTabs so it resolves to false - meaning it will not trigger a sync, which is the case
+ // we want to cover in this test.
+ sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(false);
+
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ await waitForElementVisible(browser, "#tabpickup-tabs-container", true);
+
+ ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false");
+ checkLoadingState(browser, false);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_recent_tabs_loading() {
+ // Simulate stale data by setting lastTabFetch to 10mins ago
+ const TEN_MINUTES_MS = 1000 * 60 * 10;
+ const staleFetchSeconds = Math.floor((Date.now() - TEN_MINUTES_MS) / 1000);
+ info("updating lastFetch:" + staleFetchSeconds);
+ Services.prefs.setIntPref("services.sync.lastTabFetch", staleFetchSeconds);
+
+ // cached tabs data is available, so we shouldn't wait on lastTabFetch pref value
+ const recentTabsData = structuredClone(syncedTabsData1[0].tabs);
+ const sandbox = setupMocks(recentTabsData);
+
+ await withFirefoxView({}, async browser => {
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await waitForElementVisible(browser, "#tabpickup-steps", false);
+ await waitForElementVisible(browser, "#tabpickup-tabs-container", true);
+ checkMobilePromo(browser, {
+ mobilePromo: false,
+ mobileConfirmation: false,
+ });
+ checkLoadingState(browser, false);
+ });
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js
new file mode 100644
index 0000000000..d2dc76974c
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var gSandbox;
+
+add_setup(async function () {
+ Services.prefs.lockPref("identity.fxaccounts.enabled");
+
+ registerCleanupFunction(() => {
+ gSandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+ Services.prefs.unlockPref("identity.fxaccounts.enabled");
+ Services.prefs.clearUserPref("identity.fxaccounts.enabled");
+ // 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();
+});
+
+add_task(async function test_sync_admin_disabled() {
+ const sandbox = (gSandbox = sinon.createSandbox());
+ sandbox.stub(UIState, "get").callsFake(() => {
+ return {
+ status: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: false,
+ };
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ is(
+ Services.prefs.getBoolPref("identity.fxaccounts.enabled"),
+ true,
+ "Expected identity.fxaccounts.enabled pref to be false"
+ );
+
+ is(
+ Services.prefs.prefIsLocked("identity.fxaccounts.enabled"),
+ true,
+ "Expected identity.fxaccounts.enabled pref to be locked"
+ );
+
+ await waitForVisibleSetupStep(browser, {
+ expectedVisible: "#tabpickup-steps-view0",
+ });
+
+ const errorStateHeader = document.querySelector(
+ "#tabpickup-steps-view0-header"
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ errorStateHeader,
+ { childList: true },
+ () => errorStateHeader.textContent.includes("disabled")
+ );
+
+ ok(
+ errorStateHeader
+ .getAttribute("data-l10n-id")
+ .includes("fxa-admin-disabled"),
+ "Correct message should show when fxa is disabled by an admin"
+ );
+ });
+ Services.prefs.unlockPref("identity.fxaccounts.enabled");
+});
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..021fd01bc2
--- /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.loadURIString(win.gBrowser.selectedBrowser, URL);
+ await loaded;
+ info("Opening new browser tab...");
+ const secondTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ URL
+ );
+ info("Close all broswer 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/browser_tab_pickup_device_added_telemetry.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js
new file mode 100644
index 0000000000..ce77090077
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js
@@ -0,0 +1,278 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ await clearAllParentTelemetryEvents();
+ cleanup_tab_pickup();
+});
+
+function setupWithFxaDevices() {
+ const sandbox = (gSandbox = setupSyncFxAMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "My desktop",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other device",
+ isCurrentDevice: false,
+ type: "mobile",
+ },
+ ],
+ }));
+ return sandbox;
+}
+
+const mockDesktopTab1 = {
+ client: "6c12bonqXZh8",
+ device: "My desktop",
+ deviceType: "desktop",
+ type: "tab",
+ title: "Example2",
+ url: "https://example.com",
+ icon: "https://example/favicon.png",
+ lastUsed: Math.floor((Date.now() - 1000 * 60) / 1000), // This is one minute from now, which is below the threshold for 'Just now'
+};
+
+const mockDesktopTab2 = {
+ client: "6c12bonqXZh8",
+ device: "My desktop",
+ deviceType: "desktop",
+ 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
+};
+
+const mockMobileTab1 = {
+ client: "9d0y686hBXel",
+ device: "My phone",
+ deviceType: "mobile",
+ type: "tab",
+ title: "Element",
+ url: "https://chat.mozilla.org/#room:mozilla.org",
+ icon: "https://chat.mozilla.org/vector-icons/favicon.ico",
+ lastUsed: 1664571288,
+};
+
+const NO_TABS_EVENTS = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }],
+];
+const SINGLE_TAB_EVENTS = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }],
+];
+const DEVICE_ADDED_NO_TABS_EVENTS = [
+ ["firefoxview", "synced_tabs", "tabs", undefined, undefined],
+ ["firefoxview", "synced_tabs_empty", "since_device_added", undefined],
+];
+const DEVICE_ADDED_TABS_EVENTS = [
+ ["firefoxview", "synced_tabs", "tabs", undefined, undefined],
+];
+
+async function whenResolved(functionSpy, functionLabel) {
+ info(`Waiting for ${functionLabel} to be called`);
+ await TestUtils.waitForCondition(
+ () => functionSpy.called,
+ `Waiting for ${functionLabel} to be called`
+ );
+ is(
+ functionSpy.getCall(0).returnValue.constructor.name,
+ "Promise",
+ `${functionLabel} returned a promise`
+ );
+ info(`Waiting for the promise returned by ${functionLabel} to be resolved`);
+ await functionSpy.getCall(0).returnValue;
+ info(`${functionLabel} promise resolved`);
+}
+
+async function test_device_added({
+ initialRecentTabsResult,
+ expectedInitialTelementryEvents,
+ expectedDeviceAddedTelementryEvents,
+}) {
+ const recentTabsResult = initialRecentTabsResult;
+ await clearAllParentTelemetryEvents();
+ const sandbox = setupWithFxaDevices();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${recentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(recentTabsResult);
+ });
+
+ ok(
+ !isFirefoxViewTabSelected(),
+ "Before we call withFirefoxView, about:firefoxview tab is not selected"
+ );
+ ok(
+ !TabsSetupFlowManager.hasVisibleViews,
+ "Initially hasVisibleViews is false"
+ );
+
+ await withFirefoxView({}, async browser => {
+ info("inside withFirefoxView taskFn, waiting for setupListState");
+ const { document } = browser.contentWindow;
+ const stopWaitingSpy = sandbox.spy(
+ TabsSetupFlowManager,
+ "stopWaitingForTabs"
+ );
+ const signedInChangeSpy = sandbox.spy(
+ TabsSetupFlowManager,
+ "onSignedInChange"
+ );
+
+ await setupListState(browser);
+ info("setupListState finished");
+
+ // ensure any tab syncs triggered by Fxa sign-in are complete before proceeding
+ await whenResolved(signedInChangeSpy, "onSignedInChange");
+ if (!recentTabsResult.length) {
+ info("No synced tabs so we wait for the result of the sync we trigger");
+ await whenResolved(stopWaitingSpy, "stopWaitingForTabs");
+ info("stopWaitingForTabs finished");
+ }
+
+ const isTablistVisible = !!initialRecentTabsResult.length;
+ testVisibility(browser, {
+ expectedVisible: {
+ "ol.synced-tabs-list": isTablistVisible,
+ "#synced-tabs-placeholder": !isTablistVisible,
+ },
+ });
+ const syncedTabsItems = document.querySelectorAll(
+ "ol.synced-tabs-list > li:not(.synced-tab-li-placeholder)"
+ );
+ info(
+ "list items: " +
+ Array.from(syncedTabsItems)
+ .map(li => `li.${li.className}`)
+ .join(", ")
+ );
+ is(
+ syncedTabsItems.length,
+ initialRecentTabsResult.length,
+ `synced-tabs-list should have initial count of ${initialRecentTabsResult.length} non-placeholder list items`
+ );
+
+ // confirm telemetry is in expected state?
+ info(
+ "Checking telemetry against expectedInitialTelementryEvents: " +
+ JSON.stringify(expectedInitialTelementryEvents, null, 2)
+ );
+ TelemetryTestUtils.assertEvents(
+ expectedInitialTelementryEvents,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ // add a new mock device
+ info("Adding a new mock fxa dedvice");
+ gMockFxaDevices.push({
+ id: 1,
+ name: "My primary phone",
+ isCurrentDevice: false,
+ type: "mobile",
+ });
+
+ const startWaitingSpy = sandbox.spy(
+ TabsSetupFlowManager,
+ "startWaitingForNewDeviceTabs"
+ );
+ // Notify of the newly added device
+ info("Notifying devicelist_updated with the new mobile device");
+ Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
+
+ // Some time passes here waiting for sync to get data from that device
+ // we expect new-device handling to kick in. If there are 0 tabs we'll signal we're waiting,
+ // create a timestamp and only clear it when there are > 0 tabs.
+ // If there are already > 0 tabs, we'll basically do nothing, showing any new tabs when they arrive
+ await whenResolved(startWaitingSpy, "startWaitingForNewDeviceTabs");
+
+ info(
+ "Initial tabs count: " +
+ recentTabsResult.length +
+ ", assert on _noTabsVisibleFromAddedDeviceTimestamp: " +
+ TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp
+ );
+ if (recentTabsResult.length) {
+ ok(
+ !TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp,
+ "Should not be waiting if there were > 0 tabs initially"
+ );
+ } else {
+ ok(
+ TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp,
+ "Should be waiting if there were 0 tabs initially"
+ );
+ }
+
+ // Add tab data from this new device and notify of the changed data
+ recentTabsResult.push(mockMobileTab1);
+ stopWaitingSpy.resetHistory();
+
+ info("Notifying tabs.changed with the new mobile device's tabs");
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ // handling the tab.change and clearing the timestamp is necessarily async
+ // as counting synced tabs via getRecentTabs() is async.
+ // There may not be any outcome depending on the tab state, so we just wait
+ // for stopWaitingForTabs to get called and its promise to resolve
+ info("Waiting for the stopWaitingSpy to be called");
+ await whenResolved(stopWaitingSpy, "stopWaitingForTabs");
+ await TestUtils.waitForTick(); // allow time for the telemetry event to get recorded
+
+ info(
+ "We've added a synced tab and updated the tab list, got snapshotEvents:" +
+ JSON.stringify(
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ),
+ null,
+ 2
+ )
+ );
+ // confirm no telemetry was recorded for tabs from the newly-added device
+ // as the tab list was never empty
+ info(
+ "Checking telemetry against expectedDeviceAddedTelementryEvents: " +
+ JSON.stringify(expectedDeviceAddedTelementryEvents, null, 2)
+ );
+ TelemetryTestUtils.assertEvents(
+ expectedDeviceAddedTelementryEvents,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+ });
+ sandbox.restore();
+ cleanup_tab_pickup();
+}
+
+add_task(async function test_device_added_with_existing_tabs() {
+ /* Confirm that no telemetry is recorded when a new device is added while the synced tabs list has tabs */
+ await test_device_added({
+ initialRecentTabsResult: [mockDesktopTab1],
+ expectedInitialTelementryEvents: SINGLE_TAB_EVENTS,
+ expectedDeviceAddedTelementryEvents: DEVICE_ADDED_TABS_EVENTS,
+ });
+});
+
+add_task(async function test_device_added_with_empty_list() {
+ /* Confirm that telemetry is recorded when a device is added and the synced tabs list
+ is empty until its tabs get synced
+ */
+ await test_device_added({
+ initialRecentTabsResult: [],
+ expectedInitialTelementryEvents: NO_TABS_EVENTS,
+ expectedDeviceAddedTelementryEvents: DEVICE_ADDED_NO_TABS_EVENTS,
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js
new file mode 100644
index 0000000000..875b8a5a10
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js
@@ -0,0 +1,794 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+});
+
+const twoTabs = [
+ {
+ type: "tab",
+ title: "Phabricator Home",
+ url: "https://phabricator.services.mozilla.com/",
+ icon: "https://phabricator.services.mozilla.com/favicon.d25d81d39065.ico",
+ lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT
+ },
+ {
+ type: "tab",
+ title: "Firefox Privacy Notice",
+ url: "https://www.mozilla.org/en-US/privacy/firefox/",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT
+ },
+];
+const syncedTabsData2 = structuredClone(syncedTabsData1);
+syncedTabsData2[1].tabs = [...syncedTabsData2[1].tabs, ...twoTabs];
+
+const syncedTabsData3 = [
+ {
+ 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
+ },
+ ],
+ },
+];
+
+const syncedTabsData4 = structuredClone(syncedTabsData3);
+syncedTabsData4[0].tabs = [...syncedTabsData4[0].tabs, ...twoTabs];
+
+const syncedTabsData5 = [
+ {
+ id: 1,
+ type: "client",
+ name: "My desktop",
+ clientType: "desktop",
+ lastModified: Date.now(),
+ tabs: [
+ {
+ type: "tab",
+ title: "Example2",
+ url: "https://example.com",
+ icon: "https://example/favicon.png",
+ lastUsed: Math.floor((Date.now() - 1000 * 60) / 1000), // This is one minute from now, which is below the threshold for 'Just now'
+ },
+ ],
+ },
+];
+
+const desktopTabs = [
+ {
+ 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 Jan 19 1970 22:55:30 GMT+0000
+ },
+ {
+ type: "tab",
+ title: "Firefox Privacy Notice",
+ url: "https://www.mozilla.org/en-US/privacy/firefox/",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ lastUsed: 1673991540155, // Tue, 17 Jan 2023 16:39:00 GMT
+ },
+ {
+ type: "tab",
+ title: "Bugzilla Main Page",
+ url: "https://bugzilla.mozilla.org/",
+ icon: "https://bugzilla.mozilla.org/extensions/BMO/web/images/favicon.ico",
+ lastUsed: 1673513538000, // Thu, 12 Jan 2023 03:52:18 GMT
+ },
+];
+
+const mobileTabs = [
+ {
+ 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: 1606510800000, // Fri Nov 27 2020 16:00:00 GMT+0000
+ },
+ {
+ type: "tab",
+ title: "Firefox Privacy Notice",
+ url: "https://www.mozilla.org/en-US/privacy/firefox/",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ lastUsed: 1606510800000, // Fri Nov 27 2020 16:00:00 GMT+0000
+ },
+];
+
+const syncedTabsData6 = structuredClone(syncedTabsData1);
+syncedTabsData6[0].tabs = desktopTabs;
+syncedTabsData6[1].tabs = mobileTabs;
+
+const NO_TABS_EVENTS = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }],
+];
+
+const TAB_PICKUP_EVENT = [
+ ["firefoxview", "entered", "firefoxview", undefined],
+ ["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }],
+ [
+ "firefoxview",
+ "tab_pickup",
+ "tabs",
+ undefined,
+ { position: "1", deviceType: "desktop" },
+ ],
+];
+
+const TAB_PICKUP_OPEN_EVENT = [
+ ["firefoxview", "tab_pickup_open", "tabs", "false"],
+];
+
+registerCleanupFunction(async function () {
+ cleanup_tab_pickup();
+});
+
+add_setup(async function setup() {
+ // set updateTimeMs to 0 to prevent unexpected/unrelated DOM mutations during testing
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", 0]],
+ });
+});
+
+add_task(async function test_tab_list_ordering() {
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData1);
+ let mockTabs2 = getMockTabData(syncedTabsData2);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await setupListState(browser);
+
+ testVisibility(browser, {
+ expectedVisible: {
+ "ol.synced-tabs-list": true,
+ },
+ });
+
+ ok(
+ document.querySelector("ol.synced-tabs-list").children.length === 3,
+ "synced-tabs-list should have three list items"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .firstChild.textContent.includes("Internet for people, not profits"),
+ "First list item in synced-tabs-list is in the correct order"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .children[2].textContent.includes("Sandboxes - Sinon.JS"),
+ "Last list item in synced-tabs-list is in the correct order"
+ );
+
+ getRecentTabsResult = mockTabs2;
+ // Initiate a synced tabs update
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ const syncedTabsList = document.querySelector("ol.synced-tabs-list");
+ // first list item has been updated
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsList,
+ { childList: true, subtree: true },
+ () => syncedTabsList.firstChild.textContent.includes("Firefox")
+ );
+
+ ok(
+ document.querySelector("ol.synced-tabs-list").children.length === 3,
+ "Synced-tabs-list should still have three list items"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .children[1].textContent.includes("Phabricator"),
+ "Second list item in synced-tabs-list has been updated"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .children[2].textContent.includes("Internet for people, not profits"),
+ "Last list item in synced-tabs-list has been updated"
+ );
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_empty_list_items() {
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData3);
+ let mockTabs2 = getMockTabData(syncedTabsData4);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await setupListState(browser);
+
+ testVisibility(browser, {
+ expectedVisible: {
+ "ol.synced-tabs-list": true,
+ },
+ });
+
+ ok(
+ document.querySelector("ol.synced-tabs-list").children.length === 3,
+ "synced-tabs-list should have three list items"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .firstChild.textContent.includes("Sandboxes - Sinon.JS"),
+ "First list item in synced-tabs-list is in the correct order"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .children[1].classList.contains("synced-tab-li-placeholder"),
+ "Second list item in synced-tabs-list should be a placeholder"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .lastChild.classList.contains("synced-tab-li-placeholder"),
+ "Last list item in synced-tabs-list should be a placeholder"
+ );
+
+ getRecentTabsResult = mockTabs2;
+ // Initiate a synced tabs update
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ const syncedTabsList = document.querySelector("ol.synced-tabs-list");
+ // first list item has been updated
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsList,
+ { childList: true, subtree: true },
+ () =>
+ syncedTabsList.firstChild.textContent.includes("Firefox Privacy Notice")
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .children[1].textContent.includes("Phabricator"),
+ "Second list item in synced-tabs-list has been updated"
+ );
+
+ ok(
+ document
+ .querySelector("ol.synced-tabs-list")
+ .lastChild.textContent.includes("Sandboxes - Sinon.JS"),
+ "Last list item in synced-tabs-list has been updated"
+ );
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_empty_list() {
+ await clearAllParentTelemetryEvents();
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData([]);
+ let mockTabs2 = getMockTabData(syncedTabsData4);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await setupListState(browser);
+ info("setupListState complete, checking placeholder and list visibility");
+ testVisibility(browser, {
+ expectedVisible: {
+ "#synced-tabs-placeholder": true,
+ "ol.synced-tabs-list": false,
+ },
+ });
+
+ ok(
+ document
+ .querySelector("#synced-tabs-placeholder")
+ .classList.contains("empty-container"),
+ "collapsible container should have correct styling when the list is empty"
+ );
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 2;
+ },
+ "Waiting for entered and synced_tabs firefoxview telemetry events.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ NO_TABS_EVENTS,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ getRecentTabsResult = mockTabs2;
+ // Initiate a synced tabs update
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ const syncedTabsList = document.querySelector("ol.synced-tabs-list");
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsList,
+ { childList: true, subtree: true },
+ () => syncedTabsList.children.length
+ );
+
+ testVisibility(browser, {
+ expectedVisible: {
+ "#synced-tabs-placeholder": false,
+ "ol.synced-tabs-list": true,
+ },
+ });
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_time_updates_correctly() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", 100]],
+ });
+ await clearAllParentTelemetryEvents();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData5);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await setupListState(browser);
+
+ let initialTimeText = document.querySelector(
+ "span.synced-tab-li-time"
+ ).textContent;
+ Assert.stringContains(
+ initialTimeText,
+ "Just now",
+ "synced-tab-li-time text is 'Just now'"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", 100]],
+ });
+
+ const timeLabel = document.querySelector("span.synced-tab-li-time");
+ await BrowserTestUtils.waitForMutationCondition(
+ timeLabel,
+ { childList: true, subtree: true },
+ () => !timeLabel.textContent.includes("now")
+ );
+
+ isnot(
+ timeLabel.textContent,
+ initialTimeText,
+ "synced-tab-li-time text has updated"
+ );
+
+ document.querySelector(".synced-tab-a").click();
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 3;
+ },
+ "Waiting for entered, synced_tabs, and tab_pickup firefoxview telemetry events.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ TAB_PICKUP_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ let gBrowser = browser.getTabBrowser();
+ is(
+ gBrowser.visibleTabs.indexOf(gBrowser.selectedTab),
+ 0,
+ "Tab opened at the beginning of the tab strip"
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+ // make sure we're back on fx-view
+ browser.ownerGlobal.FirefoxViewHandler.openTab();
+
+ info("Waiting for the tab pickup summary to be visible");
+ await waitForElementVisible(browser, "#tab-pickup-container > summary");
+ // click on the details summary and verify telemetry gets logged for this event
+ await clearAllParentTelemetryEvents();
+ info("clicking the summary to collapse it");
+ document.querySelector("#tab-pickup-container > summary").click();
+
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for tab_pickup_open firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ TAB_PICKUP_OPEN_EVENT,
+ { category: "firefoxview" },
+ { clear: true, process: "parent" }
+ );
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Ensure that tabs sync when a user reloads Firefox View.
+ * This is accomplished by asserting that a new set of tabs are loaded
+ * on page reload.
+ */
+add_task(async function test_tabs_sync_on_user_page_reload() {
+ const sandbox = setupRecentDeviceListMocks();
+ sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true);
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData1);
+ let expectedTabsAfterReload = getMockTabData(syncedTabsData3);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ let reloadButton = browser.ownerDocument.getElementById("reload-button");
+
+ await setupListState(browser);
+
+ let tabLoaded = BrowserTestUtils.browserLoaded(browser);
+ EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal);
+ await tabLoaded;
+ // Wait until the window is reloaded, then get the current instance
+ // of the contentWindow
+ const { document } = browser.contentWindow;
+ ok(true, "Firefox View has been reloaded");
+ ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true");
+
+ let waitedForTabs = TestUtils.waitForCondition(() => {
+ return !TabsSetupFlowManager.waitingForTabs;
+ });
+
+ getRecentTabsResult = expectedTabsAfterReload;
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+
+ const syncedTabsList = document.querySelector("ol.synced-tabs-list");
+ // The tab pickup list has been updated
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsList,
+ { childList: true, subtree: true },
+ () =>
+ syncedTabsList.firstChild.textContent.includes("Sandboxes - Sinon.JS")
+ );
+ await waitedForTabs;
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_keyboard_navigation() {
+ 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);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+
+ await setupListState(browser);
+ const tab = (shiftKey = false) => {
+ info(`${shiftKey ? "Shift + Tab" : "Tab"}`);
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey }, win);
+ };
+ const arrowDown = () => {
+ info("Arrow Down");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, win);
+ };
+ const arrowUp = () => {
+ info("Arrow Up");
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, win);
+ };
+ const arrowLeft = () => {
+ info("Arrow Left");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ };
+ const arrowRight = () => {
+ info("Arrow Right");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ };
+
+ let syncedTabsLinks = document
+ .querySelector("ol.synced-tabs-list")
+ .querySelectorAll("a");
+ let summary = document
+ .getElementById("tab-pickup-container")
+ .querySelector("summary");
+ summary.focus();
+ tab();
+ is(
+ syncedTabsLinks[0],
+ document.activeElement,
+ "First synced tab should be focused"
+ );
+ arrowDown();
+ is(
+ syncedTabsLinks[1],
+ document.activeElement,
+ "Second synced tab should be focused"
+ );
+ arrowDown();
+ is(
+ syncedTabsLinks[2],
+ document.activeElement,
+ "Third synced tab should be focused"
+ );
+ arrowDown();
+ is(
+ syncedTabsLinks[2],
+ document.activeElement,
+ "Third synced tab should still be focused"
+ );
+ arrowUp();
+ is(
+ syncedTabsLinks[1],
+ document.activeElement,
+ "Second synced tab should be focused"
+ );
+ arrowLeft();
+ is(
+ syncedTabsLinks[0],
+ document.activeElement,
+ "First synced tab should be focused"
+ );
+ arrowRight();
+ is(
+ syncedTabsLinks[1],
+ document.activeElement,
+ "Second synced tab should be focused"
+ );
+ arrowDown();
+ is(
+ syncedTabsLinks[2],
+ document.activeElement,
+ "Third synced tab should be focused"
+ );
+ arrowLeft();
+ is(
+ syncedTabsLinks[0],
+ document.activeElement,
+ "First synced tab should be focused"
+ );
+
+ tab(true);
+ is(
+ summary,
+ document.activeElement,
+ "Summary element should be focused when shift tabbing away from list"
+ );
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_duplicate_tab_filter() {
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs6 = getMockTabData(syncedTabsData6);
+ let getRecentTabsResult = mockTabs6;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+
+ await withFirefoxView({}, async browser => {
+ await setupListState(browser);
+
+ Assert.equal(
+ mockTabs6[0].title,
+ "Firefox Privacy Notice",
+ `First tab should be ${mockTabs6[0].title}`
+ );
+
+ Assert.equal(
+ mockTabs6[0].lastUsed,
+ 1673991540155,
+ `First tab lastUsed value should be ${mockTabs6[0].lastUsed}`
+ );
+
+ Assert.equal(
+ mockTabs6[1].title,
+ "Bugzilla Main Page",
+ `Second tab should be ${mockTabs6[1].title}`
+ );
+
+ Assert.equal(
+ mockTabs6[1].lastUsed,
+ 1673513538000,
+ `Second tab lastUsed value should be ${mockTabs6[1].lastUsed}`
+ );
+
+ Assert.equal(
+ mockTabs6[2].title,
+ "Internet for people, not profits - Mozilla",
+ `Third tab should be ${mockTabs6[2].title}`
+ );
+
+ Assert.equal(
+ mockTabs6[2].lastUsed,
+ 1606510800000,
+ `Third tab lastUsed value should be ${mockTabs6[2].lastUsed}`
+ );
+
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
+
+add_task(async function test_tabs_dont_update_unnecessarily() {
+ 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);
+ });
+
+ await withFirefoxView({}, async browser => {
+ await setupListState(browser);
+
+ const { document } = browser.contentWindow;
+ const syncedTabsList = document.querySelector("ol.synced-tabs-list");
+
+ Assert.ok(
+ syncedTabsList.children.length === 3,
+ "Tab Pickup list should have three list items"
+ );
+
+ Assert.ok(
+ syncedTabsList.firstChild.textContent.includes(
+ "Internet for people, not profits - Mozilla"
+ ),
+ `First item in the Tab Pickup list is ${mockTabs1[0].title}`
+ );
+
+ Assert.ok(
+ syncedTabsList.children[1].textContent.includes("The Times"),
+ `Second item in Tab Pickup list is ${mockTabs1[1].title}`
+ );
+
+ Assert.ok(
+ syncedTabsList.children[2].textContent.includes("Sandboxes - Sinon.JS"),
+ `Third item in Tab Pickup list is ${mockTabs1[2].title}`
+ );
+
+ let wasMutated = false;
+
+ const callback = mutationList => {
+ // some logging so if this starts to fail we have some clues as to why
+ for (const mutation of mutationList) {
+ if (mutation.type === "childList") {
+ info(
+ "A child node has been added or removed:" + mutation.target.nodeName
+ );
+ } else if (mutation.type === "attributes") {
+ info(`The ${mutation.attributeName} attribute was modified.`);
+ } else if (mutation.type === "characterData") {
+ info(`The characterData was modified.`);
+ }
+ }
+ wasMutated = true;
+ };
+
+ const observer = new MutationObserver(callback);
+
+ observer.observe(syncedTabsList, { childList: true, subtree: true });
+
+ getRecentTabsResult = mockTabs1;
+ const tabPickupList = document.querySelector("tab-pickup-list");
+ const updateTabsListSpy = sandbox.spy(tabPickupList, "updateTabsList");
+
+ // Initiate a synced tabs update
+ Services.obs.notifyObservers(null, "services.sync.tabs.changed");
+ await TestUtils.waitForCondition(() => {
+ return !TabsSetupFlowManager.waitingForTabs;
+ });
+ await TestUtils.waitForCondition(() => updateTabsListSpy.called);
+ Assert.ok(!wasMutated, "The synced tabs list was not mutated");
+
+ observer.disconnect();
+ sandbox.restore();
+ cleanup_tab_pickup();
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js
new file mode 100644
index 0000000000..d9ec59a57f
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
+});
+
+async function setup({ open } = {}) {
+ TabsSetupFlowManager.resetInternalState();
+ // sanity check initial values
+ ok(
+ !TabsSetupFlowManager.hasVisibleViews,
+ "Initially hasVisibleViews is false"
+ );
+ is(
+ TabsSetupFlowManager._viewVisibilityStates.size,
+ 0,
+ "Initially, there are no visible views"
+ );
+ ok(
+ !isFirefoxViewTabSelected(),
+ "During setup, the about:firefoxview tab is not selected"
+ );
+
+ if (typeof open == "undefined") {
+ Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
+ } else {
+ await SpecialPowers.pushPrefEnv({
+ set: [[TAB_PICKUP_STATE_PREF, open]],
+ });
+ }
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(TabsSetupFlowManager, "isTabSyncSetupComplete").get(() => true);
+ return sandbox;
+}
+
+add_task(async function test_tab_pickup_visibility() {
+ /* Confirm the correct number of tab-pickup views are registered as visible */
+ const sandbox = await setup();
+
+ await withFirefoxView({ win: window }, async function (browser) {
+ const { document } = browser.contentWindow;
+ let tabPickupContainer = document.querySelector("#tab-pickup-container");
+
+ ok(tabPickupContainer.open, "Tab Pickup container should be open");
+ ok(isFirefoxViewTabSelected(), "The firefox view tab is selected");
+ ok(TabsSetupFlowManager.hasVisibleViews, "hasVisibleViews");
+ is(TabsSetupFlowManager._viewVisibilityStates.size, 1, "One view");
+
+ info("Opening and switching to different tab to background fx-view");
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:mozilla"
+ );
+ ok(!isFirefoxViewTabSelected(), "The firefox view tab is not selected");
+ ok(
+ !TabsSetupFlowManager.hasVisibleViews,
+ "no view visible when fx-view is not active"
+ );
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await openFirefoxViewTab(newWin);
+
+ ok(
+ isFirefoxViewTabSelected(newWin),
+ "The firefox view tab in the new window is selected"
+ );
+ ok(
+ TabsSetupFlowManager.hasVisibleViews,
+ "view registered as visible when fx-view is opened in a new window"
+ );
+ is(TabsSetupFlowManager._viewVisibilityStates.size, 2, "2 tracked views");
+
+ await BrowserTestUtils.closeWindow(newWin);
+
+ ok(
+ !isFirefoxViewTabSelected(),
+ "The firefox view tab in the original window is not selected"
+ );
+ ok(
+ !TabsSetupFlowManager.hasVisibleViews,
+ "no visible views when fx-view is not the active tab in the remaining window"
+ );
+ is(
+ TabsSetupFlowManager._viewVisibilityStates.size,
+ 1,
+ "Back to one tracked view"
+ );
+
+ // Switch back to FxView:
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ gBrowser.getTabForBrowser(browser)
+ );
+
+ ok(
+ isFirefoxViewTabSelected(),
+ "The firefox view tab in the original window is now selected"
+ );
+ ok(
+ TabsSetupFlowManager.hasVisibleViews,
+ "View visibility updated when we switch tab"
+ );
+ BrowserTestUtils.removeTab(newTab);
+ });
+ sandbox.restore();
+ await SpecialPowers.popPrefEnv();
+ ok(
+ !TabsSetupFlowManager.hasVisibleViews,
+ "View visibility updated after withFirefoxView"
+ );
+});
+
+add_task(async function test_instance_closed() {
+ /* Confirm tab-pickup views are correctly accounted for when toggled closed */
+ const sandbox = await setup({ open: false });
+ await withFirefoxView({ win: window }, async function (browser) {
+ const { document } = browser.contentWindow;
+ info(
+ "tab-pickup.open pref: " +
+ Services.prefs.getBoolPref(
+ "browser.tabs.firefox-view.ui-state.tab-pickup.open"
+ )
+ );
+ info(
+ "isTabSyncSetupComplete: " + TabsSetupFlowManager.isTabSyncSetupComplete
+ );
+ let tabPickupContainer = document.querySelector("#tab-pickup-container");
+ ok(!tabPickupContainer.open, "Tab Pickup container should be closed");
+ info(
+ "_viewVisibilityStates" +
+ JSON.stringify(
+ Array.from(TabsSetupFlowManager._viewVisibilityStates.values()),
+ null,
+ 2
+ )
+ );
+ ok(!TabsSetupFlowManager.hasVisibleViews, "no visible views");
+ is(
+ TabsSetupFlowManager._viewVisibilityStates.size,
+ 1,
+ "One registered view"
+ );
+
+ tabPickupContainer.open = true;
+ await TestUtils.waitForTick();
+ ok(TabsSetupFlowManager.hasVisibleViews, "view visible");
+ });
+ sandbox.restore();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_ui_state.js b/browser/components/firefoxview/tests/browser/browser_ui_state.js
new file mode 100644
index 0000000000..979a8c9d5c
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_ui_state.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_state_prefs_unset() {
+ await SpecialPowers.clearUserPref(TAB_PICKUP_STATE_PREF);
+ await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF);
+
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(true);
+
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+ let recentlyClosedTabsContainer = document.querySelector(
+ "#recently-closed-tabs-container"
+ );
+ ok(
+ recentlyClosedTabsContainer.open,
+ "Recently Closed Tabs should be open if the pref is unset and sync setup is complete"
+ );
+
+ let tabPickupContainer = document.querySelector("#tab-pickup-container");
+ ok(
+ tabPickupContainer.open,
+ "Tab Pickup container should be open if the pref is unset and sync setup is complete"
+ );
+
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_state_prefs_defined() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TAB_PICKUP_STATE_PREF, false],
+ [RECENTLY_CLOSED_STATE_PREF, false],
+ ],
+ });
+
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(true);
+
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+ let recentlyClosedTabsContainer = document.querySelector(
+ "#recently-closed-tabs-container"
+ );
+ ok(
+ !recentlyClosedTabsContainer.getAttribute("open"),
+ "Recently Closed Tabs should not be open if the pref is set to false"
+ );
+
+ let tabPickupContainer = document.querySelector("#tab-pickup-container");
+ ok(
+ !tabPickupContainer.getAttribute("open"),
+ "Tab Pickup container should not be open if the pref is set to false and sync setup is complete"
+ );
+
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_state_pref_set_on_toggle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TAB_PICKUP_STATE_PREF, true],
+ [RECENTLY_CLOSED_STATE_PREF, true],
+ ],
+ });
+
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(true);
+
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+
+ await waitForElementVisible(browser, "#tab-pickup-container > summary");
+
+ document.querySelector("#tab-pickup-container > summary").click();
+
+ document.querySelector("#recently-closed-tabs-container > summary").click();
+
+ // Wait a turn for the click to propagate to the pref.
+ await TestUtils.waitForTick();
+
+ ok(
+ !Services.prefs.getBoolPref(RECENTLY_CLOSED_STATE_PREF),
+ "Hiding the recently closed container should have flipped the UI state pref value"
+ );
+ ok(
+ !Services.prefs.getBoolPref(TAB_PICKUP_STATE_PREF),
+ "Hiding the tab pickup container should have flipped the UI state pref value"
+ );
+
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_state_prefs_ignored_during_sync_setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TAB_PICKUP_STATE_PREF, false],
+ [RECENTLY_CLOSED_STATE_PREF, false],
+ ],
+ });
+ const sandbox = sinon.createSandbox();
+ let setupCompleteStub = sandbox.stub(
+ TabsSetupFlowManager,
+ "isTabSyncSetupComplete"
+ );
+ setupCompleteStub.returns(false);
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+ let recentlyClosedTabsContainer = document.querySelector(
+ "#recently-closed-tabs-container"
+ );
+ ok(
+ !recentlyClosedTabsContainer.open,
+ "Recently Closed Tabs should not be open if the pref is set to false"
+ );
+
+ let tabPickupContainer = document.querySelector("#tab-pickup-container");
+ ok(
+ tabPickupContainer.open,
+ "Tab Pickup container should be open if the pref is set to false but sync setup is not complete"
+ );
+
+ sandbox.restore();
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js
new file mode 100644
index 0000000000..40a8c0cac2
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/head.js
@@ -0,0 +1,550 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+} = ChromeUtils.importESModule(
+ "resource://testing-common/FirefoxViewTestUtils.sys.mjs"
+);
+
+/* exported testVisibility */
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+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://activity-stream/lib/FeatureCalloutMessages.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
+});
+
+const MOBILE_PROMO_DISMISSED_PREF =
+ "browser.tabs.firefox-view.mobilePromo.dismissed";
+const RECENTLY_CLOSED_STATE_PREF =
+ "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open";
+const TAB_PICKUP_STATE_PREF =
+ "browser.tabs.firefox-view.ui-state.tab-pickup.open";
+
+const calloutId = "multi-stage-message-root";
+const calloutSelector = `#${calloutId}.featureCallout`;
+const primaryButtonSelector = `#${calloutId} .primary`;
+
+/**
+ * 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/",
+];
+
+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
+ },
+ {
+ 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
+ },
+ ],
+ },
+ {
+ 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
+ },
+ {
+ 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
+ },
+ ],
+ },
+];
+
+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.is_visible(elem),
+ `Expected ${selector} to be visible`
+ );
+ } else {
+ ok(BrowserTestUtils.is_hidden(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.is_visible(elem)
+ : BrowserTestUtils.is_hidden(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.is_visible(nextStepElem);
+ }
+ );
+
+ for (let elem of stepElems) {
+ if (elem == nextStepElem) {
+ ok(
+ BrowserTestUtils.is_visible(elem),
+ `Expected ${elem.id || elem.className} to be visible`
+ );
+ } else {
+ ok(
+ BrowserTestUtils.is_hidden(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, 3);
+}
+
+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");
+}
+
+function checkMobilePromo(browser, expected = {}) {
+ const { document } = browser.contentWindow;
+ const promoElem = document.querySelector(
+ "#tab-pickup-container > .promo-box"
+ );
+ const successElem = document.querySelector(
+ "#tab-pickup-container > .confirmation-message-box"
+ );
+
+ info("checkMobilePromo: " + JSON.stringify(expected));
+ if (expected.mobilePromo) {
+ ok(BrowserTestUtils.is_visible(promoElem), "Mobile promo is visible");
+ } else {
+ ok(
+ !promoElem || BrowserTestUtils.is_hidden(promoElem),
+ "Mobile promo is hidden"
+ );
+ }
+ if (expected.mobileConfirmation) {
+ ok(
+ BrowserTestUtils.is_visible(successElem),
+ "Success confirmation is visible"
+ );
+ } else {
+ ok(
+ !successElem || BrowserTestUtils.is_hidden(successElem),
+ "Success confirmation is hidden"
+ );
+ }
+}
+
+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,
+ }),
+ };
+ });
+ return sandbox;
+}
+
+async function tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+ Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF);
+}
+
+/**
+ * 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
+ * @return {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 clickPrimaryButton = async doc => {
+ doc.querySelector(primaryButtonSelector).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} 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
+ * @return {Promise} Promise that resolves when the session store
+ * has been updated after closing the tab.
+ */
+async function open_then_close(url) {
+ let { updatePromise } = await BrowserTestUtils.withNewTab(
+ url,
+ async browser => {
+ return {
+ updatePromise: BrowserTestUtils.waitForSessionStoreUpdate({
+ linkedBrowser: browser,
+ }),
+ };
+ }
+ );
+ await updatePromise;
+ return TestUtils.topicObserved("sessionstore-closed-objects-changed");
+}
+
+/**
+ * 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");
+ Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
+}
+
+function isFirefoxViewTabSelected(win = window) {
+ return isFirefoxViewTabSelectedInWindow(win);
+}
+
+registerCleanupFunction(() => {
+ is(
+ typeof SyncedTabs._internal?._createRecentTabsList,
+ "function",
+ "in firefoxview/head.js, SyncedTabs._internal._createRecentTabsList is a function"
+ );
+ // ensure all the stubs are restored, regardless of any exceptions
+ // that might have prevented it
+ gSandbox?.restore();
+});
diff --git a/browser/components/firefoxview/tests/chrome/chrome.ini b/browser/components/firefoxview/tests/chrome/chrome.ini
new file mode 100644
index 0000000000..a6a1475190
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/chrome.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+
+[test_card_container.html]
+[test_fxview_category_navigation.html]
+[test_fxview_tab_list.html]
diff --git a/browser/components/firefoxview/tests/chrome/test_card_container.html b/browser/components/firefoxview/tests/chrome/test_card_container.html
new file mode 100644
index 0000000000..c76c6ec222
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_card_container.html
@@ -0,0 +1,122 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>CardContainer Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <link rel="localization" href="browser/firefoxView.ftl"/>
+ <script type="module" src="chrome://browser/content/firefoxview/card-container.mjs"></script>
+</head>
+<body>
+ <style>
+ </style>
+<p id="display"></p>
+<div id="content">
+ <card-container viewAllPage="history">
+ <h2 slot="header" data-l10n-id="history-header"></h2>
+ <ul slot="main">
+ <li>History Row 1</li>
+ <li>History Row 2</li>
+ <li>History Row 3</li>
+ <li>History Row 4</li>
+ <li>History Row 5</li>
+ </ul>
+ </card-container>
+</div>
+<pre id="test">
+<script class="testbody" type="application/javascript">
+ const cardContainer = document.querySelector("card-container");
+
+ /**
+ * Tests that the card-container can expand and collapse when the summary element is clicked
+ */
+ add_task(async function test_open_close_card() {
+ is(
+ cardContainer.isExpanded,
+ true,
+ "The card-container is expanded initially"
+ );
+
+ // Click the summary to collapse the details disclosure
+ cardContainer.summaryEl.click();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+
+ // Click on the summary again to expand the details disclosure
+ cardContainer.summaryEl.click();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+ });
+
+ /**
+ * Tests keyboard navigation of the card-container component
+ */
+ add_task(async function test_keyboard_navigation() {
+ const tab = async shiftKey => {
+ info(`Tab${shiftKey ? ' + Shift' : ''}`);
+ synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ const enter = async () => {
+ info("Enter");
+ synthesizeKey("KEY_Enter", {});
+ };
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ cardContainer.summaryEl.focus();
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.summaryEl,
+ "Focus should be on the summary element within card-container"
+ );
+
+ // Tab to the 'View all' link
+ await tab();
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.viewAllLink,
+ "Focus should be on the 'View all' link within card-container"
+ );
+
+ // Shift + Tab back to the summary element
+ await tab(true);
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.summaryEl,
+ "Focus should be back on the summary element within card-container"
+ );
+
+ // Select the summary to collapse the details disclosure
+ await enter();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+
+ // Select the summary again to expand the details disclosure
+ await enter();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</pre>
+</body>
+</html>
diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html
new file mode 100644
index 0000000000..d074d96740
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html
@@ -0,0 +1,322 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>FxviewCategoryNavigation Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <script type="module" src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs"></script>
+</head>
+<style>
+body {
+ display: flex;
+}
+#navigation {
+ width: var(--in-content-sidebar-width);
+}
+fxview-category-button[name="category-one"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-two"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-three"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-four"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-five"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+</style>
+<body>
+ <p id="display"></p>
+ <div id="content">
+ <div id="navigation">
+ <fxview-category-navigation>
+ <h2 slot="category-nav-header">Header</h2>
+ <fxview-category-button class="category" slot="category-button" name="category-one">
+ <span class="category-name">Category 1</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-two">
+ <span class="category-name">Category 2</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-three">
+ <span class="category-name">Category 3</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-four">
+ <span class="category-name">Category 4</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-five">
+ <span class="category-name">Category 5</span>
+ </fxview-category-button>
+ </fxview-category-navigation>
+ </div>
+ </div>
+<pre id="test"></pre>
+<script>
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/utilityOverlay.js",
+ this
+ );
+ const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+ );
+
+const fxviewCategoryNav = document.querySelector("fxview-category-navigation");
+
+function isActiveElement(expectedActiveEl) {
+ return expectedActiveEl.getRootNode().activeElement == expectedActiveEl;
+ }
+
+ /**
+ * Tests that the first category is selected by default
+ */
+ add_task(async function test_first_item_selected_by_default() {
+ is(
+ fxviewCategoryNav.categoryButtons.length,
+ 5,
+ "Five category buttons are in the navigation"
+ );
+
+ ok(
+ fxviewCategoryNav.categoryButtons[0].name === fxviewCategoryNav.currentCategory,
+ "The first category button is selected by default"
+ )
+ });
+
+ /**
+ * Tests that categories are selected when clicked
+ */
+ add_task(async function test_select_category() {
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ let secondCategory = fxviewCategoryNav.categoryButtons[1];
+ let categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ secondCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ secondCategory.name === fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+
+ let thirdCategory = fxviewCategoryNav.categoryButtons[2];
+ categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ thirdCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ thirdCategory.name === fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+
+ let firstCategory = fxviewCategoryNav.categoryButtons[0];
+ categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ firstCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ firstCategory.name === fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ });
+
+ /**
+ * Tests that categories are keyboard-navigable
+ */
+ add_task(async function test_keyboard_navigation() {
+ const arrowDown = async () => {
+ info("Arrow down");
+ synthesizeKey("KEY_ArrowDown", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowUp = async () => {
+ info("Arrow up");
+ synthesizeKey("KEY_ArrowUp", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowLeft = async () => {
+ info("Arrow left");
+ synthesizeKey("KEY_ArrowLeft", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowRight = async () => {
+ info("Arrow right");
+ synthesizeKey("KEY_ArrowRight", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ let firstCategory = fxviewCategoryNav.categoryButtons[0];
+ let secondCategory = fxviewCategoryNav.categoryButtons[1];
+ let thirdCategory = fxviewCategoryNav.categoryButtons[2];
+ let fourthCategory = fxviewCategoryNav.categoryButtons[3];
+ let fifthCategory = fxviewCategoryNav.categoryButtons[4];
+
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ firstCategory.focus();
+ await arrowDown();
+ ok(
+ isActiveElement(secondCategory),
+ "The second category button is the active element after first arrow down"
+ );
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowDown();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowDown();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowDown();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is selected"
+ )
+ await arrowDown();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is still selected"
+ )
+ await arrowUp();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowUp();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowUp();
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowUp();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ await arrowUp();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is still selected"
+ )
+
+ // Test navigation with arrow left/right keys
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ firstCategory.focus();
+ await arrowRight();
+ ok(
+ isActiveElement(secondCategory),
+ "The second category button is the active element after first arrow right"
+ );
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowRight();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowRight();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowRight();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is selected"
+ )
+ await arrowRight();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is still selected"
+ )
+ await arrowLeft();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowLeft();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowLeft();
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowLeft();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ await arrowLeft();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is still selected"
+ )
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</body>
+</html>
diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
new file mode 100644
index 0000000000..92a645c431
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
@@ -0,0 +1,465 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>FxviewTabList Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="localization" href="browser/places.ftl">
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <script type="module" src="chrome://browser/content/firefoxview/fxview-tab-list.mjs"></script>
+</head>
+<body>
+ <style>
+ fxview-tab-list.history::part(secondary-button) {
+ background-image: url("chrome://global/skin/icons/more.svg");
+ }
+ </style>
+<p id="display"></p>
+<div id="content" style="max-width: 750px">
+ <fxview-tab-list class="history" .dateTimeFormat="relative" .hasPopup="menu">
+ <panel-list slot="menu">
+ <panel-item data-l10n-id="fxviewtabrow-delete"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-forget-about-this-site"></panel-item>
+ <hr />
+ <panel-item data-l10n-id="fxviewtabrow-open-in-window"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-open-in-private-window"></panel-item>
+ <hr />
+ <panel-item data-l10n-id="fxviewtabrow-add-bookmark"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-save-to-pocket"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-copy-link"></panel-item>
+ </panel-list>
+ </fxview-tab-list>
+</div>
+<pre id="test">
+<script class="testbody" type="application/javascript">
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/utilityOverlay.js",
+ this
+ );
+
+ const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+ );
+ const { PlacesQuery } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesQuery.sys.mjs"
+ );
+ const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+ );
+ const { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+ );
+ const { PlacesUIUtils } = ChromeUtils.importESModule(
+ "resource:///modules/PlacesUIUtils.sys.mjs"
+ );
+ const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+ );
+ const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+ );
+
+ const fxviewTabList = document.querySelector("fxview-tab-list");
+ let tabItems = [];
+ const placesQuery = new PlacesQuery();
+
+ const URLs = [
+ "http://mochi.test:8888/browser/",
+ "https://www.example.com/",
+ "https://example.net/",
+ "https://example.org/",
+ "https://www.mozilla.org/"
+ ];
+
+ async function addHistoryItems() {
+ await PlacesUtils.history.clear();
+ let history = await placesQuery.getHistory();
+
+ const now = new Date();
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: now }],
+ });
+ let historyUpdated = PromiseUtils.defer();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[1],
+ title: "Example Domain 2",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+ historyUpdated = PromiseUtils.defer();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[2],
+ title: "Example Domain 3",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+ historyUpdated = PromiseUtils.defer();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[3],
+ title: "Example Domain 4",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+
+ let normalized = normalizeHistoryData(history);
+ fxviewTabList.tabItems = normalized;
+
+ await fxviewTabList.getUpdateComplete();
+ tabItems = Array.from(fxviewTabList.rowEls);
+ }
+
+ function normalizeHistoryData(history) {
+ history.forEach(historyItem => {
+ historyItem.time = historyItem.date.getTime();
+ historyItem.icon = `page-icon:${historyItem.url}`;
+ historyItem.primaryL10nId = "fxviewtabrow-tabs-list-tab";
+ historyItem.primaryL10nArgs = JSON.stringify({ targetURI: historyItem.url });
+ historyItem.secondaryL10nId = "fxviewtabrow-open-menu-button";
+ });
+ return history;
+ }
+
+ function getCurrentDisplayDate() {
+ let lastItemMainEl = tabItems[tabItems.length - 1].mainEl;
+ return lastItemMainEl.querySelector("#fxview-tab-row-date span:not([hidden])")?.textContent.trim() ?? "";
+ }
+
+ function getCurrentDisplayTime() {
+ let lastItemMainEl = tabItems[tabItems.length - 1].mainEl;
+ return lastItemMainEl.querySelector("#fxview-tab-row-time")?.textContent.trim() ?? "";
+ }
+
+ function isActiveElement(expectedLinkEl) {
+ return expectedLinkEl.getRootNode().activeElement == expectedLinkEl;
+ }
+
+ function onPrimaryAction(e) {
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ gBrowser.addTrustedTab(e.originalTarget.url);
+ }
+
+ function onSecondaryAction(e) {
+ e.target.querySelector("panel-list").toggle(e.detail.originalEvent);
+ }
+
+ add_setup(function setup() {
+ fxviewTabList.addEventListener("fxview-tab-list-primary-action", onPrimaryAction);
+ fxviewTabList.addEventListener("fxview-tab-list-secondary-action", onSecondaryAction);
+ });
+
+ /**
+ * Tests that history items are loaded in the expected order
+ */
+ add_task(async function test_list_ordering() {
+ await addHistoryItems();
+ is(
+ tabItems.length,
+ 4,
+ "Four history items are shown in the list."
+ );
+
+ // Check ordering
+ ok(
+ tabItems[0].title === "Example Domain 4",
+ "First history item in fxview-tab-list is in the correct order."
+ )
+
+ ok(
+ tabItems[3].title === "Example Domain 1",
+ "Last history item in fxview-tab-list is in the correct order."
+ )
+ });
+
+ /**
+ * Tests the primary action function is triggered when selecting the main row element
+ */
+ add_task(async function test_primary_action(){
+ await addHistoryItems();
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, tabItems[0].url);
+ tabItems[0].mainEl.click();
+ await newTabPromise;
+
+ is(
+ tabItems.length,
+ 4,
+ "Four history items are still shown in the list."
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ });
+
+ /**
+ * Tests that a max tabs length value can be given to fxview-tab-list
+ */
+ add_task(async function test_max_list_items() {
+ const mockMaxTabsLength = 3;
+
+ // override this value for testing purposes
+ fxviewTabList.maxTabsLength = mockMaxTabsLength;
+ await addHistoryItems();
+
+ is(
+ tabItems.length,
+ mockMaxTabsLength,
+ `fxview-tabs-list should have ${mockMaxTabsLength} list items`
+ );
+
+ // Add new history items
+ let history = await placesQuery.getHistory();
+
+ const now = new Date();
+ await PlacesUtils.history.insert({
+ url: URLs[4],
+ title: "Internet for people, not profits - Mozilla",
+ visits: [{ date: now }],
+ });
+ let historyUpdated = PromiseUtils.defer();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await historyUpdated.promise;
+
+ ok(
+ history.length === 5,
+ "Five total history items after inserting another node"
+ );
+
+ // Update fxview-tab-list component with latest history data
+ let normalized = normalizeHistoryData(history);
+ fxviewTabList.tabItems = normalized;
+ await fxviewTabList.getUpdateComplete();
+ tabItems = Array.from(fxviewTabList.rowEls);
+
+ is(
+ tabItems.length,
+ mockMaxTabsLength,
+ `fxview-tabs-list should have ${mockMaxTabsLength} list items`
+ );
+
+ ok(
+ tabItems[0].title === "Internet for people, not profits - Mozilla",
+ "History list has been updated with the expected maxTabsLength."
+ )
+ fxviewTabList.maxTabsLength = 25;
+ });
+
+ /**
+ * Tests keyboard navigation of the fxview-tab-list component
+ */
+ add_task(async function test_keyboard_navigation() {
+ const arrowDown = async () => {
+ info("Arrow down");
+ synthesizeKey("KEY_ArrowDown", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowUp = async () => {
+ info("Arrow up");
+ synthesizeKey("KEY_ArrowUp", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowRight = async () => {
+ info("Arrow right");
+ synthesizeKey("KEY_ArrowRight", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowLeft = async () => {
+ info("Arrow left");
+ synthesizeKey("KEY_ArrowLeft", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+
+ await addHistoryItems();
+ tabItems[0].mainEl.focus();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+
+ // Arrow down/up the list
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[2].mainEl),
+ "Focus should be on the third main element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[3].mainEl),
+ "Focus should be on the fourth main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[2].mainEl),
+ "Focus should be on the third main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+ await arrowRight();
+ ok(
+ isActiveElement(tabItems[0].buttonEl),
+ "Focus should be on the first row's context menu button element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[1].buttonEl),
+ "Focus should be on the second row's context menu button element of the list"
+ );
+ await arrowLeft();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+ });
+
+ /**
+ * Tests relative time format for the fxview-tab-list component
+ */
+ add_task(async function test_relative_format() {
+ await addHistoryItems();
+ ok(
+ getCurrentDisplayDate().includes("Just now"),
+ "Current dateTime format is 'relative' and date displays 'Just now' initially"
+ );
+ ok(
+ !getCurrentDisplayTime().length,
+ "Current dateTime format is 'relative' and time displays an empty string"
+ );
+ });
+
+ /**
+ * Tests date only format for the fxview-tab-list component
+ */
+ add_task(async function test_date_only_format() {
+ await addHistoryItems();
+
+ // Check date only format
+ fxviewTabList.dateTimeFormat = "date";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("/");
+ });
+ ok(
+ getCurrentDisplayDate().includes("/"),
+ "Current dateTime format is 'date' and displays the current date"
+ );
+ ok(
+ !getCurrentDisplayTime().length,
+ "Current dateTime format is 'date' and time displays an empty string"
+ );
+ });
+
+ /**
+ * Tests time only format for the fxview-tab-list component
+ */
+ add_task(async function test_time_only_format() {
+ await addHistoryItems();
+
+ // Check time only format
+ fxviewTabList.dateTimeFormat = "time";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM");
+ });
+ ok(
+ !getCurrentDisplayDate().length,
+ "Current dateTime format is 'time' and date displays an empty string"
+ );
+ ok(
+ getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"),
+ "Current dateTime format is 'time' and displays the current time"
+ );
+ });
+
+ /**
+ * Tests date and time format for the fxview-tab-list component
+ */
+ add_task(async function test_date_and_time_format() {
+ await addHistoryItems();
+
+ // Check date and time format
+ fxviewTabList.dateTimeFormat = "dateTime";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("/") &&
+ (getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"));
+ });
+ ok(
+ getCurrentDisplayDate().includes("/"),
+ "Current dateTime format is 'dateTime' and date displays the current date"
+ );
+ ok(
+ getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"),
+ "Current dateTime format is 'dateTime' and displays the current time"
+ );
+
+ // Reset dateTimeFormat to relative before next test
+ fxviewTabList.dateTimeFormat = "relative";
+ await fxviewTabList.getUpdateComplete();
+ });
+
+ /**
+ * Tests that relative time updates properly for the fxview-tab-list component
+ */
+ add_task(async function test_relative_time_updates() {
+ await addHistoryItems();
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("Just now");
+ });
+
+ ok(
+ getCurrentDisplayDate().includes("Just now"),
+ "Current date element displays 'Just now' initially"
+ );
+
+ // Set the updateTimeMs pref to something low to check that relative time updates properly
+ const TAB_UPDATE_TIME_MS = 500;
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]],
+ });
+ await BrowserTestUtils.waitForCondition(() => {
+ return !getCurrentDisplayDate().includes("now");
+ });
+ info("Currently displayed date is something other than 'Just now'");
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</pre>
+</body>
+</html>