diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/firefoxview/tests | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
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> |