/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ const { withFirefoxView, assertFirefoxViewTab, assertFirefoxViewTabSelected, openFirefoxViewTab, closeFirefoxViewTab, isFirefoxViewTabSelectedInWindow, } = ChromeUtils.importESModule( "resource://testing-common/FirefoxViewTestUtils.sys.mjs" ); /* exported testVisibility */ const { ASRouter } = ChromeUtils.import( "resource://activity-stream/lib/ASRouter.jsm" ); const { UIState } = ChromeUtils.importESModule( "resource://services-sync/UIState.sys.mjs" ); const { sinon } = ChromeUtils.importESModule( "resource://testing-common/Sinon.sys.mjs" ); const { FeatureCalloutMessages } = ChromeUtils.importESModule( "resource://activity-stream/lib/FeatureCalloutMessages.sys.mjs" ); const { TelemetryTestUtils } = ChromeUtils.importESModule( "resource://testing-common/TelemetryTestUtils.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", }); XPCOMUtils.defineLazyModuleGetters(this, { AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm", }); const MOBILE_PROMO_DISMISSED_PREF = "browser.tabs.firefox-view.mobilePromo.dismissed"; const RECENTLY_CLOSED_STATE_PREF = "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; const TAB_PICKUP_STATE_PREF = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; const calloutId = "multi-stage-message-root"; const calloutSelector = `#${calloutId}.featureCallout`; const primaryButtonSelector = `#${calloutId} .primary`; /** * URLs used for browser_recently_closed_tabs_keyboard and * browser_firefoxview_accessibility */ const URLs = [ "http://mochi.test:8888/browser/", "https://www.example.com/", "https://example.net/", "https://example.org/", ]; const syncedTabsData1 = [ { id: 1, type: "client", name: "My desktop", clientType: "desktop", lastModified: 1655730486760, tabs: [ { type: "tab", title: "Sandboxes - Sinon.JS", url: "https://sinonjs.org/releases/latest/sandbox/", icon: "https://sinonjs.org/assets/images/favicon.png", lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 }, { type: "tab", title: "Internet for people, not profits - Mozilla", url: "https://www.mozilla.org/", icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000 }, ], }, { id: 2, type: "client", name: "My iphone", clientType: "phone", lastModified: 1655727832930, tabs: [ { type: "tab", title: "The Guardian", url: "https://www.theguardian.com/", icon: "page-icon:https://www.theguardian.com/", lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000 }, { type: "tab", title: "The Times", url: "https://www.thetimes.co.uk/", icon: "page-icon:https://www.thetimes.co.uk/", lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000 }, ], }, ]; async function clearAllParentTelemetryEvents() { // Clear everything. await TestUtils.waitForCondition(() => { Services.telemetry.clearEvents(); let events = Services.telemetry.snapshotEvents( Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, true ).parent; return !events || !events.length; }); } function testVisibility(browser, expected) { const { document } = browser.contentWindow; for (let [selector, shouldBeVisible] of Object.entries( expected.expectedVisible )) { const elem = document.querySelector(selector); if (shouldBeVisible) { ok( BrowserTestUtils.is_visible(elem), `Expected ${selector} to be visible` ); } else { ok(BrowserTestUtils.is_hidden(elem), `Expected ${selector} to be hidden`); } } } async function waitForElementVisible(browser, selector, isVisible = true) { const { document } = browser.contentWindow; const elem = document.querySelector(selector); if (!isVisible && !elem) { return; } ok(elem, `Got element with selector: ${selector}`); await BrowserTestUtils.waitForMutationCondition( elem, { attributeFilter: ["hidden"], }, () => { return isVisible ? BrowserTestUtils.is_visible(elem) : BrowserTestUtils.is_hidden(elem); } ); } async function waitForVisibleSetupStep(browser, expected) { const { document } = browser.contentWindow; const deck = document.querySelector(".sync-setup-container"); const nextStepElem = deck.querySelector(expected.expectedVisible); const stepElems = deck.querySelectorAll(".setup-step"); await BrowserTestUtils.waitForMutationCondition( deck, { attributeFilter: ["selected-view"], }, () => { return BrowserTestUtils.is_visible(nextStepElem); } ); for (let elem of stepElems) { if (elem == nextStepElem) { ok( BrowserTestUtils.is_visible(elem), `Expected ${elem.id || elem.className} to be visible` ); } else { ok( BrowserTestUtils.is_hidden(elem), `Expected ${elem.id || elem.className} to be hidden` ); } } } var gMockFxaDevices = null; var gUIStateStatus; var gSandbox; function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) { gUIStateStatus = state || UIState.STATUS_SIGNED_IN; if (gSandbox) { gSandbox.restore(); } const sandbox = (gSandbox = sinon.createSandbox()); gMockFxaDevices = fxaDevices; sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); sandbox.stub(UIState, "get").callsFake(() => { return { status: gUIStateStatus, syncEnabled, email: gUIStateStatus === UIState.STATUS_NOT_CONFIGURED ? undefined : "email@example.com", }; }); return sandbox; } function setupRecentDeviceListMocks() { const sandbox = sinon.createSandbox(); sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ { id: 1, name: "My desktop", isCurrentDevice: true, type: "desktop", }, { id: 2, name: "My iphone", type: "mobile", }, ]); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true, email: "email@example.com", }); return sandbox; } function getMockTabData(clients) { return SyncedTabs._internal._createRecentTabsList(clients, 3); } async function setupListState(browser) { // Skip the synced tabs sign up flow to get to a loaded list state await SpecialPowers.pushPrefEnv({ set: [["services.sync.engine.tabs", true]], }); UIState.refresh(); const recentFetchTime = Math.floor(Date.now() / 1000); info("updating lastFetch:" + recentFetchTime); Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); Services.obs.notifyObservers(null, UIState.ON_UPDATE); await waitForElementVisible(browser, "#tabpickup-steps", false); await waitForElementVisible(browser, "#tabpickup-tabs-container", true); const tabsContainer = browser.contentWindow.document.querySelector( "#tabpickup-tabs-container" ); await tabsContainer.tabListAdded; await BrowserTestUtils.waitForMutationCondition( tabsContainer, { attributeFilter: ["class"], attributes: true }, () => { return !tabsContainer.classList.contains("loading"); } ); info("tabsContainer isn't loading anymore, returning"); } function checkMobilePromo(browser, expected = {}) { const { document } = browser.contentWindow; const promoElem = document.querySelector( "#tab-pickup-container > .promo-box" ); const successElem = document.querySelector( "#tab-pickup-container > .confirmation-message-box" ); info("checkMobilePromo: " + JSON.stringify(expected)); if (expected.mobilePromo) { ok(BrowserTestUtils.is_visible(promoElem), "Mobile promo is visible"); } else { ok( !promoElem || BrowserTestUtils.is_hidden(promoElem), "Mobile promo is hidden" ); } if (expected.mobileConfirmation) { ok( BrowserTestUtils.is_visible(successElem), "Success confirmation is visible" ); } else { ok( !successElem || BrowserTestUtils.is_hidden(successElem), "Success confirmation is hidden" ); } } async function touchLastTabFetch() { // lastTabFetch stores a timestamp in *seconds*. const nowSeconds = Math.floor(Date.now() / 1000); info("updating lastFetch:" + nowSeconds); Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds); // wait so all pref observers can complete await TestUtils.waitForTick(); } let gUIStateSyncEnabled; function setupMocks({ fxaDevices = null, state, syncEnabled = true }) { gUIStateStatus = state || UIState.STATUS_SIGNED_IN; gUIStateSyncEnabled = syncEnabled; if (gSandbox) { gSandbox.restore(); } const sandbox = (gSandbox = sinon.createSandbox()); gMockFxaDevices = fxaDevices; sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); sandbox.stub(UIState, "get").callsFake(() => { return { status: gUIStateStatus, // Sometimes syncEnabled is not present on UIState, for example when the user signs // out the state is just { status: "not_configured" } ...(gUIStateSyncEnabled != undefined && { syncEnabled: gUIStateSyncEnabled, }), }; }); return sandbox; } async function tearDown(sandbox) { sandbox?.restore(); Services.prefs.clearUserPref("services.sync.lastTabFetch"); Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF); } /** * Returns a value that can be used to set * `browser.firefox-view.feature-tour` to change the feature tour's * UI state. * * @see FeatureCalloutMessages.sys.mjs for valid values of "screen" * * @param {number} screen The full ID of the feature callout screen * @return {string} JSON string used to set * `browser.firefox-view.feature-tour` */ const getPrefValueByScreen = screen => { return JSON.stringify({ screen: `FEATURE_CALLOUT_${screen}`, complete: false, }); }; /** * Wait for a feature callout screen of given parameters to be shown * @param {Document} doc the document where the callout appears. * @param {String} screenPostfix The full ID of the feature callout screen. */ const waitForCalloutScreen = async (doc, screenPostfix) => { await BrowserTestUtils.waitForCondition(() => doc.querySelector(`${calloutSelector}:not(.hidden) .${screenPostfix}`) ); }; /** * Waits for the feature callout screen to be removed. * * @param {Document} doc The document where the callout appears. */ const waitForCalloutRemoved = async doc => { await BrowserTestUtils.waitForCondition(() => { return !doc.body.querySelector(calloutSelector); }); }; /** * NOTE: Should be replaced with synthesizeMouseAtCenter for * simulating user input. See Bug 1798322 * * Clicks the primary button in the feature callout dialog * * @param {document} doc Firefox View document */ const clickPrimaryButton = async doc => { doc.querySelector(primaryButtonSelector).click(); }; /** * Closes a feature callout via a click to the dismiss button. * * @param {Document} doc The document where the callout appears. */ const closeCallout = async doc => { // close the callout dialog const dismissBtn = doc.querySelector(`${calloutSelector} .dismiss-button`); if (!dismissBtn) { return; } doc.querySelector(`${calloutSelector} .dismiss-button`).click(); await BrowserTestUtils.waitForCondition(() => { return !document.querySelector(calloutSelector); }); }; /** * Get a Feature Callout message by id. * * @param {string} Message id */ const getCalloutMessageById = id => { return { message: FeatureCalloutMessages.getMessages().find(m => m.id === id), }; }; /** * Create a sinon sandbox with `sendTriggerMessage` stubbed * to return a specified test message for featureCalloutCheck. * * @param {object} testMessage * @param {string} [source="about:firefoxview"] */ const createSandboxWithCalloutTriggerStub = ( testMessage, source = "about:firefoxview" ) => { const firefoxViewMatch = sinon.match({ id: "featureCalloutCheck", context: { source }, }); const sandbox = sinon.createSandbox(); const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); sendTriggerStub.withArgs(firefoxViewMatch).resolves(testMessage); sendTriggerStub.callThrough(); return sandbox; }; /** * A helper to check that correct telemetry was sent by AWSendEventTelemetry. * This is a wrapper around sinon's spy functionality. * * @example * let spy = new TelemetrySpy(); * element.click(); * spy.assertCalledWith({ event: "CLICK" }); * spy.restore(); */ class TelemetrySpy { /** * @param {object} [sandbox] A pre-existing sinon sandbox to build the spy in. * If not provided, a new sandbox will be created. */ constructor(sandbox = sinon.createSandbox()) { this.sandbox = sandbox; this.spy = this.sandbox .spy(AboutWelcomeParent.prototype, "onContentMessage") .withArgs("AWPage:TELEMETRY_EVENT"); registerCleanupFunction(() => this.restore()); } /** * Assert that AWSendEventTelemetry sent the expected telemetry object. * @param {Object} expectedData */ assertCalledWith(expectedData) { let match = this.spy.calledWith("AWPage:TELEMETRY_EVENT", expectedData); if (match) { ok(true, "Expected telemetry sent"); } else if (this.spy.called) { ok( false, "Wrong telemetry sent: " + JSON.stringify(this.spy.lastCall.args) ); } else { ok(false, "No telemetry sent"); } } reset() { this.spy.resetHistory(); } restore() { this.sandbox.restore(); } } /** * Helper function to open and close a tab so the recently * closed tabs list can have data. * * @param {string} url * @return {Promise} Promise that resolves when the session store * has been updated after closing the tab. */ async function open_then_close(url) { let { updatePromise } = await BrowserTestUtils.withNewTab( url, async browser => { return { updatePromise: BrowserTestUtils.waitForSessionStoreUpdate({ linkedBrowser: browser, }), }; } ); await updatePromise; return TestUtils.topicObserved("sessionstore-closed-objects-changed"); } /** * Clears session history. Used to clear out the recently closed tabs list. * */ function clearHistory() { Services.obs.notifyObservers(null, "browser:purge-session-history"); } /** * Cleanup function for tab pickup tests. * */ function cleanup_tab_pickup() { Services.prefs.clearUserPref("services.sync.engine.tabs"); Services.prefs.clearUserPref("services.sync.lastTabFetch"); Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF); } function isFirefoxViewTabSelected(win = window) { return isFirefoxViewTabSelectedInWindow(win); } registerCleanupFunction(() => { is( typeof SyncedTabs._internal?._createRecentTabsList, "function", "in firefoxview/head.js, SyncedTabs._internal._createRecentTabsList is a function" ); // ensure all the stubs are restored, regardless of any exceptions // that might have prevented it gSandbox?.restore(); });