From db44741b60093c87c9842108d5b6189275ed3c40 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Thu, 14 May 2026 09:06:37 -0400 Subject: [PATCH] feat(functional-tests): Add payments-next coverage for checkout --- .circleci/config.yml | 3 + .../functional-tests/lib/fixtures/payments.ts | 19 + .../functional-tests/lib/stripe-test-cards.ts | 2 +- .../functional-tests/pages/payments/base.ts | 71 ++++ .../pages/payments/checkout.ts | 334 ++++++++++++++++++ .../functional-tests/pages/payments/index.ts | 13 + packages/functional-tests/pages/relier.ts | 23 ++ .../scripts/start-payments-services.sh | 1 + .../tests-payments-next/checkout.spec.ts | 300 +++++++++++++++- 9 files changed, 760 insertions(+), 6 deletions(-) create mode 100644 packages/functional-tests/lib/fixtures/payments.ts create mode 100644 packages/functional-tests/pages/payments/base.ts create mode 100644 packages/functional-tests/pages/payments/checkout.ts create mode 100644 packages/functional-tests/pages/payments/index.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index ccf4bc7893c..6e5a76cea09 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -893,6 +893,9 @@ jobs: environment: NODE_ENV: test MYSQL_CONFIG__HOST: 127.0.0.1 + STRAPI_CLIENT_CONFIG__GRAPHQL_API_URI: ${STRAPI_CLIENT_GRAPHQL_API_URI} + STRAPI_CLIENT_CONFIG__API_KEY: ${STRAPI_CLIENT_API_KEY_PAYMENTS} + STRIPE_CONFIG__API_KEY: ${SUBHUB_STRIPE_APIKEY} no_output_timeout: 10m - run-playwright-tests: project: << parameters.project >> diff --git a/packages/functional-tests/lib/fixtures/payments.ts b/packages/functional-tests/lib/fixtures/payments.ts new file mode 100644 index 00000000000..6da5c5425c6 --- /dev/null +++ b/packages/functional-tests/lib/fixtures/payments.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { test as standardTest, expect } from './standard'; +import { create as createPaymentPages } from '../../pages/payments'; + +export type PaymentPOMS = ReturnType; + +export { expect }; + +export const test = standardTest.extend<{ + paymentPages: PaymentPOMS; +}>({ + paymentPages: async ({ target, page }, use) => { + const paymentPages = createPaymentPages(page, target); + await use(paymentPages); + }, +}); diff --git a/packages/functional-tests/lib/stripe-test-cards.ts b/packages/functional-tests/lib/stripe-test-cards.ts index 795e8ab1d36..611c52e85b0 100644 --- a/packages/functional-tests/lib/stripe-test-cards.ts +++ b/packages/functional-tests/lib/stripe-test-cards.ts @@ -9,7 +9,7 @@ export const StripeTestCards = { SUCCESS: '4242424242424242', - THREE_DS_REQUIRED: '4000002760003184', + THREE_DS_AUTHENTICATE: '4000000000003220', DECLINED: '4000000000000002', INSUFFICIENT_FUNDS: '4000000000009995', EXPIRED: '4000000000000069', diff --git a/packages/functional-tests/pages/payments/base.ts b/packages/functional-tests/pages/payments/base.ts new file mode 100644 index 00000000000..67bfcf1df35 --- /dev/null +++ b/packages/functional-tests/pages/payments/base.ts @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Page, expect } from '@playwright/test'; +import { BaseTarget } from '../../lib/targets/base'; + +/** + * Shared locators and actions used by both CheckoutPage and UpgradePage. + * These map to DOM elements that appear on multiple payment flow pages + * (consent checkbox, success/error sections). + */ +export class BasePaymentPage { + constructor( + public page: Page, + readonly target: BaseTarget + ) {} + + // Consent checkbox (shared CheckoutForm component) + + get consentCheckbox() { + return this.page.locator( + 'input[type="checkbox"][name="confirm"][aria-required]' + ); + } + + // Success page + + get successHeading() { + return this.page.locator('#subscription-confirmation-heading'); + } + + get invoiceNumber() { + return this.page.getByText(/Invoice #/); + } + + get successActionButton() { + return this.page + .locator('section[aria-labelledby="subscription-confirmation-heading"]') + .locator('a[role="button"]'); + } + + // Error page + + get errorHeading() { + return this.page.locator('#page-information-heading'); + } + + get errorButton() { + return this.page.getByRole('link', { + name: /Contact Support|Manage my subscription|Try again/i, + }); + } + + // Shared actions + + /** + * Check the consent checkbox. Waits for it to become interactive + * before clicking. + */ + async checkConsent() { + await expect(this.consentCheckbox).toBeVisible({ timeout: 10_000 }); + await expect(this.consentCheckbox).not.toHaveAttribute( + 'aria-disabled', + 'true', + { timeout: 10_000 } + ); + await this.consentCheckbox.click(); + await expect(this.consentCheckbox).toBeChecked(); + } +} diff --git a/packages/functional-tests/pages/payments/checkout.ts b/packages/functional-tests/pages/payments/checkout.ts new file mode 100644 index 00000000000..ae50af2b6ff --- /dev/null +++ b/packages/functional-tests/pages/payments/checkout.ts @@ -0,0 +1,334 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect } from '@playwright/test'; +import { TestCardDefaults } from '../../lib/stripe-test-cards'; +import { BasePaymentPage } from './base'; + +export class CheckoutPage extends BasePaymentPage { + + // Start page — sign-in section (unauthenticated only) + + get signinHeading() { + return this.page.getByRole('heading', { name: /Sign in or create/ }); + } + + get emailInput() { + return this.page.getByTestId('email'); + } + + get signInContinueButton() { + return this.page.getByRole('button', { name: 'Continue', exact: true }); + } + + // Start page — payment section + + get paymentHeading() { + return this.page.getByTestId('header'); + } + + get checkoutForm() { + return this.page.getByRole('form', { name: 'Checkout form' }); + } + + get subscribeButton() { + return this.page.getByRole('button', { name: /Subscribe Now/i }); + } + + // Stripe PaymentElement (iframe matched by URL, same approach as legacy tests) + + private get stripeFrame() { + return this.page.frameLocator( + 'iframe[title*="Secure payment input frame"]' + ); + } + + get cardNumberInput() { + return this.stripeFrame.locator('[autocomplete="cc-number"]'); + } + + get cardExpiryInput() { + return this.stripeFrame.locator('[autocomplete="cc-exp"]'); + } + + get cardCvcInput() { + return this.stripeFrame.locator('[autocomplete="cc-csc"]'); + } + + get postalCodeInput() { + return this.stripeFrame.locator( + '[autocomplete="postal-code"], [name="postalCode"], [name="zip"]' + ); + } + + get stripeLinkCheckbox() { + return this.stripeFrame.getByText(/Save (?:my )?information for/i); + } + + get linkPhoneInput() { + return this.stripeFrame.locator('[name="linkMobilePhone"]'); + } + + // Processing page + + get processingSection() { + return this.page.getByTestId('payment-processing'); + } + + get loadingSpinner() { + return this.page.getByTestId('loading-spinner'); + } + + get processingHeading() { + return this.page.getByRole('heading', { + name: /process your payment/i, + }); + } + + // Success page (checkout-specific; shared locators are in BasePaymentPage) + + get paymentAmount() { + return this.page + .locator('section[aria-labelledby="subscription-confirmation-heading"]') + .getByText(/\$[\d.]+/); + } + + get productName() { + return this.page.getByTestId('plan-details-product'); + } + + // Error page (checkout-specific; shared locators are in BasePaymentPage) + + get errorBanner() { + return this.page.locator( + 'section[aria-labelledby="page-information-heading"]' + ); + } + + get retryButton() { + return this.page.getByRole('link', { name: /Try again/i }); + } + + // Location page (shown when geolocation can't determine tax address) + + get locationHeading() { + return this.page.locator('#location-page-heading'); + } + + get countrySelect() { + return this.page.locator('select[name="countryCode"]'); + } + + get locationPostalCodeInput() { + return this.page.getByTestId('postal-code'); + } + + get locationSaveButton() { + return this.page.getByTestId('tax-location-save-button'); + } + + /** + * Handle the location page if shown, then wait for the sign-in form. + * When geolocation can't resolve the user's location (e.g. CI), + * the flow goes: /new → /location → fill country/zip → /new → /start. + * When geolocation works (local), it goes directly to /start. + */ + async handleLocationIfNeeded(country = 'US', postalCode = '10001') { + // Wait for either the location page or the sign-in heading + const locationOrSignin = this.locationHeading.or(this.signinHeading); + await expect(locationOrSignin).toBeVisible({ timeout: 90_000 }); + + // If on the location page, fill it out + if (await this.locationHeading.isVisible()) { + await this.countrySelect.selectOption(country); + await this.locationPostalCodeInput.fill(postalCode); + await this.locationSaveButton.click(); + // After location submit, wait for redirect to checkout with sign-in + await expect(this.signinHeading).toBeVisible({ timeout: 30_000 }); + } + } + + // Actions + + /** + * Wait for the Stripe iframe and card number field to be ready. + * Retries until the frame exists and the field is visible. + */ + async waitForStripeReady() { + const stripeIframe = this.page.locator( + 'iframe[title*="Secure payment input frame"]' + ); + await expect(stripeIframe).toBeVisible({ timeout: 30_000 }); + await stripeIframe.scrollIntoViewIfNeeded(); + await expect(this.cardNumberInput).toBeAttached({ timeout: 10_000 }); + } + + /** + * Fill Stripe PaymentElement card fields inside the iframe. + * Uses pressSequentially to simulate real keystrokes, which + * Stripe's internal event handlers require to register field + * completion (fill() sets value synthetically and Stripe may + * not detect it). + */ + private async typeInStripeField( + locator: ReturnType, + value: string + ) { + await locator.click(); + await locator.pressSequentially(value, { delay: 50 }); + } + + /** + * Fill card fields and uncheck Stripe Link. + * Link always auto-checks after card details are filled, revealing + * additional email/phone fields. This method unchecks it for the + * standard (non-Link) checkout flow. + */ + async fillCard( + number: string, + exp = `${TestCardDefaults.EXP_MONTH}/${String(TestCardDefaults.EXP_YEAR).slice(-2)}`, + cvc = TestCardDefaults.CVC, + zip = '10001' + ) { + // Re-verify the Stripe iframe is ready (it may remount after + // the consent checkbox toggles the PaymentElement out of readOnly) + await this.waitForStripeReady(); + + await this.typeInStripeField(this.cardNumberInput, number); + await this.typeInStripeField(this.cardExpiryInput, exp); + await this.typeInStripeField(this.cardCvcInput, cvc); + await this.typeInStripeField(this.postalCodeInput, zip); + + // Link always auto-checks after card fill — uncheck it + await expect(this.stripeLinkCheckbox).toBeVisible({ timeout: 5_000 }); + await this.stripeLinkCheckbox.click(); + } + + /** + * Fill card fields and complete Stripe Link sign-up. + * Keeps the "Save my information" checkbox checked and fills the + * email and phone fields that Link requires. + */ + async fillCardWithLink( + number: string, + phone = '2015550123', + exp = `${TestCardDefaults.EXP_MONTH}/${String(TestCardDefaults.EXP_YEAR).slice(-2)}`, + cvc = TestCardDefaults.CVC, + zip = '10001' + ) { + await this.waitForStripeReady(); + + await this.typeInStripeField(this.cardNumberInput, number); + await this.typeInStripeField(this.cardExpiryInput, exp); + await this.typeInStripeField(this.cardCvcInput, cvc); + await this.typeInStripeField(this.postalCodeInput, zip); + + // Link always auto-checks — email is auto-filled, just fill phone + await expect(this.linkPhoneInput).toBeVisible({ timeout: 10_000 }); + await this.typeInStripeField(this.linkPhoneInput, phone); + } + + /** + * Click the Subscribe Now button. Waits for it to be enabled + * (aria-disabled="false") before clicking. + */ + async submit() { + await expect(this.subscribeButton).toHaveAttribute( + 'aria-disabled', + 'false', + { timeout: 30_000 } + ); + await this.subscribeButton.click(); + } + + /** + * Wait for the processing page to appear. + */ + async waitForProcessing(timeout = 30_000) { + await expect(this.processingSection).toBeVisible({ timeout }); + } + + /** + * Wait for the success page heading to become visible. + * First waits for processing to resolve to any terminal state, + * then asserts we landed on success specifically. + */ + async waitForSuccess(timeout = 90_000) { + await expect(this.page).toHaveURL(/success|error|needs_input/, { + timeout, + }); + await expect(this.page).toHaveURL(/success/); + await expect(this.successHeading).toBeVisible({ timeout: 30_000 }); + } + + /** + * Wait for the error page to become visible. + * First waits for processing to resolve to any terminal state, + * then asserts we landed on error specifically. + */ + async waitForError(timeout = 90_000) { + await expect(this.page).toHaveURL(/success|error|needs_input/, { + timeout, + }); + await expect(this.page).toHaveURL(/error/); + await expect(this.errorBanner).toBeVisible({ timeout: 10_000 }); + } + + /** + * Handle Stripe 3D Secure authentication challenge. + * The test card 4000000000003220 shows a Stripe-hosted 3DS dialog. + */ + async handle3dsChallenge() { + // After submit, the page navigates: /start -> /processing -> /needs_input + // The 3DS dialog is in nested iframes inside a top-layer overlay: + // div[data-react-aria-top-layer] > + // iframe[name*="__privateStripeFrame"] > + // iframe[name="stripe-challenge-frame"] > + // button#test-source-authorize-3ds ("COMPLETE") + await expect(this.page).toHaveURL(/success|error|needs_input/, { + timeout: 90_000, + }); + await expect(this.page).toHaveURL(/needs_input/); + + // The 3DS Complete button is in nested cross-origin iframes that + // Playwright's frameLocator can't reliably click. Use CDP to + // find the frame by URL and dispatch the click via evaluate. + // Retry in a loop and verify the page actually navigates away. + // Find and click the 3DS Complete button, then wait for the + // challenge frame to disappear (confirming 3DS was completed). + // eslint-disable-next-line playwright/no-wait-for-timeout -- 3DS + // challenge is in nested cross-origin iframes that Playwright's + // frameLocator can't reliably interact with. We use page.frames() + // + evaluate to click the button, with polling since there are no + // reliable DOM signals for cross-origin iframe availability. + let clicked = false; + for (let i = 0; i < 30; i++) { + const frames = this.page.frames(); + const challengeFrame = frames.find((f) => + f.url().includes('3d_secure_2_test') + ); + if (!challengeFrame) { + if (clicked) return; // Frame gone after click = success + await this.page.waitForTimeout(1000); // eslint-disable-line playwright/no-wait-for-timeout + continue; + } + try { + await challengeFrame.waitForLoadState('domcontentloaded'); + await challengeFrame.evaluate(() => { + const btn = document.querySelector( + '#test-source-authorize-3ds' + ) as HTMLButtonElement; + btn?.click(); + }); + clicked = true; + // Wait for the 3DS completion to propagate + await this.page.waitForTimeout(2000); // eslint-disable-line playwright/no-wait-for-timeout + } catch { + // Frame detached during interaction = click worked + if (clicked) return; + } + } + throw new Error('Failed to complete 3DS challenge'); + } +} diff --git a/packages/functional-tests/pages/payments/index.ts b/packages/functional-tests/pages/payments/index.ts new file mode 100644 index 00000000000..012f6a98318 --- /dev/null +++ b/packages/functional-tests/pages/payments/index.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Page } from '@playwright/test'; +import { BaseTarget } from '../../lib/targets/base'; +import { CheckoutPage } from './checkout'; + +export function create(page: Page, target: BaseTarget) { + return { + checkout: new CheckoutPage(page, target), + }; +} diff --git a/packages/functional-tests/pages/relier.ts b/packages/functional-tests/pages/relier.ts index 838427ac90d..5485b260721 100644 --- a/packages/functional-tests/pages/relier.ts +++ b/packages/functional-tests/pages/relier.ts @@ -88,6 +88,29 @@ export class RelierPage extends BaseLayout { return this.page.locator('.ready .prompt-none').click(); } + async clickSubscribeMonthly() { + await this.page + .getByRole('link', { name: 'SP3 - Sub to Pro 1m', exact: true }) + .click(); + await this.page.waitForURL( + (url) => !url.href.includes(this.target.relierUrl) + ); + } + + async clickSubscribe6Month() { + await this.page.getByRole('link', { name: 'SP3 - Sub to Pro 6m' }).click(); + await this.page.waitForURL( + (url) => !url.href.includes(this.target.relierUrl) + ); + } + + async clickSubscribe12Month() { + await this.page.getByRole('link', { name: 'SP3 - Sub to Pro 12m' }).click(); + await this.page.waitForURL( + (url) => !url.href.includes(this.target.relierUrl) + ); + } + async clickRequire2FA() { await this.page.getByText('Sign In (Require 2FA)').click(); return this.page.waitForURL(`${this.target.contentServerUrl}/**`); diff --git a/packages/functional-tests/scripts/start-payments-services.sh b/packages/functional-tests/scripts/start-payments-services.sh index 63ab2997c3e..4809ae4f06b 100755 --- a/packages/functional-tests/scripts/start-payments-services.sh +++ b/packages/functional-tests/scripts/start-payments-services.sh @@ -16,6 +16,7 @@ NODE_OPTIONS="--max-old-space-size=7168" NODE_ENV=test npx nx run-many \ --parallel=2 \ --verbose \ -p \ + 123done \ fxa-auth-server \ fxa-content-server \ fxa-profile-server \ diff --git a/packages/functional-tests/tests-payments-next/checkout.spec.ts b/packages/functional-tests/tests-payments-next/checkout.spec.ts index 62fd356a351..ea9136573a0 100644 --- a/packages/functional-tests/tests-payments-next/checkout.spec.ts +++ b/packages/functional-tests/tests-payments-next/checkout.spec.ts @@ -2,15 +2,305 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { expect, test } from '../lib/fixtures/standard'; +import { expect, test } from '../lib/fixtures/payments'; +import { StripeTestCards } from '../lib/stripe-test-cards'; + +// Each test creates its own account and cart — no shared state. +test.describe.configure({ retries: 2 }); test.describe('severity-1 #smoke', () => { - test('Unauthenticated checkout redirects to sign-in', async ({ - target, - page, - }) => { + test('Unauthenticated checkout page loads', async ({ target, page }) => { const checkoutUrl = `${target.paymentsNextUrl}/${target.paymentsTestOfferingId}/monthly/landing`; await page.goto(checkoutUrl); await expect(page).toHaveURL(new RegExp(target.contentServerUrl)); }); }); + +test.describe('severity-1 #smoke', () => { + test.setTimeout(180_000); + test.use({ viewport: { width: 1280, height: 1080 } }); + + test.beforeEach(async ({}, { project }) => { + test.skip( + project.name.includes('production'), + 'Checkout smoke tests are not run in production' + ); + }); + + test('Stripe checkout success (does not check Save information checkbox, etc.)', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Navigate from relier to checkout + await relier.goto(); + await relier.clickSubscribeMonthly(); + + // Wait for checkout start page with embedded sign-in form + await checkout.handleLocationIfNeeded(); + + // Use the checkout page's embedded sign-in form + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + // Redirected to FXA to complete sign-in + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + // Wait for redirect back to checkout start page (now authenticated) + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + // Wait for Stripe iframe to fully load before interacting + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + // Verify success + await checkout.waitForSuccess(); + await expect(checkout.successHeading).toContainText('check your email'); + await expect(checkout.invoiceNumber).toBeVisible(); + await expect(checkout.successActionButton).toBeVisible(); + }); + + test('Stripe checkout success (checks Save information checkbox, etc.)', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(); + await relier.clickSubscribeMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCardWithLink(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + await expect(checkout.successHeading).toContainText('check your email'); + }); + + test('Stripe checkout 3DS', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(); + await relier.clickSubscribeMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.THREE_DS_AUTHENTICATE); + await checkout.submit(); + + await checkout.handle3dsChallenge(); + + await checkout.waitForSuccess(); + await expect(checkout.successHeading).toContainText('check your email'); + }); + + test('Stripe checkout declined card - shows error and restart', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(); + await relier.clickSubscribeMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + // Wait for Stripe iframe to fully load before interacting + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.DECLINED); + await checkout.submit(); + + // Verify error page + await checkout.waitForError(); + await expect(checkout.errorHeading).toBeVisible(); + await expect(checkout.retryButton).toBeVisible(); + + // Restart flow — "Try again" navigates to /landing which triggers + // the OAuth redirect chain, creating a new cart at /start or /new + await checkout.retryButton.click(); + await expect(page).toHaveURL(/checkout.*start|\/new/, { timeout: 30_000 }); + }); +}); + +test.describe('severity-2', () => { + test.setTimeout(180_000); + test.use({ viewport: { width: 1280, height: 1080 } }); + + test.beforeEach(async ({}, { project }) => { + test.skip( + project.name.includes('production'), + 'Checkout tests are not run in production' + ); + }); + + test('Stripe checkout insufficient funds - shows error', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(); + await relier.clickSubscribeMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + // Wait for Stripe iframe to fully load before interacting + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.INSUFFICIENT_FUNDS); + await checkout.submit(); + + await checkout.waitForError(); + await expect(checkout.errorHeading).toContainText(/insufficient funds/i); + }); + + test('Stripe checkout expired card - shows error', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(); + await relier.clickSubscribeMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + // Wait for Stripe iframe to fully load before interacting + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.EXPIRED); + await checkout.submit(); + + await checkout.waitForError(); + await expect(checkout.errorHeading).toContainText( + /credit card has expired/i + ); + }); + + test('New user checkout with account creation', async ({ + target, + page, + pages: { relier, signup, confirmSignupCode }, + paymentPages: { checkout }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + // Navigate from relier to checkout + await relier.goto(); + await relier.clickSubscribeMonthly(); + + // Wait for checkout start page with embedded sign-in form + await checkout.handleLocationIfNeeded(); + + // Enter new email in the checkout sign-in form to trigger signup + await checkout.emailInput.fill(email); + await checkout.signInContinueButton.click(); + + // Redirected to FXA — complete signup flow + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signup.fillOutSignupForm(password); + + // Confirm signup code + await expect(page).toHaveURL(/confirm_signup_code/); + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + // After verification, should be redirected to checkout start (authenticated) + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + // Complete checkout + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + await expect(checkout.successHeading).toContainText('check your email'); + }); +});