diff options
Diffstat (limited to 'browser/components/aboutwelcome/tests')
32 files changed, 9232 insertions, 0 deletions
diff --git a/browser/components/aboutwelcome/tests/browser/browser.toml b/browser/components/aboutwelcome/tests/browser/browser.toml new file mode 100644 index 0000000000..22d95272e8 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser.toml @@ -0,0 +1,57 @@ +[DEFAULT] +support-files = [ + "head.js", +] + +prefs = [ + "intl.multilingual.aboutWelcome.languageMismatchEnabled=false", +] + +["browser_aboutwelcome_attribution.js"] +skip-if = [ + "os == 'linux'", # Test setup only implemented for OSX and Windows + "os == 'win' && msix", # These tests rely on the ability to write postSigningData, which we can't do in MSIX builds. https://bugzilla.mozilla.org/show_bug.cgi?id=1805911 +] + +["browser_aboutwelcome_configurable_ui.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1784548 + +["browser_aboutwelcome_fxa_signin_flow.js"] + +["browser_aboutwelcome_glean.js"] + +["browser_aboutwelcome_import.js"] +fail-if = ["a11y_checks"] # Bug 1854514 clicked primary button may not be focusable + +["browser_aboutwelcome_mobile_downloads.js"] + +["browser_aboutwelcome_multistage_addonspicker.js"] + +["browser_aboutwelcome_multistage_default.js"] + +["browser_aboutwelcome_multistage_experimentAPI.js"] + +["browser_aboutwelcome_multistage_languageSwitcher.js"] +skip-if = ["os == 'linux' && bits == 64"] # Bug 1757875 + +["browser_aboutwelcome_multistage_mr.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1812050 + +["browser_aboutwelcome_multistage_video.js"] + +["browser_aboutwelcome_observer.js"] +https_first_disabled = true + +["browser_aboutwelcome_rtamo.js"] +skip-if = [ + "os == 'linux'", # Test setup only implemented for OSX and Windows + "os == 'win' && msix", # These tests rely on the ability to write postSigningData, which we can't do in MSIX builds. https://bugzilla.mozilla.org/show_bug.cgi?id=1805911 +] + +["browser_aboutwelcome_screen_targeting.js"] + +["browser_aboutwelcome_upgrade_multistage_mr.js"] +skip-if = [ + "win11_2009 && debug", + "os == 'linux' && debug", # Bug 1804804 - disabled on win && linux for extremely high failure rate +] diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_attribution.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_attribution.js new file mode 100644 index 0000000000..f0727c9b6f --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_attribution.js @@ -0,0 +1,214 @@ +"use strict"; + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { AttributionCode } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { AddonRepository } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonRepository.sys.mjs" +); + +const TEST_ATTRIBUTION_DATA = { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + // with the sinon override, the id doesn't matter + content: "rta:whatever", +}; + +const TEST_ADDON_INFO = [ + { + name: "Test Add-on", + sourceURI: { scheme: "https", spec: "https://test.xpi" }, + icons: { 32: "test.png", 64: "test.png" }, + type: "extension", + }, +]; + +const TEST_UA_ATTRIBUTION_DATA = { + ua: "chrome", +}; + +const TEST_PROTON_CONTENT = [ + { + id: "AW_STEP1", + content: { + title: "Step 1", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP2", + content: { + title: "Step 2", + primary_button: { + label: { + string_id: "onboarding-multistage-import-primary-button-label", + }, + action: { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + }, + has_noodles: true, + }, + }, +]; + +async function openRTAMOWithAttribution() { + const sandbox = sinon.createSandbox(); + sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO); + + await AttributionCode.deleteFileAsync(); + await ASRouter.forceAttribution(TEST_ATTRIBUTION_DATA); + + AttributionCode._clearCache(); + const data = await AttributionCode.getAttrDataAsync(); + + Assert.equal( + data.source, + "addons.mozilla.org", + "Attribution data should be set" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await ASRouter.forceAttribution(""); + sandbox.restore(); + }); + return tab.linkedBrowser; +} + +/** + * Setup and test RTAMO welcome UI + */ +async function test_screen_content( + browser, + experiment, + expectedSelectors = [], + unexpectedSelectors = [] +) { + await ContentTask.spawn( + browser, + { expectedSelectors, experiment, unexpectedSelectors }, + async ({ + expectedSelectors: expected, + experiment: experimentName, + unexpectedSelectors: unexpected, + }) => { + for (let selector of expected) { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(selector), + `Should render ${selector} in ${experimentName}` + ); + } + for (let selector of unexpected) { + ok( + !content.document.querySelector(selector), + `Should not render ${selector} in ${experimentName}` + ); + } + } + ); +} + +add_task(async function test_rtamo_attribution() { + let browser = await openRTAMOWithAttribution(); + + await test_screen_content( + browser, + "RTAMO UI", + // Expected selectors: + [ + "div.onboardingContainer", + "h2[data-l10n-id='mr1-return-to-amo-addon-title']", + "div.rtamo-icon", + "button.primary", + "button.secondary", + ], + // Unexpected selectors: + [ + "main.AW_STEP1", + "main.AW_STEP2", + "main.AW_STEP3", + "div.tiles-container.info", + ] + ); +}); + +async function openMultiStageWithUserAgentAttribution() { + const sandbox = sinon.createSandbox(); + await ASRouter.forceAttribution(TEST_UA_ATTRIBUTION_DATA); + const TEST_PROTON_JSON = JSON.stringify(TEST_PROTON_CONTENT); + + await setAboutWelcomePref(true); + await pushPrefs(["browser.aboutwelcome.screens", TEST_PROTON_JSON]); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await ASRouter.forceAttribution(""); + sandbox.restore(); + }); + return tab.linkedBrowser; +} + +async function onButtonClick(browser, elementId) { + await ContentTask.spawn( + browser, + { elementId }, + async ({ elementId: buttonId }) => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(buttonId), + buttonId + ); + let button = content.document.querySelector(buttonId); + button.click(); + } + ); +} + +add_task(async function test_ua_attribution() { + let browser = await openMultiStageWithUserAgentAttribution(); + + await test_screen_content( + browser, + "multistage step 1 with ua attribution", + // Expected selectors: + ["div.onboardingContainer", "main.AW_STEP1", "button.primary"], + // Unexpected selectors: + ["main.AW_STEP2"] + ); + + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage step 2 with ua attribution", + // Expected selectors: + [ + "div.onboardingContainer", + "main.AW_STEP2", + "button.primary[data-l10n-args*='Google Chrome']", + ], + // Unexpected selectors: + ["main.AW_STEP1"] + ); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js new file mode 100644 index 0000000000..d53b5acc14 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js @@ -0,0 +1,724 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); + +const BASE_SCREEN_CONTENT = { + title: "Step 1", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, +}; + +const makeTestContent = (id, contentAdditions) => { + return { + id, + content: Object.assign({}, BASE_SCREEN_CONTENT, contentAdditions), + }; +}; + +async function openAboutWelcome(json) { + if (json) { + await setAboutWelcomeMultiStage(json); + } + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab.linkedBrowser; +} + +async function testAboutWelcomeLogoFor(logo = {}) { + info(`Testing logo: ${JSON.stringify(logo)}`); + + let screens = [makeTestContent("TEST_LOGO_SELECTION_STEP", { logo })]; + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { enabled: true, screens }, + }); + + let browser = await openAboutWelcome(JSON.stringify(screens)); + + let expected = [ + `.brand-logo[src="${ + logo.imageURL ?? "chrome://branding/content/about-logo.svg" + }"][alt=""]`, + ]; + let unexpected = []; + (logo.alt ? unexpected : expected).push('.brand-logo[role="presentation"]'); + (logo.width ? expected : unexpected).push(`.brand-logo[style*="width"]`); + (logo.height ? expected : unexpected).push(`.brand-logo[style*="height"]`); + (logo.marginBlock ? expected : unexpected).push( + `.logo-container[style*="margin-block"]` + ); + (logo.marginInline ? expected : unexpected).push( + `.logo-container[style*="margin-inline"]` + ); + (logo.darkModeImageURL ? expected : unexpected).push( + `.logo-container source[media="(prefers-color-scheme: dark)"]${ + logo.darkModeImageURL ? `[srcset="${logo.darkModeImageURL}"]` : "" + }` + ); + (logo.reducedMotionImageURL ? expected : unexpected).push( + `.logo-container source[media="(prefers-reduced-motion: reduce)"]${ + logo.reducedMotionImageURL + ? `[srcset="${logo.reducedMotionImageURL}"]` + : "" + }` + ); + (logo.darkModeReducedMotionImageURL ? expected : unexpected).push( + `.logo-container source[media="(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce)"]${ + logo.darkModeReducedMotionImageURL + ? `[srcset="${logo.darkModeReducedMotionImageURL}"]` + : "" + }` + ); + await test_screen_content( + browser, + "renders screen with passed logo", + expected, + unexpected + ); + + await doExperimentCleanup(); + browser.closeBrowser(); +} + +/** + * Test rendering a screen in about welcome with decorative noodles + */ +add_task(async function test_aboutwelcome_with_noodles() { + const TEST_NOODLE_CONTENT = makeTestContent("TEST_NOODLE_STEP", { + has_noodles: true, + }); + const TEST_NOODLE_JSON = JSON.stringify([TEST_NOODLE_CONTENT]); + let browser = await openAboutWelcome(TEST_NOODLE_JSON); + + await test_screen_content( + browser, + "renders screen with noodles", + // Expected selectors: + [ + "main.TEST_NOODLE_STEP[pos='center']", + "div.noodle.purple-C", + "div.noodle.orange-L", + "div.noodle.outline-L", + "div.noodle.yellow-circle", + ] + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a customized logo + */ +add_task(async function test_aboutwelcome_with_customized_logo() { + const TEST_LOGO_URL = "chrome://branding/content/icon64.png"; + const TEST_LOGO_HEIGHT = "50px"; + const TEST_LOGO_CONTENT = makeTestContent("TEST_LOGO_STEP", { + logo: { + height: TEST_LOGO_HEIGHT, + imageURL: TEST_LOGO_URL, + }, + }); + const TEST_LOGO_JSON = JSON.stringify([TEST_LOGO_CONTENT]); + let browser = await openAboutWelcome(TEST_LOGO_JSON); + + await test_screen_content( + browser, + "renders screen with customized logo", + // Expected selectors: + ["main.TEST_LOGO_STEP[pos='center']", `.brand-logo[src="${TEST_LOGO_URL}"]`] + ); + + // Ensure logo has custom height + await test_element_styles( + browser, + ".brand-logo", + // Expected styles: + { + // Override default text-link styles + height: TEST_LOGO_HEIGHT, + } + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with empty logo used for padding + */ +add_task(async function test_aboutwelcome_with_empty_logo_spacing() { + const TEST_LOGO_HEIGHT = "50px"; + const TEST_LOGO_CONTENT = makeTestContent("TEST_LOGO_STEP", { + logo: { + height: TEST_LOGO_HEIGHT, + imageURL: "none", + }, + }); + const TEST_LOGO_JSON = JSON.stringify([TEST_LOGO_CONTENT]); + let browser = await openAboutWelcome(TEST_LOGO_JSON); + + await test_screen_content( + browser, + "renders screen with empty logo element", + // Expected selectors: + ["main.TEST_LOGO_STEP[pos='center']", ".brand-logo[src='none']"] + ); + + // Ensure logo has custom height + await test_element_styles( + browser, + ".brand-logo", + // Expected styles: + { + // Override default text-link styles + height: TEST_LOGO_HEIGHT, + } + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a "Title Logo" + */ +add_task(async function test_title_logo() { + const TEST_TITLE_LOGO_URL = "chrome://branding/content/icon64.png"; + const TEST_CONTENT = makeTestContent("TITLE_LOGO", { + title_logo: { + imageURL: TEST_TITLE_LOGO_URL, + }, + }); + const TEST_JSON = JSON.stringify([TEST_CONTENT]); + let browser = await openAboutWelcome(TEST_JSON); + + await test_screen_content( + browser, + "renders screen with title_logo", + // Expected selectors: + [".inline-icon-container"] + ); + + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a title with custom styles. + */ +add_task(async function test_aboutwelcome_with_title_styles() { + const TEST_TITLE_STYLE_CONTENT = makeTestContent("TEST_TITLE_STYLE_STEP", { + title: { + fontSize: "36px", + fontWeight: 276, + letterSpacing: 0, + raw: "test", + }, + title_style: "fancy shine", + }); + + const TEST_TITLE_STYLE_JSON = JSON.stringify([TEST_TITLE_STYLE_CONTENT]); + let browser = await openAboutWelcome(TEST_TITLE_STYLE_JSON); + + await test_screen_content( + browser, + "renders screen with customized title style", + // Expected selectors: + [`div.welcome-text.fancy.shine`] + ); + + await test_element_styles( + browser, + "#mainContentHeader", + // Expected styles: + { + "font-weight": "276", + "font-size": "36px", + animation: "50s linear 0s infinite normal none running shine", + "letter-spacing": "normal", + } + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with an image for the dialog window's background + */ +add_task(async function test_aboutwelcome_with_background() { + const BACKGROUND_URL = + "chrome://activity-stream/content/data/content/assets/confetti.svg"; + const TEST_BACKGROUND_CONTENT = makeTestContent("TEST_BACKGROUND_STEP", { + background: `url(${BACKGROUND_URL}) no-repeat center/cover`, + }); + + const TEST_BACKGROUND_JSON = JSON.stringify([TEST_BACKGROUND_CONTENT]); + let browser = await openAboutWelcome(TEST_BACKGROUND_JSON); + + await test_screen_content( + browser, + "renders screen with dialog background image", + // Expected selectors: + [`div.main-content[style*='${BACKGROUND_URL}'`] + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with defined dimensions + */ +add_task(async function test_aboutwelcome_with_dimensions() { + const TEST_DIMENSIONS_CONTENT = makeTestContent("TEST_DIMENSIONS_STEP", { + width: "100px", + position: "center", + }); + + const TEST_DIMENSIONS_JSON = JSON.stringify([TEST_DIMENSIONS_CONTENT]); + let browser = await openAboutWelcome(TEST_DIMENSIONS_JSON); + + await test_screen_content( + browser, + "renders screen with defined dimensions", + // Expected selectors: + [`div.main-content[style*='width: 100px;']`] + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a dismiss button + */ +add_task(async function test_aboutwelcome_dismiss_button() { + let browser = await openAboutWelcome( + JSON.stringify( + // Use 2 screens to test that the message is dismissed, not navigated + [1, 2].map(i => + makeTestContent(`TEST_DISMISS_STEP_${i}`, { + dismiss_button: { action: { dismiss: true }, size: "small" }, + }) + ) + ) + ); + + await test_screen_content( + browser, + "renders screen with dismiss button", + // Expected selectors: + ['div.section-main button.dismiss-button[button-size="small"]'] + ); + + // Click dismiss button + await onButtonClick(browser, "button.dismiss-button"); + + // Wait for about:home to load + await BrowserTestUtils.browserLoaded(browser, false, "about:home"); + is(browser.currentURI.spec, "about:home", "about:home loaded"); + + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with the "split" position + */ +add_task(async function test_aboutwelcome_split_position() { + const TEST_SPLIT_STEP = makeTestContent("TEST_SPLIT_STEP", { + position: "split", + hero_text: "hero test", + }); + + const TEST_SPLIT_JSON = JSON.stringify([TEST_SPLIT_STEP]); + let browser = await openAboutWelcome(TEST_SPLIT_JSON); + + await test_screen_content( + browser, + "renders screen secondary section containing hero text", + // Expected selectors: + [`main.screen[pos="split"]`, `.section-secondary`, `.message-text h1`] + ); + + // Ensure secondary section has split template styling + await test_element_styles( + browser, + "main.screen .section-secondary", + // Expected styles: + { + display: "flex", + margin: "auto 0px auto auto", + } + ); + + // Ensure secondary action has button styling + await test_element_styles( + browser, + ".action-buttons .secondary-cta .secondary", + // Expected styles: + { + // Override default text-link styles + "background-color": "color(srgb 0.0823529 0.0784314 0.101961 / 0.07)", + color: "rgb(21, 20, 26)", + } + ); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a URL value and default color for backdrop + */ +add_task(async function test_aboutwelcome_with_url_backdrop() { + const TEST_BACKDROP_URL = `url("chrome://activity-stream/content/data/content/assets/confetti.svg")`; + const TEST_BACKDROP_VALUE = `#212121 ${TEST_BACKDROP_URL} center/cover no-repeat fixed`; + const TEST_URL_BACKDROP_CONTENT = makeTestContent("TEST_URL_BACKDROP_STEP"); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + backdrop: TEST_BACKDROP_VALUE, + screens: [TEST_URL_BACKDROP_CONTENT], + }, + }); + let browser = await openAboutWelcome(); + + await test_screen_content( + browser, + "renders screen with background image", + // Expected selectors: + [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP_URL}']`] + ); + await doExperimentCleanup(); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a color name for backdrop + */ +add_task(async function test_aboutwelcome_with_color_backdrop() { + const TEST_BACKDROP_COLOR = "transparent"; + const TEST_BACKDROP_COLOR_CONTENT = makeTestContent( + "TEST_COLOR_NAME_BACKDROP_STEP" + ); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + backdrop: TEST_BACKDROP_COLOR, + screens: [TEST_BACKDROP_COLOR_CONTENT], + }, + }); + let browser = await openAboutWelcome(); + + await test_screen_content( + browser, + "renders screen with background color", + // Expected selectors: + [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP_COLOR}']`] + ); + await doExperimentCleanup(); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a text color override + */ +add_task(async function test_aboutwelcome_with_text_color_override() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Override the system color scheme to dark + ["ui.systemUsesDarkTheme", 1], + ], + }); + + let screens = []; + // we need at least two screens to test the step indicator + for (let i = 0; i < 2; i++) { + screens.push( + makeTestContent("TEST_TEXT_COLOR_OVERRIDE_STEP", { + text_color: "dark", + background: "white", + }) + ); + } + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + screens, + }, + }); + let browser = await openAboutWelcome(JSON.stringify(screens)); + + await test_screen_content( + browser, + "renders screen with dark text", + // Expected selectors: + [`main.screen.dark-text`, `.indicator.current`, `.indicator:not(.current)`], + // Unexpected selectors: + [`main.screen.light-text`] + ); + + // Ensure title inherits light text color + await test_element_styles( + browser, + "#mainContentHeader", + // Expected styles: + { + color: "rgb(21, 20, 26)", + } + ); + + // Ensure next step indicator inherits light color + await test_element_styles( + browser, + ".indicator:not(.current)", + // Expected styles: + { + color: "rgb(251, 251, 254)", + } + ); + + await doExperimentCleanup(); + await SpecialPowers.popPrefEnv(); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with a "progress bar" style step indicator + */ +add_task(async function test_aboutwelcome_with_progress_bar() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["ui.systemUsesDarkTheme", 0], + ["ui.prefersReducedMotion", 0], + ], + }); + let screens = []; + // we need at least three screens to test the progress bar styling + for (let i = 0; i < 3; i++) { + screens.push( + makeTestContent(`TEST_MR_PROGRESS_BAR_${i + 1}`, { + position: "split", + progress_bar: true, + primary_button: { + label: "next", + action: { + navigate: true, + }, + }, + }) + ); + } + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + screens, + }, + }); + let browser = await openAboutWelcome(JSON.stringify(screens)); + + await SpecialPowers.spawn(browser, [], async () => { + const progressBar = await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".progress-bar") + ); + const indicator = await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".indicator") + ); + // Progress bar should have a gray background. + is( + content.window.getComputedStyle(progressBar)["background-color"], + "color(srgb 0.0823529 0.0784314 0.101961 / 0.25)", + "Correct progress bar background" + ); + + const indicatorStyles = content.window.getComputedStyle(indicator); + for (let [key, val] of Object.entries({ + // The filled "completed" element should have + // `background-color: var(--in-content-primary-button-background);` + "background-color": "rgb(0, 97, 224)", + // Base progress bar step styles. + height: "6px", + "margin-inline": "-1px", + "padding-block": "0px", + })) { + is(indicatorStyles[key], val, `Correct indicator ${key} style`); + } + const indicatorX = indicator.getBoundingClientRect().x; + content.document.querySelector("button.primary").click(); + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector(".indicator")?.getBoundingClientRect() + .x > indicatorX, + "Indicator should have grown" + ); + }); + + await doExperimentCleanup(); + browser.closeBrowser(); +}); + +/** + * Test rendering a message with session history updates disabled + */ +add_task(async function test_aboutwelcome_history_updates_disabled() { + let screens = []; + // we need at least two screens to test the history state + for (let i = 1; i < 3; i++) { + screens.push(makeTestContent(`TEST_PUSH_STATE_STEP_${i}`)); + } + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + disableHistoryUpdates: true, + screens, + }, + }); + let browser = await openAboutWelcome(JSON.stringify(screens)); + + let startHistoryLength = await SpecialPowers.spawn(browser, [], () => { + return content.window.history.length; + }); + // Advance to second screen + await onButtonClick(browser, "button.primary"); + let endHistoryLength = await SpecialPowers.spawn(browser, [], async () => { + // Ensure next screen has rendered + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".TEST_PUSH_STATE_STEP_2") + ); + return content.window.history.length; + }); + + Assert.strictEqual( + startHistoryLength, + endHistoryLength, + "No entries added to the session's history stack with history updates disabled" + ); + + await doExperimentCleanup(); + browser.closeBrowser(); +}); + +/** + * Test rendering a screen with different logos depending on reduced motion and + * color scheme preferences + */ +add_task(async function test_aboutwelcome_logo_selection() { + // Test a screen config that includes every logo parameter + await testAboutWelcomeLogoFor({ + imageURL: "chrome://branding/content/icon16.png", + darkModeImageURL: "chrome://branding/content/icon32.png", + reducedMotionImageURL: "chrome://branding/content/icon64.png", + darkModeReducedMotionImageURL: "chrome://branding/content/icon128.png", + alt: "TEST_LOGO_SELECTION_ALT", + width: "16px", + height: "16px", + marginBlock: "0px", + marginInline: "0px", + }); + // Test a screen config with no animated/static logos + await testAboutWelcomeLogoFor({ + imageURL: "chrome://branding/content/icon16.png", + darkModeImageURL: "chrome://branding/content/icon32.png", + }); + // Test a screen config with no dark mode logos + await testAboutWelcomeLogoFor({ + imageURL: "chrome://branding/content/icon16.png", + reducedMotionImageURL: "chrome://branding/content/icon64.png", + }); + // Test a screen config that includes only the default logo + await testAboutWelcomeLogoFor({ + imageURL: "chrome://branding/content/icon16.png", + }); + // Test a screen config with no logos + await testAboutWelcomeLogoFor(); +}); + +/** + * Test rendering a message that starts on a specific screen + */ +add_task(async function test_aboutwelcome_start_screen_configured() { + let startScreen = 1; + let screens = []; + // we need at least two screens to test + for (let i = 1; i < 3; i++) { + screens.push(makeTestContent(`TEST_START_STEP_${i}`)); + } + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + enabled: true, + startScreen, + screens, + }, + }); + + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(AboutWelcomeTelemetry.prototype, "sendTelemetry"); + + let browser = await openAboutWelcome(JSON.stringify(screens)); + + let secondScreenShown = await SpecialPowers.spawn(browser, [], async () => { + // Ensure screen has rendered + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".TEST_START_STEP_2") + ); + return true; + }); + + ok( + secondScreenShown, + `Starts on second screen when configured with startScreen index equal to ${startScreen}` + ); + // Wait for screen elements to render before checking impression pings + await test_screen_content( + browser, + "renders second screen elements", + // Expected selectors: + [`main.screen`, "div.secondary-cta"] + ); + + let expectedTelemetry = sinon.match({ + event: "IMPRESSION", + message_id: `MR_WELCOME_DEFAULT_${startScreen}_TEST_START_STEP_${ + startScreen + 1 + }_${screens.map(({ id }) => id?.split("_")[1]?.[0]).join("")}`, + }); + if (spy.calledWith(expectedTelemetry)) { + ok( + true, + "Impression events have the correct message id with start screen configured" + ); + } else if (spy.called) { + ok( + false, + `Wrong telemetry sent: ${JSON.stringify( + spy.getCalls().map(c => c.args[0]), + null, + 2 + )}` + ); + } else { + ok(false, "No telemetry sent"); + } + + await doExperimentCleanup(); + browser.closeBrowser(); + sandbox.restore(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_fxa_signin_flow.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_fxa_signin_flow.js new file mode 100644 index 0000000000..9de9acb7b3 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_fxa_signin_flow.js @@ -0,0 +1,303 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +const TEST_ROOT = "https://example.com/"; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.remote.root", TEST_ROOT]], + }); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW special action resolves to `true` and + * closes the FxA sign-in tab if sign-in is successful. + */ +add_task(async function test_fxa_sign_success() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + }); + }); + let fxaTab = await fxaTabPromise; + let fxaTabClosing = BrowserTestUtils.waitForTabClosing(fxaTab); + + // We'll fake-out the UIState being in the STATUS_SIGNED_IN status + // and not test the actual FxA sign-in mechanism. + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await fxaTabClosing; + Assert.ok(true, "FxA tab automatically closed."); + let result = await resultPromise; + Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true"); + }); + + sandbox.restore(); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action's data.autoClose parameter can + * disable the autoclose behavior. + */ +add_task(async function test_fxa_sign_success_no_autoclose() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { autoClose: false }, + }); + }); + let fxaTab = await fxaTabPromise; + + // We'll fake-out the UIState being in the STATUS_SIGNED_IN status + // and not test the actual FxA sign-in mechanism. + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let result = await resultPromise; + Assert.ok(result, "FXA_SIGNIN_FLOW should have resolved to true"); + Assert.ok(!fxaTab.closing, "FxA tab was not asked to close."); + BrowserTestUtils.removeTab(fxaTab); + }); + + sandbox.restore(); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action resolves to `false` if the tab + * closes before sign-in completes. + */ +add_task(async function test_fxa_signin_aborted() { + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + }); + }); + let fxaTab = await fxaTabPromise; + Assert.ok(!fxaTab.closing, "FxA tab was not asked to close yet."); + + BrowserTestUtils.removeTab(fxaTab); + let result = await resultPromise; + Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false"); + }); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need + * be, and that if that window closes, the flow is considered aborted. + */ +add_task(async function test_fxa_signin_window_aborted() { + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaWindowPromise = BrowserTestUtils.waitForNewWindow(); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { + where: "window", + }, + }); + }); + let fxaWindow = await fxaWindowPromise; + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet."); + + await BrowserTestUtils.closeWindow(fxaWindow); + let result = await resultPromise; + Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false"); + }); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need + * be, and that if sign-in completes, that new window will close automatically. + */ +add_task(async function test_fxa_signin_window_success() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaWindowPromise = BrowserTestUtils.waitForNewWindow(); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { + where: "window", + }, + }); + }); + let fxaWindow = await fxaWindowPromise; + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet."); + + let windowClosed = BrowserTestUtils.windowClosed(fxaWindow); + + // We'll fake-out the UIState being in the STATUS_SIGNED_IN status + // and not test the actual FxA sign-in mechanism. + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let result = await resultPromise; + Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true"); + + await windowClosed; + Assert.ok(fxaWindow.closed, "Sign-in window was automatically closed."); + }); + + sandbox.restore(); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need + * be, and that if a new tab is opened in that window and the sign-in tab + * is closed: + * + * 1. The new window isn't closed + * 2. The sign-in is considered aborted. + */ +add_task(async function test_fxa_signin_window_multiple_tabs_aborted() { + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaWindowPromise = BrowserTestUtils.waitForNewWindow(); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { + where: "window", + }, + }); + }); + let fxaWindow = await fxaWindowPromise; + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet."); + let fxaTab = fxaWindow.gBrowser.selectedTab; + await BrowserTestUtils.openNewForegroundTab( + fxaWindow.gBrowser, + "about:blank" + ); + BrowserTestUtils.removeTab(fxaTab); + + let result = await resultPromise; + Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false"); + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close."); + await BrowserTestUtils.closeWindow(fxaWindow); + }); +}); + +/** + * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need + * be, and that if a new tab is opened in that window but then sign-in + * completes + * + * 1. The new window isn't closed, but the sign-in tab is. + * 2. The sign-in is considered a success. + */ +add_task(async function test_fxa_signin_window_multiple_tabs_success() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaWindowPromise = BrowserTestUtils.waitForNewWindow(); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { + where: "window", + }, + }); + }); + let fxaWindow = await fxaWindowPromise; + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet."); + let fxaTab = fxaWindow.gBrowser.selectedTab; + + // This will open an about:blank tab in the background. + await BrowserTestUtils.addTab(fxaWindow.gBrowser); + let fxaTabClosed = BrowserTestUtils.waitForTabClosing(fxaTab); + + // We'll fake-out the UIState being in the STATUS_SIGNED_IN status + // and not test the actual FxA sign-in mechanism. + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let result = await resultPromise; + Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true"); + await fxaTabClosed; + + Assert.ok(!fxaWindow.closed, "FxA window was not asked to close."); + await BrowserTestUtils.closeWindow(fxaWindow); + }); + + sandbox.restore(); +}); + +/** + * Tests that we can pass an entrypoint and UTM parameters to the FxA sign-in + * page. + */ +add_task(async function test_fxa_signin_flow_entrypoint_utm_params() { + await BrowserTestUtils.withNewTab("about:welcome", async browser => { + let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let resultPromise = SpecialPowers.spawn(browser, [], async () => { + return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", { + type: "FXA_SIGNIN_FLOW", + data: { + entrypoint: "test-entrypoint", + extraParams: { + utm_test1: "utm_test1", + utm_test2: "utm_test2", + }, + }, + }); + }); + let fxaTab = await fxaTabPromise; + + let uriParams = new URLSearchParams(fxaTab.linkedBrowser.currentURI.query); + Assert.equal(uriParams.get("entrypoint"), "test-entrypoint"); + Assert.equal(uriParams.get("utm_test1"), "utm_test1"); + Assert.equal(uriParams.get("utm_test2"), "utm_test2"); + + BrowserTestUtils.removeTab(fxaTab); + await resultPromise; + }); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_glean.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_glean.js new file mode 100644 index 0000000000..d08a3c834c --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_glean.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for the Glean version of onboarding telemetry. + */ + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); + +const TEST_DEFAULT_CONTENT = [ + { + id: "AW_STEP1", + + content: { + position: "split", + title: "Step 1", + page: "page 1", + source: "test", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + secondary_button_top: { + label: "link top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: { entrypoint: "test" }, + }, + }, + }, + }, + { + id: "AW_STEP2", + content: { + position: "center", + title: "Step 2", + page: "page 1", + source: "test", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + has_noodles: true, + }, + }, +]; + +const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT); + +async function openAboutWelcome() { + await setAboutWelcomePref(true); + await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab.linkedBrowser; +} + +add_task(async function test_welcome_telemetry() { + // Have to turn on AS telemetry for anything to be recorded. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.telemetry", true]], + }); + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + }); + + Services.fog.testResetFOG(); + // Let's check that there is nothing in the impression event. + // This is useful in mochitests because glean inits fairly late in startup. + // We want to make sure we are fully initialized during testing so that + // when we call testGetValue() we get predictable behavior. + Assert.equal(undefined, Glean.messagingSystem.messageId.testGetValue()); + + // Setup testBeforeNextSubmit. We do this first, progress onboarding, submit + // and then check submission. We put the asserts inside testBeforeNextSubmit + // because metric lifetimes are 'ping' and are cleared after submission. + // See: https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/instrumentation_tests.html#xpcshell-tests + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + + const message = Glean.messagingSystem.messageId.testGetValue(); + // Because of the asynchronous nature of receiving messages, we cannot + // guarantee that we will get the same message first. Instead we check + // that the one we get is a valid example of that type. + Assert.ok( + message.startsWith("MR_WELCOME_DEFAULT"), + "Ping is of an expected type" + ); + Assert.equal( + Glean.messagingSystem.unknownKeyCount.testGetValue(), + undefined + ); + }); + + let browser = await openAboutWelcome(); + // `openAboutWelcome` isn't synchronous wrt the onboarding flow impressing. + await TestUtils.waitForCondition( + () => pingSubmitted, + "Ping was submitted, callback was called." + ); + + // Let's reset and assert some values in the next button click. + pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + + // Sometimes the impression for MR_WELCOME_DEFAULT_0_AW_STEP1_SS reaches + // the parent process before the button click does. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1834620 + if (Glean.messagingSystem.event.testGetValue() === "IMPRESSION") { + Assert.equal( + Glean.messagingSystem.eventPage.testGetValue(), + "about:welcome" + ); + const message = Glean.messagingSystem.messageId.testGetValue(); + Assert.ok( + message.startsWith("MR_WELCOME_DEFAULT"), + "Ping is of an expected type" + ); + } else { + // This is the common and, to my mind, correct case: + // the click coming before the next steps' impression. + Assert.equal(Glean.messagingSystem.event.testGetValue(), "CLICK_BUTTON"); + Assert.equal( + Glean.messagingSystem.eventSource.testGetValue(), + "primary_button" + ); + Assert.equal( + Glean.messagingSystem.messageId.testGetValue(), + "MR_WELCOME_DEFAULT_0_AW_STEP1" + ); + } + Assert.equal( + Glean.messagingSystem.unknownKeyCount.testGetValue(), + undefined + ); + }); + await onButtonClick(browser, "button.primary"); + Assert.ok(pingSubmitted, "Ping was submitted, callback was called."); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_import.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_import.js new file mode 100644 index 0000000000..f2ec85001a --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_import.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const IMPORT_SCREEN = { + id: "AW_IMPORT", + content: { + primary_button: { + label: "import", + action: { + navigate: true, + type: "SHOW_MIGRATION_WIZARD", + }, + }, + }, +}; + +add_task(async function test_wait_import_modal() { + await setAboutWelcomeMultiStage( + JSON.stringify([IMPORT_SCREEN, { id: "AW_NEXT", content: {} }]) + ); + const { cleanup, browser } = await openMRAboutWelcome(); + + // execution + await test_screen_content( + browser, + "renders IMPORT screen", + //Expected selectors + ["main.AW_IMPORT", "button[value='primary_button']"], + + //Unexpected selectors: + ["main.AW_NEXT"] + ); + + const wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + const prefsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences" + ); + await onButtonClick(browser, "button.primary"); + const wizard = await wizardPromise; + + await test_screen_content( + browser, + "still shows IMPORT screen", + //Expected selectors + ["main.AW_IMPORT", "button[value='primary_button']"], + + //Unexpected selectors: + ["main.AW_NEXT"] + ); + + await BrowserTestUtils.removeTab(wizard); + + await test_screen_content( + browser, + "moved to NEXT screen", + //Expected selectors + ["main.AW_NEXT"], + + //Unexpected selectors: + [] + ); + + // cleanup + await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage + BrowserTestUtils.removeTab(prefsTab); + await cleanup(); +}); + +add_task(async function test_wait_import_spotlight() { + const spotlightPromise = TestUtils.topicObserved("subdialog-loaded"); + ChromeUtils.importESModule( + "resource:///modules/asrouter/Spotlight.sys.mjs" + ).Spotlight.showSpotlightDialog(gBrowser.selectedBrowser, { + content: { modal: "tab", screens: [IMPORT_SCREEN] }, + }); + const [win] = await spotlightPromise; + + const wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + const prefsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences" + ); + win.document + .querySelector(".onboardingContainer button[value='primary_button']") + .click(); + const wizard = await wizardPromise; + + await BrowserTestUtils.removeTab(wizard); + + // cleanup + BrowserTestUtils.removeTab(prefsTab); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_mobile_downloads.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_mobile_downloads.js new file mode 100644 index 0000000000..bb94d575fe --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_mobile_downloads.js @@ -0,0 +1,112 @@ +"use strict"; + +const BASE_CONTENT = { + id: "MOBILE_DOWNLOADS", + content: { + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: "chrome://browser/content/assets/focus-qr-code.svg", + alt_text: "Test alt", + }, + email: { + link_text: { + string_id: "spotlight-focus-promo-email-link", + }, + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, +}; + +async function openAboutWelcome(json) { + if (json) { + await setAboutWelcomeMultiStage(json); + } + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab.linkedBrowser; +} + +const ALT_TEXT = BASE_CONTENT.content.tiles.data.QR_code.alt_text; + +/** + * Test rendering a screen with a mobile downloads tile + * including QR code, email, and marketplace elements + */ +add_task(async function test_aboutwelcome_mobile_downloads_all() { + const TEST_JSON = JSON.stringify([BASE_CONTENT]); + let browser = await openAboutWelcome(TEST_JSON); + + await test_screen_content( + browser, + "renders screen with all mobile download elements", + // Expected selectors: + [ + `img.qr-code-image[alt="${ALT_TEXT}"]`, + "ul.mobile-download-buttons", + "li.android", + "li.ios", + "button.email-link", + ] + ); +}); + +/** + * Test rendering a screen with a mobile downloads tile + * including only a QR code and marketplace elements + */ +add_task( + async function test_aboutwelcome_mobile_downloads_qr_and_marketplace() { + const SCREEN_CONTENT = structuredClone(BASE_CONTENT); + delete SCREEN_CONTENT.content.tiles.data.email; + const TEST_JSON = JSON.stringify([SCREEN_CONTENT]); + let browser = await openAboutWelcome(TEST_JSON); + + await test_screen_content( + browser, + "renders screen with QR code and marketplace badges", + // Expected selectors: + [ + `img.qr-code-image[alt="${ALT_TEXT}"]`, + "ul.mobile-download-buttons", + "li.android", + "li.ios", + ], + // Unexpected selectors: + [`button.email-link`] + ); + } +); + +/** + * Test rendering a screen with a mobile downloads tile + * including only a QR code + */ +add_task(async function test_aboutwelcome_mobile_downloads_qr() { + let SCREEN_CONTENT = structuredClone(BASE_CONTENT); + const QR_CODE_SRC = SCREEN_CONTENT.content.tiles.data.QR_code.image_url; + + delete SCREEN_CONTENT.content.tiles.data.email; + delete SCREEN_CONTENT.content.tiles.data.marketplace_buttons; + const TEST_JSON = JSON.stringify([SCREEN_CONTENT]); + let browser = await openAboutWelcome(TEST_JSON); + + await test_screen_content( + browser, + "renders screen with QR code", + // Expected selectors: + [`img.qr-code-image[alt="${ALT_TEXT}"][src="${QR_CODE_SRC}"]`], + // Unexpected selectors: + ["button.email-link", "li.android", "li.ios"] + ); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_addonspicker.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_addonspicker.js new file mode 100644 index 0000000000..71c72f6d1d --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_addonspicker.js @@ -0,0 +1,178 @@ +"use strict"; + +const { AboutWelcomeParent } = ChromeUtils.importESModule( + "resource:///actors/AboutWelcomeParent.sys.mjs" +); + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); +const { AWScreenUtils } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AWScreenUtils.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); + +async function clickVisibleButton(browser, selector) { + // eslint-disable-next-line no-shadow + await ContentTask.spawn(browser, { selector }, async ({ selector }) => { + function getVisibleElement() { + for (const el of content.document.querySelectorAll(selector)) { + if (el.offsetParent !== null) { + return el; + } + } + return null; + } + await ContentTaskUtils.waitForCondition( + getVisibleElement, + selector, + 200, // interval + 100 // maxTries + ); + getVisibleElement().click(); + }); +} + +add_setup(async function () { + SpecialPowers.pushPrefEnv({ + set: [ + ["ui.prefersReducedMotion", 1], + ["browser.aboutwelcome.transitions", false], + ], + }); +}); + +add_task(async function test_aboutwelcome_addonspicker() { + const TEST_ADDON_CONTENT = [ + { + id: "AW_ADDONS_PICKER", + content: { + position: "center", + tiles: { + type: "addons-picker", + data: [ + { + id: "addon-one-id", + name: "uBlock Origin", + install_label: "Add to Firefox", + icon: "", + type: "extension", + description: "An efficient wide-spectrum content blocker.", + source_id: "ADD_EXTENSION_BUTTON", + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { + url: "https://test.xpi", + telemetrySource: "aboutwelcome-addon", + }, + }, + }, + { + id: "addon-two-id", + name: "Tree-Style Tabs", + install_label: "Add to Firefox", + icon: "", + type: "extension", + description: "Show tabs like a tree.", + source_id: "ADD_EXTENSION_BUTTON", + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { + url: "https://test.xpi", + telemetrySource: "aboutwelcome-addon", + }, + }, + }, + ], + }, + progress_bar: true, + logo: {}, + title: { + raw: "Customize your Firefox", + }, + subtitle: { + raw: "Extensions and themes are like apps for your browser, and they let you protect passwords, download videos, find deals, block annoying ads, change how your browser looks, and much more.", + }, + additional_button: { + label: { + raw: "Explore more add-ons", + }, + style: "link", + action: { + type: "OPEN_URL", + data: { + args: "https://test.xpi", + where: "tab", + }, + }, + }, + secondary_button: { + label: { + string_id: "mr2-onboarding-start-browsing-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ]; + + await setAboutWelcomeMultiStage(JSON.stringify(TEST_ADDON_CONTENT)); // NB: calls SpecialPowers.pushPrefEnv + let { cleanup, browser } = await openMRAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + const messageSandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + messageSandbox.restore(); + }); + // Stub AboutWelcomeParent's Content Message Handler + const messageStub = messageSandbox + .stub(aboutWelcomeActor, "onContentMessage") + .withArgs("AWPage:SPECIAL_ACTION"); + + // execution + await test_screen_content( + browser, + "renders the addons-picker screen and tiles", + //Expected selectors + [ + "main.AW_ADDONS_PICKER", + "div.addons-picker-container", + "button[value='secondary_button']", + "button[value='additional_button']", + ], + + //Unexpected selectors: + [ + `main.screen[pos="split"]`, + "main.AW_SET_DEFAULT", + "button[value='primary_button']", + ] + ); + + await clickVisibleButton(browser, ".addon-container button[value='0']"); //click the first install button + + const installExtensionCall = messageStub.getCall(0); + info( + `Call #${installExtensionCall}: ${ + installExtensionCall.args[0] + } ${JSON.stringify(installExtensionCall.args[1])}` + ); + Assert.equal( + installExtensionCall.args[0], + "AWPage:SPECIAL_ACTION", + "send special action to install add on" + ); + Assert.equal( + installExtensionCall.args[1].type, + "INSTALL_ADDON_FROM_URL", + "Special action type is INSTALL_ADDON_FROM_URL" + ); + + // cleanup + await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage + await cleanup(); + messageSandbox.restore(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_default.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_default.js new file mode 100644 index 0000000000..d234ff7d06 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_default.js @@ -0,0 +1,794 @@ +"use strict"; +const { SpecialMessageActions } = ChromeUtils.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); + +const DID_SEE_ABOUT_WELCOME_PREF = "trailhead.firstrun.didSeeAboutWelcome"; + +const TEST_DEFAULT_CONTENT = [ + { + id: "AW_STEP1", + content: { + position: "split", + title: "Step 1", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + secondary_button_top: { + label: "link top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: { entrypoint: "test" }, + }, + }, + info_text: { + raw: "Here's some sample help text", + }, + }, + }, + { + id: "AW_STEP2", + content: { + position: "center", + title: "Step 2", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP3", + content: { + title: "Step 3", + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: "theme-1", + tooltip: "test-tooltip", + }, + { + theme: "dark", + label: "theme-2", + }, + ], + }, + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Import", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: { source: "chrome" }, + }, + }, + }, + }, + { + id: "AW_STEP4", + auto_advance: "primary_button", + content: { + title: "Step 4", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + }, + }, +]; + +const TEST_AMO_CONTENT = [ + { + id: "AW_AMO_INTRODUCE", + content: { + position: "split", + split_narrow_bkg_position: "-58px", + progress_bar: true, + logo: {}, + title: { string_id: "amo-screen-title" }, + subtitle: { string_id: "amo-screen-subtitle" }, + primary_button: { + label: { string_id: "amo-screen-primary-cta" }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, +]; + +const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT); + +async function openAboutWelcome() { + await setAboutWelcomePref(true); + await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab.linkedBrowser; +} + +/** + * Test the multistage welcome default UI + */ +add_task(async function test_multistage_aboutwelcome_default() { + const sandbox = sinon.createSandbox(); + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent Content Message Handler + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await test_screen_content( + browser, + "multistage step 1", + // Expected selectors: + [ + "main.AW_STEP1", + "div.onboardingContainer", + "div.section-secondary", + "div.secondary-cta.top", + "div.steps", + "div.indicator.current", + "span.info-text", + ], + // Unexpected selectors: + [ + "main.AW_STEP2", + "main.AW_STEP3", + "main.dialog-initial", + "main.dialog-last", + ] + ); + + await onButtonClick(browser, "button.primary"); + + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + let clickCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + clickCall = call; + } + } + + Assert.ok( + clickCall.args[1].message_id === "MR_WELCOME_DEFAULT_0_AW_STEP1", + "AboutWelcome MR message id joined with screen id" + ); + + await test_screen_content( + browser, + "multistage step 2", + // Expected selectors: + [ + "main.AW_STEP2", + "div.onboardingContainer", + "div.section-main", + "div.steps", + "div.indicator.current", + "main.with-noodles", + ], + // Unexpected selectors: + [ + "main.AW_STEP1", + "main.AW_STEP3", + "div.section-secondary", + "main.dialog-last", + ] + ); + + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage step 3", + // Expected selectors: + [ + "main.AW_STEP3", + "div.onboardingContainer", + "div.section-main", + "div.tiles-theme-container", + "div.steps", + "div.indicator.current", + ], + // Unexpected selectors: + [ + "main.AW_STEP2", + "main.AW_STEP1", + "div.section-secondary", + "main.dialog-initial", + "main.with-noodles", + "main.dialog-last", + ] + ); + + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage step 4", + // Expected selectors: + [ + "main.AW_STEP4.screen-1", + "main.AW_STEP4.dialog-last", + "div.onboardingContainer", + ], + // Unexpected selectors: + [ + "main.AW_STEP2", + "main.AW_STEP1", + "main.AW_STEP3", + "div.steps", + "main.dialog-initial", + "main.AW_STEP4.screen-0", + "main.AW_STEP4.screen-2", + "main.AW_STEP4.screen-3", + ] + ); +}); + +/** + * Test navigating back/forward between screens + */ +add_task(async function test_Multistage_About_Welcome_navigation() { + let browser = await openAboutWelcome(); + + await onButtonClick(browser, "button.primary"); + await TestUtils.waitForCondition(() => browser.canGoBack); + browser.goBack(); + + await test_screen_content( + browser, + "multistage step 1", + // Expected selectors: + [ + "div.onboardingContainer", + "main.AW_STEP1", + "div.secondary-cta", + "div.secondary-cta.top", + "button[value='secondary_button']", + "button[value='secondary_button_top']", + "span.info-text", + ], + // Unexpected selectors: + ["main.AW_STEP2", "main.AW_STEP3"] + ); + + await document.getElementById("forward-button").click(); +}); + +/** + * Test the multistage welcome UI primary button action + */ +add_task(async function test_AWMultistage_Primary_Action() { + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + const sandbox = sinon.createSandbox(); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await onButtonClick(browser, "button.primary"); + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + + let clickCall; + let performanceCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + clickCall = call; + } else if ( + call.calledWithMatch("", { + event_context: { mountStart: sinon.match.number }, + }) + ) { + performanceCall = call; + } + } + + // For some builds, we can stub fast enough to catch the performance + if (performanceCall) { + Assert.equal( + performanceCall.args[0], + "AWPage:TELEMETRY_EVENT", + "send telemetry event" + ); + Assert.equal( + performanceCall.args[1].event, + "IMPRESSION", + "performance impression event recorded in telemetry" + ); + Assert.equal( + typeof performanceCall.args[1].event_context.domComplete, + "number", + "numeric domComplete recorded in telemetry" + ); + Assert.equal( + typeof performanceCall.args[1].event_context.domInteractive, + "number", + "numeric domInteractive recorded in telemetry" + ); + Assert.equal( + typeof performanceCall.args[1].event_context.mountStart, + "number", + "numeric mountStart recorded in telemetry" + ); + Assert.equal( + performanceCall.args[1].message_id, + "MR_WELCOME_DEFAULT", + "MessageId sent in performance event telemetry" + ); + } + + Assert.equal( + clickCall.args[0], + "AWPage:TELEMETRY_EVENT", + "send telemetry event" + ); + Assert.equal( + clickCall.args[1].event, + "CLICK_BUTTON", + "click button event recorded in telemetry" + ); + Assert.equal( + clickCall.args[1].event_context.source, + "primary_button", + "primary button click source recorded in telemetry" + ); + Assert.equal( + clickCall.args[1].message_id, + "MR_WELCOME_DEFAULT_0_AW_STEP1", + "MessageId sent in click event telemetry" + ); +}); + +add_task(async function test_AWMultistage_Secondary_Open_URL_Action() { + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + const sandbox = sinon.createSandbox(); + // Stub AboutWelcomeParent Content Message Handler + sandbox.stub(aboutWelcomeActor, "onContentMessage").resolves(null); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await onButtonClick(browser, "button[value='secondary_button_top']"); + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual( + callCount, + 2, + `${callCount} Stub called twice to handle FxA open URL and Telemetry` + ); + + let actionCall; + let eventCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("SPECIAL")) { + actionCall = call; + } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + eventCall = call; + } + } + + Assert.equal( + actionCall.args[0], + "AWPage:SPECIAL_ACTION", + "Got call to handle special action" + ); + Assert.equal( + actionCall.args[1].type, + "SHOW_FIREFOX_ACCOUNTS", + "Special action SHOW_FIREFOX_ACCOUNTS event handled" + ); + Assert.equal( + actionCall.args[1].data.extraParams.utm_term, + "aboutwelcome-default-screen", + "UTMTerm set in FxA URL" + ); + Assert.equal( + actionCall.args[1].data.entrypoint, + "test", + "EntryPoint set in FxA URL" + ); + Assert.equal( + eventCall.args[0], + "AWPage:TELEMETRY_EVENT", + "Got call to handle Telemetry event" + ); + Assert.equal( + eventCall.args[1].event, + "CLICK_BUTTON", + "click button event recorded in Telemetry" + ); + Assert.equal( + eventCall.args[1].event_context.source, + "secondary_button_top", + "secondary_top button click source recorded in Telemetry" + ); +}); + +add_task(async function test_AWMultistage_Themes() { + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + + const sandbox = sinon.createSandbox(); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + + registerCleanupFunction(() => { + sandbox.restore(); + }); + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage proton step 2", + // Expected selectors: + ["main.AW_STEP2"], + // Unexpected selectors: + ["main.AW_STEP1"] + ); + await onButtonClick(browser, "button.primary"); + + await ContentTask.spawn(browser, "Themes", async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("label.theme"), + "Theme Icons" + ); + let themes = content.document.querySelectorAll("label.theme"); + Assert.equal(themes.length, 2, "Two themes displayed"); + }); + + await onButtonClick(browser, "input[value=automatic]"); + + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + + let actionCall; + let eventCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("SELECT_THEME")) { + actionCall = call; + } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + eventCall = call; + } + } + + Assert.equal( + actionCall.args[0], + "AWPage:SELECT_THEME", + "Got call to handle select theme" + ); + Assert.equal( + actionCall.args[1], + "AUTOMATIC", + "Theme value passed as AUTOMATIC" + ); + Assert.equal( + eventCall.args[0], + "AWPage:TELEMETRY_EVENT", + "Got call to handle Telemetry event when theme tile clicked" + ); + Assert.equal( + eventCall.args[1].event, + "CLICK_BUTTON", + "click button event recorded in Telemetry" + ); + Assert.equal( + eventCall.args[1].event_context.source, + "automatic", + "automatic click source recorded in Telemetry" + ); +}); + +add_task(async function test_AWMultistage_can_restore_theme() { + const { XPIExports } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(() => sandbox.restore()); + + const fakeAddons = []; + class FakeAddon { + constructor({ id = "default-theme@mozilla.org", isActive = false } = {}) { + this.id = id; + this.isActive = isActive; + } + + enable() { + for (let addon of fakeAddons) { + addon.isActive = false; + } + this.isActive = true; + } + } + fakeAddons.push( + new FakeAddon({ id: "fake-theme-1@mozilla.org", isActive: true }), + new FakeAddon({ id: "fake-theme-2@mozilla.org" }) + ); + + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + + sandbox.stub(XPIExports.XPIProvider, "getAddonsByTypes").resolves(fakeAddons); + sandbox + .stub(XPIExports.XPIProvider, "getAddonByID") + .callsFake(id => fakeAddons.find(addon => addon.id === id)); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + + // Test that the active theme ID is stored in LIGHT_WEIGHT_THEMES + await aboutWelcomeActor.receiveMessage({ + name: "AWPage:GET_SELECTED_THEME", + }); + Assert.equal( + await aboutWelcomeActor.onContentMessage.lastCall.returnValue, + "automatic", + `Should return "automatic" for non-built-in theme` + ); + + await aboutWelcomeActor.receiveMessage({ + name: "AWPage:SELECT_THEME", + data: "AUTOMATIC", + }); + Assert.equal( + XPIExports.XPIProvider.getAddonByID.lastCall.args[0], + fakeAddons[0].id, + `LIGHT_WEIGHT_THEMES.AUTOMATIC should be ${fakeAddons[0].id}` + ); + + // Enable a different theme... + fakeAddons[1].enable(); + // And test that AWGetSelectedTheme updates the active theme ID + await aboutWelcomeActor.receiveMessage({ + name: "AWPage:GET_SELECTED_THEME", + }); + await aboutWelcomeActor.receiveMessage({ + name: "AWPage:SELECT_THEME", + data: "AUTOMATIC", + }); + Assert.equal( + XPIExports.XPIProvider.getAddonByID.lastCall.args[0], + fakeAddons[1].id, + `LIGHT_WEIGHT_THEMES.AUTOMATIC should be ${fakeAddons[1].id}` + ); +}); + +add_task(async function test_AWMultistage_Import() { + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + + // Click twice to advance to screen 3 + await onButtonClick(browser, "button.primary"); + await test_screen_content( + browser, + "multistage proton step 2", + // Expected selectors: + ["main.AW_STEP2"], + // Unexpected selectors: + ["main.AW_STEP1"] + ); + await onButtonClick(browser, "button.primary"); + + const sandbox = sinon.createSandbox(); + sandbox.stub(SpecialMessageActions, "handleAction"); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + + registerCleanupFunction(() => { + sandbox.restore(); + }); + + await test_screen_content( + browser, + "multistage proton step 2", + // Expected selectors: + ["main.AW_STEP3"], + // Unexpected selectors: + ["main.AW_STEP2"] + ); + + await onButtonClick(browser, "button[value='secondary_button']"); + const { callCount } = aboutWelcomeActor.onContentMessage; + + let actionCall; + let eventCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("SPECIAL")) { + actionCall = call; + } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + eventCall = call; + } + } + + Assert.equal( + actionCall.args[0], + "AWPage:SPECIAL_ACTION", + "Got call to handle special action" + ); + Assert.equal( + actionCall.args[1].type, + "SHOW_MIGRATION_WIZARD", + "Special action SHOW_MIGRATION_WIZARD event handled" + ); + Assert.equal( + actionCall.args[1].data.source, + "chrome", + "Source passed to event handler" + ); + Assert.equal( + eventCall.args[0], + "AWPage:TELEMETRY_EVENT", + "Got call to handle Telemetry event" + ); +}); + +add_task(async function test_updatesPrefOnAWOpen() { + Services.prefs.setBoolPref(DID_SEE_ABOUT_WELCOME_PREF, false); + await setAboutWelcomePref(true); + + await openAboutWelcome(); + await TestUtils.waitForCondition( + () => + Services.prefs.getBoolPref(DID_SEE_ABOUT_WELCOME_PREF, false) === true, + "Updated pref to seen AW" + ); + Services.prefs.clearUserPref(DID_SEE_ABOUT_WELCOME_PREF); +}); + +add_setup(async function () { + const sandbox = sinon.createSandbox(); + // This needs to happen before any about:welcome page opens + sandbox.stub(FxAccounts.config, "promiseMetricsFlowURI").resolves(""); + await setAboutWelcomeMultiStage(""); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_FxA_metricsFlowURI() { + let browser = await openAboutWelcome(); + + await ContentTask.spawn(browser, {}, async () => { + Assert.ok( + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("div.onboardingContainer"), + "Wait for about:welcome to load" + ), + "about:welcome loaded" + ); + }); + + Assert.ok(FxAccounts.config.promiseMetricsFlowURI.called, "Stub was called"); + Assert.equal( + FxAccounts.config.promiseMetricsFlowURI.firstCall.args[0], + "aboutwelcome", + "Called by AboutWelcomeParent" + ); + + SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_send_aboutwelcome_as_page_in_event_telemetry() { + const sandbox = sinon.createSandbox(); + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + + await onButtonClick(browser, "button.primary"); + + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + + let eventCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + eventCall = call; + } + } + + Assert.equal( + eventCall.args[1].event, + "CLICK_BUTTON", + "Event telemetry sent on primary button press" + ); + Assert.equal( + eventCall.args[1].event_context.page, + "about:welcome", + "Event context page set to 'about:welcome' in event telemetry" + ); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_AMO_untranslated_strings() { + const sandbox = sinon.createSandbox(); + + await setAboutWelcomePref(true); + await setAboutWelcomeMultiStage(JSON.stringify(TEST_AMO_CONTENT)); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + + let browser = tab.linkedBrowser; + + await test_screen_content( + browser, + "renders the AMO screen with preview strings", + //Expected selectors + [ + "main.AW_AMO_INTRODUCE", + `main.screen[pos="split"]`, + "button.primary[data-l10n-id='amo-screen-primary-cta']", + "h1[data-l10n-id='amo-screen-title']", + "h2[data-l10n-id='amo-screen-subtitle']", + ], + + //Unexpected selectors: + ["main.AW_EASY_SETUP_NEEDS_DEFAULT"] + ); + + registerCleanupFunction(async () => { + await popPrefs(); // for setAboutWelcomePref() + sandbox.restore(); + }); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_experimentAPI.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_experimentAPI.js new file mode 100644 index 0000000000..960d42a1f8 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_experimentAPI.js @@ -0,0 +1,641 @@ +"use strict"; + +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const TEST_PROTON_CONTENT = [ + { + id: "AW_STEP1", + content: { + title: "Step 1", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + secondary_button_top: { + label: "link top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: { entrypoint: "test" }, + }, + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP2", + content: { + title: "Step 2", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP3", + content: { + title: "Step 3", + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: "theme-1", + tooltip: "test-tooltip", + }, + { + theme: "dark", + label: "theme-2", + }, + ], + }, + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Import", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: { source: "chrome" }, + }, + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP4", + content: { + title: "Step 4", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + has_noodles: true, + }, + }, +]; + +/** + * Test the zero onboarding using ExperimentAPI + */ +add_task(async function test_multistage_zeroOnboarding_experimentAPI() { + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { enabled: false }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + + const browser = tab.linkedBrowser; + + await test_screen_content( + browser, + "Opens new tab", + // Expected selectors: + ["div.search-wrapper", "body.activity-stream"], + // Unexpected selectors: + ["div.onboardingContainer", "main.AW_STEP1"] + ); + + await doExperimentCleanup(); +}); + +/** + * Test the multistage welcome UI with test content theme as first screen + */ +add_task(async function test_multistage_aboutwelcome_experimentAPI() { + const TEST_CONTENT = [ + { + id: "AW_STEP1", + content: { + title: "Step 1", + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: "theme-1", + tooltip: "test-tooltip", + }, + { + theme: "dark", + label: "theme-2", + }, + ], + }, + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + secondary_button_top: { + label: "link top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: { entrypoint: "test" }, + }, + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP2", + content: { + zap: true, + title: "Step 2 test", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "link", + }, + has_noodles: true, + }, + }, + { + id: "AW_STEP3", + content: { + logo: {}, + title: "Step 3", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Import", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: { source: "chrome" }, + }, + }, + has_noodles: true, + }, + }, + ]; + const sandbox = sinon.createSandbox(); + NimbusFeatures.aboutwelcome._didSendExposureEvent = false; + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + enabled: true, + value: { + id: "my-mochitest-experiment", + screens: TEST_CONTENT, + }, + }); + + sandbox.spy(ExperimentAPI, "recordExposureEvent"); + + Services.telemetry.clearScalars(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + const browser = tab.linkedBrowser; + + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent Content Message Handler + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + sandbox.restore(); + }); + + // Test first (theme) screen. + await test_screen_content( + browser, + "multistage step 1", + // Expected selectors: + [ + "div.onboardingContainer", + "main.AW_STEP1", + "div.secondary-cta", + "div.secondary-cta.top", + "button[value='secondary_button']", + "button[value='secondary_button_top']", + "label.theme", + "input[type='radio']", + ], + // Unexpected selectors: + ["main.AW_STEP2", "main.AW_STEP3", "div.tiles-container.info"] + ); + + await onButtonClick(browser, "button.primary"); + + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + let clickCall; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + clickCall = call; + } + } + + Assert.equal( + clickCall.args[0], + "AWPage:TELEMETRY_EVENT", + "send telemetry event" + ); + + Assert.equal( + clickCall.args[1].message_id, + "MY-MOCHITEST-EXPERIMENT_0_AW_STEP1", + "Telemetry should join id defined in feature value with screen" + ); + + await test_screen_content( + browser, + "multistage step 2", + // Expected selectors: + [ + "div.onboardingContainer", + "main.AW_STEP2", + "button[value='secondary_button']", + ], + // Unexpected selectors: + ["main.AW_STEP1", "main.AW_STEP3", "div.secondary-cta.top"] + ); + await onButtonClick(browser, "button.primary"); + await test_screen_content( + browser, + "multistage step 3", + // Expected selectors: + [ + "div.onboardingContainer", + "main.AW_STEP3", + "img.brand-logo", + "div.welcome-text", + ], + // Unexpected selectors: + ["main.AW_STEP1", "main.AW_STEP2"] + ); + await onButtonClick(browser, "button.primary"); + await test_screen_content( + browser, + "home", + // Expected selectors: + ["body.activity-stream"], + // Unexpected selectors: + ["div.onboardingContainer"] + ); + + Assert.equal( + ExperimentAPI.recordExposureEvent.callCount, + 1, + "Called only once for exposure event" + ); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "telemetry.event_counts", + "normandy#expose#nimbus_experiment", + 1 + ); + + await doExperimentCleanup(); +}); + +/** + * Test the multistage proton welcome UI using ExperimentAPI with transitions + */ +add_task(async function test_multistage_aboutwelcome_transitions() { + const sandbox = sinon.createSandbox(); + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + id: "my-mochitest-experiment", + enabled: true, + screens: TEST_PROTON_CONTENT, + transitions: true, + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + const browser = tab.linkedBrowser; + + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent Content Message Handler + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + sandbox.restore(); + }); + + await test_screen_content( + browser, + "multistage proton step 1", + // Expected selectors: + ["div.proton.transition- .screen"], + // Unexpected selectors: + ["div.proton.transition-out"] + ); + + // Double click should still only transition once. + await onButtonClick(browser, "button.primary"); + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage proton step 1 transition to 2", + // Expected selectors: + ["div.proton.transition-out .screen", "div.proton.transition- .screen-1"] + ); + + await doExperimentCleanup(); +}); + +/** + * Test the multistage proton welcome UI using ExperimentAPI without transitions + */ +add_task(async function test_multistage_aboutwelcome_transitions_off() { + const sandbox = sinon.createSandbox(); + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + id: "my-mochitest-experiment", + enabled: true, + screens: TEST_PROTON_CONTENT, + transitions: false, + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + const browser = tab.linkedBrowser; + + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent Content Message Handler + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + sandbox.restore(); + }); + + await test_screen_content( + browser, + "multistage proton step 1", + // Expected selectors: + ["div.proton.transition- .screen"], + // Unexpected selectors: + ["div.proton.transition-out"] + ); + + await onButtonClick(browser, "button.primary"); + await test_screen_content( + browser, + "multistage proton step 1 no transition to 2", + // Expected selectors: + [], + // Unexpected selectors: + ["div.proton.transition-out .screen-0"] + ); + + await doExperimentCleanup(); +}); + +/* Test multistage custom backdrop + */ +add_task(async function test_multistage_aboutwelcome_backdrop() { + const sandbox = sinon.createSandbox(); + const TEST_BACKDROP = "blue"; + + const TEST_CONTENT = [ + { + id: "TEST_SCREEN", + content: { + position: "split", + logo: {}, + title: "test", + }, + }, + ]; + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + await pushPrefs(["browser.aboutwelcome.backdrop", TEST_BACKDROP]); + + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + id: "my-mochitest-experiment", + screens: TEST_CONTENT, + }, + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + const browser = tab.linkedBrowser; + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + sandbox.restore(); + }); + + await test_screen_content( + browser, + "multistage step 1", + // Expected selectors: + [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP}']`] + ); + + await doExperimentCleanup(); +}); + +add_task(async function test_multistage_aboutwelcome_utm_term() { + const sandbox = sinon.createSandbox(); + + const TEST_CONTENT = [ + { + id: "TEST_SCREEN", + content: { + position: "split", + logo: {}, + title: "test", + secondary_button_top: { + label: "test", + style: "link", + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/", + }, + }, + }, + }, + }, + ]; + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + id: "my-mochitest-experiment", + screens: TEST_CONTENT, + UTMTerm: "test", + }, + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + const browser = tab.linkedBrowser; + const aboutWelcomeActor = await getAboutWelcomeParent(browser); + + sandbox.stub(aboutWelcomeActor, "onContentMessage"); + + await onButtonClick(browser, "button[value='secondary_button_top']"); + + let actionCall; + + const { callCount } = aboutWelcomeActor.onContentMessage; + for (let i = 0; i < callCount; i++) { + const call = aboutWelcomeActor.onContentMessage.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("SPECIAL")) { + actionCall = call; + } + } + + Assert.equal( + actionCall.args[1].data.args, + "https://www.mozilla.org/?utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral&utm_term=test-screen", + "UTMTerm set in mobile" + ); + + registerCleanupFunction(() => { + sandbox.restore(); + BrowserTestUtils.removeTab(tab); + }); + + await doExperimentCleanup(); +}); + +add_task(async function test_multistage_aboutwelcome_newtab_urlbar_focus() { + const sandbox = sinon.createSandbox(); + + const TEST_CONTENT = [ + { + id: "TEST_SCREEN", + content: { + position: "split", + logo: {}, + title: "Test newtab url focus", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + }, + }, + ]; + await setAboutWelcomePref(true); + await ExperimentAPI.ready(); + await pushPrefs(["browser.aboutwelcome.newtabUrlBarFocus", true]); + + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { + id: "my-mochitest-experiment", + screens: TEST_CONTENT, + }, + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + const browser = tab.linkedBrowser; + let focused = BrowserTestUtils.waitForEvent(gURLBar.inputField, "focus"); + await onButtonClick(browser, "button.primary"); + await focused; + Assert.ok(gURLBar.focused, "focus should be on url bar"); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + sandbox.restore(); + }); + await doExperimentCleanup(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_languageSwitcher.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_languageSwitcher.js new file mode 100644 index 0000000000..5291a66f7e --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_languageSwitcher.js @@ -0,0 +1,708 @@ +"use strict"; + +const { getAddonAndLocalAPIsMocker } = ChromeUtils.importESModule( + "resource://testing-common/LangPackMatcherTestUtils.sys.mjs" +); + +const { AWScreenUtils } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AWScreenUtils.sys.mjs" +); + +const sandbox = sinon.createSandbox(); +const mockAddonAndLocaleAPIs = getAddonAndLocalAPIsMocker(this, sandbox); +add_task(function initSandbox() { + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +/** + * Spy specifically on the button click telemetry. + * + * The returned function flushes the spy of all of the matching button click events, and + * returns the events. + * + * @returns {function(): TelemetryEvents[]} + */ +async function spyOnTelemetryButtonClicks(browser) { + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + return () => { + const result = aboutWelcomeActor.onContentMessage + .getCalls() + .filter( + call => + call.args[0] === "AWPage:TELEMETRY_EVENT" && + call.args[1]?.event === "CLICK_BUTTON" + ) + // The second argument is the telemetry event. + .map(call => call.args[1]); + + aboutWelcomeActor.onContentMessage.resetHistory(); + return result; + }; +} + +async function openAboutWelcome() { + await pushPrefs( + // Speed up the tests by disabling transitions. + ["browser.aboutwelcome.transitions", false], + ["intl.multilingual.aboutWelcome.languageMismatchEnabled", true] + ); + await setAboutWelcomePref(true); + + sandbox + .stub(AWScreenUtils, "evaluateScreenTargeting") + .resolves(true) + // Renders easy setup import screen as first screen to prevent pin/default dialog boxes breaking tests + .withArgs( + "doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser" + ) + .resolves(false) + .withArgs( + "!doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser" + ) + .resolves(false) + .withArgs( + "doesAppNeedPin && (!'browser.shell.checkDefaultBrowser'|preferenceValue || isDefaultBrowser)" + ) + .resolves(false) + .withArgs("isDeviceMigration") + .resolves(false); + + info("Opening about:welcome"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + }); + + return { + browser: tab.linkedBrowser, + flushClickTelemetry: await spyOnTelemetryButtonClicks(tab.linkedBrowser), + }; +} + +async function clickVisibleButton(browser, selector) { + // eslint-disable-next-line no-shadow + await ContentTask.spawn(browser, { selector }, async ({ selector }) => { + function getVisibleElement() { + for (const el of content.document.querySelectorAll(selector)) { + if (el.offsetParent !== null) { + return el; + } + } + return null; + } + + await ContentTaskUtils.waitForCondition( + getVisibleElement, + selector, + 200, // interval + 100 // maxTries + ); + getVisibleElement().click(); + }); +} + +/** + * Test that selectors are present and visible. + */ +async function testScreenContent( + browser, + name, + expectedSelectors = [], + unexpectedSelectors = [] +) { + await ContentTask.spawn( + browser, + { expectedSelectors, name, unexpectedSelectors }, + async ({ + expectedSelectors: expected, + name: experimentName, + unexpectedSelectors: unexpected, + }) => { + function selectorIsVisible(selector) { + const els = content.document.querySelectorAll(selector); + // The offsetParent will be null if element is hidden through "display: none;" + return [...els].some(el => el.offsetParent !== null); + } + + for (let selector of expected) { + await ContentTaskUtils.waitForCondition( + () => selectorIsVisible(selector), + `Should render ${selector} in ${experimentName}` + ); + } + for (let selector of unexpected) { + ok( + !selectorIsVisible(selector), + `Should not render ${selector} in ${experimentName}` + ); + } + } + ); +} + +/** + * Report telemetry mismatches nicely. + */ +function eventsMatch( + actualEvents, + expectedEvents, + message = "Telemetry events match" +) { + if (actualEvents.length !== expectedEvents.length) { + console.error("Events do not match"); + console.error("Actual: ", JSON.stringify(actualEvents, null, 2)); + console.error("Expected: ", JSON.stringify(expectedEvents, null, 2)); + } + for (let i = 0; i < actualEvents.length; i++) { + const actualEvent = JSON.stringify(actualEvents[i], null, 2); + const expectedEvent = JSON.stringify(expectedEvents[i], null, 2); + if (actualEvent !== expectedEvent) { + console.error("Events do not match"); + dump(`Actual: ${actualEvent}`); + dump("\n"); + dump(`Expected: ${expectedEvent}`); + dump("\n"); + } + Assert.strictEqual(actualEvent, expectedEvent, message); + } +} + +const liveLanguageSwitchSelectors = [ + ".screen.AW_LANGUAGE_MISMATCH", + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="mr2022-onboarding-live-language-text"]`, +]; + +/** + * Accept the about:welcome offer to change the Firefox language when + * there is a mismatch between the operating system language and the Firefox + * language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_accept() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller, mockable } = + mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + await testScreenContent( + browser, + "First Screen primary CTA loaded", + // Expected selectors: + [`button.primary[value="primary_button"]`], + // Unexpected selectors: + [] + ); + + info("Clicking the primary button to start the onboarding process."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + await testScreenContent( + browser, + "Live language switching (waiting for languages)", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="mr2022-onboarding-live-language-text"]`, + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`, + ], + // Unexpected selectors: + [] + ); + + // Ignore the telemetry of the initial welcome screen. + flushClickTelemetry(); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `button.primary[value="primary_button"]`, + `button.primary[value="decline"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="mr2022-onboarding-live-language-waiting-button"]`, + `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`, + ] + ); + + info("Clicking the primary button to view language switching page."); + + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching, waiting for langpack to download", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-button-label-downloading"]`, + `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + ] + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "download_langpack", + page: "about:welcome", + }, + message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH", + }, + ]); + + sinon.assert.notCalled(mockable.setRequestedAppLocales); + + await resolveInstaller(); + + await testScreenContent( + browser, + "Language changed", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + info("The app locale was changed to the OS locale."); + sinon.assert.calledWith(mockable.setRequestedAppLocales, ["es-ES", "en-US"]); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "download_complete", + page: "about:welcome", + }, + message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH", + }, + ]); +}); + +/** + * Test declining the about:welcome offer to change the Firefox language when + * there is a mismatch between the operating system language and the Firefox + * language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_decline() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller, mockable } = + mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + await testScreenContent( + browser, + "First Screen primary CTA loaded", + // Expected selectors: + [`button.primary[value="primary_button"]`], + // Unexpected selectors: + [] + ); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + await testScreenContent( + browser, + "Live language switching (waiting for languages)", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="mr2022-onboarding-live-language-text"]`, + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`, + ], + // Unexpected selectors: + [] + ); + + // Ignore the telemetry of the initial welcome screen. + flushClickTelemetry(); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + resolveInstaller(); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `button.primary[value="primary_button"]`, + `button.primary[value="decline"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`, + ] + ); + + sinon.assert.notCalled(mockable.setRequestedAppLocales); + + info("Clicking the secondary button to skip installing the langpack."); + await clickVisibleButton(browser, `button.primary[value="decline"]`); + + await testScreenContent( + browser, + "Language selection declined", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + info("The requested locale should be set to the original en-US"); + sinon.assert.calledWith(mockable.setRequestedAppLocales, ["en-US"]); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "decline", + page: "about:welcome", + }, + message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH", + }, + ]); +}); + +/** + * Ensure the langpack can be installed before the user gets to the language screen. + */ +add_task(async function test_aboutwelcome_languageSwitcher_asyncCalls() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller, mockable } = + mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + await openAboutWelcome(); + + info("Waiting for getAvailableLangpacks to be called."); + await TestUtils.waitForCondition( + () => mockable.getAvailableLangpacks.called, + "getAvailableLangpacks called once" + ); + ok(mockable.installLangPack.notCalled); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await TestUtils.waitForCondition( + () => mockable.installLangPack.called, + "installLangPack was called once" + ); + ok(mockable.getAvailableLangpacks.called); + + resolveInstaller(); +}); + +/** + * Test that the "en-US" langpack is installed, if it's already available as the last + * fallback locale. + */ +add_task(async function test_aboutwelcome_fallback_locale() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller, mockable } = + mockAddonAndLocaleAPIs({ + systemLocale: "en-US", + appLocale: "it", + }); + + await openAboutWelcome(); + + info("Waiting for getAvailableLangpacks to be called."); + await TestUtils.waitForCondition( + () => mockable.getAvailableLangpacks.called, + "getAvailableLangpacks called once" + ); + ok(mockable.installLangPack.notCalled); + + resolveLangPacks(["en-US"]); + + await TestUtils.waitForCondition( + () => mockable.installLangPack.called, + "installLangPack was called once" + ); + ok(mockable.getAvailableLangpacks.called); + + resolveInstaller(); +}); + +/** + * Test when AMO does not have a matching language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_noMatch() { + sandbox.restore(); + const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({ + systemLocale: "tlh", // Klingon + appLocale: "en-US", + }); + + const { browser } = await openAboutWelcome(); + + info("Clicking the primary button to start installing the langpack."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + // Klingon is not supported. + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Language selection skipped", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + [ + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="onboarding-live-language-header"]`, + ] + ); + sinon.assert.notCalled(mockable.setRequestedAppLocales); +}); + +/** + * Test when bidi live reloading is not supported. + */ +add_task(async function test_aboutwelcome_languageSwitcher_bidiNotSupported() { + sandbox.restore(); + await pushPrefs(["intl.multilingual.liveReloadBidirectional", false]); + + const { mockable } = mockAddonAndLocaleAPIs({ + systemLocale: "ar-EG", // Arabic (Egypt) + appLocale: "en-US", + }); + + const { browser } = await openAboutWelcome(); + + info("Clicking the primary button to start installing the langpack."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + await testScreenContent( + browser, + "Language selection skipped for bidi", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + [ + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="onboarding-live-language-header"]`, + ] + ); + + sinon.assert.notCalled(mockable.setRequestedAppLocales); +}); + +/** + * Test when bidi live reloading is not supported and no langpacks. + */ +add_task( + async function test_aboutwelcome_languageSwitcher_bidiNotSupported_noLangPacks() { + sandbox.restore(); + await pushPrefs(["intl.multilingual.liveReloadBidirectional", false]); + + const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({ + systemLocale: "ar-EG", // Arabic (Egypt) + appLocale: "en-US", + }); + resolveLangPacks([]); + + const { browser } = await openAboutWelcome(); + + info("Clicking the primary button to start installing the langpack."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + await testScreenContent( + browser, + "Language selection skipped for bidi", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + [ + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="onboarding-live-language-header"]`, + ] + ); + + sinon.assert.notCalled(mockable.setRequestedAppLocales); + } +); + +/** + * Test when bidi live reloading is supported. + */ +add_task(async function test_aboutwelcome_languageSwitcher_bidiNotSupported() { + sandbox.restore(); + await pushPrefs(["intl.multilingual.liveReloadBidirectional", true]); + + const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({ + systemLocale: "ar-EG", // Arabic (Egypt) + appLocale: "en-US", + }); + + const { browser } = await openAboutWelcome(); + + info("Clicking the primary button to start installing the langpack."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + resolveLangPacks(["ar-EG", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching with bidi supported", + // Expected selectors: + [...liveLanguageSwitchSelectors], + // Unexpected selectors: + [] + ); + + sinon.assert.notCalled(mockable.setRequestedAppLocales); +}); + +/** + * Test hitting the cancel button when waiting on a langpack. + */ +add_task(async function test_aboutwelcome_languageSwitcher_cancelWaiting() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller, mockable } = + mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + + info("Clicking the primary button to start the onboarding process."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + liveLanguageSwitchSelectors, + // Unexpected selectors: + [] + ); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + await testScreenContent( + browser, + "Live language switching, waiting for langpack to download", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-button-label-downloading"]`, + `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + ] + ); + + // Ignore all the telemetry up to this point. + flushClickTelemetry(); + + info("Cancel the request for the language"); + await clickVisibleButton(browser, "button.secondary"); + + await testScreenContent( + browser, + "Language selection declined waiting", + // Expected selectors: + [`.screen.AW_IMPORT_SETTINGS_EMBEDDED`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "cancel_waiting", + page: "about:welcome", + }, + message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH", + }, + ]); + + await resolveInstaller(); + + is(flushClickTelemetry().length, 0); + sinon.assert.notCalled(mockable.setRequestedAppLocales); +}); + +/** + * Test MR About Welcome language mismatch screen + */ +add_task(async function test_aboutwelcome_languageSwitcher_MR() { + sandbox.restore(); + + const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser } = await openAboutWelcome(true); + + info("Clicking the primary button to view language switching screen."); + await clickVisibleButton(browser, `button.primary[value="primary_button"]`); + + resolveLangPacks(["es-AR"]); + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + `#mainContentHeader[data-l10n-id="mr2022-onboarding-live-language-text"]`, + `[data-l10n-id="mr2022-language-mismatch-subtitle"]`, + `.section-secondary [data-l10n-id="mr2022-onboarding-live-language-text"]`, + `[data-l10n-id="mr2022-onboarding-live-language-switch-to"]`, + `button.primary[value="primary_button"]`, + `button.primary[value="decline"]`, + ], + // Unexpected selectors: + [`[data-l10n-id="onboarding-live-language-header"]`] + ); + + await resolveInstaller(); + await testScreenContent( + browser, + "Switched some to langpack (raw) strings after install", + // Expected selectors: + [`#mainContentHeader[data-l10n-id="mr2022-onboarding-live-language-text"]`], + // Unexpected selectors: + [ + `.section-secondary [data-l10n-id="mr2022-onboarding-live-language-text"]`, + `[data-l10n-id="mr2022-onboarding-live-language-switch-to"]`, + ] + ); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js new file mode 100644 index 0000000000..1832b75778 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js @@ -0,0 +1,770 @@ +"use strict"; + +const { AboutWelcomeParent } = ChromeUtils.importESModule( + "resource:///actors/AboutWelcomeParent.sys.mjs" +); + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); +const { AWScreenUtils } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AWScreenUtils.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); + +async function clickVisibleButton(browser, selector) { + // eslint-disable-next-line no-shadow + await ContentTask.spawn(browser, { selector }, async ({ selector }) => { + function getVisibleElement() { + for (const el of content.document.querySelectorAll(selector)) { + if (el.offsetParent !== null) { + return el; + } + } + return null; + } + await ContentTaskUtils.waitForCondition( + getVisibleElement, + selector, + 200, // interval + 100 // maxTries + ); + getVisibleElement().click(); + }); +} + +add_setup(async function () { + SpecialPowers.pushPrefEnv({ + set: [ + ["ui.prefersReducedMotion", 1], + ["browser.aboutwelcome.transitions", false], + ["browser.shell.checkDefaultBrowser", true], + ], + }); +}); + +/** + * Test MR message telemetry + */ +add_task(async function test_aboutwelcome_mr_template_telemetry() { + const sandbox = sinon.createSandbox(); + + let { browser, cleanup } = await openMRAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent's Content Message Handler + const messageStub = sandbox.spy(aboutWelcomeActor, "onContentMessage"); + await clickVisibleButton(browser, ".action-buttons button.secondary"); + + const { callCount } = messageStub; + Assert.greaterOrEqual(callCount, 1, `${callCount} Stub was called`); + let clickCall; + for (let i = 0; i < callCount; i++) { + const call = messageStub.getCall(i); + info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`); + if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) { + clickCall = call; + } + } + + Assert.ok( + clickCall.args[1].message_id.startsWith("MR_WELCOME_DEFAULT"), + "Telemetry includes MR message id" + ); + + await cleanup(); + sandbox.restore(); +}); + +/** + * Telemetry Impression with Easy Setup Need Default and Pin as First Screen + */ +add_task(async function test_aboutwelcome_easy_setup_screen_impression() { + const sandbox = sinon.createSandbox(); + sandbox + .stub(AWScreenUtils, "evaluateScreenTargeting") + .resolves(false) + .withArgs( + "doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser" + ) + .resolves(true) + .withArgs("isDeviceMigration") + .resolves(false); + + let impressionSpy = sandbox.spy( + AboutWelcomeTelemetry.prototype, + "sendTelemetry" + ); + + let { browser, cleanup } = await openMRAboutWelcome(); + // Wait for screen elements to render before checking impression pings + await test_screen_content( + browser, + "Onboarding screen elements rendered", + // Expected selectors: + [ + `main.screen[pos="split"]`, + "div.secondary-cta.top", + "button[value='secondary_button_top']", + ] + ); + + const { callCount } = impressionSpy; + Assert.greaterOrEqual(callCount, 1, `${callCount} impressionSpy was called`); + let impressionCall; + for (let i = 0; i < callCount; i++) { + const call = impressionSpy.getCall(i); + info(`Call #${i}: ${JSON.stringify(call.args[0])}`); + if ( + call.calledWithMatch({ event: "IMPRESSION" }) && + !call.calledWithMatch({ message_id: "MR_WELCOME_DEFAULT" }) + ) { + info(`Screen Impression Call #${i}: ${JSON.stringify(call.args[0])}`); + impressionCall = call; + } + } + + Assert.ok( + impressionCall.args[0].message_id.startsWith( + "MR_WELCOME_DEFAULT_0_AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN" + ), + "Impression telemetry includes correct message id" + ); + await cleanup(); + sandbox.restore(); +}); + +add_task(async function test_aboutwelcome_gratitude() { + const TEST_CONTENT = [ + { + id: "AW_GRATITUDE", + content: { + position: "split", + split_narrow_bkg_position: "-228px", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat, var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: { + string_id: "mr2022-onboarding-gratitude-title", + }, + subtitle: { + string_id: "mr2022-onboarding-gratitude-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-primary-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ]; + await setAboutWelcomeMultiStage(JSON.stringify(TEST_CONTENT)); // NB: calls SpecialPowers.pushPrefEnv + let { cleanup, browser } = await openMRAboutWelcome(); + + // execution + await test_screen_content( + browser, + "doesn't render secondary button on gratitude screen", + //Expected selectors + ["main.AW_GRATITUDE", "button[value='primary_button']"], + + //Unexpected selectors: + ["button[value='secondary_button']"] + ); + await clickVisibleButton(browser, ".action-buttons button.primary"); + + // make sure the button navigates to newtab + await test_screen_content( + browser, + "home", + //Expected selectors + ["body.activity-stream"], + + //Unexpected selectors: + ["main.AW_GRATITUDE"] + ); + + // cleanup + await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage + await cleanup(); +}); + +add_task(async function test_aboutwelcome_embedded_migration() { + // Let's make sure at least one migrator is available and enabled - the + // InternalTestingProfileMigrator. + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.internal-testing.enabled", true]], + }); + + const sandbox = sinon.createSandbox(); + sandbox + .stub(InternalTestingProfileMigrator.prototype, "getResources") + .callsFake(() => + Promise.resolve([ + { + type: MigrationUtils.resourceTypes.BOOKMARKS, + migrate: () => {}, + }, + ]) + ); + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: 123, + history: 123, + logins: 123, + }); + const migrated = new Promise(resolve => { + sandbox + .stub(InternalTestingProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + let telemetrySpy = sandbox.spy( + AboutWelcomeTelemetry.prototype, + "sendTelemetry" + ); + + const TEST_CONTENT = [ + { + id: "AW_IMPORT_SETTINGS_EMBEDDED", + content: { + tiles: { type: "migration-wizard" }, + position: "split", + split_narrow_bkg_position: "-42px", + image_alt_text: { + string_id: "mr2022-onboarding-import-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-import.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + migrate_start: { + action: {}, + }, + migrate_close: { + action: { navigate: true }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "AW_STEP2", + content: { + position: "split", + split_narrow_bkg_position: "-228px", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat, var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: { + string_id: "mr2022-onboarding-gratitude-title", + }, + subtitle: { + string_id: "mr2022-onboarding-gratitude-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-primary-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ]; + + await setAboutWelcomeMultiStage(JSON.stringify(TEST_CONTENT)); // NB: calls SpecialPowers.pushPrefEnv + let { cleanup, browser } = await openMRAboutWelcome(); + + // execution + await test_screen_content( + browser, + "Renders a <migration-wizard> custom element", + // We expect <migration-wizard> to automatically request the set of migrators + // upon binding to the DOM, and to not be in dialog mode. + [ + "main.AW_IMPORT_SETTINGS_EMBEDDED", + "migration-wizard[auto-request-state]:not([dialog-mode])", + ] + ); + + // Do a basic test to make sure that the <migration-wizard> is on the right + // page and the <panel-list> can open. + await SpecialPowers.spawn( + browser, + [`panel-item[key="${InternalTestingProfileMigrator.key}"]`], + async menuitemSelector => { + const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" + ); + + let wizard = content.document.querySelector("migration-wizard"); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + // It's unlikely but possible that the deck might not yet be showing the + // selection page yet, in which case we wait for that page to appear. + if (deck.selectedViewName !== MigrationWizardConstants.PAGES.SELECTION) { + await ContentTaskUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") === + `page-${MigrationWizardConstants.PAGES.SELECTION}` + ); + } + ); + } + + Assert.ok(true, "Selection page is being shown in the migration wizard."); + + // Now let's make sure that the <panel-list> can appear. + let panelList = shadow.querySelector("panel-list"); + Assert.ok(panelList, "Found the <panel-list>."); + + // The "shown" event from the panel-list is coming from a lower level + // of privilege than where we're executing this SpecialPowers.spawn + // task. In order to properly listen for it, we have to ask + // ContentTaskUtils.waitForEvent to listen for untrusted events. + let shown = ContentTaskUtils.waitForEvent( + panelList, + "shown", + false /* capture */, + null /* checkFn */, + true /* wantsUntrusted */ + ); + let selector = shadow.querySelector("#browser-profile-selector"); + + // The migration wizard programmatically focuses the selector after + // the selection page is shown using an rAF. If we click the button + // before that occurs, then the focus can shift after the panel opens + // which will cause it to immediately close again. So we wait for the + // selection button to gain focus before continuing. + if (!selector.matches(":focus")) { + await ContentTaskUtils.waitForEvent(selector, "focus"); + } + + selector.click(); + await shown; + + let panelRect = panelList.getBoundingClientRect(); + let selectorRect = selector.getBoundingClientRect(); + + // Recalculate the <panel-list> rect top value relative to the top-left + // of the selectorRect. We expect the <panel-list> to be tightly anchored + // to the bottom of the <button>, so we expect this new value to be close to 0, + // to account for subpixel rounding + let panelTopLeftRelativeToAnchorTopLeft = + panelRect.top - selectorRect.top - selectorRect.height; + + function isfuzzy(actual, expected, epsilon, msg) { + if (actual >= expected - epsilon && actual <= expected + epsilon) { + ok(true, msg); + } else { + is(actual, expected, msg); + } + } + + isfuzzy( + panelTopLeftRelativeToAnchorTopLeft, + 0, + 1, + "Panel should be tightly anchored to the bottom of the button shadow node." + ); + + let panelItem = shadow.querySelector(menuitemSelector); + panelItem.click(); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + } + ); + + await migrated; + Assert.ok( + telemetrySpy.calledWithMatch({ + event: "CLICK_BUTTON", + event_context: { source: "primary_button", page: "about:welcome" }, + message_id: sinon.match.string, + }), + "Should have sent telemetry for clicking the 'Import' button." + ); + + await SpecialPowers.spawn(browser, [], async () => { + let wizard = content.document.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let continueButton = shadow.querySelector( + "div[name='page-progress'] .continue-button" + ); + continueButton.click(); + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("main.AW_STEP2"), + "Waiting for step 2 to render" + ); + }); + + Assert.ok( + telemetrySpy.calledWithMatch({ + event: "CLICK_BUTTON", + event_context: { source: "migrate_close", page: "about:welcome" }, + message_id: sinon.match.string, + }), + "Should have sent telemetry for clicking the 'Continue' button." + ); + + // Ensure that we can go back and get the migration wizard to appear + // again. + await SpecialPowers.spawn(browser, [], async () => { + const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" + ); + + let migrationWizardReady = ContentTaskUtils.waitForEvent( + content, + "MigrationWizard:Ready" + ); + + // Waiting for the history length to update seems to allow us to avoid + // an intermittent test failure. + await ContentTaskUtils.waitForCondition(() => { + return content.history.length === 2; + }); + + content.history.back(); + await migrationWizardReady; + + let wizard = content.document.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + Assert.equal( + deck.getAttribute("selected-view"), + `page-${MigrationWizardConstants.PAGES.SELECTION}` + ); + }); + + // cleanup + await SpecialPowers.popPrefEnv(); // for the InternalTestingProfileMigrator. + await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage + await cleanup(); + sandbox.restore(); + let migrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + migrator.flushResourceCache(); +}); + +add_task(async function test_aboutwelcome_multiselect() { + const TEST_SCREENS = [ + { + id: "AW_EASY_SETUP_X", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + progress_bar: true, + logo: {}, + title: { string_id: "mr2022-onboarding-set-default-title" }, + tiles: { + type: "multiselect", + style: { flexDirection: "column", alignItems: "flex-start" }, + data: [ + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: true, + label: { + raw: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + }, + action: { type: "OPEN_PROTECTION_REPORT" }, + }, + { + id: "radio-2", + type: "radio", + group: "radios", + defaultValue: false, + label: { + raw: "Nulla facilisi nullam vehicula ipsum a arcu cursus vitae.", + }, + action: { type: "OPEN_FIREFOX_VIEW" }, + }, + { + id: "radio-3", + type: "radio", + group: "radios", + defaultValue: false, + label: { raw: "Natoque penatibus et magnis dis." }, + action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" }, + }, + ], + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { navigate: true }, + has_arrow_icon: true, + }, + }, + }, + { + id: "AW_EASY_SETUP_Y", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + progress_bar: true, + logo: {}, + title: { string_id: "mr2022-onboarding-set-default-title" }, + tiles: { + type: "multiselect", + style: { flexDirection: "row", gap: "24px" }, + data: [ + { + id: "checkbox-1", + defaultValue: true, + label: { raw: "Test1" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + }, + { + id: "checkbox-2", + defaultValue: true, + label: { raw: "Test2" }, + action: { type: "OPEN_FIREFOX_VIEW" }, + }, + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: true, + label: { raw: "Test3" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + }, + { + id: "radio-2", + type: "radio", + group: "radios", + defaultValue: false, + label: { raw: "Test4" }, + action: { type: "OPEN_FIREFOX_VIEW" }, + }, + { + id: "radio-3", + type: "radio", + group: "radios", + defaultValue: false, + label: { raw: "Test5" }, + action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" }, + }, + ], + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { navigate: true }, + has_arrow_icon: true, + }, + }, + }, + { + id: "AW_EASY_SETUP_Z", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + progress_bar: true, + logo: {}, + title: { string_id: "mr2022-onboarding-set-default-title" }, + tiles: { + type: "multiselect", + style: { + flexDirection: "column", + flexShrink: 1, + justifyContent: "flex-start", + }, + data: [ + { + id: "checkbox-1", + defaultValue: true, + label: { raw: "Test1" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + }, + { + id: "checkbox-2", + defaultValue: true, + label: { raw: "Test2" }, + action: { type: "OPEN_FIREFOX_VIEW" }, + }, + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: true, + label: { raw: "Test3" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + }, + { + id: "radio-2", + type: "radio", + group: "radios", + defaultValue: false, + label: { raw: "Test4" }, + action: { type: "OPEN_FIREFOX_VIEW" }, + }, + { + id: "radio-3", + type: "radio", + group: "radios", + defaultValue: false, + label: { raw: "Test5" }, + action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" }, + }, + ], + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { navigate: true }, + has_arrow_icon: true, + }, + }, + }, + ]; + + const sandbox = sinon.createSandbox(); + sandbox.stub(AWScreenUtils, "addScreenImpression").resolves(); + + await setAboutWelcomeMultiStage(JSON.stringify(TEST_SCREENS)); + let { cleanup, browser } = await openMRAboutWelcome(); + + await test_screen_content( + browser, + "renders default screen", + ["main.AW_EASY_SETUP_X", "#radio-1:checked"], + ["#radio-2:checked", "#radio-3:checked"] + ); + + await clickVisibleButton(browser, "#radio-3"); + + await test_screen_content( + browser, + "renders radio button selection", + ["main.AW_EASY_SETUP_X", "#radio-3:checked"], + ["#radio-1:checked", "#radio-2:checked"] + ); + + await test_element_styles( + browser, + ".multi-select-container", + { flexDirection: "column", alignItems: "flex-start" }, + {} + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + + await test_screen_content( + browser, + "renders screen 2", + [ + "main.AW_EASY_SETUP_Y", + "#checkbox-1:checked", + "#checkbox-2:checked", + "#radio-1:checked", + ], + ["#radio-2:checked", "#radio-3:checked"] + ); + + await clickVisibleButton(browser, "#checkbox-1"); + await clickVisibleButton(browser, "#checkbox-2"); + await clickVisibleButton(browser, "#radio-2"); + + await test_screen_content( + browser, + "renders checkbox and radio button selection", + ["main.AW_EASY_SETUP_Y", "#radio-2:checked"], + ["#checkbox-1:checked", "#checkbox-2:checked", "#radio-1:checked"] + ); + + await test_element_styles( + browser, + ".multi-select-container", + { flexDirection: "row", gap: "24px" }, + {} + ); + + browser.goBack(); + + await test_screen_content( + browser, + "renders screen 1 and remembers selection", + ["main.AW_EASY_SETUP_X", "#radio-3:checked"], + ["#radio-1:checked", "#radio-2:checked"] + ); + + browser.goForward(); + + await test_screen_content( + browser, + "renders screen 2 and remembers selection", + ["main.AW_EASY_SETUP_Y", "#radio-2:checked"], + ["#checkbox-1:checked", "#checkbox-2:checked", "#radio-1:checked"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + + await test_screen_content( + browser, + "renders screen 3", + [ + "main.AW_EASY_SETUP_Z", + "#checkbox-1:checked", + "#checkbox-2:checked", + "#radio-1:checked", + ], + ["#radio-2:checked", "#radio-3:checked"] + ); + + await clickVisibleButton(browser, "#radio-3"); + + await test_screen_content( + browser, + "renders radio button selection without removing checkbox selection", + [ + "main.AW_EASY_SETUP_Z", + "#checkbox-1:checked", + "#checkbox-2:checked", + "#radio-3:checked", + ], + ["#radio-1:checked", "#radio-2:checked"] + ); + + await test_element_styles( + browser, + ".multi-select-container", + { flexDirection: "column", flexShrink: 1, justifyContent: "flex-start" }, + {} + ); + + await SpecialPowers.popPrefEnv(); + await cleanup(); + + sandbox.restore(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_video.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_video.js new file mode 100644 index 0000000000..ed331e6752 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_video.js @@ -0,0 +1,97 @@ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const videoUrl = + "https://www.mozilla.org/tests/dom/media/webaudio/test/noaudio.webm"; + +function testAutoplayPermission(browser) { + let principal = browser.contentPrincipal; + is( + PermissionTestUtils.testPermission(principal, "autoplay-media"), + Services.perms.ALLOW_ACTION, + `Autoplay is allowed on ${principal.origin}` + ); +} + +async function openAWWithVideo({ + autoPlay = false, + video_url = videoUrl, + ...rest +} = {}) { + const content = [ + { + id: "VIDEO_ONBOARDING", + content: { + position: "center", + logo: {}, + title: "Video onboarding", + secondary_button: { label: "Skip video", action: { navigate: true } }, + video_container: { + video_url, + action: { navigate: true }, + autoPlay, + ...rest, + }, + }, + }, + ]; + await setAboutWelcomeMultiStage(JSON.stringify(content)); + let { cleanup, browser } = await openMRAboutWelcome(); + return { + browser, + content, + async cleanup() { + await SpecialPowers.popPrefEnv(); + await cleanup(); + }, + }; +} + +add_task(async function test_aboutwelcome_video_autoplay() { + let { cleanup, browser } = await openAWWithVideo({ autoPlay: true }); + + testAutoplayPermission(browser); + + await SpecialPowers.spawn(browser, [videoUrl], async url => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("main.with-video"), + "Waiting for video onboarding screen" + ); + let video = content.document.querySelector(`video[src='${url}'][autoplay]`); + await ContentTaskUtils.waitForCondition( + () => + video.currentTime > 0 && + !video.paused && + !video.ended && + video.readyState > 2, + "Waiting for video to play" + ); + ok(!video.error, "Video should not have an error"); + }); + + await cleanup(); +}); + +add_task(async function test_aboutwelcome_video_no_autoplay() { + let { cleanup, browser } = await openAWWithVideo(); + + testAutoplayPermission(browser); + + await SpecialPowers.spawn(browser, [videoUrl], async url => { + let video = await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector(`video[src='${url}']:not([autoplay])`), + "Waiting for video element to render" + ); + await ContentTaskUtils.waitForCondition( + () => video.paused && !video.ended && video.readyState > 2, + "Waiting for video to be playable but not playing" + ); + ok(!video.error, "Video should not have an error"); + }); + + await cleanup(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_observer.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_observer.js new file mode 100644 index 0000000000..58f9059532 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_observer.js @@ -0,0 +1,73 @@ +"use strict"; + +const { AboutWelcomeParent } = ChromeUtils.importESModule( + "resource:///actors/AboutWelcomeParent.sys.mjs" +); + +async function openAboutWelcomeTab() { + await setAboutWelcomePref(true); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome" + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab; +} + +/** + * Test simplified welcome UI tab closed terminate reason + */ +add_task(async function test_About_Welcome_Tab_Close() { + await setAboutWelcomePref(true); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + false + ); + + Assert.ok(Services.focus.activeWindow, "Active window is not null"); + let AWP = new AboutWelcomeParent(); + Assert.ok(AWP.AboutWelcomeObserver, "AboutWelcomeObserver is not null"); + + BrowserTestUtils.removeTab(tab); + Assert.equal( + AWP.AboutWelcomeObserver.terminateReason, + AWP.AboutWelcomeObserver.AWTerminate.TAB_CLOSED, + "Terminated due to tab closed" + ); +}); + +/** + * Test simplified welcome UI closed due to change in location uri + */ +add_task(async function test_About_Welcome_Location_Change() { + await openAboutWelcomeTab(); + let windowGlobalParent = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal; + + let aboutWelcomeActor = await windowGlobalParent.getActor("AboutWelcome"); + + Assert.ok( + aboutWelcomeActor.AboutWelcomeObserver, + "AboutWelcomeObserver is not null" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/#foo" + ); + await BrowserTestUtils.waitForLocationChange( + gBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/#foo" + ); + + Assert.equal( + aboutWelcomeActor.AboutWelcomeObserver.terminateReason, + aboutWelcomeActor.AboutWelcomeObserver.AWTerminate.ADDRESS_BAR_NAVIGATED, + "Terminated due to location uri changed" + ); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_rtamo.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_rtamo.js new file mode 100644 index 0000000000..4db4569eb6 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_rtamo.js @@ -0,0 +1,299 @@ +"use strict"; + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { AddonRepository } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonRepository.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const TEST_ADDON_INFO = [ + { + name: "Test Add-on", + sourceURI: { scheme: "https", spec: "https://test.xpi" }, + icons: { 32: "test.png", 64: "test.png" }, + type: "extension", + }, +]; + +const TEST_ADDON_INFO_THEME = [ + { + name: "Test Add-on", + sourceURI: { scheme: "https", spec: "https://test.xpi" }, + icons: { 32: "test.png", 64: "test.png" }, + screenshots: [{ url: "test.png" }], + type: "theme", + }, +]; + +async function openRTAMOWelcomePage() { + // Can't properly stub the child/parent actors so instead + // we stub the modules they depend on for the RTAMO flow + // to ensure the right thing is rendered. + await ASRouter.forceAttribution({ + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + // with the sinon override, the id doesn't matter + content: "rta:whatever", + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + // Clear cache call is only possible in a testing environment + Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing"); + await ASRouter.forceAttribution({ + source: "", + medium: "", + campaign: "", + content: "", + experiment: "", + variation: "", + ua: "", + dltoken: "", + }); + }); + + return tab.linkedBrowser; +} + +/** + * Setup and test RTAMO welcome UI + */ +async function test_screen_content( + browser, + experiment, + expectedSelectors = [], + unexpectedSelectors = [] +) { + await ContentTask.spawn( + browser, + { expectedSelectors, experiment, unexpectedSelectors }, + async ({ + expectedSelectors: expected, + experiment: experimentName, + unexpectedSelectors: unexpected, + }) => { + for (let selector of expected) { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(selector), + `Should render ${selector} in ${experimentName}` + ); + } + for (let selector of unexpected) { + ok( + !content.document.querySelector(selector), + `Should not render ${selector} in ${experimentName}` + ); + } + } + ); +} + +async function onButtonClick(browser, elementId) { + await ContentTask.spawn( + browser, + { elementId }, + async ({ elementId: buttonId }) => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(buttonId), + buttonId + ); + let button = content.document.querySelector(buttonId); + button.click(); + } + ); +} + +/** + * Test the RTAMO welcome UI + */ +add_task(async function test_rtamo_aboutwelcome() { + let sandbox = sinon.createSandbox(); + sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO); + + let browser = await openRTAMOWelcomePage(); + + await test_screen_content( + browser, + "RTAMO UI", + // Expected selectors: + [ + `div.onboardingContainer[style*='background: var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)']`, + "h2[data-l10n-id='mr1-return-to-amo-addon-title']", + `h2[data-l10n-args='{"addon-name":"${TEST_ADDON_INFO[0].name}"}'`, + "div.rtamo-icon", + "button.primary[data-l10n-id='mr1-return-to-amo-add-extension-label']", + "button[data-l10n-id='onboarding-not-now-button-label']", + ], + // Unexpected selectors: + [ + "main.AW_STEP1", + "main.AW_STEP2", + "main.AW_STEP3", + "div.tiles-container.info", + ] + ); + + await onButtonClick( + browser, + "button[data-l10n-id='onboarding-not-now-button-label']" + ); + Assert.ok(gURLBar.focused, "Focus should be on awesome bar"); + + let windowGlobalParent = browser.browsingContext.currentWindowGlobal; + let aboutWelcomeActor = windowGlobalParent.getActor("AboutWelcome"); + const messageSandbox = sinon.createSandbox(); + // Stub AboutWelcomeParent Content Message Handler + messageSandbox.stub(aboutWelcomeActor, "onContentMessage"); + registerCleanupFunction(() => { + messageSandbox.restore(); + }); + + await onButtonClick(browser, "button.primary"); + const { callCount } = aboutWelcomeActor.onContentMessage; + Assert.strictEqual( + callCount, + 2, + `${callCount} Stub called twice to install extension and send telemetry` + ); + + const installExtensionCall = aboutWelcomeActor.onContentMessage.getCall(0); + Assert.equal( + installExtensionCall.args[0], + "AWPage:SPECIAL_ACTION", + "send special action to install add on" + ); + Assert.equal( + installExtensionCall.args[1].type, + "INSTALL_ADDON_FROM_URL", + "Special action type is INSTALL_ADDON_FROM_URL" + ); + Assert.equal( + installExtensionCall.args[1].data.url, + "https://test.xpi", + "Install add on url" + ); + Assert.equal( + installExtensionCall.args[1].data.telemetrySource, + "rtamo", + "Install add on telemetry source" + ); + const telemetryCall = aboutWelcomeActor.onContentMessage.getCall(1); + Assert.equal( + telemetryCall.args[0], + "AWPage:TELEMETRY_EVENT", + "send add extension telemetry" + ); + Assert.equal( + telemetryCall.args[1].event, + "CLICK_BUTTON", + "Telemetry event sent as INSTALL" + ); + Assert.equal( + telemetryCall.args[1].event_context.source, + "ADD_EXTENSION_BUTTON", + "Source of the event is Add Extension Button" + ); + Assert.equal( + telemetryCall.args[1].message_id, + "RTAMO_DEFAULT_WELCOME_EXTENSION", + "Message Id sent in telemetry for default RTAMO" + ); + + sandbox.restore(); +}); + +add_task(async function test_rtamo_over_experiments() { + let sandbox = sinon.createSandbox(); + sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "aboutwelcome", + value: { screens: [], enabled: true }, + }); + + let browser = await openRTAMOWelcomePage(); + + // If addon attribution exist, we should see RTAMO even if enrolled + // in about:welcome experiment + await test_screen_content( + browser, + "Experiment RTAMO UI", + // Expected selectors: + ["h2[data-l10n-id='mr1-return-to-amo-addon-title']"], + // Unexpected selectors: + [] + ); + + await doExperimentCleanup(); + + browser = await openRTAMOWelcomePage(); + + await test_screen_content( + browser, + "No Experiment RTAMO UI", + // Expected selectors: + [ + "div.onboardingContainer", + "h2[data-l10n-id='mr1-return-to-amo-addon-title']", + "div.rtamo-icon", + "button.primary", + "button.secondary", + ], + // Unexpected selectors: + [ + "main.AW_STEP1", + "main.AW_STEP2", + "main.AW_STEP3", + "div.tiles-container.info", + ] + ); + + sandbox.restore(); +}); + +add_task(async function test_rtamo_primary_button_theme() { + let themeSandbox = sinon.createSandbox(); + themeSandbox + .stub(AddonRepository, "getAddonsByIDs") + .resolves(TEST_ADDON_INFO_THEME); + + let browser = await openRTAMOWelcomePage(); + + await test_screen_content( + browser, + "RTAMO UI", + // Expected selectors: + [ + "div.onboardingContainer", + "h2[data-l10n-id='mr1-return-to-amo-addon-title']", + "div.rtamo-icon", + "button.primary[data-l10n-id='return-to-amo-add-theme-label']", + "button[data-l10n-id='onboarding-not-now-button-label']", + "img.rtamo-theme-icon", + ], + // Unexpected selectors: + [ + "main.AW_STEP1", + "main.AW_STEP2", + "main.AW_STEP3", + "div.tiles-container.info", + ] + ); + + themeSandbox.restore(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_screen_targeting.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_screen_targeting.js new file mode 100644 index 0000000000..1d91d4ca05 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_screen_targeting.js @@ -0,0 +1,274 @@ +"use strict"; + +const TEST_DEFAULT_CONTENT = [ + { + id: "AW_STEP1", + content: { + title: "Step 1", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Secondary", + }, + }, + }, + { + id: "AW_STEP2", + targeting: "false", + content: { + title: "Step 2", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Secondary", + }, + }, + }, + { + id: "AW_STEP3", + content: { + title: "Step 3", + primary_button: { + label: "Next", + action: { + navigate: true, + }, + }, + secondary_button: { + label: "Secondary", + }, + }, + }, +]; + +const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT); +async function openAboutWelcome() { + await setAboutWelcomePref(true); + await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); + return tab.linkedBrowser; +} + +add_task(async function second_screen_filtered_by_targeting() { + const sandbox = sinon.createSandbox(); + let browser = await openAboutWelcome(); + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + // Stub AboutWelcomeParent Content Message Handler + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + + await test_screen_content( + browser, + "multistage step 1", + // Expected selectors: + ["main.AW_STEP1"], + // Unexpected selectors: + ["main.AW_STEP2", "main.AW_STEP3"] + ); + + await onButtonClick(browser, "button.primary"); + + await test_screen_content( + browser, + "multistage step 3", + // Expected selectors: + ["main.AW_STEP3"], + // Unexpected selectors: + ["main.AW_STEP2", "main.AW_STEP1"] + ); + + sandbox.restore(); + await popPrefs(); +}); + +/** + * Test MR template easy setup default content - Browser is not pinned + * and not set as default + */ +add_task(async function test_aboutwelcome_mr_template_easy_setup_default() { + const sandbox = sinon.createSandbox(); + await pushPrefs( + ["browser.shell.checkDefaultBrowser", true], + ["messaging-system-action.showEmbeddedImport", false] + ); + sandbox.stub(ShellService, "doesAppNeedPin").returns(true); + sandbox.stub(ShellService, "isDefaultBrowser").returns(false); + + await clearHistoryAndBookmarks(); + + const { browser, cleanup } = await openMRAboutWelcome(); + + //should render easy setup with all checkboxes (default, pin, import) + await test_screen_content( + browser, + "doesn't render only pin, default, or import easy setup", + //Expected selectors: + ["main.AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN"], + //Unexpected selectors: + [ + "main.AW_EASY_SETUP_NEEDS_DEFAULT", + "main.AW_EASY_SETUP_NEEDS_PIN", + "main.AW_ONLY_IMPORT", + ] + ); + + await onButtonClick(browser, ".action-buttons button.secondary"); + + await test_screen_content( + browser, + "renders mobile download screen", + //Expected selectors: + ["main.AW_MOBILE_DOWNLOAD"], + //Unexpected selectors: + ["main.AW_IMPORT_SETTINGS_EMBEDDED"] + ); + + await cleanup(); + await popPrefs(); + sandbox.restore(); +}); + +/** + * Test MR template easy setup content - Browser is not pinned + * and set as default + */ +add_task(async function test_aboutwelcome_mr_template_easy_setup_needs_pin() { + const sandbox = sinon.createSandbox(); + await pushPrefs( + ["browser.shell.checkDefaultBrowser", true], + ["messaging-system-action.showEmbeddedImport", false] + ); + sandbox.stub(ShellService, "doesAppNeedPin").returns(true); + sandbox.stub(ShellService, "isDefaultBrowser").returns(true); + + await clearHistoryAndBookmarks(); + + const { browser, cleanup } = await openMRAboutWelcome(); + + //should render easy setup needs pin + await test_screen_content( + browser, + "doesn't render default and pin, only default or import easy setup", + //Expected selectors: + ["main.AW_EASY_SETUP_NEEDS_PIN"], + //Unexpected selectors: + [ + "main.AW_EASY_SETUP_NEEDS_DEFAULT", + "main.AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN", + "main.AW_ONLY_IMPORT", + ] + ); + + await cleanup(); + await popPrefs(); + sandbox.restore(); +}); + +/** + * Test MR template easy setup content - Browser is pinned and + * not set as default + */ +add_task( + async function test_aboutwelcome_mr_template_easy_setup_needs_default() { + const sandbox = sinon.createSandbox(); + await pushPrefs( + ["browser.shell.checkDefaultBrowser", true], + ["messaging-system-action.showEmbeddedImport", false] + ); + sandbox.stub(ShellService, "doesAppNeedPin").returns(false); + sandbox.stub(ShellService, "isDefaultBrowser").returns(false); + + await clearHistoryAndBookmarks(); + + const { browser, cleanup } = await openMRAboutWelcome(); + + //should render easy setup needs default + await test_screen_content( + browser, + "doesn't render pin, import and set to default", + //Expected selectors: + ["main.AW_EASY_SETUP_NEEDS_DEFAULT"], + //Unexpected selectors: + [ + "main.AW_EASY_SETUP_NEEDS_PIN", + "main.AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN", + "main.AW_ONLY_IMPORT", + ] + ); + + await onButtonClick(browser, ".action-buttons button.secondary"); + await test_screen_content( + browser, + "renders mobile download screen", + //Expected selectors: + ["main.AW_MOBILE_DOWNLOAD"], + //Unexpected selectors: + ["main.AW_IMPORT_SETTINGS_EMBEDDED"] + ); + + await cleanup(); + await popPrefs(); + sandbox.restore(); + } +); + +/** + * Test MR template easy setup content - Browser is pinned and + * set as default + */ +add_task(async function test_aboutwelcome_mr_template_easy_setup_only_import() { + const sandbox = sinon.createSandbox(); + await pushPrefs( + ["browser.shell.checkDefaultBrowser", true], + ["messaging-system-action.showEmbeddedImport", false] + ); + sandbox.stub(ShellService, "doesAppNeedPin").returns(false); + sandbox.stub(ShellService, "isDefaultBrowser").returns(true); + + await clearHistoryAndBookmarks(); + + const { browser, cleanup } = await openMRAboutWelcome(); + + //should render easy setup - only import + await test_screen_content( + browser, + "doesn't render any combination of pin and default", + //Expected selectors: + ["main.AW_EASY_SETUP_ONLY_IMPORT"], + //Unexpected selectors: + [ + "main.AW_EASY_SETUP_NEEDS_PIN", + "main.AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN", + "main.AW_EASY_SETUP_NEEDS_DEFAULT", + ] + ); + + await onButtonClick(browser, ".action-buttons button.secondary"); + await test_screen_content( + browser, + "renders mobile download screen", + //Expected selectors: + ["main.AW_MOBILE_DOWNLOAD"], + //Unexpected selectors: + ["main.AW_IMPORT_SETTINGS_EMBEDDED"] + ); + + await cleanup(); + await popPrefs(); + sandbox.restore(); +}); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_upgrade_multistage_mr.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_upgrade_multistage_mr.js new file mode 100644 index 0000000000..d6bc53d8ce --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_upgrade_multistage_mr.js @@ -0,0 +1,320 @@ +"use strict"; + +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { SpecialMessageActions } = ChromeUtils.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); +const { + assertFirefoxViewTabSelected, + closeFirefoxViewTab, + init: FirefoxViewTestUtilsInit, +} = ChromeUtils.importESModule( + "resource://testing-common/FirefoxViewTestUtils.sys.mjs" +); +FirefoxViewTestUtilsInit(this); + +const HOMEPAGE_PREF = "browser.startup.homepage"; +const NEWTAB_PREF = "browser.newtabpage.enabled"; +const PINPBM_DISABLED_PREF = "browser.startup.upgradeDialog.pinPBM.disabled"; + +// A bunch of the helper functions here are variants of the helper functions in +// browser_aboutwelcome_multistage_mr.js, because the onboarding +// experience runs in the parent process rather than elsewhere. +// If these start to get used in more than just the two files, it may become +// worth refactoring them to avoid duplicated code, and hoisting them +// into head.js. + +let sandbox; + +add_setup(async () => { + requestLongerTimeout(2); + + await setAboutWelcomePref(true); + + sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(false); + + sandbox.stub(SpecialMessageActions, "pinFirefoxToTaskbar").resolves(); + + registerCleanupFunction(async () => { + await popPrefs(); + sandbox.restore(); + }); +}); + +/** + * Get the content by OnboardingMessageProvider.getUpgradeMessage(), + * discard any screens whose ids are not in the "screensToTest" array, + * and then open an upgrade dialog with just those screens. + * + * @param {Array} screensToTest + * A list of which screen ids to be displayed + * + * @returns {Promise<Window>} + * Resolves to the window global object for the dialog once it has been + * opened + */ +async function openMRUpgradeWelcome(screensToTest) { + const data = await OnboardingMessageProvider.getUpgradeMessage(); + + if (screensToTest) { + data.content.screens = data.content.screens.filter(screen => + screensToTest.includes(screen.id) + ); + } + + sandbox.stub(OnboardingMessageProvider, "getUpgradeMessage").resolves(data); + + let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://browser/content/spotlight.html", + { isSubDialog: true } + ); + + Cc["@mozilla.org/browser/browserglue;1"] + .getService() + .wrappedJSObject._showUpgradeDialog(); + + let browser = await dialogOpenPromise; + + OnboardingMessageProvider.getUpgradeMessage.restore(); + return Promise.resolve(browser); +} + +async function clickVisibleButton(browser, selector) { + await BrowserTestUtils.waitForCondition( + () => browser.document.querySelector(selector), + `waiting for selector ${selector}`, + 200, // interval + 100 // maxTries + ); + browser.document.querySelector(selector).click(); +} + +async function test_upgrade_screen_content( + browser, + expected = [], + unexpected = [] +) { + for (let selector of expected) { + await TestUtils.waitForCondition( + () => browser.document.querySelector(selector), + `Should render ${selector}` + ); + } + for (let selector of unexpected) { + Assert.ok( + !browser.document.querySelector(selector), + `Should not render ${selector}` + ); + } +} + +async function waitForDialogClose(browser) { + await BrowserTestUtils.waitForCondition( + () => !browser.top?.document.querySelector(".dialogFrame"), + "waiting for dialog to close" + ); +} + +/** + * Test homepage/newtab prefs start off as defaults and do not change + */ +add_task(async function test_aboutwelcome_upgrade_mr_prefs_off() { + let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors: + ["main.UPGRADE_GET_STARTED"], + //Unexpected selectors: + ["main.PIN_FIREFOX"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + await clickVisibleButton(browser, ".action-buttons button.primary"); + await waitForDialogClose(browser); + + Assert.ok( + !Services.prefs.prefHasUserValue(HOMEPAGE_PREF), + "homepage pref should be default" + ); + Assert.ok( + !Services.prefs.prefHasUserValue(NEWTAB_PREF), + "newtab pref should be default" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/* + *Test checkbox if needPrivatePin is true + */ +add_task(async function test_aboutwelcome_upgrade_mr_private_pin() { + OnboardingMessageProvider._doesAppNeedPin.resolves(true); + let browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors: + ["main.UPGRADE_PIN_FIREFOX", "input#action-checkbox"], + //Unexpected selectors: + ["main.UPGRADE_COLORWAY"] + ); + await clickVisibleButton(browser, ".action-buttons button.primary"); + await waitForDialogClose(browser); + + const pinStub = SpecialMessageActions.pinFirefoxToTaskbar; + Assert.equal( + pinStub.callCount, + 2, + "pinFirefoxToTaskbar should have been called twice" + ); + Assert.ok( + // eslint-disable-next-line eqeqeq + pinStub.firstCall.lastArg != pinStub.secondCall.lastArg, + "pinFirefoxToTaskbar should have been called once for private, once not" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/* + *Test checkbox shouldn't be shown in get started screen + */ + +add_task(async function test_aboutwelcome_upgrade_mr_private_pin_get_started() { + OnboardingMessageProvider._doesAppNeedPin.resolves(false); + + let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors + ["main.UPGRADE_GET_STARTED"], + //Unexpected selectors: + ["input#action-checkbox"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + + await waitForDialogClose(browser); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/* + *Test checkbox shouldn't be shown if needPrivatePin is false + */ +add_task(async function test_aboutwelcome_upgrade_mr_private_pin_not_needed() { + OnboardingMessageProvider._doesAppNeedPin + .resolves(true) + .withArgs(true) + .resolves(false); + + let browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors + ["main.UPGRADE_PIN_FIREFOX"], + //Unexpected selectors: + ["input#action-checkbox"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + await waitForDialogClose(browser); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/* + * Make sure we don't get an extraneous checkbox here. + */ +add_task( + async function test_aboutwelcome_upgrade_mr_pin_not_needed_default_needed() { + OnboardingMessageProvider._doesAppNeedPin.resolves(false); + OnboardingMessageProvider._doesAppNeedDefault.resolves(false); + + let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors + ["main.UPGRADE_GET_STARTED"], + //Unexpected selectors: + ["input#action-checkbox"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + await waitForDialogClose(browser); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +); + +add_task(async function test_aboutwelcome_privacy_segmentation_pref() { + async function testPrivacySegmentation(enabled = false) { + await pushPrefs(["browser.privacySegmentation.preferences.show", enabled]); + let screenIds = ["UPGRADE_DATA_RECOMMENDATION", "UPGRADE_GRATITUDE"]; + let browser = await openMRUpgradeWelcome(screenIds); + await test_upgrade_screen_content( + browser, + //Expected selectors + [`main.${screenIds[enabled ? 0 : 1]}`], + //Unexpected selectors: + [`main.${screenIds[enabled ? 1 : 0]}`] + ); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + await popPrefs(); + } + + for (let enabled of [true, false]) { + await testPrivacySegmentation(enabled); + } +}); + +add_task(async function test_aboutwelcome_upgrade_show_firefox_view() { + let browser = await openMRUpgradeWelcome(["UPGRADE_GRATITUDE"]); + + // execution + await test_upgrade_screen_content( + browser, + //Expected selectors + ["main.UPGRADE_GRATITUDE"], + //Unexpected selectors: + [] + ); + await clickVisibleButton(browser, ".action-buttons button.primary"); + + // verification + await BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone"); + assertFirefoxViewTabSelected(gBrowser.ownerGlobal); + + closeFirefoxViewTab(gBrowser.ownerGlobal); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/* + *Checkbox shouldn't be shown if pinPBMDisabled pref is true + */ +add_task(async function test_aboutwelcome_upgrade_mr_private_pin_not_needed() { + OnboardingMessageProvider._doesAppNeedPin.resolves(true); + await pushPrefs([PINPBM_DISABLED_PREF, true]); + + const browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]); + + await test_upgrade_screen_content( + browser, + //Expected selectors + ["main.UPGRADE_PIN_FIREFOX"], + //Unexpected selectors: + ["input#action-checkbox"] + ); + + await clickVisibleButton(browser, ".action-buttons button.secondary"); + await waitForDialogClose(browser); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/aboutwelcome/tests/browser/head.js b/browser/components/aboutwelcome/tests/browser/head.js new file mode 100644 index 0000000000..0854a3efb0 --- /dev/null +++ b/browser/components/aboutwelcome/tests/browser/head.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + QueryCache: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// Set the content pref to make it available across tests +const ABOUT_WELCOME_OVERRIDE_CONTENT_PREF = "browser.aboutwelcome.screens"; + +function popPrefs() { + return SpecialPowers.popPrefEnv(); +} +function pushPrefs(...prefs) { + return SpecialPowers.pushPrefEnv({ set: prefs }); +} + +async function getAboutWelcomeParent(browser) { + let windowGlobalParent = browser.browsingContext.currentWindowGlobal; + return windowGlobalParent.getActor("AboutWelcome"); +} + +async function setAboutWelcomeMultiStage(value = "") { + return pushPrefs([ABOUT_WELCOME_OVERRIDE_CONTENT_PREF, value]); +} + +async function setAboutWelcomePref(value) { + return pushPrefs(["browser.aboutwelcome.enabled", value]); +} + +async function openMRAboutWelcome() { + await setAboutWelcomePref(true); // NB: Calls pushPrefs + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + return { + browser: tab.linkedBrowser, + cleanup: async () => { + BrowserTestUtils.removeTab(tab); + await popPrefs(); // for setAboutWelcomePref() + }, + }; +} + +async function onButtonClick(browser, elementId) { + await ContentTask.spawn( + browser, + { elementId }, + async ({ elementId: buttonId }) => { + let button = await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(buttonId), + buttonId + ); + button.click(); + } + ); +} + +async function clearHistoryAndBookmarks() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + QueryCache.expireAll(); +} + +/** + * Setup functions to test welcome UI + */ +async function test_screen_content( + browser, + experiment, + expectedSelectors = [], + unexpectedSelectors = [] +) { + await ContentTask.spawn( + browser, + { expectedSelectors, experiment, unexpectedSelectors }, + async ({ + expectedSelectors: expected, + experiment: experimentName, + unexpectedSelectors: unexpected, + }) => { + for (let selector of expected) { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(selector), + `Should render ${selector} in ${experimentName}` + ); + } + for (let selector of unexpected) { + ok( + !content.document.querySelector(selector), + `Should not render ${selector} in ${experimentName}` + ); + } + + if (experimentName === "home") { + Assert.equal( + content.document.location.href, + "about:home", + "Navigated to about:home" + ); + } else { + Assert.equal( + content.document.location.href, + "about:welcome", + "Navigated to a welcome screen" + ); + } + } + ); +} + +async function test_element_styles( + browser, + elementSelector, + expectedStyles = {}, + unexpectedStyles = {} +) { + await ContentTask.spawn( + browser, + [elementSelector, expectedStyles, unexpectedStyles], + async ([selector, expected, unexpected]) => { + const element = await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(selector) + ); + const computedStyles = content.window.getComputedStyle(element); + Object.entries(expected).forEach(([attr, val]) => + is( + computedStyles[attr], + val, + `${selector} should have computed ${attr} of ${val}` + ) + ); + Object.entries(unexpected).forEach(([attr, val]) => + isnot( + computedStyles[attr], + val, + `${selector} should not have computed ${attr} of ${val}` + ) + ); + } + ); +} diff --git a/browser/components/aboutwelcome/tests/unit/AWScreenUtils.test.jsx b/browser/components/aboutwelcome/tests/unit/AWScreenUtils.test.jsx new file mode 100644 index 0000000000..b6e9489ef9 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/AWScreenUtils.test.jsx @@ -0,0 +1,140 @@ +import { AWScreenUtils } from "modules/AWScreenUtils.sys.mjs"; +import { GlobalOverrider } from "newtab/test/unit/utils"; +import { ASRouter } from "asrouter/modules/ASRouter.sys.mjs"; + +describe("AWScreenUtils", () => { + let sandbox; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set({ + ASRouter, + ASRouterTargeting: { + Environment: {}, + }, + }); + + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("removeScreens", () => { + it("should run callback function once for each array element", async () => { + const callback = sandbox.stub().resolves(false); + const arr = ["foo", "bar"]; + await AWScreenUtils.removeScreens(arr, callback); + assert.calledTwice(callback); + }); + it("should remove screen when passed function evaluates true", async () => { + const callback = sandbox.stub().resolves(true); + const arr = ["foo", "bar"]; + await AWScreenUtils.removeScreens(arr, callback); + assert.deepEqual(arr, []); + }); + }); + describe("evaluateScreenTargeting", () => { + it("should return the eval result if the eval succeeds", async () => { + const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({ + evaluationStatus: { + success: true, + result: false, + }, + }); + const result = await AWScreenUtils.evaluateScreenTargeting( + "test expression" + ); + assert.calledOnce(evalStub); + assert.equal(result, false); + }); + it("should return true if the targeting eval fails", async () => { + const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({ + evaluationStatus: { + success: false, + result: false, + }, + }); + const result = await AWScreenUtils.evaluateScreenTargeting( + "test expression" + ); + assert.calledOnce(evalStub); + assert.equal(result, true); + }); + }); + describe("evaluateTargetingAndRemoveScreens", () => { + it("should manipulate an array of screens", async () => { + const screens = [ + { + id: "first", + targeting: true, + }, + { + id: "second", + targeting: false, + }, + ]; + + const expectedScreens = [ + { + id: "first", + targeting: true, + }, + ]; + sandbox.stub(ASRouter, "evaluateExpression").callsFake(targeting => { + return { + evaluationStatus: { + success: true, + result: targeting.expression, + }, + }; + }); + const evaluatedStrings = + await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens); + assert.deepEqual(evaluatedStrings, expectedScreens); + }); + it("should not remove screens with no targeting", async () => { + const screens = [ + { + id: "first", + }, + { + id: "second", + targeting: false, + }, + ]; + + const expectedScreens = [ + { + id: "first", + }, + ]; + sandbox + .stub(AWScreenUtils, "evaluateScreenTargeting") + .callsFake(targeting => { + if (targeting === undefined) { + return true; + } + return targeting; + }); + const evaluatedStrings = + await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens); + assert.deepEqual(evaluatedStrings, expectedScreens); + }); + }); + + describe("addScreenImpression", () => { + it("Should call addScreenImpression with provided screen ID", () => { + const addScreenImpressionStub = sandbox.stub( + ASRouter, + "addScreenImpression" + ); + const testScreen = { id: "test" }; + AWScreenUtils.addScreenImpression(testScreen); + + assert.calledOnce(addScreenImpressionStub); + assert.equal(addScreenImpressionStub.firstCall.args[0].id, testScreen.id); + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/CTAParagraph.test.jsx b/browser/components/aboutwelcome/tests/unit/CTAParagraph.test.jsx new file mode 100644 index 0000000000..c60e8e2666 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/CTAParagraph.test.jsx @@ -0,0 +1,49 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { CTAParagraph } from "content-src/components/CTAParagraph"; + +describe("CTAParagraph component", () => { + let sandbox; + let wrapper; + let handleAction; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleAction = sandbox.stub(); + wrapper = shallow( + <CTAParagraph + content={{ + text: { + raw: "Link Text", + string_name: "Test Name", + }, + }} + handleAction={handleAction} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render CTAParagraph component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render CTAParagraph component if only CTA text is passed", () => { + wrapper.setProps({ content: { text: "CTA Text" } }); + assert.ok(wrapper.exists()); + }); + + it("should call handleAction method when button is link is clicked", () => { + const btnLink = wrapper.find(".cta-paragraph span"); + btnLink.simulate("click"); + assert.calledOnce(handleAction); + }); + + it("should not render CTAParagraph component if CTA text is not passed", () => { + wrapper.setProps({ content: { text: null } }); + assert.ok(wrapper.isEmptyRender()); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/HelpText.test.jsx b/browser/components/aboutwelcome/tests/unit/HelpText.test.jsx new file mode 100644 index 0000000000..e9b722b9d8 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/HelpText.test.jsx @@ -0,0 +1,41 @@ +import { HelpText } from "content-src/components/HelpText"; +import { Localized } from "content-src/components/MSLocalized"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<HelpText>", () => { + it("should render text inside Localized", () => { + const shallowWrapper = shallow(<HelpText text="test" />); + + assert.equal(shallowWrapper.find(Localized).props().text, "test"); + }); + it("should render the img if there is an img and a string_id", () => { + const shallowWrapper = shallow( + <HelpText + text={{ string_id: "test_id" }} + hasImg={{ + src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png", + }} + /> + ); + assert.ok( + shallowWrapper + .find(Localized) + .findWhere(n => n.text.string_id === "test_id") + ); + assert.lengthOf(shallowWrapper.find("p.helptext"), 1); + assert.lengthOf(shallowWrapper.find("img[data-l10n-name='help-img']"), 1); + }); + it("should render the img if there is an img and plain text", () => { + const shallowWrapper = shallow( + <HelpText + text={"Sample help text"} + hasImg={{ + src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png", + }} + /> + ); + assert.equal(shallowWrapper.find("p.helptext").text(), "Sample help text"); + assert.lengthOf(shallowWrapper.find("img.helptext-img"), 1); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/HeroImage.test.jsx b/browser/components/aboutwelcome/tests/unit/HeroImage.test.jsx new file mode 100644 index 0000000000..244e64f906 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/HeroImage.test.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { HeroImage } from "content-src/components/HeroImage"; + +describe("HeroImage component", () => { + const imageUrl = "https://example.com"; + const imageHeight = "100px"; + const imageAlt = "Alt text"; + + let wrapper; + beforeEach(() => { + wrapper = shallow( + <HeroImage url={imageUrl} alt={imageAlt} height={imageHeight} /> + ); + }); + + it("should render HeroImage component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render an image element with src prop", () => { + let imgEl = wrapper.find("img"); + assert.strictEqual(imgEl.prop("src"), imageUrl); + }); + + it("should render image element with alt text prop", () => { + let imgEl = wrapper.find("img"); + assert.equal(imgEl.prop("alt"), imageAlt); + }); + + it("should render an image with a set height prop", () => { + let imgEl = wrapper.find("img"); + assert.propertyVal(imgEl.prop("style"), "height", imageHeight); + }); + + it("should not render HeroImage component", () => { + wrapper.setProps({ url: null }); + assert.ok(wrapper.isEmptyRender()); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/LinkParagraph.test.jsx b/browser/components/aboutwelcome/tests/unit/LinkParagraph.test.jsx new file mode 100644 index 0000000000..240342b5e2 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/LinkParagraph.test.jsx @@ -0,0 +1,102 @@ +import React from "react"; +import { mount } from "enzyme"; +import { LinkParagraph } from "content-src/components/LinkParagraph"; + +describe("LinkParagraph component", () => { + let sandbox; + let wrapper; + let handleAction; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleAction = sandbox.stub(); + + wrapper = mount( + <LinkParagraph + text_content={{ + text: { + string_id: + "shopping-onboarding-opt-in-privacy-policy-and-terms-of-use3", + }, + link_keys: ["privacy_policy"], + font_styles: "legal", + }} + handleAction={handleAction} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render LinkParagraph component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render copy with legal style if legal is passed to font_styles", () => { + assert.strictEqual(wrapper.find(".legal-paragraph").length, 1); + }); + + it("should render one link when only one link id is passed", () => { + assert.strictEqual(wrapper.find(".legal-paragraph a").length, 1); + }); + + it("should call handleAction method when link is clicked", () => { + const linkEl = wrapper.find(".legal-paragraph a"); + linkEl.simulate("click"); + assert.calledOnce(handleAction); + }); + + it("should render two links if an additional link id is passed", () => { + wrapper.setProps({ + text_content: { + text: { + string_id: + "shopping-onboarding-opt-in-privacy-policy-and-terms-of-use3", + }, + link_keys: ["privacy_policy", "terms_of_use"], + font_styles: "legal", + }, + }); + assert.strictEqual(wrapper.find(".legal-paragraph a").length, 2); + }); + + it("should render no links when no link id is passed", () => { + wrapper.setProps({ + text_content: { links: null }, + }); + assert.strictEqual(wrapper.find(".legal-paragraph a").length, 0); + }); + + it("should render copy even when no link id is passed", () => { + wrapper.setProps({ + text_content: { links: null }, + }); + assert.ok(wrapper.find(".legal-paragraph")); + }); + + it("should not render LinkParagraph component if text is not passed", () => { + wrapper.setProps({ text_content: { text: null } }); + assert.ok(wrapper.isEmptyRender()); + }); + + it("should render copy in link style if no font style is passed", () => { + wrapper.setProps({ + text_content: { + text: { + string_id: "shopping-onboarding-body", + }, + link_keys: ["learn_more"], + }, + }); + assert.strictEqual(wrapper.find(".link-paragraph").length, 1); + }); + + it("should not render links if string_id is not provided", () => { + wrapper.setProps({ + text_content: { text: { string_id: null } }, + }); + assert.strictEqual(wrapper.find(".link-paragraph a").length, 0); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MRColorways.test.jsx b/browser/components/aboutwelcome/tests/unit/MRColorways.test.jsx new file mode 100644 index 0000000000..2d9ebf7ec9 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MRColorways.test.jsx @@ -0,0 +1,328 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { + Colorways, + computeColorWay, + ColorwayDescription, + computeVariationIndex, +} from "content-src/components/MRColorways"; +import { WelcomeScreen } from "content-src/components/MultiStageAboutWelcome"; + +describe("Multistage AboutWelcome module", () => { + let sandbox; + let COLORWAY_SCREEN_PROPS; + beforeEach(() => { + sandbox = sinon.createSandbox(); + COLORWAY_SCREEN_PROPS = { + id: "test-colorway-screen", + totalNumberofScreens: 1, + content: { + subtitle: "test subtitle", + tiles: { + type: "colorway", + action: { + theme: "<event>", + }, + defaultVariationIndex: 0, + systemVariations: ["automatic", "light"], + variations: ["soft", "bold"], + colorways: [ + { + id: "default", + label: "Default", + }, + { + id: "abstract", + label: "Abstract", + }, + ], + }, + primary_button: { + action: {}, + label: "test button", + }, + }, + messageId: "test-mr-colorway-screen", + activeTheme: "automatic", + }; + }); + afterEach(() => { + sandbox.restore(); + }); + + describe("MRColorway component", () => { + it("should render WelcomeScreen", () => { + const wrapper = shallow(<WelcomeScreen {...COLORWAY_SCREEN_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should use default when activeTheme is not set", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + wrapper.setProps({ activeTheme: null }); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + assert.strictEqual(colorwaysOptionIcons.length, 2); + + // Default automatic theme is selected by default + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("default"), + true + ); + }); + + it("should use default when activeTheme is alpenglow", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + wrapper.setProps({ activeTheme: "alpenglow" }); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + assert.strictEqual(colorwaysOptionIcons.length, 2); + + // Default automatic theme is selected when unsupported in colorway alpenglow theme is active + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("default"), + true + ); + }); + + it("should render colorways options", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + + const colorwaysOptions = wrapper.find( + ".tiles-theme-section .theme input[name='theme']" + ); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + + const colorwaysLabels = wrapper.find( + ".tiles-theme-section .theme span.sr-only" + ); + + assert.strictEqual(colorwaysOptions.length, 2); + assert.strictEqual(colorwaysOptionIcons.length, 2); + assert.strictEqual(colorwaysLabels.length, 2); + + // First colorway option + // Default theme radio option is selected by default + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + //Colorway should be using id property + assert.strictEqual( + colorwaysOptions.first().prop("data-colorway"), + "default" + ); + + // Second colorway option + assert.strictEqual( + colorwaysOptionIcons.last().prop("className").includes("selected"), + false + ); + + //Colorway should be using id property + assert.strictEqual( + colorwaysOptions.last().prop("data-colorway"), + "abstract" + ); + + //Colorway should be labelled for screen readers (parent label is for tooltip only, and does not describe the Colorway) + assert.strictEqual( + colorwaysOptions.last().prop("aria-labelledby"), + "abstract-label" + ); + }); + + it("should handle colorway clicks", () => { + sandbox.stub(React, "useEffect").callsFake((fn, vals) => { + if (vals === undefined) { + fn(); + } else if (vals[0] === "in") { + fn(); + } + }); + + const handleAction = sandbox.stub(); + const wrapper = shallow( + <Colorways handleAction={handleAction} {...COLORWAY_SCREEN_PROPS} /> + ); + const colorwaysOptions = wrapper.find( + ".tiles-theme-section .theme input[name='theme']" + ); + + let props = wrapper.find(ColorwayDescription).props(); + assert.propertyVal(props.colorway, "label", "Default"); + + const option = colorwaysOptions.last(); + assert.propertyVal(option.props(), "value", "abstract-soft"); + colorwaysOptions.last().simulate("click"); + assert.calledOnce(handleAction); + }); + + it("should render colorway description", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + + let descriptionsWrapper = wrapper.find(ColorwayDescription); + assert.ok(descriptionsWrapper.exists()); + + let props = descriptionsWrapper.props(); + + // Colorway description should display Default theme desc by default + assert.strictEqual(props.colorway.label, "Default"); + }); + + it("ColorwayDescription should display active colorway desc", () => { + let TEST_COLORWAY_PROPS = { + colorway: { + label: "Activist", + description: "Test Activist", + }, + }; + const descWrapper = shallow( + <ColorwayDescription {...TEST_COLORWAY_PROPS} /> + ); + assert.ok(descWrapper.exists()); + const descText = descWrapper.find(".colorway-text"); + assert.equal( + descText.props()["data-l10n-args"].includes("Activist"), + true + ); + }); + + it("should computeColorWayId for default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const colorwayId = computeColorWay( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations + ); + assert.strictEqual(colorwayId, "default"); + }); + + it("should computeColorWayId for non-default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + activeTheme: "abstract-soft", + }; + + const colorwayId = computeColorWay( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations + ); + assert.strictEqual(colorwayId, "abstract"); + }); + + it("should computeVariationIndex for default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual( + variationIndex, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + }); + + it("should computeVariationIndex for active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + "light", + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual(variationIndex, 1); + }); + + it("should computeVariationIndex for colorway theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + "abstract-bold", + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual(variationIndex, 1); + }); + + describe("random colorways", () => { + let test; + beforeEach(() => { + COLORWAY_SCREEN_PROPS.handleAction = sandbox.stub(); + sandbox.stub(window, "matchMedia"); + // eslint-disable-next-line max-nested-callbacks + sandbox.stub(React, "useEffect").callsFake((fn, vals) => { + if (vals?.length === 0) { + fn(); + } + }); + test = () => { + shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + return COLORWAY_SCREEN_PROPS.handleAction.firstCall.firstArg + .currentTarget; + }; + }); + + it("should select a random colorway", () => { + const { value } = test(); + + assert.strictEqual(value, "abstract-soft"); + assert.calledThrice(React.useEffect); + assert.notCalled(window.matchMedia); + }); + + it("should select a random soft colorway when not dark", () => { + window.matchMedia.returns({ matches: false }); + COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1; + + const { value } = test(); + + assert.strictEqual(value, "abstract-soft"); + assert.calledThrice(React.useEffect); + assert.calledOnce(window.matchMedia); + }); + + it("should select a random bold colorway when dark", () => { + window.matchMedia.returns({ matches: true }); + COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1; + + const { value } = test(); + + assert.strictEqual(value, "abstract-bold"); + assert.calledThrice(React.useEffect); + assert.calledOnce(window.matchMedia); + }); + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MSLocalized.test.jsx b/browser/components/aboutwelcome/tests/unit/MSLocalized.test.jsx new file mode 100644 index 0000000000..57f7e5526c --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MSLocalized.test.jsx @@ -0,0 +1,48 @@ +import { Localized } from "content-src/components/MSLocalized"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<MSLocalized>", () => { + it("should render span with no children", () => { + const shallowWrapper = shallow(<Localized text="test" />); + + assert.ok(shallowWrapper.find("span").exists()); + assert.equal(shallowWrapper.text(), "test"); + }); + it("should render span when using string_id with no children", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }} /> + ); + + assert.ok(shallowWrapper.find("span[data-l10n-id='test_id']").exists()); + }); + it("should render text inside child", () => { + const shallowWrapper = shallow( + <Localized text="test"> + <div /> + </Localized> + ); + + assert.ok(shallowWrapper.find("div").text(), "test"); + }); + it("should use l10n id on child", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }}> + <div /> + </Localized> + ); + + assert.ok(shallowWrapper.find("div[data-l10n-id='test_id']").exists()); + }); + it("should keep original children", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }}> + <h1> + <span data-l10n-name="test" /> + </h1> + </Localized> + ); + + assert.ok(shallowWrapper.find("span[data-l10n-name='test']").exists()); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MobileDownloads.test.jsx b/browser/components/aboutwelcome/tests/unit/MobileDownloads.test.jsx new file mode 100644 index 0000000000..143c7d2f8d --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MobileDownloads.test.jsx @@ -0,0 +1,69 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; +import { GlobalOverrider } from "newtab/test/unit/utils"; +import { MobileDownloads } from "content-src/components/MobileDownloads"; + +describe("Multistage AboutWelcome MobileDownloads module", () => { + let globals; + let sandbox; + + beforeEach(async () => { + globals = new GlobalOverrider(); + globals.set({ + AWFinish: () => Promise.resolve(), + AWSendToDeviceEmailsSupported: () => Promise.resolve(), + }); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("Mobile Downloads component", () => { + const MOBILE_DOWNLOADS_PROPS = { + data: { + QR_code: { + image_url: + "chrome://browser/components/privatebrowsing/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + email: { + link_text: "Email yourself a link", + }, + marketplace_buttons: ["ios", "android"], + }, + handleAction: () => { + window.AWFinish(); + }, + }; + + it("should render MobileDownloads", () => { + const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should handle action on markeplace badge click", () => { + const wrapper = mount(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + const stub = sandbox.stub(global, "AWFinish"); + wrapper.find(".ios button").simulate("click"); + wrapper.find(".android button").simulate("click"); + + assert.calledTwice(stub); + }); + + it("should handle action on email button click", () => { + const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + const stub = sandbox.stub(global, "AWFinish"); + wrapper.find("button.email-link").simulate("click"); + + assert.calledOnce(stub); + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MultiSelect.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiSelect.test.jsx new file mode 100644 index 0000000000..b42964f906 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MultiSelect.test.jsx @@ -0,0 +1,221 @@ +import React from "react"; +import { mount } from "enzyme"; +import { MultiSelect } from "content-src/components/MultiSelect"; + +describe("MultiSelect component", () => { + let sandbox; + let MULTISELECT_SCREEN_PROPS; + let setScreenMultiSelects; + let setActiveMultiSelect; + beforeEach(() => { + sandbox = sinon.createSandbox(); + setScreenMultiSelects = sandbox.stub(); + setActiveMultiSelect = sandbox.stub(); + MULTISELECT_SCREEN_PROPS = { + id: "multiselect-screen", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + image_alt_text: { + string_id: "mr2022-onboarding-default-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: "Test Title", + tiles: { + type: "multiselect", + label: "Test Subtitle", + data: [ + { + id: "checkbox-1", + defaultValue: true, + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + type: "SET_DEFAULT_BROWSER", + }, + }, + { + id: "checkbox-2", + defaultValue: true, + label: "Test Checkbox 2", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + }, + { + id: "checkbox-3", + defaultValue: false, + label: "Test Checkbox 3", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + }, + ], + }, + primary_button: { + label: "Save and Continue", + action: { + type: "MULTI_ACTION", + collectSelect: true, + navigate: true, + data: { actions: [] }, + }, + }, + secondary_button: { + label: "Skip", + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + setScreenMultiSelects, + setActiveMultiSelect, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should call setScreenMultiSelects with all ids of checkboxes", () => { + mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + + assert.calledOnce(setScreenMultiSelects); + assert.calledWith(setScreenMultiSelects, [ + "checkbox-1", + "checkbox-2", + "checkbox-3", + ]); + }); + + it("should not call setScreenMultiSelects if it's already set", () => { + let map = sandbox + .stub() + .returns(MULTISELECT_SCREEN_PROPS.content.tiles.data); + + mount( + <MultiSelect screenMultiSelects={{ map }} {...MULTISELECT_SCREEN_PROPS} /> + ); + + assert.notCalled(setScreenMultiSelects); + assert.calledOnce(map); + assert.calledWith(map, sinon.match.func); + }); + + it("should call setActiveMultiSelect with ids of checkboxes with defaultValue true", () => { + const wrapper = mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + + wrapper.setProps({ activeMultiSelect: null }); + assert.calledOnce(setActiveMultiSelect); + assert.calledWith(setActiveMultiSelect, ["checkbox-1", "checkbox-2"]); + }); + + it("should use activeMultiSelect ids to set checked state for respective checkbox", () => { + const wrapper = mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + const checkBoxes = wrapper.find(".checkbox-container input"); + assert.strictEqual(checkBoxes.length, 3); + + assert.strictEqual(checkBoxes.first().props().checked, true); + assert.strictEqual(checkBoxes.at(1).props().checked, true); + assert.strictEqual(checkBoxes.last().props().checked, false); + }); + + it("cover the randomize property", async () => { + MULTISELECT_SCREEN_PROPS.content.tiles.data.forEach( + item => (item.randomize = true) + ); + + const wrapper = mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + + const checkBoxes = wrapper.find(".checkbox-container input"); + assert.strictEqual(checkBoxes.length, 3); + + // We don't want to actually test the randomization, just that it doesn't + // throw. We _could_ render the component until we get a different order, + // and that should work the vast majority of the time, but it's + // theoretically possible that we get the same order over and over again + // until we hit the 2 second timeout. That would be an extremely low failure + // rate, but we already know Math.random() works, so we don't really need to + // test it anyway. It's not worth the added risk of false failures. + }); + + it("should filter out id when checkbox is unchecked", () => { + const wrapper = mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + + const ckbx1 = wrapper.find(".checkbox-container input").at(0); + assert.strictEqual(ckbx1.prop("value"), "checkbox-1"); + ckbx1.getDOMNode().checked = false; + ckbx1.simulate("change"); + assert.calledWith(setActiveMultiSelect, ["checkbox-2"]); + }); + + it("should add id when checkbox is checked", () => { + const wrapper = mount(<MultiSelect {...MULTISELECT_SCREEN_PROPS} />); + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + + const ckbx3 = wrapper.find(".checkbox-container input").at(2); + assert.strictEqual(ckbx3.prop("value"), "checkbox-3"); + ckbx3.getDOMNode().checked = true; + ckbx3.simulate("change"); + assert.calledWith(setActiveMultiSelect, [ + "checkbox-1", + "checkbox-2", + "checkbox-3", + ]); + }); + + it("should render radios and checkboxes with correct styles", async () => { + const SCREEN_PROPS = { ...MULTISELECT_SCREEN_PROPS }; + SCREEN_PROPS.content.tiles.style = { flexDirection: "row", gap: "24px" }; + SCREEN_PROPS.content.tiles.data = [ + { + id: "checkbox-1", + defaultValue: true, + label: { raw: "Test1" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + style: { color: "red" }, + icon: { style: { color: "blue" } }, + }, + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: true, + label: { raw: "Test3" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + style: { color: "purple" }, + icon: { style: { color: "yellow" } }, + }, + ]; + const wrapper = mount(<MultiSelect {...SCREEN_PROPS} />); + + // wait for effect hook + await new Promise(resolve => queueMicrotask(resolve)); + // activeMultiSelect was called on effect hook with default values + assert.calledWith(setActiveMultiSelect, ["checkbox-1", "radio-1"]); + + const container = wrapper.find(".multi-select-container"); + assert.strictEqual(container.prop("style").flexDirection, "row"); + assert.strictEqual(container.prop("style").gap, "24px"); + + // checkboxes/radios are rendered with correct styles + const checkBoxes = wrapper.find(".checkbox-container"); + assert.strictEqual(checkBoxes.length, 2); + assert.strictEqual(checkBoxes.first().prop("style").color, "red"); + assert.strictEqual(checkBoxes.at(1).prop("style").color, "purple"); + + const checks = wrapper.find(".checkbox-container input"); + assert.strictEqual(checks.length, 2); + assert.strictEqual(checks.first().prop("style").color, "blue"); + assert.strictEqual(checks.at(1).prop("style").color, "yellow"); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx new file mode 100644 index 0000000000..a40af1c4a1 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx @@ -0,0 +1,571 @@ +import { AboutWelcomeDefaults } from "modules/AboutWelcomeDefaults.sys.mjs"; +import { MultiStageProtonScreen } from "content-src/components/MultiStageProtonScreen"; +import { AWScreenUtils } from "modules/AWScreenUtils.sys.mjs"; +import React from "react"; +import { mount } from "enzyme"; + +describe("MultiStageAboutWelcomeProton module", () => { + let sandbox; + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + }); + + describe("MultiStageAWProton component", () => { + it("should render MultiStageProton Screen", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + }); + + it("should render secondary section for split positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + hero_text: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal( + wrapper.find(".section-secondary h1").text(), + "test subtitle" + ); + assert.equal(wrapper.find("main").prop("pos"), "split"); + }); + + it("should render secondary section with content background for split positioned screens", () => { + const BACKGROUND_URL = + "chrome://activity-stream/content/data/content/assets/confetti.svg"; + const SCREEN_PROPS = { + content: { + position: "split", + background: `url(${BACKGROUND_URL}) var(--mr-secondary-position) no-repeat`, + split_narrow_bkg_position: "10px", + title: "test title", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.ok( + wrapper + .find("div.section-secondary") + .prop("style") + .background.includes("--mr-secondary-position") + ); + assert.ok( + wrapper.find("div.section-secondary").prop("style")[ + "--mr-secondary-background-position-y" + ], + "10px" + ); + }); + + it("should render with secondary section for split positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + hero_text: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal( + wrapper.find(".section-secondary h1").text(), + "test subtitle" + ); + assert.equal(wrapper.find("main").prop("pos"), "split"); + }); + + it("should render with no secondary section for center positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "center", + title: "test title", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".section-secondary").exists(), false); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal(wrapper.find("main").prop("pos"), "center"); + }); + + it("should not render multiple action buttons if an additional button does not exist", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isFalse(wrapper.find(".additional-cta").exists()); + }); + + it("should render an additional action button with primary styling if no style has been specified", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isTrue(wrapper.find(".additional-cta.primary").exists()); + }); + + it("should render an additional action button with secondary styling", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "secondary", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.secondary").exists(), true); + }); + + it("should render an additional action button with primary styling", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "primary", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.primary").exists(), true); + }); + + it("should render an additional action with link styling", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "link", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.cta-link").exists(), true); + }); + + it("should render an additional button with vertical orientation", () => { + const SCREEN_PROPS = { + content: { + position: "center", + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "secondary", + flow: "column", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal( + wrapper.find(".additional-cta-container[flow='column']").exists(), + true + ); + }); + + it("should render disabled primary button if activeMultiSelect is in disabled property", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + disabled: "activeMultiSelect", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isTrue(wrapper.find("button.primary[disabled]").exists()); + }); + + it("should render disabled secondary button if activeMultiSelect is in disabled property", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + secondary_button: { + label: "test secondary button", + disabled: "activeMultiSelect", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isTrue(wrapper.find("button.secondary[disabled]").exists()); + }); + + it("should not render a progress bar if there is 1 step", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + progress_bar: true, + }, + isSingleScreen: true, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".steps.progress-bar").exists(), false); + }); + + it("should not render a steps indicator if steps indicator is force hidden", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + }, + forceHideStepsIndicator: true, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".steps").exists(), false); + }); + + it("should render a steps indicator above action buttons", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + progress_bar: true, + primary_button: {}, + }, + aboveButtonStepsIndicator: true, + totalNumberOfScreens: 2, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + + const stepsIndicator = wrapper.find(".steps"); + assert.ok(stepsIndicator, true); + + const stepsDOMNode = stepsIndicator.getDOMNode(); + const siblingElement = stepsDOMNode.nextElementSibling; + assert.equal(siblingElement.classList.contains("action-buttons"), true); + }); + + it("should render a progress bar if there are 2 steps", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + progress_bar: true, + }, + totalNumberOfScreens: 2, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".steps.progress-bar").exists(), true); + }); + + it("should render confirmation-screen if layout property is set to inline", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + layout: "inline", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find("[layout='inline']").exists(), true); + }); + + it("should render an inline image with alt text and height property", async () => { + const SCREEN_PROPS = { + content: { + above_button_content: [ + { + type: "image", + url: "https://example.com/test.svg", + height: "auto", + alt_text: "test alt text", + }, + ], + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + const imageEl = wrapper.find(".inline-image img"); + assert.equal(imageEl.exists(), true); + assert.propertyVal(imageEl.prop("style"), "height", "auto"); + const altTextCointainer = wrapper.find(".sr-only"); + assert.equal(altTextCointainer.contains("test alt text"), true); + }); + + it("should render multiple inline elements in correct order", async () => { + const SCREEN_PROPS = { + content: { + above_button_content: [ + { + type: "image", + url: "https://example.com/test.svg", + height: "auto", + alt_text: "test alt text", + }, + { + type: "text", + text: { + string_id: "test-string-id", + }, + link_keys: ["privacy_policy", "terms_of_use"], + }, + { + type: "image", + url: "https://example.com/test_2.svg", + height: "auto", + alt_text: "test alt text 2", + }, + { + type: "text", + text: { + string_id: "test-string-id-2", + }, + link_keys: ["privacy_policy", "terms_of_use"], + }, + ], + }, + }; + + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + const imageEl = wrapper.find(".inline-image img"); + const textEl = wrapper.find(".link-paragraph"); + + assert.equal(imageEl.length, 2); + assert.equal(textEl.length, 2); + + assert.equal(imageEl.at(0).prop("src"), "https://example.com/test.svg"); + assert.equal(imageEl.at(1).prop("src"), "https://example.com/test_2.svg"); + + assert.equal(textEl.at(0).prop("data-l10n-id"), "test-string-id"); + assert.equal(textEl.at(1).prop("data-l10n-id"), "test-string-id-2"); + }); + }); + + describe("AboutWelcomeDefaults for proton", () => { + const getData = () => AboutWelcomeDefaults.getDefaults(); + + async function prepConfig(config, evalFalseScreenIds) { + let data = await getData(); + + if (evalFalseScreenIds?.length) { + data.screens.forEach(async screen => { + if (evalFalseScreenIds.includes(screen.id)) { + screen.targeting = false; + } + }); + data.screens = await AWScreenUtils.evaluateTargetingAndRemoveScreens( + data.screens + ); + } + + return AboutWelcomeDefaults.prepareContentForReact({ + ...data, + ...config, + }); + } + beforeEach(() => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + sandbox.stub(AWScreenUtils, "evaluateScreenTargeting").returnsArg(0); + // This is necessary because there are still screens being removed with + // `removeScreens` in `prepareContentForReact()`. Once we've migrated + // to using screen targeting instead of manually removing screens, + // we can remove this stub. + sandbox + .stub(global.AWScreenUtils, "removeScreens") + .callsFake((screens, callback) => + AWScreenUtils.removeScreens(screens, callback) + ); + }); + it("should have a multi action primary button by default", async () => { + const data = await prepConfig({}, ["AW_WELCOME_BACK"]); + assert.propertyVal( + data.screens[0].content.primary_button.action, + "type", + "MULTI_ACTION" + ); + }); + it("should have a FxA button", async () => { + const data = await prepConfig({}, ["AW_WELCOME_BACK"]); + + assert.notProperty(data, "skipFxA"); + assert.property(data.screens[0].content, "secondary_button_top"); + }); + it("should remove the FxA button if pref disabled", async () => { + global.Services.prefs.getBoolPref.returns(false); + + const data = await prepConfig(); + + assert.property(data, "skipFxA", true); + assert.notProperty(data.screens[0].content, "secondary_button_top"); + }); + }); + + describe("AboutWelcomeDefaults for MR split template proton", () => { + const getData = () => AboutWelcomeDefaults.getDefaults(true); + beforeEach(() => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + }); + + it("should use 'split' position template by default", async () => { + const data = await getData(); + assert.propertyVal(data.screens[0].content, "position", "split"); + }); + + it("should not include noodles by default", async () => { + const data = await getData(); + assert.notProperty(data.screens[0].content, "has_noodles"); + }); + }); + + describe("AboutWelcomeDefaults prepareMobileDownload", () => { + const TEST_CONTENT = { + screens: [ + { + id: "AW_MOBILE_DOWNLOAD", + content: { + title: "test", + hero_image: { + url: "https://example.com/test.svg", + }, + cta_paragraph: { + text: {}, + action: {}, + }, + }, + }, + ], + }; + it("should not set url for default qrcode svg", async () => { + sandbox.stub(global.AppConstants, "isChinaRepack").returns(false); + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal( + data.screens[0].content.hero_image, + "url", + "https://example.com/test.svg" + ); + }); + it("should set url for cn qrcode svg", async () => { + sandbox.stub(global.AppConstants, "isChinaRepack").returns(true); + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal( + data.screens[0].content.hero_image, + "url", + "https://example.com/test-cn.svg" + ); + }); + }); + + describe("AboutWelcomeDefaults prepareContentForReact", () => { + it("should not set action without screens", async () => { + const data = await AboutWelcomeDefaults.prepareContentForReact({ + ua: "test", + }); + + assert.propertyVal(data, "ua", "test"); + assert.notProperty(data, "screens"); + }); + it("should set action for import action", async () => { + const TEST_CONTENT = { + ua: "test", + screens: [ + { + id: "AW_IMPORT_SETTINGS", + content: { + primary_button: { + action: { + type: "SHOW_MIGRATION_WIZARD", + }, + }, + }, + }, + ], + }; + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal(data, "ua", "test"); + assert.propertyVal( + data.screens[0].content.primary_button.action.data, + "source", + "test" + ); + }); + it("should not set action if the action type != SHOW_MIGRATION_WIZARD", async () => { + const TEST_CONTENT = { + ua: "test", + screens: [ + { + id: "AW_IMPORT_SETTINGS", + content: { + primary_button: { + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: {}, + }, + }, + }, + }, + ], + }; + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal(data, "ua", "test"); + assert.notPropertyVal( + data.screens[0].content.primary_button.action.data, + "source", + "test" + ); + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx new file mode 100644 index 0000000000..b4593a45f3 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx @@ -0,0 +1,859 @@ +import { GlobalOverrider } from "newtab/test/unit/utils"; +import { + MultiStageAboutWelcome, + SecondaryCTA, + StepsIndicator, + ProgressBar, + WelcomeScreen, +} from "content-src/components/MultiStageAboutWelcome"; +import { Themes } from "content-src/components/Themes"; +import React from "react"; +import { shallow, mount } from "enzyme"; +import { AboutWelcomeDefaults } from "modules/AboutWelcomeDefaults.sys.mjs"; +import { AboutWelcomeUtils } from "content-src/lib/aboutwelcome-utils.mjs"; + +describe("MultiStageAboutWelcome module", () => { + let globals; + let sandbox; + + const DEFAULT_PROPS = { + defaultScreens: AboutWelcomeDefaults.getDefaults().screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + + beforeEach(async () => { + globals = new GlobalOverrider(); + globals.set({ + AWGetSelectedTheme: () => Promise.resolve("automatic"), + AWSendEventTelemetry: () => {}, + AWWaitForMigrationClose: () => Promise.resolve(), + AWSelectTheme: () => Promise.resolve(), + AWFinish: () => Promise.resolve(), + }); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("MultiStageAboutWelcome functional component", () => { + it("should render MultiStageAboutWelcome", () => { + const wrapper = shallow(<MultiStageAboutWelcome {...DEFAULT_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should pass activeTheme and initialTheme props to WelcomeScreen", async () => { + let wrapper = mount(<MultiStageAboutWelcome {...DEFAULT_PROPS} />); + // Spin the event loop to allow the useEffect hooks to execute, + // any promises to resolve, and re-rendering to happen after the + // promises have updated the state/props + await new Promise(resolve => setTimeout(resolve, 0)); + // sync up enzyme's representation with the real DOM + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + assert.strictEqual(welcomeScreenWrapper.prop("activeTheme"), "automatic"); + assert.strictEqual( + welcomeScreenWrapper.prop("initialTheme"), + "automatic" + ); + }); + + it("should handle primary Action", () => { + const screens = [ + { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + label: "Test button", + action: { + navigate: true, + }, + }, + }, + }, + ]; + + const PRIMARY_ACTION_PROPS = { + defaultScreens: screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + + const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry"); + let wrapper = mount(<MultiStageAboutWelcome {...PRIMARY_ACTION_PROPS} />); + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + const btnPrimary = welcomeScreenWrapper.find(".primary"); + btnPrimary.simulate("click"); + assert.calledOnce(stub); + assert.equal( + stub.firstCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.equal(stub.firstCall.args[1], "primary_button"); + stub.restore(); + }); + + it("should autoAdvance on last screen and send appropriate telemetry", () => { + let clock = sinon.useFakeTimers(); + const screens = [ + { + auto_advance: "primary_button", + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + label: "Test Button", + action: { + navigate: true, + }, + }, + }, + }, + ]; + const AUTO_ADVANCE_PROPS = { + defaultScreens: screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + const wrapper = mount(<MultiStageAboutWelcome {...AUTO_ADVANCE_PROPS} />); + wrapper.update(); + const finishStub = sandbox.stub(global, "AWFinish"); + const telemetryStub = sinon.stub( + AboutWelcomeUtils, + "sendActionTelemetry" + ); + + assert.notCalled(finishStub); + clock.tick(20001); + assert.calledOnce(finishStub); + assert.calledOnce(telemetryStub); + assert.equal(telemetryStub.lastCall.args[2], "AUTO_ADVANCE"); + clock.restore(); + finishStub.restore(); + telemetryStub.restore(); + }); + + it("should send telemetry ping on collectSelect", () => { + const screens = [ + { + id: "EASY_SETUP_TEST", + content: { + tiles: { + type: "multiselect", + data: [ + { + id: "checkbox-1", + defaultValue: true, + }, + ], + }, + primary_button: { + label: "Test Button", + action: { + collectSelect: true, + }, + }, + }, + }, + ]; + const EASY_SETUP_PROPS = { + defaultScreens: screens, + message_id: "DEFAULT_ABOUTWELCOME", + startScreen: 0, + }; + const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry"); + let wrapper = mount(<MultiStageAboutWelcome {...EASY_SETUP_PROPS} />); + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + const btnPrimary = welcomeScreenWrapper.find(".primary"); + btnPrimary.simulate("click"); + assert.calledTwice(stub); + assert.equal( + stub.firstCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.equal(stub.firstCall.args[1], "primary_button"); + assert.equal( + stub.lastCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.ok(stub.lastCall.args[1].includes("checkbox-1")); + assert.equal(stub.lastCall.args[2], "SELECT_CHECKBOX"); + stub.restore(); + }); + }); + + describe("WelcomeScreen component", () => { + describe("easy setup screen", () => { + const screen = AboutWelcomeDefaults.getDefaults().screens.find( + s => s.id === "AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN" + ); + let EASY_SETUP_SCREEN_PROPS; + + beforeEach(() => { + EASY_SETUP_SCREEN_PROPS = { + id: screen.id, + content: screen.content, + messageId: `${DEFAULT_PROPS.message_id}_${screen.id}`, + UTMTerm: DEFAULT_PROPS.utm_term, + flowParams: null, + totalNumberOfScreens: 1, + setScreenMultiSelects: sandbox.stub(), + setActiveMultiSelect: sandbox.stub(), + }; + }); + + it("should render Easy Setup screen", () => { + const wrapper = shallow(<WelcomeScreen {...EASY_SETUP_SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + }); + + it("should render secondary.top button", () => { + let SCREEN_PROPS = { + content: { + title: "Step", + secondary_button_top: { + text: "test", + label: "test label", + }, + }, + position: "top", + }; + const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />); + assert.ok(wrapper.find("div.secondary-cta.top").exists()); + }); + + it("should render the arrow icon in the secondary button", () => { + let SCREEN_PROPS = { + content: { + title: "Step", + secondary_button: { + has_arrow_icon: true, + label: "test label", + }, + }, + }; + const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />); + assert.ok(wrapper.find("button.arrow-icon").exists()); + }); + + it("should render steps indicator", () => { + let PROPS = { totalNumberOfScreens: 1 }; + const wrapper = mount(<StepsIndicator {...PROPS} />); + assert.ok(wrapper.find("div.indicator").exists()); + }); + + it("should assign the total number of screens and current screen to the aria-valuemax and aria-valuenow labels", () => { + const EXTRA_PROPS = { totalNumberOfScreens: 3, order: 1 }; + const wrapper = mount( + <WelcomeScreen {...EASY_SETUP_SCREEN_PROPS} {...EXTRA_PROPS} /> + ); + + const steps = wrapper.find(`div.steps`); + assert.ok(steps.exists()); + const { attributes } = steps.getDOMNode(); + assert.equal( + parseInt(attributes.getNamedItem("aria-valuemax").value, 10), + EXTRA_PROPS.totalNumberOfScreens + ); + assert.equal( + parseInt(attributes.getNamedItem("aria-valuenow").value, 10), + EXTRA_PROPS.order + 1 + ); + }); + + it("should render progress bar", () => { + let SCREEN_PROPS = { + step: 1, + previousStep: 0, + totalNumberOfScreens: 2, + }; + const wrapper = mount(<ProgressBar {...SCREEN_PROPS} />); + assert.ok(wrapper.find("div.indicator").exists()); + assert.propertyVal( + wrapper.find("div.indicator").prop("style"), + "--progress-bar-progress", + "50%" + ); + }); + + it("should have a primary, secondary and secondary.top button in the rendered input", () => { + const wrapper = mount(<WelcomeScreen {...EASY_SETUP_SCREEN_PROPS} />); + assert.ok(wrapper.find(".primary").exists()); + assert.ok( + wrapper + .find(".secondary-cta button.secondary[value='secondary_button']") + .exists() + ); + assert.ok( + wrapper + .find( + ".secondary-cta.top button.secondary[value='secondary_button_top']" + ) + .exists() + ); + }); + }); + + describe("theme screen", () => { + const THEME_SCREEN_PROPS = { + id: "test-theme-screen", + totalNumberOfScreens: 1, + content: { + title: "test title", + subtitle: "test subtitle", + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: "test-label", + tooltip: "test-tooltip", + description: "test-description", + }, + ], + }, + primary_button: { + action: {}, + label: "test button", + }, + }, + navigate: null, + messageId: `${DEFAULT_PROPS.message_id}_"test-theme-screen"`, + UTMTerm: DEFAULT_PROPS.utm_term, + flowParams: null, + activeTheme: "automatic", + }; + + it("should render WelcomeScreen", () => { + const wrapper = shallow(<WelcomeScreen {...THEME_SCREEN_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should check this.props.activeTheme in the rendered input", () => { + const wrapper = shallow(<Themes {...THEME_SCREEN_PROPS} />); + + const selectedThemeInput = wrapper.find(".theme input[checked=true]"); + assert.strictEqual( + selectedThemeInput.prop("value"), + THEME_SCREEN_PROPS.activeTheme + ); + }); + }); + describe("import screen", () => { + const IMPORT_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + }, + }; + it("should render ImportScreen", () => { + const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + }); + it("should not have a primary or secondary button", () => { + const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />); + assert.isFalse(wrapper.find(".primary").exists()); + assert.isFalse( + wrapper.find(".secondary button[value='secondary_button']").exists() + ); + assert.isFalse( + wrapper + .find(".secondary button[value='secondary_button_top']") + .exists() + ); + }); + }); + describe("#handleAction", () => { + let SCREEN_PROPS; + let TEST_ACTION; + beforeEach(() => { + SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: {}, + label: "test button", + }, + }, + navigate: sandbox.stub(), + setActiveTheme: sandbox.stub(), + UTMTerm: "you_tee_emm", + }; + TEST_ACTION = SCREEN_PROPS.content.primary_button.action; + sandbox.stub(AboutWelcomeUtils, "handleUserAction").resolves(); + }); + it("should handle navigate", () => { + TEST_ACTION.navigate = true; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledOnce(SCREEN_PROPS.navigate); + }); + it("should handle theme", () => { + TEST_ACTION.theme = "test"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(SCREEN_PROPS.setActiveTheme, "test"); + }); + it("should handle dismiss", () => { + SCREEN_PROPS.content.dismiss_button = { + action: { dismiss: true }, + }; + const finishStub = sandbox.stub(global, "AWFinish"); + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".dismiss-button").simulate("click"); + + assert.calledOnce(finishStub); + }); + it("should handle SHOW_FIREFOX_ACCOUNTS", () => { + TEST_ACTION.type = "SHOW_FIREFOX_ACCOUNTS"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + data: { + extraParams: { + utm_campaign: "firstrun", + utm_medium: "referral", + utm_source: "activity-stream", + utm_term: "you_tee_emm-screen", + }, + }, + type: "SHOW_FIREFOX_ACCOUNTS", + }); + }); + it("should handle OPEN_URL", () => { + TEST_ACTION.type = "OPEN_URL"; + TEST_ACTION.data = { + args: "https://example.com?utm_campaign=test-campaign", + }; + TEST_ACTION.addFlowParams = true; + let flowBeginTime = Date.now(); + const wrapper = mount( + <WelcomeScreen + {...SCREEN_PROPS} + flowParams={{ + deviceId: "test-device-id", + flowId: "test-flow-id", + flowBeginTime, + }} + /> + ); + + wrapper.find(".primary").simulate("click"); + + let [handledAction] = AboutWelcomeUtils.handleUserAction.firstCall.args; + assert.equal(handledAction.type, "OPEN_URL"); + let { searchParams } = new URL(handledAction.data.args); + assert.equal(searchParams.get("utm_campaign"), "test-campaign"); + assert.equal(searchParams.get("utm_medium"), "referral"); + assert.equal(searchParams.get("utm_source"), "activity-stream"); + assert.equal(searchParams.get("utm_term"), "you_tee_emm-screen"); + assert.equal(searchParams.get("device_id"), "test-device-id"); + assert.equal(searchParams.get("flow_id"), "test-flow-id"); + assert.equal( + searchParams.get("flow_begin_time"), + flowBeginTime.toString() + ); + }); + it("should handle SHOW_MIGRATION_WIZARD", () => { + TEST_ACTION.type = "SHOW_MIGRATION_WIZARD"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "SHOW_MIGRATION_WIZARD", + }); + }); + it("should handle SHOW_MIGRATION_WIZARD INSIDE MULTI_ACTION", async () => { + const migrationCloseStub = sandbox.stub( + global, + "AWWaitForMigrationClose" + ); + const MULTI_ACTION_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + label: "test button", + }, + }, + navigate: sandbox.stub(), + }; + const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }); + // handleUserAction returns a Promise, so let's let the microtask queue + // flush so that anything waiting for the handleUserAction Promise to + // resolve can run. + await new Promise(resolve => queueMicrotask(resolve)); + assert.calledOnce(migrationCloseStub); + }); + + it("should handle SHOW_MIGRATION_WIZARD INSIDE NESTED MULTI_ACTION", async () => { + const migrationCloseStub = sandbox.stub( + global, + "AWWaitForMigrationClose" + ); + const MULTI_ACTION_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + ], + }, + }, + label: "test button", + }, + }, + navigate: sandbox.stub(), + }; + const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + ], + }, + }); + // handleUserAction returns a Promise, so let's let the microtask queue + // flush so that anything waiting for the handleUserAction Promise to + // resolve can run. + await new Promise(resolve => queueMicrotask(resolve)); + assert.calledOnce(migrationCloseStub); + }); + it("should unset prefs from unchecked checkboxes", () => { + const PREF_SCREEN_PROPS = { + content: { + title: "Checkboxes", + tiles: { + type: "multiselect", + data: [ + { + id: "checkbox-1", + label: "checkbox 1", + checkedAction: { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + value: true, + }, + }, + }, + uncheckedAction: { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + }, + }, + }, + }, + { + id: "checkbox-2", + label: "checkbox 2", + checkedAction: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "pref-b", + value: "pref-b", + }, + }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "pref-c", + value: 3, + }, + }, + }, + ], + }, + }, + uncheckedAction: { + type: "SET_PREF", + data: { + pref: { name: "pref-b" }, + }, + }, + }, + ], + }, + primary_button: { + label: "Set Prefs", + action: { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [], + }, + }, + }, + }, + navigate: sandbox.stub(), + setScreenMultiSelects: sandbox.stub(), + setActiveMultiSelect: sandbox.stub(), + }; + + // No checkboxes checked. All prefs will be unset and pref-c will not be + // reset. + { + const wrapper = mount( + <WelcomeScreen {...PREF_SCREEN_PROPS} activeMultiSelect={[]} /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { type: "SET_PREF", data: { pref: { name: "pref-a" } } }, + { type: "SET_PREF", data: { pref: { name: "pref-b" } } }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // The first checkbox is checked. Only pref-a will be set and pref-c + // will not be reset. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-1"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + value: true, + }, + }, + }, + { type: "SET_PREF", data: { pref: { name: "pref-b" } } }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // The second checkbox is checked. Prefs pref-b and pref-c will be set. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-2"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { type: "SET_PREF", data: { pref: { name: "pref-a" } } }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-b", value: "pref-b" } }, + }, + { + type: "SET_PREF", + data: { pref: { name: "pref-c", value: 3 } }, + }, + ], + }, + }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // // Both checkboxes are checked. All prefs will be set. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-1", "checkbox-2"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-a", value: true } }, + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-b", value: "pref-b" } }, + }, + { + type: "SET_PREF", + data: { pref: { name: "pref-c", value: 3 } }, + }, + ], + }, + }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + }); + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/OnboardingVideoTest.test.jsx b/browser/components/aboutwelcome/tests/unit/OnboardingVideoTest.test.jsx new file mode 100644 index 0000000000..078c8e17c4 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/OnboardingVideoTest.test.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import { mount } from "enzyme"; +import { OnboardingVideo } from "content-src/components/OnboardingVideo"; + +describe("OnboardingVideo component", () => { + let sandbox; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const SCREEN_PROPS = { + content: { + title: "Test title", + video_container: { + video_url: "test url", + }, + }, + }; + + it("should handle video_start action when video is played", () => { + const handleAction = sandbox.stub(); + const wrapper = mount( + <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} /> + ); + wrapper.find("video").simulate("play"); + assert.calledWith(handleAction, { + currentTarget: { value: "video_start" }, + }); + }); + it("should handle video_end action when video has completed playing", () => { + const handleAction = sandbox.stub(); + const wrapper = mount( + <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} /> + ); + wrapper.find("video").simulate("ended"); + assert.calledWith(handleAction, { + currentTarget: { value: "video_end" }, + }); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/addUtmParams.test.js b/browser/components/aboutwelcome/tests/unit/addUtmParams.test.js new file mode 100644 index 0000000000..2c078b4f49 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/addUtmParams.test.js @@ -0,0 +1,34 @@ +import { addUtmParams, BASE_PARAMS } from "content-src/lib/addUtmParams.mjs"; + +describe("addUtmParams", () => { + const originalBaseParams = JSON.parse(JSON.stringify(BASE_PARAMS)); + afterEach(() => Object.assign(BASE_PARAMS, originalBaseParams)); + it("should convert a string URL", () => { + const result = addUtmParams("https://foo.com", "foo"); + assert.equal(result.hostname, "foo.com"); + }); + it("should add all base params", () => { + assert.match( + addUtmParams(new URL("https://foo.com"), "foo").toString(), + /utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral/ + ); + }); + it("should allow updating base params utm values", () => { + BASE_PARAMS.utm_campaign = "firstrun-default"; + assert.match( + addUtmParams(new URL("https://foo.com"), "foo", "default").toString(), + /utm_source=activity-stream&utm_campaign=firstrun-default&utm_medium=referral/ + ); + }); + it("should add utm_term", () => { + const params = addUtmParams(new URL("https://foo.com"), "foo").searchParams; + assert.equal(params.get("utm_term"), "foo", "utm_term"); + }); + it("should not override the URL's existing utm param values", () => { + const url = new URL("https://foo.com/?utm_source=foo&utm_campaign=bar"); + const params = addUtmParams(url, "foo").searchParams; + assert.equal(params.get("utm_source"), "foo", "utm_source"); + assert.equal(params.get("utm_campaign"), "bar", "utm_campaign"); + assert.equal(params.get("utm_medium"), "referral", "utm_medium"); + }); +}); diff --git a/browser/components/aboutwelcome/tests/unit/unit-entry.js b/browser/components/aboutwelcome/tests/unit/unit-entry.js new file mode 100644 index 0000000000..fb70eeb843 --- /dev/null +++ b/browser/components/aboutwelcome/tests/unit/unit-entry.js @@ -0,0 +1,716 @@ +import { + EventEmitter, + FakePrefs, + FakensIPrefService, + GlobalOverrider, + FakeConsoleAPI, + FakeLogger, +} from "newtab/test/unit/utils"; +import Adapter from "enzyme-adapter-react-16"; +import { chaiAssertions } from "newtab/test/schemas/pings"; +import enzyme from "enzyme"; + +enzyme.configure({ adapter: new Adapter() }); + +// Cause React warnings to make tests that trigger them fail +const origConsoleError = console.error; +console.error = function (msg, ...args) { + origConsoleError.apply(console, [msg, ...args]); + + if ( + /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test( + msg + ) + ) { + throw new Error(msg); + } +}; + +const req = require.context(".", true, /\.test\.jsx?$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, { prefix: "" }); + +chai.use(chaiAssertions); + +const overrider = new GlobalOverrider(); + +const RemoteSettings = name => ({ + get: () => { + if (name === "attachment") { + return Promise.resolve([{ attachment: {} }]); + } + return Promise.resolve([]); + }, + on: () => {}, + off: () => {}, +}); +RemoteSettings.pollChanges = () => {}; + +class JSWindowActorParent { + sendAsyncMessage(name, data) { + return { name, data }; + } +} + +class JSWindowActorChild { + sendAsyncMessage(name, data) { + return { name, data }; + } + + sendQuery(name, data) { + return Promise.resolve({ name, data }); + } + + get contentWindow() { + return { + Promise, + }; + } +} + +// Detect plain object passed to lazy getter APIs, and set its prototype to +// global object, and return the global object for further modification. +// Returns the object if it's not plain object. +// +// This is a workaround to make the existing testharness and testcase keep +// working even after lazy getters are moved to plain `lazy` object. +const cachedPlainObject = new Set(); +function updateGlobalOrObject(object) { + // Given this function modifies the prototype, and the following + // condition doesn't meet on the second call, cache the result. + if (cachedPlainObject.has(object)) { + return global; + } + + if (Object.getPrototypeOf(object).constructor.name !== "Object") { + return object; + } + + cachedPlainObject.add(object); + Object.setPrototypeOf(object, global); + return global; +} + +const TEST_GLOBAL = { + JSWindowActorParent, + JSWindowActorChild, + AboutReaderParent: { + addMessageListener: (messageName, listener) => {}, + removeMessageListener: (messageName, listener) => {}, + }, + AboutWelcomeTelemetry: class { + submitGleanPingForPing() {} + }, + AddonManager: { + getActiveAddons() { + return Promise.resolve({ addons: [], fullData: false }); + }, + }, + AppConstants: { + MOZILLA_OFFICIAL: true, + MOZ_APP_VERSION: "69.0a1", + isChinaRepack() { + return false; + }, + isPlatformAndVersionAtMost() { + return false; + }, + platform: "win", + }, + ASRouterPreferences: { + console: new FakeConsoleAPI({ + maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests + prefix: "ASRouter", + }), + }, + AWScreenUtils: { + evaluateTargetingAndRemoveScreens() { + return true; + }, + async removeScreens() { + return true; + }, + evaluateScreenTargeting() { + return true; + }, + }, + BrowserUtils: { + sendToDeviceEmailsSupported() { + return true; + }, + }, + UpdateUtils: { getUpdateChannel() {} }, + BasePromiseWorker: class { + constructor() { + this.ExceptionHandlers = []; + } + post() {} + }, + browserSearchRegion: "US", + BrowserWindowTracker: { getTopWindow() {} }, + ChromeUtils: { + defineLazyGetter(object, name, f) { + updateGlobalOrObject(object)[name] = f(); + }, + defineModuleGetter: updateGlobalOrObject, + defineESModuleGetters: updateGlobalOrObject, + generateQI() { + return {}; + }, + import() { + return global; + }, + importESModule() { + return global; + }, + }, + ClientEnvironment: { + get userId() { + return "foo123"; + }, + }, + Components: { + Constructor(classId) { + switch (classId) { + case "@mozilla.org/referrer-info;1": + return function (referrerPolicy, sendReferrer, originalReferrer) { + this.referrerPolicy = referrerPolicy; + this.sendReferrer = sendReferrer; + this.originalReferrer = originalReferrer; + }; + } + return function () {}; + }, + isSuccessCode: () => true, + }, + ConsoleAPI: FakeConsoleAPI, + // NB: These are functions/constructors + // eslint-disable-next-line object-shorthand + ContentSearchUIController: function () {}, + // eslint-disable-next-line object-shorthand + ContentSearchHandoffUIController: function () {}, + Cc: { + "@mozilla.org/browser/nav-bookmarks-service;1": { + addObserver() {}, + getService() { + return this; + }, + removeObserver() {}, + SOURCES: {}, + TYPE_BOOKMARK: {}, + }, + "@mozilla.org/browser/nav-history-service;1": { + addObserver() {}, + executeQuery() {}, + getNewQuery() {}, + getNewQueryOptions() {}, + getService() { + return this; + }, + insert() {}, + markPageAsTyped() {}, + removeObserver() {}, + }, + "@mozilla.org/io/string-input-stream;1": { + createInstance() { + return {}; + }, + }, + "@mozilla.org/security/hash;1": { + createInstance() { + return { + init() {}, + updateFromStream() {}, + finish() { + return "0"; + }, + }; + }, + }, + "@mozilla.org/updates/update-checker;1": { createInstance() {} }, + "@mozilla.org/widget/useridleservice;1": { + getService() { + return { + idleTime: 0, + addIdleObserver() {}, + removeIdleObserver() {}, + }; + }, + }, + "@mozilla.org/streamConverters;1": { + getService() { + return this; + }, + }, + "@mozilla.org/network/stream-loader;1": { + createInstance() { + return {}; + }, + }, + }, + Ci: { + nsICryptoHash: {}, + nsIReferrerInfo: { UNSAFE_URL: 5 }, + nsITimer: { TYPE_ONE_SHOT: 1 }, + nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 }, + nsIDOMWindow: Object, + nsITrackingDBService: { + TRACKERS_ID: 1, + TRACKING_COOKIES_ID: 2, + CRYPTOMINERS_ID: 3, + FINGERPRINTERS_ID: 4, + SOCIAL_ID: 5, + }, + nsICookieBannerService: { + MODE_DISABLED: 0, + MODE_REJECT: 1, + MODE_REJECT_OR_ACCEPT: 2, + MODE_UNSET: 3, + }, + }, + Cu: { + importGlobalProperties() {}, + now: () => window.performance.now(), + cloneInto: o => JSON.parse(JSON.stringify(o)), + }, + console: { + ...console, + error() {}, + }, + dump() {}, + EveryWindow: { + registerCallback: (id, init, uninit) => {}, + unregisterCallback: id => {}, + }, + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + fetch() {}, + // eslint-disable-next-line object-shorthand + Image: function () {}, // NB: This is a function/constructor + IOUtils: { + writeJSON() { + return Promise.resolve(0); + }, + readJSON() { + return Promise.resolve({}); + }, + read() { + return Promise.resolve(new Uint8Array()); + }, + makeDirectory() { + return Promise.resolve(0); + }, + write() { + return Promise.resolve(0); + }, + exists() { + return Promise.resolve(0); + }, + remove() { + return Promise.resolve(0); + }, + stat() { + return Promise.resolve(0); + }, + }, + NewTabUtils: { + activityStreamProvider: { + getTopFrecentSites: () => [], + executePlacesQuery: async (sql, options) => ({ sql, options }), + }, + }, + OS: { + File: { + writeAtomic() {}, + makeDir() {}, + stat() {}, + Error: {}, + read() {}, + exists() {}, + remove() {}, + removeEmptyDir() {}, + }, + Path: { + join() { + return "/"; + }, + }, + Constants: { + Path: { + localProfileDir: "/", + }, + }, + }, + PathUtils: { + join(...parts) { + return parts[parts.length - 1]; + }, + joinRelative(...parts) { + return parts[parts.length - 1]; + }, + getProfileDir() { + return Promise.resolve("/"); + }, + getLocalProfileDir() { + return Promise.resolve("/"); + }, + }, + PlacesUtils: { + get bookmarks() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"]; + }, + get history() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"]; + }, + observers: { + addListener() {}, + removeListener() {}, + }, + }, + Preferences: FakePrefs, + PrivateBrowsingUtils: { + isBrowserPrivate: () => false, + isWindowPrivate: () => false, + permanentPrivateBrowsing: false, + }, + DownloadsViewUI: { + getDisplayName: () => "filename.ext", + getSizeWithUnits: () => "1.5 MB", + }, + FileUtils: { + // eslint-disable-next-line object-shorthand + File: function () {}, // NB: This is a function/constructor + }, + Region: { + home: "US", + REGION_TOPIC: "browser-region-updated", + }, + Services: { + dirsvc: { + get: () => ({ parent: { parent: { path: "appPath" } } }), + }, + env: { + set: () => undefined, + }, + locale: { + get appLocaleAsBCP47() { + return "en-US"; + }, + negotiateLanguages() {}, + }, + urlFormatter: { formatURL: str => str, formatURLPref: str => str }, + mm: { + addMessageListener: (msg, cb) => this.receiveMessage(), + removeMessageListener() {}, + }, + obs: { + addObserver() {}, + removeObserver() {}, + notifyObservers() {}, + }, + telemetry: { + setEventRecordingEnabled: () => {}, + recordEvent: eventDetails => {}, + scalarSet: () => {}, + keyedScalarAdd: () => {}, + }, + uuid: { + generateUUID() { + return "{foo-123-foo}"; + }, + }, + console: { logStringMessage: () => {} }, + prefs: new FakensIPrefService(), + tm: { + dispatchToMainThread: cb => cb(), + idleDispatchToMainThread: cb => cb(), + }, + eTLD: { + getBaseDomain({ spec }) { + return spec.match(/\/([^/]+)/)[1]; + }, + getBaseDomainFromHost(host) { + return host.match(/.*?(\w+\.\w+)$/)[1]; + }, + getPublicSuffix() {}, + }, + io: { + newURI: spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + }), + }, + search: { + init() { + return Promise.resolve(); + }, + getVisibleEngines: () => + Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]), + defaultEngine: { + identifier: "google", + searchForm: + "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b", + aliases: ["@google"], + }, + defaultPrivateEngine: { + identifier: "bing", + searchForm: "https://www.bing.com", + aliases: ["@bing"], + }, + getEngineByAlias: async () => null, + }, + scriptSecurityManager: { + createNullPrincipal() {}, + getSystemPrincipal() {}, + }, + wm: { + getMostRecentWindow: () => window, + getMostRecentBrowserWindow: () => window, + getEnumerator: () => [], + }, + ww: { registerNotification() {}, unregisterNotification() {} }, + appinfo: { appBuildID: "20180710100040", version: "69.0a1" }, + scriptloader: { loadSubScript: () => {} }, + startup: { + getStartupInfo() { + return { + process: { + getTime() { + return 1588010448000; + }, + }, + }; + }, + }, + }, + XPCOMUtils: { + defineLazyGlobalGetters: updateGlobalOrObject, + defineLazyModuleGetters: updateGlobalOrObject, + defineLazyServiceGetter: updateGlobalOrObject, + defineLazyServiceGetters: updateGlobalOrObject, + defineLazyPreferenceGetter(object, name) { + updateGlobalOrObject(object)[name] = ""; + }, + generateQI() { + return {}; + }, + }, + EventEmitter, + ShellService: { + doesAppNeedPin: () => false, + isDefaultBrowser: () => true, + }, + FilterExpressions: { + eval() { + return Promise.resolve(false); + }, + }, + RemoteSettings, + Localization: class { + async formatMessages(stringsIds) { + return Promise.resolve( + stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } })) + ); + } + async formatValue(stringId) { + return Promise.resolve(stringId); + } + }, + FxAccountsConfig: { + promiseConnectAccountURI(id) { + return Promise.resolve(id); + }, + }, + FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id", + ExperimentAPI: { + getExperiment() {}, + getExperimentMetaData() {}, + getRolloutMetaData() {}, + }, + NimbusFeatures: { + glean: { + getVariable() {}, + }, + newtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + pocketNewtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + cookieBannerHandling: { + getVariable() {}, + }, + }, + TelemetryEnvironment: { + setExperimentActive() {}, + currentEnvironment: { + profile: { + creationDate: 16587, + }, + settings: {}, + }, + }, + TelemetryStopwatch: { + start: () => {}, + finish: () => {}, + }, + Sampling: { + ratioSample(seed, ratios) { + return Promise.resolve(0); + }, + }, + BrowserHandler: { + get kiosk() { + return false; + }, + }, + TelemetrySession: { + getMetadata(reason) { + return { + reason, + sessionId: "fake_session_id", + }; + }, + }, + PageThumbs: { + addExpirationFilter() {}, + removeExpirationFilter() {}, + }, + Logger: FakeLogger, + getFxAccountsSingleton() {}, + AboutNewTab: {}, + Glean: { + newtab: { + opened: { + record() {}, + }, + closed: { + record() {}, + }, + locale: { + set() {}, + }, + newtabCategory: { + set() {}, + }, + homepageCategory: { + set() {}, + }, + blockedSponsors: { + set() {}, + }, + sovAllocation: { + set() {}, + }, + }, + newtabSearch: { + enabled: { + set() {}, + }, + }, + pocket: { + enabled: { + set() {}, + }, + impression: { + record() {}, + }, + isSignedIn: { + set() {}, + }, + sponsoredStoriesEnabled: { + set() {}, + }, + click: { + record() {}, + }, + save: { + record() {}, + }, + topicClick: { + record() {}, + }, + }, + topsites: { + enabled: { + set() {}, + }, + sponsoredEnabled: { + set() {}, + }, + impression: { + record() {}, + }, + click: { + record() {}, + }, + rows: { + set() {}, + }, + showPrivacyClick: { + record() {}, + }, + dismiss: { + record() {}, + }, + prefChanged: { + record() {}, + }, + }, + topSites: { + pingType: { + set() {}, + }, + position: { + set() {}, + }, + source: { + set() {}, + }, + tileId: { + set() {}, + }, + reportingUrl: { + set() {}, + }, + advertiser: { + set() {}, + }, + contextId: { + set() {}, + }, + }, + }, + GleanPings: { + newtab: { + submit() {}, + }, + topSites: { + submit() {}, + }, + }, + Utils: { + SERVER_URL: "bogus://foo", + }, +}; +overrider.set(TEST_GLOBAL); + +describe("activity-stream", () => { + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); |