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