diff --git a/tests/qit/e2e-runner.sh b/tests/qit/e2e-runner.sh index cb2d9eee450..3689bd82713 100755 --- a/tests/qit/e2e-runner.sh +++ b/tests/qit/e2e-runner.sh @@ -14,9 +14,6 @@ if [[ -f "$QIT_ROOT/config/local.env" ]]; then . "$QIT_ROOT/config/local.env" fi -# If QIT_BINARY is not set, default to ./vendor/bin/qit -QIT_BINARY=${QIT_BINARY:-./vendor/bin/qit} - echo "Running E2E tests..." # Change to project root directory to build plugin @@ -81,15 +78,8 @@ else echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" fi -# Change to QIT directory so qit.yml is automatically found -cd "$QIT_ROOT" - -# Convert relative QIT_BINARY path to absolute for directory change compatibility -if [[ "$QIT_BINARY" = ./* ]]; then - QIT_CMD="$WCP_ROOT/$QIT_BINARY" -else - QIT_CMD="$QIT_BINARY" -fi +# QIT CLI is installed via composer as a dev dependency +QIT_CMD="./vendor/bin/qit" # Build environment arguments for local development env_args=() @@ -105,11 +95,140 @@ if [[ -n "${E2E_JP_USER_TOKEN:-}" ]]; then env_args+=( --env "E2E_JP_USER_TOKEN=${E2E_JP_USER_TOKEN}" ) fi -# Run QIT E2E tests (qit.yml automatically loaded from current directory) -echo "Running QIT E2E tests for local development..." +# Determine the desired spec target. Defaults to the whole suite unless +# overridden via the first positional argument (if it is not an option) or +# the WCP_E2E_SPEC environment variable. +SPEC_TARGET=${WCP_E2E_SPEC:-tests/qit/e2e} +TEST_TAG="" +declare -a FORWARDED_ARGS=() + +# Parse arguments to extract spec target and optional --tag +while [[ $# -gt 0 ]]; do + case "$1" in + --tag=*) + TEST_TAG="${1#*=}" + shift + ;; + --tag) + TEST_TAG="$2" + shift 2 + ;; + --*) + FORWARDED_ARGS+=("$1") + shift + ;; + *) + # First non-option argument is the spec target + if [[ -z "${SPEC_TARGET_SET:-}" ]]; then + SPEC_TARGET="$1" + SPEC_TARGET_SET=1 + fi + shift + ;; + esac +done + +# Normalize paths to work from project root +# Handle various input formats and convert them to paths QIT can use +normalize_path() { + local input="$1" + + # If path exists as-is from project root, use it + if [[ -e "$input" ]]; then + echo "$input" + return 0 + fi + + # Try prefixing with tests/qit/ + if [[ -e "tests/qit/$input" ]]; then + echo "tests/qit/$input" + return 0 + fi + + # Try prefixing with tests/qit/e2e/ + if [[ -e "tests/qit/e2e/$input" ]]; then + echo "tests/qit/e2e/$input" + return 0 + fi + + # If it looks like it starts with e2e/, try tests/qit/e2e/ + if [[ "$input" == e2e/* ]] && [[ -e "tests/qit/$input" ]]; then + echo "tests/qit/$input" + return 0 + fi + + # If just a filename (no path separators), search for it in e2e directory + if [[ "$input" != */* ]]; then + local found + found=$(find tests/qit/e2e -name "$input" -type f | head -1) + if [[ -n "$found" ]]; then + echo "$found" + return 0 + fi + fi + + # Path not found + echo "$input" + return 1 +} + +SPEC_TARGET=$(normalize_path "$SPEC_TARGET") || { + echo "Unable to locate spec target: $SPEC_TARGET" >&2 + exit 1 +} + +# Determine if we're running a specific file or directory +PW_OPTIONS="" +if [[ -f "$SPEC_TARGET" ]]; then + # Running a specific spec file - pass it to Playwright via --pw_options + # QIT needs the e2e directory, Playwright needs the specific file + E2E_ROOT="tests/qit/e2e" + + # Ensure spec is within e2e directory + case "$SPEC_TARGET" in + "$E2E_ROOT"/*) + # Extract the path relative to e2e directory + PW_OPTIONS="${SPEC_TARGET#$E2E_ROOT/}" + SPEC_TARGET="$E2E_ROOT" + ;; + *) + echo "Specified spec file must reside within tests/qit/e2e" >&2 + exit 1 + ;; + esac +fi + +# Build the final command to execute QIT. +echo "Running QIT E2E tests for local development (target: ${SPEC_TARGET}${TEST_TAG:+ | tag: ${TEST_TAG}}${PW_OPTIONS:+ | pw_options: ${PW_OPTIONS}})..." -"$QIT_CMD" run:e2e woocommerce-payments ./e2e \ - --source "$WCP_ROOT/woocommerce-payments.zip" \ +QIT_CMD_ARGS=( + "$QIT_CMD" run:e2e woocommerce-payments "$SPEC_TARGET" + --config "$QIT_ROOT/qit.yml" + --source "$WCP_ROOT/woocommerce-payments.zip" "${env_args[@]}" +) + +# Add tag filter if specified +if [[ -n "$TEST_TAG" ]]; then + QIT_CMD_ARGS+=( --pw_test_tag="${TEST_TAG}" ) +fi + +if [[ -n "$PW_OPTIONS" ]]; then + if (( ${#FORWARDED_ARGS[@]} )); then + for arg in "${FORWARDED_ARGS[@]}"; do + if [[ "$arg" == --pw_options || "$arg" == --pw_options=* ]]; then + echo "Do not combine a spec file with manual --pw_options overrides." >&2 + exit 1 + fi + done + fi + QIT_CMD_ARGS+=( --pw_options "$PW_OPTIONS" ) +fi + +if (( ${#FORWARDED_ARGS[@]} )); then + QIT_CMD_ARGS+=( "${FORWARDED_ARGS[@]}" ) +fi + +"${QIT_CMD_ARGS[@]}" -echo "QIT E2E foundation tests completed!" +echo "QIT E2E tests completed!" diff --git a/tests/qit/e2e/basic.spec.js b/tests/qit/e2e/basic.spec.js deleted file mode 100644 index c8582492925..00000000000 --- a/tests/qit/e2e/basic.spec.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * External dependencies - */ -import { test, expect } from '@playwright/test'; -import qit from '/qitHelpers'; - -/** - * Simple QIT E2E test - bare minimum to verify QIT works - */ -test( 'Load home page', async ( { page } ) => { - await page.goto( '/' ); - - // Just check that we can load the page and title exists - await expect( page ).toHaveTitle( /.*/ ); -} ); - -/** - * Test admin authentication and WooPayments plugin access - */ -test( 'Access WooPayments as admin', async ( { page } ) => { - // Use QIT helper to login as admin - await qit.loginAsAdmin( page ); - - // Navigate to WooPayments settings - await page.goto( - '/wp-admin/admin.php?page=wc-admin&path=%2Fpayments%2Foverview' - ); - - // We should see the Payments admin route load - await expect( - page.locator( 'h1:not(.screen-reader-text)' ).first() - ).toContainText( /Settings|Payments|Overview/, { timeout: 15000 } ); - - // Check that we can successfully load the WooPayments interface - // Either we get the overview (if fully connected) OR the onboarding. - const isOnboarding = page.url().includes( 'onboarding' ); - const isOverview = page.url().includes( 'payments' ); - - // We should be on either the onboarding or overview page (both indicate success) - expect( isOnboarding || isOverview ).toBe( true ); - - // If we're on onboarding, it should be functional (not errored) - if ( isOnboarding ) { - // The onboarding page should load without errors - await expect( page.locator( 'body' ) ).not.toHaveText( - /500|404|Fatal error/ - ); - } -} ); - -/** - * Test plugin activation and basic WooCommerce functionality - */ -test( 'Verify WooCommerce Payments plugin activation', async ( { page } ) => { - await qit.loginAsAdmin( page ); - - // Check plugins page to verify WooCommerce Payments is active - await page.goto( '/wp-admin/plugins.php' ); - - // Look for the WooCommerce Payments plugin row (exclude update row) - const pluginRow = page.locator( - 'tr[data-plugin*="woocommerce-payments"]:not(.plugin-update-tr)' - ); - await expect( pluginRow ).toBeVisible(); - - // Verify it shows as activated - await expect( pluginRow.locator( '.deactivate' ) ).toBeVisible(); -} ); diff --git a/tests/qit/e2e/bootstrap/setup.sh b/tests/qit/e2e/bootstrap/setup.sh index b1c2861ed0d..c5730003eef 100755 --- a/tests/qit/e2e/bootstrap/setup.sh +++ b/tests/qit/e2e/bootstrap/setup.sh @@ -11,27 +11,90 @@ echo "Setting up WooPayments for E2E testing..." # Ensure environment is marked as development so dev-only CLI commands are available wp config set WP_ENVIRONMENT_TYPE development --quiet 2>/dev/null || true -# Create a test product for payment testing -PRODUCT_ID=$(wp post create \ - --post_title="Test Product for Payments" \ - --post_content="A simple test product for QIT payment testing" \ - --post_status=publish \ - --post_type=product \ - --porcelain) - -# Set product meta data properly -wp post meta update $PRODUCT_ID _price "10.00" -wp post meta update $PRODUCT_ID _regular_price "10.00" -wp post meta update $PRODUCT_ID _virtual "yes" -wp post meta update $PRODUCT_ID _manage_stock "no" - -# Ensure WooCommerce checkout page exists and is properly configured -wp option update woocommerce_checkout_page_id $(wp post list --post_type=page --post_name=checkout --field=ID --format=ids) +echo "Installing WordPress importer for sample data..." +if ! wp plugin is-installed wordpress-importer >/dev/null 2>&1; then + wp plugin install wordpress-importer --activate +else + wp plugin activate wordpress-importer +fi + +WC_SAMPLE_DATA_PATH=$(wp eval 'echo trailingslashit( WP_CONTENT_DIR ) . "plugins/woocommerce/sample-data/sample_products.xml";' 2>/dev/null) +if [ -z "$WC_SAMPLE_DATA_PATH" ]; then + echo "Unable to resolve WooCommerce sample data path; skipping import." +else + if [ -f "$WC_SAMPLE_DATA_PATH" ]; then + echo "Importing WooCommerce sample products from $WC_SAMPLE_DATA_PATH ..." + wp import "$WC_SAMPLE_DATA_PATH" --authors=skip + else + echo "Sample data file not found at $WC_SAMPLE_DATA_PATH; skipping import." + fi +fi + +# Ensure WooCommerce core pages exist and capture IDs +echo "Ensuring WooCommerce core pages exist..." +wp wc --user=admin tool run install_pages >/dev/null 2>&1 || true + +CHECKOUT_PAGE_ID=$(wp option get woocommerce_checkout_page_id) +CART_PAGE_ID=$(wp option get woocommerce_cart_page_id) + +if [ -z "$CHECKOUT_PAGE_ID" ] || [ "$CHECKOUT_PAGE_ID" = "0" ]; then + CHECKOUT_PAGE_ID=$(wp post list --post_type=page --name=checkout --field=ID --format=ids) +fi + +if [ -z "$CART_PAGE_ID" ] || [ "$CART_PAGE_ID" = "0" ]; then + CART_PAGE_ID=$(wp post list --post_type=page --name=cart --field=ID --format=ids) +fi + +# Default to shortcode-based templates for classic checkout/cart flows +if [ -n "${CHECKOUT_PAGE_ID}" ] && [ -n "${CART_PAGE_ID}" ]; then + echo "Configuring classic checkout and cart pages..." + + CHECKOUT_SHORTCODE="[woocommerce_checkout]" + CART_SHORTCODE="[woocommerce_cart]" + + # Provision a dedicated WooCommerce Blocks checkout clone if it does not exist yet + CHECKOUT_WCB_PAGE_ID=$(wp post list --post_type=page --name=checkout-wcb --field=ID --format=ids) + if [ -z "$CHECKOUT_WCB_PAGE_ID" ]; then + echo "Creating WooCommerce Blocks checkout page..." + CHECKOUT_WCB_PAGE_ID=$(wp post create \ + --from-post="$CHECKOUT_PAGE_ID" \ + --post_type=page \ + --post_title="Checkout WCB" \ + --post_status=publish \ + --post_name="checkout-wcb" \ + --porcelain) + else + echo "WooCommerce Blocks checkout page already exists (ID: $CHECKOUT_WCB_PAGE_ID)" + fi + + wp post update "$CART_PAGE_ID" --post_content="$CART_SHORTCODE" + wp post update "$CHECKOUT_PAGE_ID" --post_content="$CHECKOUT_SHORTCODE" + wp post meta update "$CHECKOUT_PAGE_ID" _wp_page_template "template-fullwidth.php" >/dev/null 2>&1 || true + if [ -n "$CHECKOUT_WCB_PAGE_ID" ]; then + wp post meta update "$CHECKOUT_WCB_PAGE_ID" _wp_page_template "template-fullwidth.php" >/dev/null 2>&1 || true + fi +fi + +# Double check option points to the classic checkout page +if [ -n "$CHECKOUT_PAGE_ID" ]; then + wp option update woocommerce_checkout_page_id "$CHECKOUT_PAGE_ID" +fi # Configure WooCommerce for testing wp option update woocommerce_currency "USD" wp option update woocommerce_enable_guest_checkout "yes" wp option update woocommerce_force_ssl_checkout "no" +wp option set woocommerce_checkout_company_field "optional" --quiet 2>/dev/null || true +wp option set woocommerce_coming_soon "no" --quiet 2>/dev/null || true +wp option set woocommerce_store_pages_only "no" --quiet 2>/dev/null || true + +# Ensure Storefront theme is active for consistent storefront markup +if ! wp theme is-installed storefront > /dev/null 2>&1; then + wp theme install storefront --force +fi +wp theme activate storefront + + # Create a test customer wp user create testcustomer test@example.com \ @@ -84,4 +147,17 @@ wp option set wcpaydev_proxy 0 --quiet 2>/dev/null || true # Disable onboarding redirect for E2E testing wp option set wcpay_should_redirect_to_onboarding 0 --quiet 2>/dev/null || true +echo "Dismissing fraud protection welcome tour in E2E tests" +wp option set wcpay_fraud_protection_welcome_tour_dismissed 1 --quiet 2>/dev/null || true + +echo "Resetting coupons and creating standard free coupon" +wp post delete $(wp post list --post_type=shop_coupon --format=ids) --force --quiet 2>/dev/null || true +wp db query "DELETE FROM wp_postmeta WHERE post_id NOT IN (SELECT ID FROM wp_posts)" --skip-column-names 2>/dev/null || true +wp wc --user=admin shop_coupon create \ + --code=free \ + --amount=100 \ + --discount_type=percent \ + --individual_use=true \ + --free_shipping=true + echo "WooPayments configuration completed" diff --git a/tests/qit/e2e/checkout.spec.js b/tests/qit/e2e/checkout.spec.js deleted file mode 100644 index 301c0b2de3e..00000000000 --- a/tests/qit/e2e/checkout.spec.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * External dependencies - */ -const { test, expect } = require( '@playwright/test' ); -import qit from '/qitHelpers'; - -/** - * WooPayments Connection Validation Tests - * - * These tests verify that WooPayments is properly connected and configured - * in the QIT E2E testing environment. They validate the core connection without - * testing actual checkout functionality. - */ -test.describe( 'WooPayments Connection Status', () => { - test( 'should verify WooPayments is connected (not showing Connect screen)', async ( { - page, - } ) => { - // Login as admin first - await qit.loginAsAdmin( page ); - - // Navigate directly to WooPayments settings - await page.goto( - '/wp-admin/admin.php?page=wc-settings&tab=checkout§ion=woocommerce_payments' - ); - - const pageContent = await page.textContent( 'body' ); - - // If we see primary "Connect" buttons, we're NOT connected - if ( - pageContent.includes( 'Connect your store' ) || - pageContent.includes( 'Connect WooPayments' ) || - pageContent.includes( 'Set up WooPayments' ) - ) { - throw new Error( - 'WooPayments is NOT connected - showing Connect screen' - ); - } - - // Look for connected account configuration options - // These elements only appear when WC Payments is connected and configured - const hasConnectedFeatures = - pageContent.includes( 'Enable WooPayments' ) || - pageContent.includes( 'Enable/disable' ) || - pageContent.includes( 'Payment methods' ) || - pageContent.includes( 'Capture charges automatically' ) || - pageContent.includes( 'Manual capture' ) || - pageContent.includes( 'Test mode' ) || - pageContent.includes( 'Debug mode' ); - - if ( ! hasConnectedFeatures ) { - throw new Error( - 'No WooPayments configuration options found - may not be properly connected' - ); - } - - expect( hasConnectedFeatures ).toBe( true ); - - // Additional verification: Should not see primary connection prompts - expect( pageContent ).not.toContain( 'Connect your store' ); - expect( pageContent ).not.toContain( 'Connect WooPayments' ); - } ); - - test( 'should verify account data is fetched from server', async ( { - page, - } ) => { - // Login as admin first - await qit.loginAsAdmin( page ); - - // Navigate to WooPayments overview page to check account status - await page.goto( - '/wp-admin/admin.php?page=wc-admin&path=%2Fpayments%2Foverview' - ); - - const pageContent = await page.textContent( 'body' ); - - // Account should be connected via Jetpack - // Look for specific indicators that account data was fetched from test account - const hasAccountData = - pageContent.includes( 'Test account' ) || - pageContent.includes( 'Live account' ) || - pageContent.includes( 'Account status' ) || - pageContent.includes( 'Payments' ) || - pageContent.includes( 'Overview' ) || - pageContent.includes( 'Deposits' ) || - pageContent.includes( 'Transactions' ) || - // Test account specific indicators - pageContent.includes( 'acct_' ) || // Stripe account ID - pageContent.includes( 'Your store is connected' ) || - pageContent.includes( 'Payment methods' ); - - if ( ! hasAccountData ) { - // Check if we're seeing an error or connection issue - if ( - pageContent.includes( 'Connect' ) || - pageContent.includes( 'Set up' ) - ) { - throw new Error( - 'Account is not connected - showing setup/connect screen' - ); - } - - if ( - pageContent.includes( 'Error' ) || - pageContent.includes( 'Unable to connect' ) - ) { - throw new Error( - 'Connection error detected - account data not fetched' - ); - } - - throw new Error( - 'No account data indicators found - connection may have failed' - ); - } - - // Verify Jetpack connection is working - expect( hasAccountData ).toBe( true ); - - // Additional check: Should not see connection errors - expect( pageContent ).not.toContain( 'Unable to connect' ); - expect( pageContent ).not.toContain( 'Connection failed' ); - } ); -} ); diff --git a/tests/qit/e2e/config/default.ts b/tests/qit/e2e/config/default.ts new file mode 100644 index 00000000000..b04f99d4e66 --- /dev/null +++ b/tests/qit/e2e/config/default.ts @@ -0,0 +1,362 @@ +/** + * Internal dependencies + */ +import { users } from './users.json'; + +export const config = { + users: { + ...users, + // the Atomic site is a live environment, and we're storing the user passwords as secrets + // this is the only environment that is technically publicly accessible (for the GH action runners), + // so it's semi-important that we don't use plaintext passwords. + admin: { + ...users.admin, + password: + process.env.E2E_ADMIN_USER_PASSWORD || users.admin.password, + }, + customer: { + ...users.customer, + password: + process.env.E2E_CUSTOMER_USER_PASSWORD || + users.customer.password, + }, + 'subscriptions-customer': { + ...users[ 'subscriptions-customer' ], + password: + process.env.E2E_SUBSCRIPTIONS_CUSTOMER_USER_PASSWORD || + users[ 'subscriptions-customer' ].password, + }, + editor: { + ...users.editor, + password: + process.env.E2E_EDITOR_USER_PASSWORD || users.editor.password, + }, + }, + products: { + cap: { + name: 'Cap', + pageNumber: 1, + }, + belt: { + name: 'Belt', + pageNumber: 1, + }, + simple: { + name: 'Beanie', + pageNumber: 1, + }, + sunglasses: { + name: 'Sunglasses', + pageNumber: 2, + }, + variable: { + name: 'Variable Product with Three Variations', + pageNumber: 1, + }, + grouped: { + name: 'Grouped Product with Three Children', + pageNumber: 1, + }, + hoodie_with_logo: { + name: 'Hoodie with Logo', + pageNumber: 1, + }, + subscription_signup_fee: { + name: 'Subscription signup fee product', + pageNumber: 2, + }, + subscription_no_signup_fee: { + name: 'Subscription no signup fee product', + pageNumber: 2, + }, + subscription_free_trial: { + name: 'Subscription free trial product', + pageNumber: 2, + }, + } as Record< string, Product >, + addresses: { + admin: { + store: { + firstname: 'I am', + lastname: 'Admin', + company: 'Automattic', + country: 'United States (US)', + country_code: 'US', + addressfirstline: '60 29th Street #343', + addresssecondline: 'store', + countryandstate: 'United States (US) — California', + city: 'San Francisco', + state: 'CA', + postcode: '94110', + email: 'e2e-wcpay-subscriptions-customer@woocommerce.com', + }, + }, + customer: { + billing: { + firstname: 'I am', + lastname: 'Customer', + company: 'Automattic', + country: 'United States (US)', + country_code: 'US', + addressfirstline: '60 29th Street #343', + addresssecondline: 'billing', + city: 'San Francisco', + state: 'CA', + postcode: '94110', + phone: '123456789', + email: 'e2e-wcpay-customer@woocommerce.com', + }, + shipping: { + firstname: 'I am', + lastname: 'Recipient', + company: 'Automattic', + country: 'United States (US)', + country_code: 'US', + addressfirstline: '60 29th Street #343', + addresssecondline: 'shipping', + city: 'San Francisco', + state: 'CA', + postcode: '94110', + phone: '123456789', + email: 'e2e-wcpay-customer@woocommerce.com', + }, + }, + 'upe-customer': { + billing: { + be: { + firstname: 'I am', + lastname: 'Customer', + company: 'Automattic', + country: 'Belgium', + country_code: 'BE', + addressfirstline: 'Rue de l’Étuve, 1000', + addresssecondline: 'billing-be', + city: 'Bruxelles', + postcode: '1000', + phone: '123456789', + email: 'e2e-wcpay-customer@woocommerce.com', + }, + de: { + firstname: 'I am', + lastname: 'Customer', + company: 'Automattic', + country: 'Germany', + country_code: 'DE', + addressfirstline: 'Petuelring 130', + addresssecondline: 'billing-de', + city: 'München', + postcode: '80809', + state: 'DE-BY', + phone: '123456789', + email: 'e2e-wcpay-customer@woocommerce.com', + }, + }, + }, + 'subscriptions-customer': { + billing: { + firstname: 'I am', + lastname: 'Subscriptions Customer', + company: 'Automattic', + country: 'United States (US)', + country_code: 'US', + addressfirstline: '60 29th Street #343', + addresssecondline: 'billing', + city: 'San Francisco', + state: 'CA', + postcode: '94110', + phone: '123456789', + email: 'e2e-wcpay-subscriptions-customer@woocommerce.com', + }, + shipping: { + firstname: 'I am', + lastname: 'Subscriptions Recipient', + company: 'Automattic', + country: 'United States (US)', + country_code: 'US', + addressfirstline: '60 29th Street #343', + addresssecondline: 'shipping', + city: 'San Francisco', + state: 'CA', + postcode: '94110', + phone: '123456789', + email: 'e2e-wcpay-subscriptions-customer@woocommerce.com', + }, + }, + }, + cards: { + basic: { + number: '4242424242424242', + expires: { + month: '02', + year: '45', + }, + cvc: '424', + label: 'Visa ending in 4242', + }, + basic2: { + number: '4111111111111111', + expires: { + month: '11', + year: '45', + }, + cvc: '123', + label: 'Visa ending in 1111', + }, + basic3: { + number: '378282246310005', + expires: { + month: '12', + year: '45', + }, + cvc: '1234', + label: 'American Express ending in 0005', + }, + '3ds': { + number: '4000002760003184', + expires: { + month: '03', + year: '45', + }, + cvc: '525', + label: 'Visa ending in 3184', + }, + '3dsOTP': { + number: '4000002500003155', + expires: { + month: '04', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 3155', + }, + '3ds2': { + number: '4000000000003220', + expires: { + month: '04', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 3220', + }, + 'disputed-fraudulent': { + number: '4000000000000259', + expires: { + month: '05', + year: '45', + }, + cvc: '525', + label: 'Visa ending in 0259', + }, + 'disputed-unreceived': { + number: '4000000000002685', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 2685', + }, + declined: { + number: '4000000000000002', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 0002', + }, + 'declined-funds': { + number: '4000000000009995', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 9995', + }, + 'declined-incorrect': { + number: '4242424242424241', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 4241', + }, + 'declined-expired': { + number: '4000000000000069', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 0069', + }, + 'declined-cvc': { + number: '4000000000000127', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 0127', + }, + 'declined-processing': { + number: '4000000000000119', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 0119', + }, + 'declined-3ds': { + number: '4000008400001629', + expires: { + month: '06', + year: '45', + }, + cvc: '626', + label: 'Visa ending in 1629', + }, + 'invalid-exp-date': { + number: '4242424242424242', + expires: { + month: '11', + year: '12', + }, + cvc: '123', + label: 'Visa ending in 4242', + }, + 'invalid-cvv-number': { + number: '4242424242424242', + expires: { + month: '06', + year: '45', + }, + cvc: '11', + label: 'Visa ending in 4242', + }, + }, + onboardingwizard: { + industry: 'Test industry', + numberofproducts: '1 - 10', + sellingelsewhere: 'No', + }, + settings: { + shipping: { + zonename: 'United States', + zoneregions: 'United States (US)', + shippingmethod: 'Free shipping', + }, + }, +}; + +export type CustomerAddress = Omit< + typeof config.addresses.customer.billing, + 'state' +> & { + state?: string; +}; + +export type Product = { name: string; pageNumber: number }; diff --git a/tests/qit/e2e/config/users.json b/tests/qit/e2e/config/users.json new file mode 100644 index 00000000000..459c2bd2a2b --- /dev/null +++ b/tests/qit/e2e/config/users.json @@ -0,0 +1,27 @@ +{ + "users": { + "admin": { + "username": "admin", + "password": "password", + "email": "e2e-qit-wcpay-admin@woocommerce.com" + }, + "customer": { + "username": "customer", + "password": "password", + "email": "e2e-qit-wcpay-customer@woocommerce.com" + }, + "subscriptions-customer": { + "username": "subscriptions-customer", + "password": "password", + "email": "e2e-qit-wcpay-customer@woocommerce.com" + }, + "guest": { + "email": "e2e-qit-wcpay-guest@woocommerce.com" + }, + "editor": { + "username": "editor", + "password": "password", + "email": "e2e-qit-wcpay-editor@woocommerce.com" + } + } +} diff --git a/tests/qit/e2e/fixtures/auth.ts b/tests/qit/e2e/fixtures/auth.ts new file mode 100644 index 00000000000..0fa22f4940c --- /dev/null +++ b/tests/qit/e2e/fixtures/auth.ts @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import { + test as base, + Browser, + BrowserContext, + Page, + StorageState, +} from '@playwright/test'; +import qit from '/qitHelpers'; + +/** + * Internal dependencies + */ + +import { config } from '../config/default'; + +export type Role = 'admin' | 'customer' | 'editor'; + +type RoleConfig = { + login: ( page: Page ) => Promise< void >; +}; + +const roles: Record< Role, RoleConfig > = { + admin: { + login: ( page ) => qit.loginAsAdmin( page ), + }, + customer: { + login: async ( page ) => { + const { username, password } = config.users.customer; + await qit.loginAs( page, username, password ); + }, + }, + editor: { + login: async ( page ) => { + const { username, password } = config.users.editor; + await qit.loginAs( page, username, password ); + }, + }, +}; + +const stateCache = new Map< Role, Promise< StorageState > >(); + +const getState = ( browser: Browser, role: Role ) => { + if ( ! stateCache.has( role ) ) { + stateCache.set( + role, + ( async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + await roles[ role ].login( page ); + await page.waitForLoadState( 'domcontentloaded' ); + const state = await context.storageState(); + await context.close(); + return state; + } )() + ); + } + + return stateCache.get( role )!; +}; + +type Fixtures = { + adminContext: BrowserContext; + adminPage: Page; + customerContext: BrowserContext; + customerPage: Page; + editorContext: BrowserContext; + editorPage: Page; +}; + +export const test = base.extend< Fixtures >( { + adminContext: async ( { browser }, use ) => { + const context = await browser.newContext( { + storageState: await getState( browser, 'admin' ), + } ); + await use( context ); + await context.close(); + }, + adminPage: async ( { adminContext }, use ) => { + const page = await adminContext.newPage(); + await use( page ); + }, + customerContext: async ( { browser }, use ) => { + const context = await browser.newContext( { + storageState: await getState( browser, 'customer' ), + } ); + await use( context ); + await context.close(); + }, + customerPage: async ( { customerContext }, use ) => { + const page = await customerContext.newPage(); + await use( page ); + }, + editorContext: async ( { browser }, use ) => { + const context = await browser.newContext( { + storageState: await getState( browser, 'editor' ), + } ); + await use( context ); + await context.close(); + }, + editorPage: async ( { editorContext }, use ) => { + const page = await editorContext.newPage(); + await use( page ); + }, +} ); + +export const expect = test.expect; + +export const getAuthState = ( browser: Browser, role: Role ) => + getState( browser, role ); diff --git a/tests/qit/e2e/specs/basic.spec.ts b/tests/qit/e2e/specs/basic.spec.ts new file mode 100644 index 00000000000..708ea2beef8 --- /dev/null +++ b/tests/qit/e2e/specs/basic.spec.ts @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { test, expect } from '../fixtures/auth'; + +test.describe( + 'A basic set of tests to ensure WP, wp-admin and my-account load', + () => { + test( 'Load the home page', async ( { page } ) => { + await page.goto( '/' ); + const title = page.locator( 'h1.site-title' ); + await expect( title ).toHaveText( + /WooCommerce Core E2E Test Suite/i + ); + } ); + + test.describe( 'Sign in as admin', () => { + test( 'Load Payments Overview', async ( { adminPage } ) => { + await adminPage.goto( + '/wp-admin/admin.php?page=wc-admin&path=/payments/overview' + ); + await adminPage.waitForLoadState( 'domcontentloaded' ); + await expect( + adminPage.getByRole( 'heading', { name: 'Overview' } ) + ).toBeVisible(); + } ); + } ); + + test.describe( 'Sign in as customer', () => { + test( 'Load customer my account page', async ( { + customerPage, + } ) => { + await customerPage.goto( '/my-account' ); + const title = customerPage.locator( 'h1.entry-title' ); + await expect( title ).toHaveText( 'My account' ); + } ); + } ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/alipay-checkout-purchase.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/alipay-checkout-purchase.spec.ts new file mode 100644 index 00000000000..ddc9ee70749 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/alipay-checkout-purchase.spec.ts @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page } from '@playwright/test'; +import { test, expect, getAuthState } from '../../../fixtures/auth'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as merchant from '../../../utils/merchant'; +import * as shopper from '../../../utils/shopper'; +import { goToCheckoutWCB } from '../../../utils/shopper-navigation'; + +test.describe( 'Alipay Checkout', { tag: '@shopper' }, () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + await merchant.enablePaymentMethods( merchantPage, [ 'alipay' ] ); + } ); + + test.afterAll( async () => { + if ( shopperPage ) { + await shopper.emptyCart( shopperPage ); + } + + if ( merchantPage ) { + await merchant.disablePaymentMethods( merchantPage, [ 'alipay' ] ); + } + + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + test( + 'checkout on shortcode checkout page', + { tag: '@critical' }, + async () => { + await shopper.setupProductCheckout( + shopperPage, + [ [ config.products.belt, 1 ] ], + config.addresses.customer.billing + ); + + await shopper.selectPaymentMethod( shopperPage, 'Alipay' ); + await shopper.placeOrder( shopperPage ); + + await expect( + shopperPage.getByText( /Alipay test payment page/ ) + ).toBeVisible(); + + await shopperPage.getByText( 'Authorize Test Payment' ).click(); + + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + await expect( + shopperPage.getByRole( 'img', { + name: 'Alipay', + } ) + ).toBeVisible(); + } + ); + + test.describe( + 'checkout on block-based checkout page', + { tag: [ '@critical', '@blocks' ] }, + () => { + test( 'completes payment successfully', async () => { + await shopper.setupProductCheckout( + shopperPage, + [ [ config.products.cap, 1 ] ], + config.addresses.customer.billing + ); + await goToCheckoutWCB( shopperPage ); + await shopper.fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + + await shopperPage + .getByRole( 'radio', { + name: 'Alipay', + } ) + .click(); + + await shopper.placeOrderWCB( shopperPage, false ); + + await expect( + shopperPage.getByText( /Alipay test payment page/ ) + ).toBeVisible(); + + await shopperPage.getByText( 'Authorize Test Payment' ).click(); + + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + await expect( + shopperPage.getByRole( 'img', { + name: 'Alipay', + } ) + ).toBeVisible(); + } ); + } + ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/klarna-checkout-purchase.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/klarna-checkout-purchase.spec.ts new file mode 100644 index 00000000000..cba29347043 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/klarna-checkout-purchase.spec.ts @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page } from '@playwright/test'; +import { test, expect, getAuthState } from '../../../fixtures/auth'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as merchant from '../../../utils/merchant'; +import * as shopper from '../../../utils/shopper'; +import { goToProductPageBySlug } from '../../../utils/shopper-navigation'; + +test.describe( 'Klarna Checkout', { tag: '@shopper' }, () => { + let merchantContext: BrowserContext; + let shopperContext: BrowserContext; + let merchantPage: Page; + let shopperPage: Page; + let wasMulticurrencyEnabled = false; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + wasMulticurrencyEnabled = await merchant.isMulticurrencyEnabled( + merchantPage + ); + if ( wasMulticurrencyEnabled ) { + await merchant.deactivateMulticurrency( merchantPage ); + } + await merchant.enablePaymentMethods( merchantPage, [ 'klarna' ] ); + } ); + + test.afterAll( async () => { + if ( shopperPage ) { + await shopper.emptyCart( shopperPage ); + } + if ( merchantPage ) { + await merchant.disablePaymentMethods( merchantPage, [ 'klarna' ] ); + if ( wasMulticurrencyEnabled ) { + await merchant.activateMulticurrency( merchantPage ); + } + } + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + test( 'shows the message in the product page', async () => { + await goToProductPageBySlug( shopperPage, 'belt' ); + + // Since we can't control the exact contents of the iframe, we just make sure it's there. + await expect( + shopperPage + .frameLocator( '#payment-method-message iframe' ) + .locator( 'body' ) + ).not.toBeEmpty(); + } ); + + test( + 'allows to use Klarna as a payment method', + { tag: '@critical' }, + async () => { + const klarnaBillingAddress = { + ...config.addresses.customer.billing, + email: 'customer@email.us', + phone: '+13106683312', + firstname: 'Test', + lastname: 'Person-us', + }; + + await shopper.setupProductCheckout( + shopperPage, + [ [ config.products.belt, 1 ] ], + klarnaBillingAddress + ); + await shopper.selectPaymentMethod( shopperPage, 'Klarna' ); + await shopper.placeOrder( shopperPage ); + + // Since we don't control the HTML in the Klarna playground page, + // verifying the redirect is all we can do consistently. + await expect( shopperPage ).toHaveURL( /.*klarna\.com/ ); + } + ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/multi-currency-checkout.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/multi-currency-checkout.spec.ts new file mode 100644 index 00000000000..71f94695026 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/multi-currency-checkout.spec.ts @@ -0,0 +1,233 @@ +/** + * External dependencies + */ +import { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import { config } from '../../../config/default'; +import * as merchant from '../../../utils/merchant'; +import * as shopper from '../../../utils/shopper'; +import * as navigation from '../../../utils/shopper-navigation'; +import { isUIUnblocked } from '../../../utils/helpers'; + +test.describe( 'Multi-currency checkout', { tag: '@shopper' }, () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + let wasMulticurrencyEnabled: boolean; + let originalEnabledCurrencies: string[]; + const currenciesOrders: Record< string, string | null > = { + USD: null, + EUR: null, + }; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + originalEnabledCurrencies = await merchant.getEnabledCurrenciesSnapshot( + merchantPage + ); + wasMulticurrencyEnabled = await merchant.activateMulticurrency( + merchantPage + ); + await merchant.addCurrency( merchantPage, 'EUR' ); + } ); + + test.afterAll( async () => { + await merchant.restoreCurrencies( + merchantPage, + originalEnabledCurrencies + ); + await shopper.emptyCart( shopperPage ); + + if ( ! wasMulticurrencyEnabled ) { + await merchant.deactivateMulticurrency( merchantPage ); + } + + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + test.describe( 'Checkout with multiple currencies', () => { + for ( const currency of Object.keys( currenciesOrders ) ) { + test( `checkout with ${ currency }`, async () => { + await test.step( `pay with ${ currency }`, async () => { + currenciesOrders[ + currency + ] = await shopper.placeOrderWithCurrency( + shopperPage, + currency + ); + } ); + + await test.step( + `should display ${ currency } in the order received page`, + async () => { + await expect( + shopperPage.locator( + '.woocommerce-order-overview__total' + ) + ).toHaveText( new RegExp( currency ) ); + } + ); + + await test.step( + `should display ${ currency } in the customer order page`, + async () => { + const orderId = currenciesOrders[ currency ]; + expect( orderId ).toBeTruthy(); + if ( ! orderId ) { + return; + } + await navigation.goToOrder( shopperPage, orderId ); + await expect( + shopperPage.getByRole( 'cell', { + name: /\$?\d\d[\.,]\d\d\s€?\s?[A-Z]{3}/, + } ) + ).toHaveText( new RegExp( currency ) ); + } + ); + } ); + } + } ); + + test.describe( 'My account', () => { + test( 'should display the correct currency in the my account order history table', async () => { + await navigation.goToOrders( shopperPage ); + + for ( const [ currency, orderId ] of Object.entries( + currenciesOrders + ) ) { + if ( ! orderId ) { + continue; + } + + await expect( + shopperPage.locator( 'tr' ).filter( { + has: shopperPage.getByText( `#${ orderId }` ), + } ) + ).toHaveText( new RegExp( currency ) ); + } + } ); + } ); + + test.describe( 'Available payment methods', () => { + let originalStoreCurrency = 'USD'; + + test.beforeAll( async () => { + originalStoreCurrency = await merchant.getDefaultCurrency( + merchantPage + ); + await merchant.enablePaymentMethods( merchantPage, [ + 'Bancontact', + ] ); + } ); + + test.afterAll( async () => { + await merchant.disablePaymentMethods( merchantPage, [ + 'Bancontact', + ] ); + await merchant.setDefaultCurrency( + merchantPage, + originalStoreCurrency + ); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + test( 'should display EUR payment methods when switching to EUR and default is USD', async () => { + await merchant.setDefaultCurrency( merchantPage, 'USD' ); + + await shopper.addToCartFromShopPage( + shopperPage, + config.products.simple, + 'USD' + ); + await navigation.goToCheckout( shopperPage ); + await shopper.fillBillingAddress( + shopperPage, + config.addresses[ 'upe-customer' ].billing.be + ); + await shopper.selectPaymentMethod( shopperPage ); + await expect( + shopperPage.getByText( 'Bancontact' ) + ).not.toBeVisible(); + + await navigation.goToCheckout( shopperPage, { + currency: 'EUR', + } ); + await shopper.fillBillingAddress( + shopperPage, + config.addresses[ 'upe-customer' ].billing.be + ); + await shopper.selectPaymentMethod( shopperPage ); + await expect( shopperPage.getByText( 'Bancontact' ) ).toBeVisible(); + + await isUIUnblocked( shopperPage ); + await shopperPage.getByText( 'Bancontact' ).click(); + await shopperPage.waitForSelector( + '#payment_method_woocommerce_payments_bancontact:checked', + { timeout: 10_000 } + ); + + await shopper.focusPlaceOrderButton( shopperPage ); + await shopper.placeOrder( shopperPage ); + await shopperPage + .getByRole( 'link', { name: 'Authorize Test Payment' } ) + .click(); + await expect( + shopperPage.getByText( 'Order received' ).first() + ).toBeVisible(); + } ); + + test( 'should display USD payment methods when switching to USD and default is EUR', async () => { + await merchant.setDefaultCurrency( merchantPage, 'EUR' ); + + await shopper.addToCartFromShopPage( + shopperPage, + config.products.simple, + 'EUR' + ); + await navigation.goToCheckout( shopperPage ); + await shopper.fillBillingAddress( + shopperPage, + config.addresses[ 'upe-customer' ].billing.be + ); + await shopper.selectPaymentMethod( shopperPage ); + await expect( shopperPage.getByText( 'Bancontact' ) ).toBeVisible(); + + await navigation.goToCheckout( shopperPage, { + currency: 'USD', + } ); + await shopper.fillBillingAddress( + shopperPage, + config.addresses[ 'upe-customer' ].billing.be + ); + await shopper.selectPaymentMethod( shopperPage ); + await expect( + shopperPage.getByText( 'Bancontact' ) + ).not.toBeVisible(); + + await shopper.fillCardDetails( shopperPage ); + await shopper.focusPlaceOrderButton( shopperPage ); + await shopper.placeOrder( shopperPage ); + await expect( + shopperPage.getByText( 'Order received' ).first() + ).toBeVisible(); + } ); + } ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-bnpls-checkout.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-bnpls-checkout.spec.ts new file mode 100644 index 00000000000..1cf4c678d16 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-bnpls-checkout.spec.ts @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page } from '@playwright/test'; +import { test, expect, getAuthState } from '../../../fixtures/auth'; + +/** + * Internal dependencies + */ +import * as navigation from '../../../utils/shopper-navigation'; +import * as shopper from '../../../utils/shopper'; +import * as merchant from '../../../utils/merchant'; +import * as devtools from '../../../utils/devtools'; + +const cardTestingProtectionStates = [ false, true ]; +const bnplProviders = [ 'Affirm', 'Cash App Afterpay' ]; + +// Use different products per provider to avoid the order duplication protection. +const products = [ 'belt', 'sunglasses' ]; + +test.describe( 'BNPL checkout', { tag: [ '@shopper', '@critical' ] }, () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + let wasMulticurrencyEnabled: boolean; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + wasMulticurrencyEnabled = await merchant.isMulticurrencyEnabled( + merchantPage + ); + await merchant.enablePaymentMethods( merchantPage, bnplProviders ); + if ( wasMulticurrencyEnabled ) { + await merchant.deactivateMulticurrency( merchantPage ); + } + } ); + + test.afterAll( async () => { + if ( merchantPage ) { + await merchant.disablePaymentMethods( merchantPage, bnplProviders ); + if ( wasMulticurrencyEnabled ) { + await merchant.activateMulticurrency( merchantPage ); + } + } + + await merchantContext?.close().catch( () => undefined ); + await shopperContext?.close().catch( () => undefined ); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + for ( const ctpEnabled of cardTestingProtectionStates ) { + test.describe( `Carding protection ${ ctpEnabled }`, () => { + test.beforeAll( async () => { + if ( ctpEnabled ) { + await devtools.enableCardTestingProtection(); + } else { + await devtools.disableCardTestingProtection(); + } + } ); + + test.afterAll( async () => { + if ( ctpEnabled ) { + await devtools.disableCardTestingProtection(); + } + } ); + + for ( const [ index, provider ] of bnplProviders.entries() ) { + test( `Checkout with ${ provider }`, async () => { + await navigation.goToProductPageBySlug( + shopperPage, + products[ index % products.length ] + ); + + await shopperPage + .locator( '.single_add_to_cart_button' ) + .click(); + await shopperPage.waitForLoadState( 'domcontentloaded' ); + await expect( + shopperPage.getByText( /has been added to your cart\./ ) + ).toBeVisible(); + + await shopper.setupCheckout( shopperPage ); + await shopper.selectPaymentMethod( shopperPage, provider ); + await shopper.expectFraudPreventionToken( + shopperPage, + ctpEnabled + ); + await shopper.placeOrder( shopperPage ); + await expect( + shopperPage.getByText( /test payment page/ ) + ).toBeVisible(); + + await shopperPage + .getByText( 'Authorize Test Payment' ) + .click(); + + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + } + } ); + } +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts new file mode 100644 index 00000000000..b1078608881 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import { goToCart, goToCheckout } from '../../../utils/shopper-navigation'; +import { + addToCartFromShopPage, + emptyCart, + fillBillingAddress, + fillCardDetails, + placeOrder, + removeCoupon, + setupCheckout, +} from '../../../utils/shopper'; + +const couponCode = 'free'; + +test.describe( + 'Checkout with free coupon & after modifying cart on Checkout page', + { tag: '@shopper' }, + () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + } ); + + test.afterAll( async () => { + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await addToCartFromShopPage( shopperPage ); + await goToCart( shopperPage ); + await shopperPage + .getByPlaceholder( 'Coupon code' ) + .fill( couponCode ); + await shopperPage + .getByRole( 'button', { name: 'Apply coupon' } ) + .click(); + await expect( + shopperPage.getByText( 'Coupon code applied successfully' ) + ).toBeVisible(); + } ); + + test.afterEach( async () => { + await emptyCart( shopperPage ); + } ); + + test( 'Checkout with a free coupon', async () => { + await goToCheckout( shopperPage ); + await fillBillingAddress( + shopperPage, + config.addresses.customer.billing + ); + await placeOrder( shopperPage ); + await shopperPage.waitForURL( /\/order-received\//, { + waitUntil: 'load', + } ); + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + + test( 'Remove free coupon, then checkout', async () => { + await goToCheckout( shopperPage ); + await removeCoupon( shopperPage ); + await setupCheckout( + shopperPage, + config.addresses.customer.billing + ); + await fillCardDetails( shopperPage, config.cards.basic ); + await placeOrder( shopperPage ); + await shopperPage.waitForURL( /\/order-received\//, { + waitUntil: 'load', + } ); + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-failures.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-failures.spec.ts new file mode 100644 index 00000000000..f38153150a1 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-failures.spec.ts @@ -0,0 +1,191 @@ +/** + * External dependencies + */ +import { test, expect } from '../../../fixtures/auth'; +import type { Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as shopper from '../../../utils/shopper'; + +test.describe( + 'Shopper > Checkout > Failures with various cards', + { tag: [ '@shopper', '@critical' ] }, + () => { + const waitForBanner = async ( page: Page, errorText: string ) => { + await expect( page.getByText( errorText ) ).toBeVisible(); + }; + + test.beforeEach( async ( { customerPage } ) => { + await shopper.emptyCart( customerPage ); + await shopper.addToCartFromShopPage( customerPage ); + await shopper.setupCheckout( customerPage ); + await shopper.selectPaymentMethod( customerPage ); + } ); + + test( 'should throw an error that the card was simply declined', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards.declined + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + 'Error: Your card was declined.' + ); + } ); + + test( 'should throw an error that the card expiration date is in the past', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-expired' ] + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + 'Error: Your card has expired.' + ); + } ); + + test( 'should throw an error that the card CVV number is invalid', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'invalid-cvv-number' ] + ); + + await customerPage.keyboard.press( 'Tab' ); + + const frameHandle = await customerPage.waitForSelector( + '#payment .payment_method_woocommerce_payments .wcpay-upe-element iframe' + ); + + const stripeFrame = await frameHandle.contentFrame(); + if ( ! stripeFrame ) { + throw new Error( + 'Unable to load Stripe frame for CVC error expectation.' + ); + } + + const cvcErrorText = stripeFrame.locator( 'p#Field-cvcError' ); + + await expect( cvcErrorText ).toHaveText( + 'Your card’s security code is incomplete.' + ); + } ); + + test( 'should throw an error that the card was declined due to insufficient funds', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-funds' ] + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + 'Error: Your card has insufficient funds.' + ); + } ); + + test( 'should throw an error that the card was declined due to expired card', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-expired' ] + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + 'Error: Your card has expired.' + ); + } ); + + test( 'should throw an error that the card was declined due to incorrect CVC number', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-cvc' ] + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + "Error: Your card's security code is incorrect." + ); + } ); + + test( 'should throw an error that the card was declined due to processing error', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-processing' ] + ); + await shopper.placeOrder( customerPage ); + + await waitForBanner( + customerPage, + 'Error: An error occurred while processing your card. Try again in a little bit.' + ); + } ); + + test( 'should throw an error that the card was declined due to incorrect card number', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-incorrect' ] + ); + + const frameHandle = await customerPage.waitForSelector( + '#payment .payment_method_woocommerce_payments .wcpay-upe-element iframe' + ); + + const stripeFrame = await frameHandle.contentFrame(); + if ( ! stripeFrame ) { + throw new Error( + 'Unable to load Stripe frame for card number error expectation.' + ); + } + + const numberErrorText = stripeFrame.locator( + 'p#Field-numberError' + ); + + await expect( numberErrorText ).toHaveText( + 'Your card number is invalid.' + ); + } ); + + test( 'should throw an error that the card was declined due to invalid 3DS card', async ( { + customerPage, + } ) => { + await shopper.fillCardDetails( + customerPage, + config.cards[ 'declined-3ds' ] + ); + await shopper.placeOrder( customerPage ); + + await shopper.confirmCardAuthentication( customerPage, false ); + + await waitForBanner( + customerPage, + 'We are unable to authenticate your payment method. Please choose a different payment method and try again.' + ); + } ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts new file mode 100644 index 00000000000..271cd1a67f3 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts @@ -0,0 +1,123 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { + disableCardTestingProtection, + enableCardTestingProtection, +} from '../../../utils/devtools'; +import { activateTheme } from '../../../utils/merchant'; +import { config } from '../../../config/default'; +import { + addToCartFromShopPage, + confirmCardAuthentication, + emptyCart, + expectFraudPreventionToken, + fillCardDetails, + placeOrder, + setupCheckout, +} from '../../../utils/shopper'; + +/** + * Tests for successful purchases with both card testing prevention enabled + * and disabled states using a site builder enabled theme. + */ +test.describe( + 'Successful purchase, site builder theme', + { tag: '@shopper' }, + () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + await activateTheme( 'twentytwentyfour' ); + } ); + + test.afterAll( async () => { + await emptyCart( shopperPage ); + await activateTheme( 'storefront' ); + await disableCardTestingProtection( merchantPage ); + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + [ true, false ].forEach( ( cardTestingPreventionEnabled ) => { + test.describe( + `card prevention: ${ cardTestingPreventionEnabled }`, + () => { + test.beforeAll( async () => { + if ( cardTestingPreventionEnabled ) { + await enableCardTestingProtection( merchantPage ); + } else { + await disableCardTestingProtection( merchantPage ); + } + } ); + + test.beforeEach( async () => { + await emptyCart( shopperPage ); + await addToCartFromShopPage( shopperPage ); + await setupCheckout( + shopperPage, + config.addresses.customer.billing + ); + } ); + + const runPurchaseFlow = async ( + page: Page, + card: typeof config.cards.basic, + is3dsCard: boolean + ) => { + await expectFraudPreventionToken( + page, + cardTestingPreventionEnabled + ); + await fillCardDetails( page, card ); + await placeOrder( page ); + if ( is3dsCard ) { + await confirmCardAuthentication( page ); + } + await page.waitForURL( /\/order-received\//, { + waitUntil: 'load', + } ); + expect( page.url() ).toMatch( + /checkout\/order-received\/\d+\// + ); + }; + + test( `basic card`, async () => { + await runPurchaseFlow( + shopperPage, + config.cards.basic, + false + ); + } ); + + test( `3DS card`, async () => { + await runPurchaseFlow( + shopperPage, + config.cards[ '3ds' ], + true + ); + } ); + } + ); + } ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts new file mode 100644 index 00000000000..526b64a1859 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page } from '@playwright/test'; +import { test, expect, getAuthState } from '../../../fixtures/auth'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as merchant from '../../../utils/merchant'; +import * as shopper from '../../../utils/shopper'; +import * as devtools from '../../../utils/devtools'; +import { goToCheckout } from '../../../utils/shopper-navigation'; + +test.describe( + 'Local payment method checkout with card testing', + { tag: [ '@shopper', '@critical' ] }, + () => { + test.describe.configure( { timeout: 120_000 } ); + + let merchantContext: BrowserContext; + let shopperContext: BrowserContext; + let merchantPage: Page; + let shopperPage: Page; + let wasMultiCurrencyEnabled = false; + let enabledCurrenciesSnapshot: string[] = []; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + wasMultiCurrencyEnabled = await merchant.activateMulticurrency( + merchantPage + ); + enabledCurrenciesSnapshot = await merchant.getEnabledCurrenciesSnapshot( + merchantPage + ); + await merchant.addCurrency( merchantPage, 'EUR' ); + await merchant.enablePaymentMethods( merchantPage, [ + 'bancontact', + ] ); + + await shopper.changeAccountCurrency( + shopperPage, + config.addresses.customer.billing, + 'EUR' + ); + await shopper.emptyCart( shopperPage ); + } ); + + test.afterAll( async () => { + await shopper.emptyCart( shopperPage ); + await shopper.changeAccountCurrency( + shopperPage, + config.addresses.customer.billing, + 'USD' + ); + await merchant.disablePaymentMethods( merchantPage, [ + 'bancontact', + ] ); + if ( enabledCurrenciesSnapshot.length ) { + await merchant.restoreCurrencies( + merchantPage, + enabledCurrenciesSnapshot + ); + } + if ( ! wasMultiCurrencyEnabled ) { + await merchant.deactivateMulticurrency( merchantPage ); + } + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + for ( const ctpEnabled of [ false, true ] ) { + test.describe( + `Card testing protection enabled: ${ ctpEnabled }`, + () => { + test.beforeAll( async () => { + if ( ctpEnabled ) { + await devtools.enableCardTestingProtection(); + } + } ); + + test.afterAll( async () => { + if ( ctpEnabled ) { + await devtools.disableCardTestingProtection(); + } + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + test( 'should successfully place order with Bancontact', async () => { + await shopper.addToCartFromShopPage( shopperPage ); + await goToCheckout( shopperPage ); + await shopper.fillBillingAddress( + shopperPage, + config.addresses[ 'upe-customer' ].billing.be + ); + await shopper.expectFraudPreventionToken( + shopperPage, + ctpEnabled + ); + await shopperPage.getByText( 'Bancontact' ).click(); + + const bancontactRadio = shopperPage.locator( + '#payment_method_woocommerce_payments_bancontact' + ); + await bancontactRadio.scrollIntoViewIfNeeded(); + await bancontactRadio.check( { force: true } ); + await expect( bancontactRadio ).toBeChecked( { + timeout: 10_000, + } ); + + await shopper.focusPlaceOrderButton( shopperPage ); + await shopper.placeOrder( shopperPage ); + await shopperPage + .getByRole( 'link', { + name: 'Authorize Test Payment', + } ) + .click(); + await expect( + shopperPage.getByText( 'Order received' ).first() + ).toBeVisible(); + } ); + } + ); + } + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts new file mode 100644 index 00000000000..af99cc8df1e --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { Page, BrowserContext } from '@playwright/test'; +import { test, expect, getAuthState } from '../../../fixtures/auth'; + +/** + * Internal dependencies + */ + +import { config } from '../../../config/default'; +import * as shopper from '../../../utils/shopper'; +import * as devtools from '../../../utils/devtools'; + +test.describe( 'Successful purchase', { tag: '@shopper' }, () => { + let merchantPage: Page; + let shopperPage: Page; + let merchantContext: BrowserContext; + let shopperContext: BrowserContext; + + for ( const ctpEnabled of [ false, true ] ) { + test.describe( `Carding protection ${ ctpEnabled }`, () => { + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + if ( ctpEnabled ) { + await devtools.enableCardTestingProtection( merchantPage ); + } + } ); + + test.afterAll( async () => { + if ( ctpEnabled ) { + await devtools.disableCardTestingProtection( merchantPage ); + } + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + await shopper.addToCartFromShopPage( shopperPage ); + await shopper.setupCheckout( + shopperPage, + config.addresses.customer.billing + ); + await shopper.expectFraudPreventionToken( + shopperPage, + ctpEnabled + ); + } ); + + test( 'using a basic card', { tag: '@critical' }, async () => { + await shopper.fillCardDetails( shopperPage ); + await shopper.placeOrder( shopperPage ); + + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + + test( 'using a 3DS card', { tag: '@critical' }, async () => { + await shopper.fillCardDetails( + shopperPage, + config.cards[ '3ds' ] + ); + await shopper.placeOrder( shopperPage ); + await shopper.confirmCardAuthentication( shopperPage ); + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + } ); + } +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts new file mode 100644 index 00000000000..cbe9aa7edf4 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { Page, BrowserContext } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as shopper from '../../../utils/shopper'; +import { goToMyAccount } from '../../../utils/shopper-navigation'; + +type ConfigProduct = typeof config.products[ keyof typeof config.products ]; +type CardType = [ string, typeof config.cards.basic, ConfigProduct[] ]; + +const cards: CardType[] = [ + [ + 'basic', + config.cards.basic, + [ config.products.belt, config.products.cap ], + ], + [ + '3ds', + config.cards[ '3ds' ], + [ config.products.sunglasses, config.products.hoodie_with_logo ], + ], +]; + +test.describe( 'Saved cards', { tag: [ '@shopper', '@critical' ] }, () => { + cards.forEach( ( [ cardType, card, products ] ) => { + test.describe( + `When using a ${ cardType } card added on checkout`, + () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + } ); + + test.afterAll( async () => { + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await shopper.emptyCart( shopperPage ); + } ); + + test( 'should save the card', async () => { + await shopper.setupProductCheckout( shopperPage, [ + [ products[ 0 ], 1 ], + ] ); + await shopper.selectPaymentMethod( shopperPage ); + await shopper.fillCardDetails( shopperPage, card ); + await shopper.setSavePaymentMethod( shopperPage, true ); + await shopper.placeOrder( shopperPage ); + if ( cardType === '3ds' ) { + await shopper.confirmCardAuthentication( shopperPage ); + } + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + + await goToMyAccount( shopperPage, 'payment-methods' ); + await expect( + shopperPage.getByText( card.label ) + ).toBeVisible(); + await expect( + shopperPage.getByText( + `${ card.expires.month }/${ card.expires.year }` + ) + ).toBeVisible(); + } ); + + test( 'should process a payment with the saved card', async () => { + await shopper.setupProductCheckout( shopperPage, [ + [ products[ 1 ], 1 ], + ] ); + await shopper.selectPaymentMethod( shopperPage ); + await shopper.selectSavedCardOnCheckout( + shopperPage, + card + ); + await shopper.placeOrder( shopperPage ); + if ( cardType === '3ds' ) { + await shopper.confirmCardAuthentication( shopperPage ); + } + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } ); + + test( 'should delete the card', async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + await shopper.deleteSavedCard( shopperPage, card ); + await expect( + shopperPage.getByText( 'Payment method deleted' ) + ).toBeVisible(); + } ); + + test( 'should not allow guest user to save the card', async ( { + browser, + } ) => { + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + + try { + await shopper.setupProductCheckout( guestPage ); + await shopper.selectPaymentMethod( guestPage ); + await expect( + guestPage.getByLabel( + 'Save payment information to my account for future purchases.' + ) + ).not.toBeVisible(); + } finally { + await shopper.emptyCart( guestPage ); + await guestContext.close(); + } + } ); + } + ); + } ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-multi-currency-widget.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-multi-currency-widget.spec.ts new file mode 100644 index 00000000000..62e446164fb --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-multi-currency-widget.spec.ts @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import * as merchant from '../../../utils/merchant'; +import * as navigation from '../../../utils/shopper-navigation'; +import * as shopper from '../../../utils/shopper'; + +test.describe( 'Shopper Multi-Currency widget', { tag: '@shopper' }, () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + let wasMulticurrencyEnabled = false; + let originalEnabledCurrencies: string[] = []; + + // Increase the beforeAll timeout because creating contexts and fetching + // auth state can be slow in CI/docker. 60s should be sufficient. + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + await merchant.removeMultiCurrencyWidgets(); + originalEnabledCurrencies = await merchant.getEnabledCurrenciesSnapshot( + merchantPage + ); + wasMulticurrencyEnabled = await merchant.activateMulticurrency( + merchantPage + ); + await merchant.addCurrency( merchantPage, 'EUR' ); + await merchant.addMulticurrencyWidget( merchantPage ); + }, 60000 ); + + test.afterAll( async () => { + await merchant.removeMultiCurrencyWidgets(); + await merchant.restoreCurrencies( + merchantPage, + originalEnabledCurrencies + ); + if ( ! wasMulticurrencyEnabled ) { + await merchant.deactivateMulticurrency( merchantPage ); + } + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + test( 'should display currency switcher widget if multi-currency is enabled', async () => { + await navigation.goToShop( shopperPage ); + await expect( + shopperPage.locator( '.widget select[name="currency"]' ) + ).toBeVisible(); + } ); + + test.describe( 'Should allow shopper to switch currency', () => { + test.afterEach( async () => { + await shopperPage.selectOption( + '.widget select[name="currency"]', + 'EUR' + ); + await expect( shopperPage ).toHaveURL( /.*currency=EUR/ ); + await navigation.goToShop( shopperPage, { currency: 'USD' } ); + } ); + + test( 'at the product page', async () => { + await navigation.goToProductPageBySlug( shopperPage, 'beanie' ); + } ); + + test( 'at the cart page', async () => { + await navigation.goToCart( shopperPage ); + } ); + + test( 'at the checkout page', async () => { + await navigation.goToCheckout( shopperPage ); + } ); + } ); + + test.describe( 'Should not affect prices', () => { + let orderId: string | null = null; + let orderPrice: string | null = null; + + test.afterEach( async () => { + if ( orderPrice ) { + await expect( + shopperPage.getByText( `${ orderPrice } USD` ).first() + ).toBeVisible(); + } + await navigation.goToShop( shopperPage, { currency: 'USD' } ); + } ); + + test( 'at the order received page', { tag: '@critical' }, async () => { + orderId = await shopper.placeOrderWithCurrency( + shopperPage, + 'USD' + ); + orderPrice = await shopperPage + .getByRole( 'row', { name: 'Total: $' } ) + .locator( '.amount' ) + .nth( 1 ) + .textContent(); + } ); + + test( 'at My account > Orders', async () => { + expect( orderId ).toBeTruthy(); + if ( ! orderId ) { + return; + } + await navigation.goToOrders( shopperPage ); + await expect( + shopperPage + .locator( '.woocommerce-orders-table__cell-order-number' ) + .getByRole( 'link', { name: orderId } ) + ).toBeVisible(); + } ); + } ); + + test( 'should not display currency switcher on pay for order page', async () => { + const orderId = await merchant.createPendingOrder(); + + await merchantPage.goto( + `/wp-admin/post.php?post=${ orderId }&action=edit`, + { waitUntil: 'load' } + ); + const paymentLink = merchantPage.getByRole( 'link', { + name: 'Customer payment page', + } ); + const opensNewTab = + ( await paymentLink.getAttribute( 'target' ) ) === '_blank'; + let paymentPage: Page | null = null; + if ( opensNewTab ) { + [ paymentPage ] = await Promise.all( [ + merchantContext.waitForEvent( 'page' ), + paymentLink.click(), + ] ); + } else { + await paymentLink.click(); + } + const paymentView = paymentPage ?? merchantPage; + await paymentView.waitForLoadState( 'load' ); + await expect( + paymentView.locator( '.widget select[name="currency"]' ) + ).not.toBeVisible(); + await paymentPage?.close(); + } ); + + test( 'should not display currency switcher widget if multi-currency is disabled', async () => { + await merchant.deactivateMulticurrency( merchantPage ); + await navigation.goToShop( shopperPage ); + await expect( + shopperPage.locator( '.widget select[name="currency"]' ) + ).not.toBeVisible(); + await merchant.activateMulticurrency( merchantPage ); + } ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-payment-methods-add-fail.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-payment-methods-add-fail.spec.ts new file mode 100644 index 00000000000..f2699bccca6 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-payment-methods-add-fail.spec.ts @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import { goToMyAccount } from '../../../utils/shopper-navigation'; +import { isUIUnblocked } from '../../../utils/helpers'; +import { + addSavedCard, + confirmCardAuthentication, + emptyCart, +} from '../../../utils/shopper'; + +const cards: Array< [ string, typeof config.cards.declined, string ] > = [ + [ 'declined', config.cards.declined, 'Error: Your card was declined.' ], + [ + 'declined-funds', + config.cards[ 'declined-funds' ], + 'Error: Your card has insufficient funds.', + ], + [ + 'declined-incorrect', + config.cards[ 'declined-incorrect' ], + 'Your card number is invalid.', + ], + [ + 'declined-expired', + config.cards[ 'declined-expired' ], + 'Error: Your card has expired.', + ], + [ + 'declined-cvc', + config.cards[ 'declined-cvc' ], + "Error: Your card's security code is incorrect.", + ], + [ + 'declined-processing', + config.cards[ 'declined-processing' ], + 'Error: An error occurred while processing your card. Try again in a little bit.', + ], + [ + 'declined-3ds', + config.cards[ 'declined-3ds' ], + 'We are unable to authenticate your payment method. Please choose a different payment method and try again.', + ], +]; + +test.describe( 'Payment Methods', { tag: '@shopper' }, () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + } ); + + test.afterAll( async () => { + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + } ); + + cards.forEach( ( [ cardType, card, errorText ] ) => { + test.describe( `when attempting to add a ${ cardType } card`, () => { + test( 'it should not add the card', async () => { + const { label } = card; + + await addSavedCard( shopperPage, card, 'US' ); + + if ( cardType === 'declined-3ds' ) { + await confirmCardAuthentication( shopperPage, false ); + await isUIUnblocked( shopperPage ); + } + + await expect( shopperPage.getByRole( 'alert' ) ).toHaveText( + errorText + ); + + if ( cardType === 'declined-incorrect' ) { + await expect( + shopperPage + .frameLocator( + 'iframe[name^="__privateStripeFrame"]' + ) + .first() + .getByRole( 'alert' ) + ).toContainText( errorText ); + } + + await expect( + shopperPage.getByText( label ) + ).not.toBeVisible(); + } ); + } ); + } ); + + test( + 'it should not show error when adding payment method on another gateway', + { tag: '@critical' }, + async () => { + await shopperPage + .getByRole( 'link', { name: 'Add payment method' } ) + .click(); + + await shopperPage.waitForLoadState( 'domcontentloaded' ); + await isUIUnblocked( shopperPage ); + await expect( + shopperPage.locator( 'input[name="payment_method"]' ).first() + ).toBeVisible( { timeout: 5000 } ); + + await shopperPage.$eval( + 'input[name="payment_method"]:checked', + ( input ) => { + ( input as HTMLInputElement ).checked = false; + } + ); + + await shopperPage + .getByRole( 'button', { name: 'Add payment method' } ) + .click(); + await shopperPage.waitForTimeout( 300 ); + + await expect( shopperPage.getByRole( 'alert' ) ).not.toBeVisible(); + } + ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-saved-cards.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-saved-cards.spec.ts new file mode 100644 index 00000000000..e52a5f82f6f --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-myaccount-saved-cards.spec.ts @@ -0,0 +1,282 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +// QIT environments and Stripe/3DS flows can be slow; increase the per-test timeout +// so setup/login and external iframes don't trigger the default 30s timeout. +test.setTimeout( 120_000 ); + +/** + * Internal dependencies + */ +import { config, Product } from '../../../config/default'; +import { goToMyAccount } from '../../../utils/shopper-navigation'; +import { + addSavedCard, + confirmCardAuthentication, + deleteSavedCard, + placeOrder, + selectSavedCardOnCheckout, + setDefaultPaymentMethod, + setupProductCheckout, +} from '../../../utils/shopper'; + +type TestVariablesType = { + [ key: string ]: { + card: typeof config.cards.basic; + address: { + country: string; + postalCode: string; + }; + products: [ Product, number ][]; + }; +}; + +const cards: TestVariablesType = { + basic: { + card: config.cards.basic, + address: { + country: 'US', + postalCode: '94110', + }, + products: [ [ config.products.simple, 1 ] ], + }, + '3ds': { + card: config.cards[ '3ds' ], + address: { + country: 'US', + postalCode: '94110', + }, + products: [ [ config.products.belt, 1 ] ], + }, + '3ds2': { + card: config.cards[ '3ds2' ], + address: { + country: 'US', + postalCode: '94110', + }, + products: [ [ config.products.cap, 1 ] ], + }, +}; + +const makeCardTimingHelper = () => { + let lastCardAddedAt: number | null = null; + + return { + // Make sure that at least 20s had already elapsed since the last card was added. + // Otherwise, you will get the error message, + // "You cannot add a new payment method so soon after the previous one." + // Source: /docker/wordpress/wp-content/plugins/woocommerce/includes/class-wc-form-handler.php#L509-L521 + + // Be careful that this is only needed for a successful card addition, so call it only where it's needed the most, to prevent unnecessary delays. + async waitIfNeededBeforeAddingCard( page: Page ) { + if ( ! lastCardAddedAt ) return; + + const elapsed = Date.now() - lastCardAddedAt; + const waitTime = 20000 - elapsed; + + if ( waitTime > 0 ) { + await page.waitForTimeout( waitTime ); + } + }, + + markCardAdded() { + lastCardAddedAt = Date.now(); + }, + }; +}; + +test.describe( 'Shopper can save and delete cards', { tag: '@shopper' }, () => { + // Use cards different from other tests to prevent conflicts. + const card2 = config.cards.basic2; + let shopperContext: BrowserContext; + let shopperPage: Page; + + const cardTimingHelper = makeCardTimingHelper(); + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + + // calling it first here, just in case a card was added in a previous test. + cardTimingHelper.markCardAdded(); + } ); + + test.afterAll( async () => { + await shopperContext?.close(); + } ); + + // No need to run this test for all card types. + test( 'prevents adding another card for 20 seconds after a card is added', async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + + // Make sure that at least 20s had already elapsed since the last card was added. + await cardTimingHelper.waitIfNeededBeforeAddingCard( shopperPage ); + + await addSavedCard( shopperPage, config.cards.basic, 'US', '94110' ); + // Take note of the time when we added this card + cardTimingHelper.markCardAdded(); + + // Try to add a new card before 20 seconds have passed + await addSavedCard( shopperPage, config.cards.basic2, 'US', '94110' ); + + // Verify that the second card was not added. + // The error could be shown on the add form; navigate to the list to assert state. + await goToMyAccount( shopperPage, 'payment-methods' ); + await expect( + shopperPage + .getByRole( 'row', { name: config.cards.basic.label } ) + .first() + ).toBeVisible(); + await expect( + shopperPage.getByRole( 'row', { name: config.cards.basic2.label } ) + ).toHaveCount( 0 ); + + // cleanup for the next tests + await goToMyAccount( shopperPage, 'payment-methods' ); + await deleteSavedCard( shopperPage, config.cards.basic ); + + await expect( + shopperPage.getByText( 'No saved methods found.' ) + ).toBeVisible(); + } ); + + Object.entries( cards ).forEach( + ( [ cardName, { card, address, products } ] ) => { + test.describe( 'Testing card: ' + cardName, () => { + test.beforeAll( async () => { + // Ensure we have a logged-in shopper for this group + // getAuthState already produced the state used by shopperContext + } ); + + test( + `should add the ${ cardName } card as a new payment method`, + { tag: '@critical' }, + async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + // Make sure that at least 20s had already elapsed since the last card was added. + await cardTimingHelper.waitIfNeededBeforeAddingCard( + shopperPage + ); + + await addSavedCard( + shopperPage, + card, + address.country, + address.postalCode + ); + + if ( cardName === '3ds' || cardName === '3ds2' ) { + await confirmCardAuthentication( shopperPage ); + // After 3DS, wait for redirect back to Payment methods before asserting + await expect( + shopperPage.getByRole( 'heading', { + name: 'Payment methods', + } ) + ).toBeVisible( { timeout: 30000 } ); + } + + // Record time of addition early to respect the 20s rule across tests + cardTimingHelper.markCardAdded(); + + // Verify that the card was added + await expect( + shopperPage.getByText( + 'You cannot add a new payment method so soon after the previous one.' + ) + ).not.toBeVisible(); + await expect( + shopperPage.getByText( + "We're not able to add this payment method. Please refresh the page and try again." + ) + ).not.toBeVisible(); + + await expect( + shopperPage.getByText( + `${ card.expires.month }/${ card.expires.year }` + ) + ).toBeVisible(); + } + ); + + test( + `should be able to purchase with the saved ${ cardName } card`, + { tag: '@critical' }, + async () => { + await setupProductCheckout( shopperPage, products ); + await selectSavedCardOnCheckout( shopperPage, card ); + await placeOrder( shopperPage ); + if ( cardName !== 'basic' ) { + await confirmCardAuthentication( shopperPage ); + } + await expect( + shopperPage.getByRole( 'heading', { + name: 'Order received', + } ) + ).toBeVisible(); + } + ); + + test( + `should be able to set the ${ cardName } card as default payment method`, + { tag: '@critical' }, + async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + // Ensure the saved methods table is present before interacting + await expect( + shopperPage.getByRole( 'heading', { + name: 'Payment methods', + } ) + ).toBeVisible(); + // Make sure that at least 20s had already elapsed since the last card was added. + await cardTimingHelper.waitIfNeededBeforeAddingCard( + shopperPage + ); + + await addSavedCard( shopperPage, card2, 'US', '94110' ); + // Take note of the time when we added this card + cardTimingHelper.markCardAdded(); + + await expect( + shopperPage.getByText( + `${ card2.expires.month }/${ card2.expires.year }` + ) + ).toBeVisible(); + await setDefaultPaymentMethod( shopperPage, card2 ); + // Verify that the card was set as default + await expect( + shopperPage.getByText( + 'This payment method was successfully set as your default.' + ) + ).toBeVisible(); + } + ); + + test( + `should be able to delete ${ cardName } card`, + { tag: '@critical' }, + async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + await deleteSavedCard( shopperPage, card ); + await expect( + shopperPage.getByText( 'Payment method deleted.' ) + ).toBeVisible(); + + await deleteSavedCard( shopperPage, card2 ); + await expect( + shopperPage.getByText( 'Payment method deleted.' ) + ).toBeVisible(); + + await expect( + shopperPage.getByText( 'No saved methods found.' ) + ).toBeVisible(); + } + ); + } ); + } + ); +} ); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-pay-for-order.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-pay-for-order.spec.ts new file mode 100644 index 00000000000..8cab4eefb74 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-pay-for-order.spec.ts @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import * as shopper from '../../../utils/shopper'; +import * as shopperNavigation from '../../../utils/shopper-navigation'; +import * as devtools from '../../../utils/devtools'; + +const cardTestingPreventionStates = [ + { cardTestingPreventionEnabled: false }, + { cardTestingPreventionEnabled: true }, +]; + +test.describe( + 'Shopper > Pay for Order', + { tag: [ '@shopper', '@critical' ] }, + () => { + let merchantContext: BrowserContext; + let merchantPage: Page; + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + merchantContext = await browser.newContext( { + storageState: await getAuthState( browser, 'admin' ), + } ); + merchantPage = await merchantContext.newPage(); + + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + } ); + + test.afterAll( async () => { + await devtools.disableCardTestingProtection( merchantPage ); + await merchantContext?.close(); + await shopperContext?.close(); + } ); + + cardTestingPreventionStates.forEach( + ( { cardTestingPreventionEnabled } ) => { + test( `should be able to pay for a failed order with card testing protection ${ cardTestingPreventionEnabled }`, async () => { + if ( cardTestingPreventionEnabled ) { + await devtools.enableCardTestingProtection( + merchantPage + ); + } else { + await devtools.disableCardTestingProtection( + merchantPage + ); + } + + await shopper.addToCartFromShopPage( shopperPage ); + await shopper.setupCheckout( shopperPage ); + await shopper.selectPaymentMethod( shopperPage ); + await shopper.fillCardDetails( + shopperPage, + config.cards.declined + ); + await shopper.placeOrder( shopperPage ); + + await expect( + shopperPage + .getByText( 'Your card was declined' ) + .first() + ).toBeVisible(); + + await shopperNavigation.goToOrders( shopperPage ); + const payForOrderButton = shopperPage + .locator( '.woocommerce-button.button.pay', { + hasText: 'Pay', + } ) + .first(); + await payForOrderButton.click(); + + await expect( + shopperPage.getByRole( 'heading', { + name: 'Pay for order', + } ) + ).toBeVisible(); + await shopper.fillCardDetails( + shopperPage, + config.cards.basic + ); + + const token = await shopperPage.evaluate( () => { + return ( window as any ).wcpayFraudPreventionToken; + } ); + + if ( cardTestingPreventionEnabled ) { + expect( token ).not.toBeUndefined(); + } else { + expect( token ).toBeUndefined(); + } + + await shopper.placeOrder( shopperPage ); + + await expect( + shopperPage.getByText( 'Order received' ).first() + ).toBeVisible(); + } ); + } + ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts new file mode 100644 index 00000000000..99af71a2030 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import { goToCheckoutWCB } from '../../../utils/shopper-navigation'; +import * as devtools from '../../../utils/devtools'; +import { + addToCartFromShopPage, + confirmCardAuthenticationWCB, + emptyCart, + fillBillingAddressWCB, + fillCardDetailsWCB, + placeOrderWCB, +} from '../../../utils/shopper'; + +const failures = [ + { + card: config.cards.declined, + error: 'Your card was declined.', + }, + { + card: config.cards[ 'invalid-exp-date' ], + error: 'Your card’s expiration year is in the past.', + }, + { + card: config.cards[ 'invalid-cvv-number' ], + error: 'Your card’s security code is incomplete.', + }, + { + card: config.cards[ 'declined-funds' ], + error: 'Your card has insufficient funds.', + }, + { + card: config.cards[ 'declined-expired' ], + error: 'Your card has expired.', + }, + { + card: config.cards[ 'declined-cvc' ], + error: "Your card's security code is incorrect.", + }, + { + card: config.cards[ 'declined-processing' ], + error: + 'An error occurred while processing your card. Try again in a little bit.', + }, + { + card: config.cards[ 'declined-incorrect' ], + error: 'Your card number is invalid.', + }, + { + card: config.cards[ 'declined-3ds' ], + error: /Your card (?:was|has been) declined\./, + auth: true, + }, +]; + +const paymentElementFrameSelector = + '#payment-method .wcpay-payment-element iframe[name^="__privateStripeFrame"]'; +const generalNoticeMatcher = /Your payment (?:was not|wasn't|could not be|couldn't be) processed\./i; + +const assertCheckoutError = async ( + page: Page, + errorMessage: string | RegExp +) => { + const stripeErrorLocator = page + .frameLocator( paymentElementFrameSelector ) + .getByText( errorMessage ) + .first(); + + try { + await expect( stripeErrorLocator ).toBeVisible( { timeout: 5000 } ); + return; + } catch ( _error ) { + // Fall through to check for notices rendered outside the Stripe iframe. + } + + const checkoutForm = page.locator( '.wc-block-checkout__form' ); + try { + await expect( checkoutForm.getByText( errorMessage ) ).toBeVisible( { + timeout: 3000, + } ); + return; + } catch ( _error ) { + // If the specific message is not surfaced, ensure the generic + // decline banner rendered so the customer receives feedback. + } + + const generalNoticeCandidates = [ + checkoutForm.locator( '.wc-block-store-notice' ).first(), + checkoutForm.locator( '.wc-block-components-notice-banner' ).first(), + page + .getByRole( 'status' ) + .filter( { hasText: generalNoticeMatcher } ) + .first(), + ]; + + for ( const candidate of generalNoticeCandidates ) { + const count = await candidate.count().catch( () => 0 ); + if ( count === 0 ) { + continue; + } + const visible = await candidate.isVisible().catch( () => false ); + if ( ! visible ) { + continue; + } + await expect( candidate ).toContainText( generalNoticeMatcher ); + return; + } + + if ( page.isClosed() ) { + throw new Error( + 'Checkout page closed before the decline notice rendered.' + ); + } + + await expect( page.getByText( generalNoticeMatcher ) ).toBeVisible(); +}; + +test.describe( + 'WooCommerce Blocks > Checkout failures', + { tag: [ '@shopper', '@critical', '@blocks' ] }, + () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + await devtools.disableCardTestingProtection(); + await devtools.disableFailedTransactionRateLimiter(); + } ); + + test.beforeEach( async () => { + await emptyCart( shopperPage ); + await addToCartFromShopPage( shopperPage ); + await goToCheckoutWCB( shopperPage ); + await fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + } ); + + test.afterAll( async () => { + await emptyCart( shopperPage ); + await shopperContext?.close(); + } ); + + for ( const { card, error, auth } of failures ) { + test( `Should show error – ${ error }`, async () => { + await fillCardDetailsWCB( shopperPage, card ); + await placeOrderWCB( shopperPage, false ); + + if ( auth ) { + await confirmCardAuthenticationWCB( shopperPage, true ); + } + + await assertCheckoutError( shopperPage, error ); + } ); + } + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts new file mode 100644 index 00000000000..3afefba7b83 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { test, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import { goToCheckoutWCB } from '../../../utils/shopper-navigation'; +import * as devtools from '../../../utils/devtools'; +import { + addToCartFromShopPage, + confirmCardAuthenticationWCB, + fillBillingAddressWCB, + fillCardDetailsWCB, + expectFraudPreventionToken, + waitForOrderConfirmationWCB, + placeOrderWCB, + emptyCart, +} from '../../../utils/shopper'; + +test.describe( + 'WooCommerce Blocks > Successful purchase', + { tag: [ '@shopper', '@critical', '@blocks' ] }, + () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + await devtools.disableCardTestingProtection(); + await devtools.disableFailedTransactionRateLimiter(); + } ); + + test.afterAll( async () => { + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await emptyCart( shopperPage ); + } ); + + test( 'using a basic card', async () => { + await addToCartFromShopPage( shopperPage, config.products.belt ); + await goToCheckoutWCB( shopperPage ); + await expectFraudPreventionToken( shopperPage, false ); + await fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + await fillCardDetailsWCB( shopperPage, config.cards.basic ); + await placeOrderWCB( shopperPage ); + } ); + + test( 'using a 3DS card', async () => { + await addToCartFromShopPage( + shopperPage, + config.products.sunglasses + ); + await goToCheckoutWCB( shopperPage ); + await expectFraudPreventionToken( shopperPage, false ); + await fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + await fillCardDetailsWCB( shopperPage, config.cards[ '3ds' ] ); + await placeOrderWCB( shopperPage, false ); + await confirmCardAuthenticationWCB( shopperPage ); + await waitForOrderConfirmationWCB( shopperPage ); + } ); + } +); diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts new file mode 100644 index 00000000000..e5b5b10afc6 --- /dev/null +++ b/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { test, expect, getAuthState } from '../../../fixtures/auth'; +import type { BrowserContext, Page } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../../../config/default'; +import { + goToCheckoutWCB, + goToMyAccount, +} from '../../../utils/shopper-navigation'; +import { + addToCartFromShopPage, + deleteSavedCard, + emptyCart, + fillBillingAddressWCB, + fillCardDetailsWCB, + placeOrderWCB, + selectSavedCardOnCheckout, + setSavePaymentMethod, +} from '../../../utils/shopper'; + +test.describe( + 'WooCommerce Blocks > Saved cards', + { tag: [ '@shopper', '@critical', '@blocks' ] }, + () => { + let shopperContext: BrowserContext; + let shopperPage: Page; + const card = config.cards.basic; + + test.beforeAll( async ( { browser } ) => { + shopperContext = await browser.newContext( { + storageState: await getAuthState( browser, 'customer' ), + } ); + shopperPage = await shopperContext.newPage(); + } ); + + test.afterAll( async () => { + await emptyCart( shopperPage ); + await shopperContext?.close(); + } ); + + test.beforeEach( async () => { + await emptyCart( shopperPage ); + } ); + + test( 'should be able to save basic card on Blocks checkout', async () => { + await addToCartFromShopPage( shopperPage, config.products.belt ); + await goToCheckoutWCB( shopperPage ); + await fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + await fillCardDetailsWCB( shopperPage, card ); + await setSavePaymentMethod( shopperPage, true ); + await placeOrderWCB( shopperPage ); + + await expect( + shopperPage.getByRole( 'heading', { name: 'Order received' } ) + ).toBeVisible(); + + await goToMyAccount( shopperPage, 'payment-methods' ); + await expect( + shopperPage.getByText( card.label ).first() + ).toBeVisible(); + await expect( + shopperPage + .getByText( + `${ card.expires.month }/${ card.expires.year }` + ) + .first() + ).toBeVisible(); + } ); + + test( 'should process a payment with the saved card from Blocks checkout', async () => { + await addToCartFromShopPage( shopperPage, config.products.cap ); + await goToCheckoutWCB( shopperPage ); + await fillBillingAddressWCB( + shopperPage, + config.addresses.customer.billing + ); + await selectSavedCardOnCheckout( shopperPage, card ); + await placeOrderWCB( shopperPage ); + await expect( + shopperPage.getByRole( 'heading', { name: 'Order received' } ) + ).toBeVisible(); + } ); + + test( 'should delete the card', async () => { + await goToMyAccount( shopperPage, 'payment-methods' ); + await deleteSavedCard( shopperPage, card ); + await expect( + shopperPage.getByText( 'Payment method deleted.' ) + ).toBeVisible(); + } ); + } +); diff --git a/tests/qit/e2e/utils/devtools.ts b/tests/qit/e2e/utils/devtools.ts new file mode 100644 index 00000000000..1eedd24630c --- /dev/null +++ b/tests/qit/e2e/utils/devtools.ts @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import qit from '/qitHelpers'; + +/** + * The legacy E2E environment relied on the WooPayments Dev Tools plugin UI to toggle + * options like card testing protection. The QIT stack does not load that plugin, so + * these helpers mirror the behaviour by patching the relevant options directly via WP-CLI. + */ + +const setCardTestingProtection = async ( enabled: boolean ) => { + const { stdout } = await qit.wp( + 'option get wcpay_account_data --format=json', + true + ); + let cache: Record< string, unknown > = {}; + try { + cache = stdout.trim().length ? JSON.parse( stdout ) : {}; + } catch ( error ) { + cache = {}; + } + const data = { + ...( typeof cache.data === 'object' && cache.data !== null + ? cache.data + : {} ), + card_testing_protection_eligible: enabled, + }; + const updatedCache = { + ...cache, + data, + fetched: Math.floor( Date.now() / 1000 ), + errored: false, + }; + const payload = JSON.stringify( updatedCache ); + const escapedPayload = payload.replace( /'/g, `'"'"'` ); + await qit.wp( + `option update wcpay_account_data '${ escapedPayload }' --format=json`, + true + ); + await qit.wp( + `option update wcpaydev_force_card_testing_protection_on ${ + enabled ? 1 : 0 + }`, + true + ); + await qit + .wp( 'cache delete wcpay_account_data options', true ) + .catch( () => undefined ); +}; + +export const enableCardTestingProtection = async () => { + await setCardTestingProtection( true ); +}; + +export const disableCardTestingProtection = async () => { + await setCardTestingProtection( false ); +}; + +const rateLimiterOption = + 'wcpay_session_rate_limiter_disabled_wcpay_card_declined_registry'; + +export const disableFailedTransactionRateLimiter = async () => { + await qit.wp( `option set ${ rateLimiterOption } yes`, true ); +}; diff --git a/tests/qit/e2e/utils/helpers.ts b/tests/qit/e2e/utils/helpers.ts new file mode 100644 index 00000000000..fb35b165177 --- /dev/null +++ b/tests/qit/e2e/utils/helpers.ts @@ -0,0 +1,321 @@ +/* eslint-disable no-console */ +/** + * External dependencies + */ +import path from 'path'; +import { + test, + Page, + Browser, + BrowserContext, + expect, + FullProject, +} from '@playwright/test'; + +/** + * Internal dependencies + */ +import { config } from '../config/default'; + +export const merchantStorageFile = path.resolve( + __dirname, + '../.auth/merchant.json' +); + +export const customerStorageFile = path.resolve( + __dirname, + '../.auth/customer.json' +); + +export const editorStorageFile = path.resolve( + __dirname, + '../.auth/editor.json' +); + +/** + * Logs in to the WordPress admin as a given user. + */ +export const wpAdminLogin = async ( + page: Page, + user: { username: string; password: string } +): Promise< void > => { + await page.goto( '/wp-admin' ); + + await page.getByLabel( 'Username or Email Address' ).fill( user.username ); + + // Need exact match to avoid resolving "Show password" button. + const passwordInput = page.getByLabel( 'Password', { exact: true } ); + + // The focus is used to avoid the password being filled in the username field. + await passwordInput.focus(); + await passwordInput.fill( user.password ); + + await page.getByRole( 'button', { name: 'Log In' } ).click(); +}; + +/** + * Sets the shopper as the authenticated user for a test suite (describe). + */ +export const useShopper = (): void => { + test.use( { + storageState: customerStorageFile, + } ); +}; + +/** + * Sets the merchant as the authenticated user for a test suite (describe). + */ +export const useMerchant = (): void => { + test.use( { + storageState: merchantStorageFile, + } ); +}; + +/** + * Returns the merchant authenticated page and context. + * Allows switching between merchant and shopper contexts within a single test. + */ +export const getMerchant = async ( + browser: Browser +): Promise< { + merchantPage: Page; + merchantContext: BrowserContext; +} > => { + const merchantContext = await browser.newContext( { + storageState: merchantStorageFile, + } ); + const merchantPage = await merchantContext.newPage(); + return { merchantPage, merchantContext }; +}; + +/** + * Returns the shopper authenticated page and context. + * Allows switching between merchant and shopper contexts within a single test. + */ +export const getShopper = async ( + browser: Browser, + asNewCustomer = false, + baseURL = '' // Needed for recreating customer +): Promise< { + shopperPage: Page; + shopperContext: BrowserContext; +} > => { + if ( asNewCustomer ) { + const restApi = new RestAPI( baseURL ); + await restApi.recreateCustomer( + config.users.customer, + config.addresses.customer.billing, + config.addresses.customer.shipping + ); + + const shopperContext = await browser.newContext(); + const shopperPage = await shopperContext.newPage(); + await wpAdminLogin( shopperPage, config.users.customer ); + await shopperPage.waitForLoadState( 'networkidle' ); + await shopperPage.goto( '/my-account' ); + expect( + shopperPage.locator( + '.woocommerce-MyAccount-navigation-link--customer-logout' + ) + ).toBeVisible(); + await expect( + shopperPage.locator( + 'div.woocommerce-MyAccount-content > p >> nth=0' + ) + ).toContainText( 'Hello' ); + await shopperPage + .context() + .storageState( { path: customerStorageFile } ); + return { shopperPage, shopperContext }; + } + const shopperContext = await browser.newContext( { + storageState: customerStorageFile, + } ); + const shopperPage = await shopperContext.newPage(); + return { shopperPage, shopperContext }; +}; + +/** + * Returns an anonymous shopper page and context. + * Emulates a new shopper who has not been authenticated and has no previous state, e.g. cart, order, etc. + */ +export const getAnonymousShopper = async ( + browser: Browser +): Promise< { + shopperPage: Page; + shopperContext: BrowserContext; +} > => { + const shopperContext = await browser.newContext(); + const shopperPage = await shopperContext.newPage(); + return { shopperPage, shopperContext }; +}; + +/** + * Conditionally determine whether or not to skip a test suite. + */ +export const describeif = ( condition: boolean ) => + condition ? test.describe : test.describe.skip; + +export const isUIUnblocked = async ( page: Page ) => { + await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 ); +}; + +export const checkPageExists = async ( + page: Page, + pageUrl: string +): Promise< boolean > => { + // Check whether specified page exists + return page + .goto( pageUrl, { + waitUntil: 'load', + } ) + .then( ( response ) => { + if ( response.status() === 404 ) { + return false; + } + return true; + } ) + .catch( () => { + return false; + } ); +}; + +export const isCustomerLoggedIn = async ( page: Page ) => { + await page.goto( '/my-account' ); + const logoutLink = page.locator( + '.woocommerce-MyAccount-navigation-link--customer-logout' + ); + + return await logoutLink.isVisible(); +}; + +export const loginAsCustomer = async ( + page: Page, + customer: { username: string; password: string } +) => { + let customerLoggedIn = false; + const customerRetries = 5; + + for ( let i = 0; i < customerRetries; i++ ) { + try { + // eslint-disable-next-line no-console + console.log( 'Trying to log-in as customer...' ); + await wpAdminLogin( page, customer ); + + await page.goto( '/my-account' ); + await expect( + page.locator( + '.woocommerce-MyAccount-navigation-link--customer-logout' + ) + ).toBeVisible(); + await expect( + page.locator( 'div.woocommerce-MyAccount-content > p >> nth=0' ) + ).toContainText( 'Hello' ); + + console.log( 'Logged-in as customer successfully.' ); + customerLoggedIn = true; + break; + } catch ( e ) { + console.log( + `Customer log-in failed. Retrying... ${ i }/${ customerRetries }` + ); + console.log( e ); + } + } + + if ( ! customerLoggedIn ) { + throw new Error( + 'Cannot proceed e2e test, as customer login failed. Please check if the test site has been setup correctly.' + ); + } + + await page.context().storageState( { path: customerStorageFile } ); +}; + +/** + * Adds a special cookie during the session to avoid the support session detection page. + * This is temporarily displayed when navigating to the login page while Jetpack SSO and protect modules are disabled. + * Relevant for Atomic sites only. + */ +export const addSupportSessionDetectedCookie = async ( + page: Page, + project: FullProject +) => { + if ( process.env.NODE_ENV !== 'atomic' ) return; + + const domain = new URL( project.use.baseURL ).hostname; + + await page.context().addCookies( [ + { + value: 'true', + name: '_wpcomsh_support_session_detected', + path: '/', + domain, + }, + ] ); +}; + +export const ensureCustomerIsLoggedIn = async ( + page: Page, + project: FullProject +) => { + if ( ! ( await isCustomerLoggedIn( page ) ) ) { + await addSupportSessionDetectedCookie( page, project ); + await loginAsCustomer( page, config.users.customer ); + } +}; + +export const loginAsEditor = async ( + page: Page, + editor: { username: string; password: string } +) => { + let editorLoggedIn = false; + const editorRetries = 5; + + for ( let i = 0; i < editorRetries; i++ ) { + try { + // eslint-disable-next-line no-console + console.log( 'Trying to log-in as editor...' ); + await wpAdminLogin( page, editor ); + await page.goto( '/wp-admin' ); + await page.waitForLoadState( 'domcontentloaded' ); + await expect( + page.getByRole( 'heading', { name: 'Dashboard' } ) + ).toContainText( 'Dashboard' ); + + console.log( 'Logged-in as editor successfully.' ); + editorLoggedIn = true; + break; + } catch ( e ) { + console.log( + `Editor log-in failed. Retrying... ${ i }/${ editorRetries }` + ); + console.log( e ); + } + } + + if ( ! editorLoggedIn ) { + throw new Error( + 'Cannot proceed with e2e test, as editor login failed. Please check if the test site has been setup correctly.' + ); + } + + await page.context().storageState( { path: editorStorageFile } ); +}; + +/** + * Returns the editor authenticated page and context. + * Allows switching between editor and other user contexts within a single test. + */ +export const getEditor = async ( + browser: Browser +): Promise< { + editorPage: Page; + editorContext: BrowserContext; +} > => { + const editorContext = await browser.newContext( { + storageState: editorStorageFile, + } ); + const editorPage = await editorContext.newPage(); + return { editorPage, editorContext }; +}; diff --git a/tests/qit/e2e/utils/merchant.ts b/tests/qit/e2e/utils/merchant.ts new file mode 100644 index 00000000000..bc29152783a --- /dev/null +++ b/tests/qit/e2e/utils/merchant.ts @@ -0,0 +1,464 @@ +/** + * External dependencies + */ +import { Page, expect } from '@playwright/test'; +import qit from '/qitHelpers'; + +import { config } from '../config/default'; + +type WidgetEntry = { + id: string; + id_base: string; + instance?: unknown; +}; + +const parseJson = < T >( value: string, fallback: T ): T => { + try { + return JSON.parse( value ) as T; + } catch ( _error ) { + return fallback; + } +}; + +const chunkArray = < T >( items: T[], size: number ): T[][] => { + if ( size <= 0 ) { + return [ items ]; + } + const chunks: T[][] = []; + for ( let index = 0; index < items.length; index += size ) { + chunks.push( items.slice( index, index + size ) ); + } + return chunks; +}; + +const shouldRemoveWidget = ( widget: WidgetEntry ) => { + if ( widget.id_base === 'currency_switcher_widget' ) { + return true; + } + + if ( widget.id_base === 'block' ) { + const serialized = JSON.stringify( widget.instance ?? '' ); + return serialized.includes( 'currency-switcher-holder' ); + } + + return false; +}; + +export async function dataHasLoaded( page: Page ) { + await expect( page.locator( '.is-loadable-placeholder' ) ).toHaveCount( 0 ); +} + +const goToWooPaymentsSettings = async ( page: Page ) => { + await page.goto( + '/wp-admin/admin.php?page=wc-settings&tab=checkout§ion=woocommerce_payments', + { waitUntil: 'load' } + ); + await dataHasLoaded( page ); +}; + +const goToMultiCurrencySettings = async ( page: Page ) => { + await page.goto( + '/wp-admin/admin.php?page=wc-settings&tab=wcpay_multi_currency', + { waitUntil: 'load' } + ); + await dataHasLoaded( page ); +}; + +const goToWooCommerceGeneralSettings = async ( page: Page ) => { + await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=general', { + waitUntil: 'load', + } ); + await expect( page.locator( '#woocommerce_currency' ) ).toBeVisible(); +}; + +const expectSnackbarWithText = async ( + page: Page, + text: string, + timeout = 10_000 +) => { + const snackbar = page.locator( '.components-snackbar__content', { + hasText: text, + } ); + await expect( snackbar ).toBeVisible( { timeout } ); + await page.waitForTimeout( 2_000 ); +}; + +const ensureSupportPhoneIsFilled = async ( page: Page ) => { + if ( ! page.url().includes( '§ion=woocommerce_payments' ) ) { + return; + } + const supportPhoneInput = page.getByPlaceholder( 'Mobile number' ); + if ( + ( await supportPhoneInput.count() ) && + ( await supportPhoneInput.inputValue() ) === '' + ) { + await supportPhoneInput.fill( '0000000000' ); + } +}; + +export const saveWooPaymentsSettings = async ( page: Page ) => { + await ensureSupportPhoneIsFilled( page ); + await page.getByRole( 'button', { name: 'Save changes' } ).click(); + await expectSnackbarWithText( page, 'Settings saved.' ); +}; + +export const saveMultiCurrencySettings = async ( page: Page ) => { + await page.getByRole( 'button', { name: 'Save changes' } ).click(); + await expectSnackbarWithText( page, 'Currency settings updated.' ); +}; + +export const getDefaultCurrency = async ( page: Page ) => { + await goToWooCommerceGeneralSettings( page ); + return await page.locator( '#woocommerce_currency' ).inputValue(); +}; + +export const setDefaultCurrency = async ( + page: Page, + currencyCode: string +) => { + await goToWooCommerceGeneralSettings( page ); + const currencySelect = page.locator( '#woocommerce_currency' ); + const currentCurrency = await currencySelect.inputValue(); + + if ( currentCurrency === currencyCode ) { + return; + } + + await currencySelect.selectOption( currencyCode ); + await page.getByRole( 'button', { name: 'Save changes' } ).click(); + const successNotice = page + .locator( '.notice-success, .updated' ) + .filter( { hasText: 'Your settings have been saved.' } ); + await expect( successNotice ).toBeVisible(); +}; + +export const isMulticurrencyEnabled = async ( page: Page ) => { + await goToWooPaymentsSettings( page ); + return await page.getByTestId( 'multi-currency-toggle' ).isChecked(); +}; + +export const activateMulticurrency = async ( page: Page ) => { + await goToWooPaymentsSettings( page ); + const toggle = page.getByTestId( 'multi-currency-toggle' ); + const wasEnabled = await toggle.isChecked(); + + if ( ! wasEnabled ) { + await toggle.check(); + await saveWooPaymentsSettings( page ); + } + + return wasEnabled; +}; + +export const deactivateMulticurrency = async ( page: Page ) => { + await goToWooPaymentsSettings( page ); + const toggle = page.getByTestId( 'multi-currency-toggle' ); + if ( await toggle.isChecked() ) { + await toggle.uncheck(); + await saveWooPaymentsSettings( page ); + } +}; + +export const removeMultiCurrencyWidgets = async () => { + // Avoid relying on `wp widget list --fields=...` because some WP-CLI + // versions/environment may not expose `id_base` or `instance` as fields. + // Instead run a small PHP eval that examines the widget options and + // returns the widget ids to delete as a JSON array. + const php = ` +$ids = array(); +$widgets = get_option( 'widget_currency_switcher_widget', array() ); +foreach ( $widgets as $num => $inst ) { + if ( $num === '_multiwidget' ) { + continue; + } + $ids[] = 'currency_switcher_widget-' . $num; +} +$blocks = get_option( 'widget_block', array() ); +foreach ( $blocks as $num => $inst ) { + if ( $num === '_multiwidget' ) { + continue; + } + $rendered = is_array( $inst ) ? wp_json_encode( $inst ) : strval( $inst ); + if ( strpos( $rendered, 'currency-switcher-holder' ) !== false ) { + $ids[] = 'block-' . $num; + } +} +echo wp_json_encode( array_values( array_unique( $ids ) ) ); +`; + + const { stdout } = await qit.wp( + `eval '${ php.replace( /'/g, "'\"'\"'" ) }'`, + true + ); + const widgetIds = parseJson< string[] >( + stdout.trim().length ? stdout : '[]', + [] + ); + + if ( ! widgetIds.length ) { + return; + } + + for ( const batch of chunkArray( widgetIds, 5 ) ) { + await qit.wp( `widget delete ${ batch.join( ' ' ) }`, true ); + } +}; + +export const addMulticurrencyWidget = async ( + page: Page, + blocksVersion = false +) => { + await removeMultiCurrencyWidgets(); + + await page.goto( '/wp-admin/widgets.php', { + waitUntil: 'load', + } ); + + try { + await page + .locator( '.components-spinner' ) + .first() + .waitFor( { timeout: 2_000 } ); + await expect( page.locator( '.components-spinner' ) ).toHaveCount( 0 ); + } catch { + // The spinner is not always present when the widget area is empty. + } + + const closeModalButton = page.getByRole( 'button', { name: 'Close' } ); + if ( await closeModalButton.isVisible() ) { + await closeModalButton.click(); + } + + await expect( page.locator( '.components-spinner' ) ).toHaveCount( 0 ); + + const widgetName = blocksVersion + ? 'Currency Switcher Block' + : 'Currency Switcher Widget'; + const isWidgetAdded = blocksVersion + ? ( await page.locator( `[data-title="${ widgetName }"]` ).count() ) > 0 + : ( await page.getByRole( 'heading', { name: widgetName } ).count() ) > + 0; + + if ( isWidgetAdded ) { + return; + } + + await page.getByRole( 'button', { name: 'Add block' } ).click(); + const searchInput = page.locator( 'input[placeholder="Search"]' ); + await searchInput.pressSequentially( widgetName, { delay: 20 } ); + await expect( + page.locator( 'button.components-button[role="option"]' ).first() + ).toBeVisible( { timeout: 5_000 } ); + await page + .locator( 'button.components-button[role="option"]' ) + .first() + .click(); + await page.waitForTimeout( 2_000 ); + await expect( + page.getByRole( 'button', { name: 'Update' } ) + ).toBeEnabled(); + await page.getByRole( 'button', { name: 'Update' } ).click(); + await expectSnackbarWithText( page, 'Widgets saved.' ); +}; + +export const createPendingOrder = async (): Promise< string > => { + const billing = config.addresses.customer.billing; + const billingPayload = JSON.stringify( { + first_name: billing.firstname, + last_name: billing.lastname, + company: billing.company, + address_1: billing.addressfirstline, + address_2: billing.addresssecondline, + city: billing.city, + state: billing.state, + postcode: billing.postcode, + country: billing.country_code, + email: billing.email, + phone: billing.phone, + } ); + const escapedBilling = billingPayload.replace( /'/g, `'"'"'` ); + const customerUsername = config.users.customer.username; + const script = ` +$products = wc_get_products( array( + 'limit' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + 'return' => 'objects', + 'paginated' => false, + 'status' => array( 'publish' ), +) ); +if ( empty( $products ) ) { + throw new Exception( 'No products available for order creation.' ); +} +$order = wc_create_order( array( 'status' => 'pending' ) ); +$order->add_product( $products[0], 1 ); +$billing = json_decode( '${ escapedBilling }', true ); +if ( is_array( $billing ) ) { + $order->set_address( $billing, 'billing' ); + $order->set_address( $billing, 'shipping' ); +} +$customer = get_user_by( 'login', '${ customerUsername }' ); +if ( $customer && ! is_wp_error( $customer ) ) { + $order->set_customer_id( (int) $customer->ID ); +} +$order->calculate_totals(); +echo $order->get_id(); +`; + const escapedScript = script.replace( /'/g, `'"'"'` ); + const { stdout } = await qit.wp( `eval '${ escapedScript }'`, true ); + const orderId = stdout.trim().split( /\s+/ ).pop() ?? ''; + if ( ! orderId ) { + throw new Error( 'Failed to create order for pay-for-order flow.' ); + } + return orderId; +}; + +const disableAllEnabledCurrencies = async ( page: Page ) => { + await goToMultiCurrencySettings( page ); + + const deleteButtons = () => + page.locator( '.enabled-currency .enabled-currency__action.delete' ); + + while ( await deleteButtons().count() ) { + await deleteButtons().first().click(); + await expectSnackbarWithText( page, 'Enabled currencies updated.' ); + } +}; + +const setEnabledCurrencies = async ( page: Page, currencies: string[] ) => { + await disableAllEnabledCurrencies( page ); + + const currenciesToEnable = Array.from( + new Set( + currencies + .map( ( currency ) => currency.toUpperCase() ) + .filter( ( currency ) => currency !== 'USD' ) + ) + ); + + if ( ! currenciesToEnable.length ) { + return; + } + + await page.getByTestId( 'enabled-currencies-add-button' ).click(); + + for ( const currency of currenciesToEnable ) { + await page + .locator( `input[type="checkbox"][code="${ currency }"]` ) + .check(); + } + + await page.getByRole( 'button', { name: 'Update selected' } ).click(); + await expectSnackbarWithText( page, 'Enabled currencies updated.' ); + + for ( const currency of currenciesToEnable ) { + await expect( + page.locator( `li.enabled-currency.${ currency.toLowerCase() }` ) + ).toBeVisible(); + } +}; + +export const getEnabledCurrenciesSnapshot = async ( page: Page ) => { + await goToMultiCurrencySettings( page ); + + const currencies = await page + .locator( '.enabled-currencies-list li.enabled-currency' ) + .evaluateAll( ( elements ) => + elements + .map( ( element ) => { + const className = element.getAttribute( 'class' ) ?? ''; + const match = className.match( + /enabled-currency\s+([a-z]{3})/ + ); + return match ? match[ 1 ].toUpperCase() : ''; + } ) + .filter( Boolean ) + ); + + return currencies; +}; + +export const restoreCurrencies = async ( + page: Page, + currencies: string[] = [ 'EUR', 'GBP' ] +) => { + await setEnabledCurrencies( page, currencies ); +}; + +export const addCurrency = async ( page: Page, currencyCode: string ) => { + if ( currencyCode === 'USD' ) { + return; + } + + await goToMultiCurrencySettings( page ); + await page.getByTestId( 'enabled-currencies-add-button' ).click(); + + const checkbox = page.locator( + `input[type="checkbox"][code="${ currencyCode }"]` + ); + + if ( ! ( await checkbox.isChecked() ) ) { + await checkbox.check(); + } + + await page.getByRole( 'button', { name: 'Update selected' } ).click(); + await expectSnackbarWithText( page, 'Enabled currencies updated.' ); + await expect( + page.locator( `li.enabled-currency.${ currencyCode.toLowerCase() }` ) + ).toBeVisible(); +}; + +export const enablePaymentMethods = async ( + page: Page, + paymentMethods: string[] +) => { + await goToWooPaymentsSettings( page ); + let atLeastOnePaymentMethodEnabled = false; + + for ( const paymentMethodName of paymentMethods ) { + const checkbox = page.getByLabel( paymentMethodName ); + if ( ! ( await checkbox.isChecked() ) ) { + await checkbox.check(); + atLeastOnePaymentMethodEnabled = true; + } + } + + if ( atLeastOnePaymentMethodEnabled ) { + await saveWooPaymentsSettings( page ); + } +}; + +export const disablePaymentMethods = async ( + page: Page, + paymentMethods: string[] +) => { + await goToWooPaymentsSettings( page ); + let atLeastOnePaymentMethodDisabled = false; + + for ( const paymentMethodName of paymentMethods ) { + const checkbox = page.getByLabel( paymentMethodName ); + + if ( await checkbox.isChecked() ) { + await checkbox.click(); + atLeastOnePaymentMethodDisabled = true; + const removeButton = page.getByRole( 'button', { name: 'Remove' } ); + if ( await removeButton.isVisible() ) { + await removeButton.click(); + } + } + } + + if ( atLeastOnePaymentMethodDisabled ) { + await saveWooPaymentsSettings( page ); + } +}; + +export const activateTheme = async ( slug: string ) => { + try { + await qit.wp( `theme is-installed ${ slug }`, true ); + } catch ( error ) { + await qit.wp( `theme install ${ slug } --force`, true ); + } + + await qit.wp( `theme activate ${ slug }`, true ); +}; diff --git a/tests/qit/e2e/utils/shopper-navigation.ts b/tests/qit/e2e/utils/shopper-navigation.ts new file mode 100644 index 00000000000..fb815d8e0d5 --- /dev/null +++ b/tests/qit/e2e/utils/shopper-navigation.ts @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { Page } from 'playwright/test'; + +/** + * Internal dependencies + */ +import { isUIUnblocked } from './helpers'; + +export const goToShop = async ( + page: Page, + { pageNumber, currency }: { pageNumber?: number; currency?: string } = {} +) => { + let url = '/shop/'; + + if ( pageNumber ) { + url += `page/${ pageNumber }/`; + } + + if ( currency ) { + url += `?currency=${ currency }`; + } + + await page.goto( url, { waitUntil: 'load' } ); +}; + +export const goToProductPageBySlug = async ( + page: Page, + productSlug: string +) => { + await page.goto( `/product/${ productSlug }`, { waitUntil: 'load' } ); +}; + +export const goToCart = async ( page: Page ) => { + await page.goto( '/cart/', { waitUntil: 'load' } ); + await isUIUnblocked( page ); +}; + +export const goToCheckout = async ( + page: Page, + { currency }: { currency?: string } = {} +) => { + let url = '/checkout/'; + + if ( currency ) { + url += `?currency=${ currency }`; + } + + await page.goto( url, { waitUntil: 'load' } ); + await isUIUnblocked( page ); +}; + +export const goToCheckoutWCB = async ( page: Page ) => { + await page.goto( '/checkout-wcb', { + waitUntil: 'load', + } ); + // since the block-based checkout page has a few async things, we need to wait for the UI to be fully rendered. + await page + .getByRole( 'heading', { name: 'Contact information' } ) + .waitFor( { state: 'visible' } ); +}; + +export const goToOrders = async ( page: Page ) => { + await page.goto( '/my-account/orders/', { + waitUntil: 'load', + } ); +}; + +export const goToOrder = async ( page: Page, orderId: string ) => { + await page.goto( `/my-account/view-order/${ orderId }`, { + waitUntil: 'load', + } ); +}; + +export const goToMyAccount = async ( page: Page, subPage?: string ) => { + await page.goto( '/my-account/' + ( subPage ?? '' ), { + waitUntil: 'load', + } ); +}; + +export const goToSubscriptions = async ( page: Page ) => + await goToMyAccount( page, 'subscriptions' ); diff --git a/tests/qit/e2e/utils/shopper.ts b/tests/qit/e2e/utils/shopper.ts new file mode 100644 index 00000000000..09026336c50 --- /dev/null +++ b/tests/qit/e2e/utils/shopper.ts @@ -0,0 +1,870 @@ +/** + * External dependencies + */ +import { Locator, Page, expect } from 'playwright/test'; +/** + * Internal dependencies + */ +import * as navigation from './shopper-navigation'; +import { config, CustomerAddress, Product } from '../config/default'; +import { isUIUnblocked } from './helpers'; + +/** + * Waits for the UI to refresh after a user interaction. + * + * Woo core blocks and refreshes the UI after 1s after each key press + * in a text field or immediately after a select field changes. + * We need to wait to make sure that all key presses were processed by that mechanism. + */ +export const waitForUiRefresh = ( page: Page ) => page.waitForTimeout( 1000 ); + +/** + * Takes off the focus out of the Stripe elements to let Stripe logic + * wrap up and make sure the Place Order button is clickable. + */ +export const focusPlaceOrderButton = async ( page: Page ) => { + await page.locator( '#place_order' ).focus(); + await waitForUiRefresh( page ); +}; + +export const fillBillingAddress = async ( + page: Page, + billingAddress: CustomerAddress +) => { + await page + .locator( '#billing_first_name' ) + .fill( billingAddress.firstname ); + await page.locator( '#billing_last_name' ).fill( billingAddress.lastname ); + await page.locator( '#billing_company' ).fill( billingAddress.company ); + await page + .locator( '#billing_country' ) + .selectOption( billingAddress.country ); + await page + .locator( '#billing_address_1' ) + .fill( billingAddress.addressfirstline ); + await page + .locator( '#billing_address_2' ) + .fill( billingAddress.addresssecondline ); + await page.locator( '#billing_city' ).fill( billingAddress.city ); + if ( billingAddress.state ) { + // Setting the state is optional, relative to the selected country. E.g Selecting Belgium hides the state input. + await page + .locator( '#billing_state' ) + .selectOption( billingAddress.state ); + } + await page.locator( '#billing_postcode' ).fill( billingAddress.postcode ); + await page.locator( '#billing_phone' ).fill( billingAddress.phone ); + await page.locator( '#billing_email' ).fill( billingAddress.email ); +}; + +export const fillBillingAddressWCB = async ( + page: Page, + billingAddress: CustomerAddress +) => { + const editBillingAddressButton = page.getByLabel( 'Edit billing address' ); + if ( await editBillingAddressButton.isVisible() ) { + await editBillingAddressButton.click(); + } + const billingAddressForm = page.getByRole( 'group', { + name: 'Billing address', + } ); + + const countryField = billingAddressForm.getByLabel( 'Country/Region' ); + + try { + await countryField.selectOption( billingAddress.country ); + } catch ( error ) { + // Fallback for WC 7.7.0. + await countryField.focus(); + await countryField.fill( billingAddress.country ); + + await page + .locator( '.components-form-token-field__suggestion' ) + .first() + .click(); + } + + await billingAddressForm + .getByLabel( 'First Name' ) + .fill( billingAddress.firstname ); + await billingAddressForm + .getByLabel( 'Last Name' ) + .fill( billingAddress.firstname ); + await billingAddressForm + .getByLabel( 'Company (optional)' ) + .fill( billingAddress.company ); + await billingAddressForm + .getByLabel( 'Address', { exact: true } ) + .fill( billingAddress.addressfirstline ); + const addSecondLineButton = page.getByRole( 'button', { + name: '+ Add apartment, suite, etc.', + } ); + if ( ( await addSecondLineButton.count() ) > 0 ) { + await addSecondLineButton.click(); + } + await billingAddressForm + .getByLabel( 'Apartment, suite, etc. (optional)' ) + .fill( billingAddress.addresssecondline ); + await billingAddressForm.getByLabel( 'City' ).fill( billingAddress.city ); + + const stateInput = billingAddressForm.getByLabel( 'State', { + exact: true, + } ); + if ( billingAddress.state ) { + try { + await stateInput.selectOption( billingAddress.state ); + } catch ( error ) { + // Fallback for WC 7.7.0. + await stateInput.fill( billingAddress.state ); + } + } + await billingAddressForm + .getByLabel( 'ZIP Code' ) + .fill( billingAddress.postcode ); + await billingAddressForm + .getByLabel( 'Phone (optional)' ) + .fill( billingAddress.phone ); +}; + +// This is currently the source of some flaky tests since sometimes the form is not submitted +// after the first click, so we retry until the ui is blocked. +export const placeOrder = async ( page: Page ) => { + let orderPlaced = false; + while ( ! orderPlaced ) { + await page.locator( '#place_order' ).click(); + + if ( await page.$( '.blockUI' ) ) { + orderPlaced = true; + } + } +}; + +const orderConfirmationTimeout = 30_000; + +const isLocatorVisible = async ( locator: Locator ) => { + try { + return await locator.isVisible(); + } catch ( _error ) { + return false; + } +}; + +export const waitForOrderConfirmationWCB = async ( page: Page ) => { + const orderReceivedHeading = page + .getByRole( 'heading', { name: 'Order received' } ) + .first(); + const orderConfirmationHeading = page + .getByRole( 'heading', { name: 'Order confirmation' } ) + .first(); + const thankYouNotice = page + .locator( '.woocommerce-notice.woocommerce-notice--success' ) + .first(); + + await new Promise< void >( ( resolve, reject ) => { + let settled = false; + const timer = setTimeout( () => { + if ( settled ) { + return; + } + settled = true; + reject( + new Error( + 'Timed out waiting for the Blocks checkout confirmation view.' + ) + ); + }, orderConfirmationTimeout ); + + const handleSuccess = () => { + if ( settled ) { + return; + } + settled = true; + clearTimeout( timer ); + resolve(); + }; + + page.waitForURL( /\/order-received\//, { + timeout: orderConfirmationTimeout, + } ) + .then( handleSuccess ) + .catch( () => undefined ); + orderReceivedHeading + .waitFor( { + state: 'visible', + timeout: orderConfirmationTimeout, + } ) + .then( handleSuccess ) + .catch( () => undefined ); + orderConfirmationHeading + .waitFor( { + state: 'visible', + timeout: orderConfirmationTimeout, + } ) + .then( handleSuccess ) + .catch( () => undefined ); + thankYouNotice + .waitFor( { + state: 'visible', + timeout: orderConfirmationTimeout, + } ) + .then( handleSuccess ) + .catch( () => undefined ); + } ); + + if ( await isLocatorVisible( orderReceivedHeading ) ) { + await expect( orderReceivedHeading ).toBeVisible(); + return; + } + + if ( await isLocatorVisible( orderConfirmationHeading ) ) { + await expect( orderConfirmationHeading ).toBeVisible(); + return; + } + + await expect( thankYouNotice ).toBeVisible(); +}; + +export const placeOrderWCB = async ( + page: Page, + confirmOrderReceived = true +) => { + const placeOrderButton = page.getByRole( 'button', { + name: 'Place Order', + } ); + + await placeOrderButton.focus(); + await waitForUiRefresh( page ); + + await placeOrderButton.click(); + + if ( confirmOrderReceived ) { + await waitForOrderConfirmationWCB( page ); + } +}; + +const ensureSavedCardNotSelected = async ( page: Page ) => { + if ( + await page + .locator( '#wc-woocommerce_payments-payment-token-new' ) + .isVisible() + ) { + const newCardOption = await page.locator( + '#wc-woocommerce_payments-payment-token-new' + ); + if ( newCardOption ) { + await newCardOption.click(); + } + } +}; + +export const fillCardDetails = async ( + page: Page, + card = config.cards.basic +) => { + await ensureSavedCardNotSelected( page ); + if ( + await page.$( + '#payment .payment_method_woocommerce_payments .wcpay-upe-element' + ) + ) { + const frameHandle = await page.waitForSelector( + '#payment .payment_method_woocommerce_payments .wcpay-upe-element iframe' + ); + + const stripeFrame = await frameHandle.contentFrame(); + + if ( ! stripeFrame ) return; + + await stripeFrame.locator( '[name="number"]' ).fill( card.number ); + + await stripeFrame + .locator( '[name="expiry"]' ) + .fill( card.expires.month + card.expires.year ); + + await stripeFrame.locator( '[name="cvc"]' ).fill( card.cvc ); + + const zip = stripeFrame.locator( '[name="postalCode"]' ); + + if ( await zip.isVisible() ) { + await zip.fill( '90210' ); + } + } else { + const frameHandle = await page.waitForSelector( + '#payment #wcpay-card-element iframe[name^="__privateStripeFrame"]' + ); + const stripeFrame = await frameHandle.contentFrame(); + + if ( ! stripeFrame ) return; + + await stripeFrame.locator( '[name="cardnumber"]' ).fill( card.number ); + + await stripeFrame + .locator( '[name="exp-date"]' ) + .fill( card.expires.month + card.expires.year ); + + await stripeFrame.locator( '[name="cvc"]' ).fill( card.cvc ); + } +}; + +export const fillCardDetailsWCB = async ( + page: Page, + card: typeof config.cards.basic +) => { + const newPaymentMethodRadioButton = page.locator( + '#radio-control-wc-payment-method-options-woocommerce_payments' + ); + if ( await newPaymentMethodRadioButton.isVisible() ) { + await newPaymentMethodRadioButton.click(); + } + await page.waitForSelector( '.__PrivateStripeElement' ); + const frameHandle = await page.waitForSelector( + '#payment-method .wcpay-payment-element iframe[name^="__privateStripeFrame"]' + ); + const stripeFrame = await frameHandle.contentFrame(); + if ( ! stripeFrame ) return; + await stripeFrame.getByPlaceholder( '1234 1234 1234' ).fill( card.number ); + await stripeFrame + .getByPlaceholder( 'MM / YY' ) + .fill( card.expires.month + card.expires.year ); + + await stripeFrame.getByPlaceholder( 'CVC' ).fill( card.cvc ); +}; + +const stripeChallengeAppearTimeout = 8_000; +const stripeChallengeBodyTimeout = 8_000; + +export const confirmCardAuthentication = async ( + page: Page, + authorize = true +) => { + // Allow the Stripe modal to mount if it is going to show up. + await page.waitForTimeout( 1_000 ); + + // Stripe card input also uses __privateStripeFrame as a prefix, so need to make sure we wait for an iframe that + // appears at the top of the DOM. If it never appears, skip gracefully. + const privateFrame = page.locator( + 'body > div > iframe[name^="__privateStripeFrame"]' + ); + const appeared = await privateFrame + .waitFor( { + state: 'visible', + timeout: stripeChallengeAppearTimeout, + } ) + .then( () => true ) + .catch( () => false ); + if ( ! appeared ) return; + + const stripeFrame = page.frameLocator( + 'body>div>iframe[name^="__privateStripeFrame"]' + ); + if ( ! stripeFrame ) return; + + const challengeFrame = stripeFrame.frameLocator( + 'iframe[name="stripe-challenge-frame"]' + ); + // If challenge frame never appears, assume frictionless and return. + try { + await challengeFrame.locator( 'body' ).waitFor( { + state: 'visible', + timeout: stripeChallengeBodyTimeout, + } ); + } catch ( _e ) { + return; + } + + const button = challengeFrame.getByRole( 'button', { + name: authorize ? 'Complete' : 'Fail', + } ); + + await expect( + stripeFrame.locator( '.LightboxModalLoadingIndicator' ) + ).not.toBeVisible(); + + await button.click(); +}; + +/** + * Retrieves the product price from the current product page. + * + * This function assumes that the page object has already navigated to a product page. + */ +export const getPriceFromProduct = async ( page: Page, slug: string ) => { + await navigation.goToProductPageBySlug( page, slug ); + + const priceText = await page + .locator( 'ins .woocommerce-Price-amount.amount' ) + .first() + .textContent(); + + return priceText?.replace( /[^0-9.,]/g, '' ) ?? ''; +}; + +/** + * Adds a product to the cart from the shop page. + * + * @param {Page} page The Playwright page object. + * @param {Product} product The product add to the cart. + */ +export const addToCartFromShopPage = async ( + page: Page, + product: Product = config.products.simple, + currency?: string +) => { + await navigation.goToShop( page, { + pageNumber: product.pageNumber, + currency, + } ); + + // This generic regex will match the aria-label for the "Add to cart" button for any product. + // It should work for WC 7.7.0 and later. + // These unicode characters are the smart (or curly) quotes: “ ”. + const addToCartRegex = new RegExp( + `Add\\s+(?:to\\s+cart:\\s*)?\u201C${ product.name }\u201D(?:\\s+to\\s+your\\s+cart)?` + ); + + const addToCartButton = page.getByLabel( addToCartRegex ); + await addToCartButton.click(); + + try { + await expect( addToCartButton ).toHaveAttribute( 'class', /added/, { + timeout: 5000, + } ); + } catch ( error ) { + // fallback for a different theme. + await expect( addToCartButton ).toHaveText( /in cart/ ); + } +}; + +export const selectPaymentMethod = async ( + page: Page, + paymentMethod = 'Card' +) => { + // Wait for the page to be stable before attempting to select payment method + // Use a more reliable approach than networkidle which can timeout + await page.waitForLoadState( 'domcontentloaded' ); + + // Ensure UI is not blocked + await isUIUnblocked( page ); + + // Wait for payment methods to be fully loaded and stable + await page.waitForSelector( '.wc_payment_methods', { timeout: 10000 } ); + + // Try to find and click the payment method with retry logic + const maxRetries = 3; + for ( let attempt = 1; attempt <= maxRetries; attempt++ ) { + try { + // Use a more robust locator that handles mixed content in labels + // Look for the label containing the payment method text + const paymentMethodElement = page + .locator( `label:has-text("${ paymentMethod }")` ) + .first(); + + // Wait for the element to be visible and stable + await expect( paymentMethodElement ).toBeVisible( { + timeout: 5000, + } ); + + // Ensure the element is in viewport + await paymentMethodElement.scrollIntoViewIfNeeded(); + + // Wait a bit more for any animations to complete + await page.waitForTimeout( 200 ); + + // Click the payment method + await paymentMethodElement.click(); + + // Wait a moment to ensure the click was processed + await page.waitForTimeout( 100 ); + + // If we get here, the click was successful + break; + } catch ( error ) { + if ( attempt === maxRetries ) { + throw error; + } + // Wait a bit before retrying + await page.waitForTimeout( 1000 ); + } + } +}; + +/** + * The checkout page can sometimes be blank, so we need to reload it. + * + * @param page Page + */ +export const ensureCheckoutIsLoaded = async ( page: Page ) => { + if ( ! ( await page.locator( '#billing_first_name' ).isVisible() ) ) { + await page.reload(); + } +}; + +export const setupCheckout = async ( + page: Page, + billingAddress: CustomerAddress = config.addresses.customer.billing +) => { + await navigation.goToCheckout( page ); + await ensureCheckoutIsLoaded( page ); + await fillBillingAddress( page, billingAddress ); + await waitForUiRefresh( page ); + await isUIUnblocked( page ); +}; + +/** + * Sets up checkout with any number of products. + * + * @param {Array<[string, number]>} lineItems A 2D array of line items where each line item is an array + * that contains the product title as the first element, and the quantity as the second. + * For example, if you want to checkout x2 "Hoodie" and x3 "Belt" then set this parameter like this: + * + * `[ [ "Hoodie", 2 ], [ "Belt", 3 ] ]`. + * @param {CustomerAddress} billingAddress The billing address to use for the checkout. + */ +export async function setupProductCheckout( + page: Page, + lineItems: Array< [ Product, number ] > = [ [ config.products.simple, 1 ] ], + billingAddress: CustomerAddress = config.addresses.customer.billing, + currency?: string +) { + await navigation.goToShop( page ); + + const cartSizeText = await page + .locator( '.cart-contents .count' ) + .textContent(); + let cartSize = Number( cartSizeText?.replace( /\D/g, '' ) ?? '0' ); + + for ( const line of lineItems ) { + let [ product, qty ] = line; + + while ( qty-- ) { + await addToCartFromShopPage( page, product, currency ); + + // Make sure the number of items in the cart is incremented before adding another item. + await expect( page.locator( '.cart-contents .count' ) ).toHaveText( + new RegExp( `${ ++cartSize } items?` ), + { + timeout: 30000, + } + ); + + // Wait for the cart to update before adding another item. + await page.waitForTimeout( 500 ); + } + } + + await setupCheckout( page, billingAddress ); +} + +export const expectFraudPreventionToken = async ( + page: Page, + toBeDefined: boolean +) => { + const token = await page.evaluate( () => { + return ( window as any ).wcpayFraudPreventionToken; + } ); + + if ( toBeDefined ) { + expect( token ).toBeDefined(); + } else { + expect( token ).toBeUndefined(); + } +}; + +/** + * Places an order with custom options. + * + * @param page The Playwright page object. + * @param options The custom options to use for the order. + * @return The order ID. + */ +export const placeOrderWithOptions = async ( + page: Page, + options?: { + product?: Product; + billingAddress?: CustomerAddress; + createAccount?: boolean; + } +) => { + await navigation.goToShop( page ); + await addToCartFromShopPage( page, options?.product ); + await setupCheckout( page, options?.billingAddress ); + if ( + options?.createAccount && + ( await page.getByLabel( 'Create an account?' ).isVisible() ) + ) { + await page.getByLabel( 'Create an account?' ).check(); + } + await selectPaymentMethod( page ); + await fillCardDetails( page, config.cards.basic ); + await focusPlaceOrderButton( page ); + await placeOrder( page ); + await page.waitForURL( /\/order-received\//, { + waitUntil: 'load', + } ); + await expect( + page.getByRole( 'heading', { name: 'Order received' } ) + ).toBeVisible(); + + const url = await page.url(); + return url.match( /\/order-received\/(\d+)\// )?.[ 1 ] ?? ''; +}; + +/** + * Places an order with a specified currency. + * + * @param {Page} page The Playwright page object. + * @param {string} currency The currency code to use for the order. + * @return {Promise} The order ID. + */ +export const placeOrderWithCurrency = async ( + page: Page, + currency: string +) => { + await navigation.goToShop( page, { currency } ); + return placeOrderWithOptions( page ); +}; + +export const setSavePaymentMethod = async ( page: Page, save = true ) => { + const checkbox = page.getByLabel( + 'Save payment information to my account for future purchases.' + ); + + const isChecked = await checkbox.isChecked(); + + if ( save && ! isChecked ) { + await checkbox.check(); + } else if ( ! save && isChecked ) { + await checkbox.uncheck(); + } +}; + +export const emptyCart = async ( page: Page ) => { + await navigation.goToCart( page ); + + // Remove products if they exist. + let products = await page.locator( '.remove' ).all(); + + while ( products.length ) { + await products[ 0 ].click(); + await isUIUnblocked( page ); + + products = await page.locator( '.remove' ).all(); + } + + // Remove coupons if they exist. + let coupons = await page.locator( '.woocommerce-remove-coupon' ).all(); + + while ( coupons.length ) { + await coupons[ 0 ].click(); + await isUIUnblocked( page ); + + coupons = await page.locator( '.woocommerce-remove-coupon' ).all(); + } + + await expect( + page.getByText( 'Your cart is currently empty.' ) + ).toBeVisible(); +}; + +export const changeAccountCurrency = async ( + page: Page, + customerDetails: any, + currency: string +) => { + await navigation.goToMyAccount( page, 'edit-account' ); + await page.getByLabel( 'First name *' ).fill( customerDetails.firstname ); + await page.getByLabel( 'Last name *' ).fill( customerDetails.lastname ); + await page.getByLabel( 'Default currency' ).selectOption( currency ); + await page.getByRole( 'button', { name: 'Save changes' } ).click(); + await expect( + page.getByText( 'Account details changed successfully.' ) + ).toBeVisible(); +}; + +export const addSavedCard = async ( + page: Page, + card: typeof config.cards.basic, + country: string, + zipCode?: string +) => { + await page.getByRole( 'link', { name: 'Add payment method' } ).click(); + // Wait for the page to be stable and the payment method list to render + await page.waitForLoadState( 'domcontentloaded' ); + await isUIUnblocked( page ); + await expect( + page.locator( 'input[name="payment_method"]' ).first() + ).toBeVisible( { timeout: 5000 } ); + + await page.getByText( 'Card', { exact: true } ).click(); + const frameHandle = page.getByTitle( 'Secure payment input frame' ); + const stripeFrame = frameHandle.contentFrame(); + + if ( ! stripeFrame ) return; + + await stripeFrame + .getByPlaceholder( '1234 1234 1234 1234' ) + .fill( card.number ); + + await stripeFrame + .getByPlaceholder( 'MM / YY' ) + .fill( card.expires.month + card.expires.year ); + + await stripeFrame.getByPlaceholder( 'CVC' ).fill( card.cvc ); + await stripeFrame + .getByRole( 'combobox', { name: 'country' } ) + .selectOption( country ); + const zip = stripeFrame.getByLabel( 'ZIP Code' ); + if ( zip ) await zip.fill( zipCode ?? '90210' ); + + await page.getByRole( 'button', { name: 'Add payment method' } ).click(); + + // Wait for one of the expected outcomes: + // - 3DS modal appears (Stripe iframe) + // - Success notice + // - Error notice (e.g., too soon after previous) + // - Redirect back to Payment methods page + const threeDSFrame = page.locator( + 'body > div > iframe[name^="__privateStripeFrame"]' + ); + const successNotice = page.getByText( + 'Payment method successfully added.' + ); + const tooSoonNotice = page.getByText( + 'You cannot add a new payment method so soon after the previous one.' + ); + const genericError = page.getByText( + "We're not able to add this payment method. Please refresh the page and try again." + ); + const methodsHeading = page.getByRole( 'heading', { + name: 'Payment methods', + } ); + + await Promise.race( [ + threeDSFrame.waitFor( { state: 'visible', timeout: 20000 } ), + successNotice.waitFor( { state: 'visible', timeout: 20000 } ), + tooSoonNotice.waitFor( { state: 'visible', timeout: 20000 } ), + genericError.waitFor( { state: 'visible', timeout: 20000 } ), + methodsHeading.waitFor( { state: 'visible', timeout: 20000 } ), + ] ).catch( () => { + /* ignore and let the caller continue; downstream assertions will catch real issues */ + } ); +}; + +export const deleteSavedCard = async ( + page: Page, + card: typeof config.cards.basic +) => { + // Ensure UI is ready and table rendered + await isUIUnblocked( page ); + await expect( + page.getByRole( 'heading', { name: 'Payment methods' } ) + ).toBeVisible( { timeout: 10000 } ); + + // Saved methods are listed in a table in most themes; prefer the role=row + // but fall back to a simpler text-based locator if table semantics differ. + let row = page.getByRole( 'row', { name: card.label } ).first(); + const rowVisible = await row.isVisible().catch( () => false ); + if ( ! rowVisible ) { + row = page + .locator( 'tr, li, div' ) + .filter( { hasText: card.label } ) + .first(); + } + await expect( row ).toBeVisible( { timeout: 20000 } ); + const button = row.getByRole( 'link', { name: 'Delete' } ); + await expect( button ).toBeVisible( { timeout: 10000 } ); + await expect( button ).toBeEnabled( { timeout: 10000 } ); + await button.click(); + + // After clicking delete, wait for one of these to confirm deletion: + // - success notice + // - the row to be removed + const successNotice = page.getByText( 'Payment method deleted.' ); + try { + await Promise.race( [ + successNotice.waitFor( { state: 'visible', timeout: 20000 } ), + row.waitFor( { state: 'detached', timeout: 20000 } ), + ] ); + } catch ( _e ) { + // ignore; callers will assert expected state + } +}; + +export const selectSavedCardOnCheckout = async ( + page: Page, + card: typeof config.cards.basic +) => { + // Prefer the full "label (expires mm/yy)" text, but fall back to the label-only + // in environments where the expiry text may not be present in the option label. + let option = page + .getByText( + `${ card.label } (expires ${ card.expires.month }/${ card.expires.year })` + ) + .first(); + const found = await option.isVisible().catch( () => false ); + if ( ! found ) { + option = page.getByText( card.label ).first(); + } + await expect( option ).toBeVisible( { timeout: 15000 } ); + await option.click(); +}; + +export const setDefaultPaymentMethod = async ( + page: Page, + card: typeof config.cards.basic +) => { + const row = page.getByRole( 'row', { name: card.label } ).first(); + await expect( row ).toBeVisible( { timeout: 10000 } ); + + // Some themes/plugins render this as a link or a button; support both. + const makeDefault = row + .getByRole( 'link', { name: 'Make default' } ) + .or( row.getByRole( 'button', { name: 'Make default' } ) ); + + // If the card is already default, the control might be missing; bail gracefully. + if ( ! ( await makeDefault.count() ) ) { + return; + } + + await expect( makeDefault ).toBeVisible( { timeout: 10000 } ); + await expect( makeDefault ).toBeEnabled( { timeout: 10000 } ); + await makeDefault.click(); +}; + +export const removeCoupon = async ( page: Page ) => { + const couponRemovalLink = page.locator( '.woocommerce-remove-coupon' ); + + if ( await couponRemovalLink.isVisible() ) { + await couponRemovalLink.click(); + await expect( + page.getByText( 'Coupon has been removed.' ) + ).toBeVisible(); + } +}; + +/** + * When using a 3DS card, call this function after clicking the 'Place order' button + * to confirm the card authentication. + * + * @param {Page} page The Shopper page object. + * @param {boolean} authorize Whether to authorize the transaction or not. + * @return {Promise} Void. + */ +export const confirmCardAuthenticationWCB = async ( + page: Page, + authorize = true +): Promise< void > => { + const placeOrderButton = page.locator( + '.wc-block-components-checkout-place-order-button' + ); + await expect( placeOrderButton ).toBeDisabled(); + /** + * Starting around version 9.9.0 WooCommerce Blocks class names changed to + * be more specific. To cover both case, this check allows for additional + * sections in the "loading" class name. + */ + await expect( placeOrderButton ).toHaveClass( + /\bwc-block-components-(?:[-\w]+-)?button--loading\b/ + ); + await confirmCardAuthentication( page, authorize ); +}; diff --git a/tests/qit/qit.yml b/tests/qit/qit.yml index 8f00da72034..57092a4dd9e 100644 --- a/tests/qit/qit.yml +++ b/tests/qit/qit.yml @@ -18,4 +18,4 @@ plugin: # This mounts ./e2e/bootstrap (relative to this qit.yml file) to /qit/bootstrap # inside the QIT test container (read-only for safety). volumes: - - "./e2e/bootstrap:/qit/bootstrap:ro" + - "./tests/qit/e2e/bootstrap:/qit/bootstrap:ro"