diff --git a/.eslintignore b/.eslintignore index 590187b9d5e..e86dc1138a4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,4 @@ vendor/* release/* tests/e2e/docker* tests/e2e/deps* +tests/qit/test-package/* diff --git a/.prettierignore b/.prettierignore index d26e86682fb..61755509088 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ wordpress-org-assets tests/e2e/docker tests/e2e/deps +tests/qit/test-package docs/dependencies.md docs/metadata.md diff --git a/package.json b/package.json index 7cd84dbd036..4a3c9d463ae 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:qit-phpstan": "npm run build:release && ./tests/qit/phpstan.sh", "test:qit-phpstan-local": "npm run build:release && ./tests/qit/phpstan.sh --local", "test:qit-malware": "npm run build:release && ./tests/qit/malware.sh --local", - "test:qit-e2e": "./tests/qit/e2e-runner.sh", + "test:qit-e2e": "./vendor/bin/qit run:e2e woocommerce-payments --config tests/qit/qit.json --profile=default", "watch": "webpack --watch", "hmr": "webpack server", "start": "npm run watch", diff --git a/tests/qit/README.md b/tests/qit/README.md index 3a00c024800..35431b9c0f6 100644 --- a/tests/qit/README.md +++ b/tests/qit/README.md @@ -1,40 +1,61 @@ -## WooCommerce Payments QIT tests +## WooPayments QIT Tests We use the [QIT toolkit](https://qit.woo.com/docs/) for automated testing including security, PHPStan, and E2E tests. -#### Setup -- Create `local.env` inside the `tests/qit/config/` directory by copying the variables from `default.env`. -- To get the actual values for local config, refer to this [secret store](https://mc.a8c.com/secret-store/?secret_id=11043) link. -- Use standard `KEY=VALUE` format (no `export` keyword needed). -- Once configured, the first time you run a test command, it will create a local auth file for subsequent runs. +### Setup -#### Running Tests +1. Create `local.env` inside the `tests/qit/config/` directory by copying the variables from `default.env`. +2. Use standard `KEY=VALUE` format (no `export` keyword needed). +3. Configure the required credentials: + - **QIT authentication**: Get credentials from the [secret store](https://mc.a8c.com/secret-store/?secret_id=11043). These authenticate you with the QIT service. + - **E2E Jetpack credentials** (`E2E_JP_SITE_ID`, `E2E_JP_BLOG_TOKEN`, `E2E_JP_USER_TOKEN`): Get these from a Jurassic Ninja site already onboarded in test mode. +4. Once configured, the first time you run a test command, it will create a local auth file for subsequent runs. + +**Note:** E2E tests require the dev version of `qit-cli` (test packages are not yet in stable releases). Run `composer require woocommerce/qit-cli:dev-trunk --dev --ignore-platform-reqs` to install it locally. + +### Running Tests + +#### Security and PHPStan tests -**Security and PHPStan tests:** ```bash npm run test:qit-security npm run test:qit-phpstan npm run test:qit-phpstan-local # Against local development build ``` -**E2E tests:** +#### E2E Tests + +E2E tests use the [QIT Test Packages](https://qit.woo.com/docs/test-packages/) approach. Tests are located in `tests/qit/test-package/`. + +Before running E2E tests, build the plugin package: + ```bash -# Run all E2E tests -npm run test:qit-e2e +npm run build:release +``` + +This creates `woocommerce-payments.zip` which is used by QIT. Then run the tests with the required environment variables: + +```bash +# Run all E2E tests (prepend with env vars from local.env) +E2E_JP_SITE_ID='' E2E_JP_BLOG_TOKEN='' E2E_JP_USER_TOKEN='' npm run test:qit-e2e -# Run specific test file -npm run test:qit-e2e tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts +# Run specific test file (passthrough to Playwright) +E2E_JP_SITE_ID='' E2E_JP_BLOG_TOKEN='' E2E_JP_USER_TOKEN='' npm run test:qit-e2e -- -- shopper-checkout-purchase.spec.ts -# Run tests with specific tag -npm run test:qit-e2e -- --tag=@basic +# Run tests filtered by tag (e.g., @blocks, @shopper) +E2E_JP_SITE_ID='' E2E_JP_BLOG_TOKEN='' E2E_JP_USER_TOKEN='' npm run test:qit-e2e -- -- --grep "@blocks" ``` -**Note:** E2E tests require valid Jetpack credentials in `local.env` (`E2E_JP_SITE_ID`, `E2E_JP_BLOG_TOKEN`, `E2E_JP_USER_TOKEN`). +**Tip:** You can export the variables once per shell session instead of prepending each command: -- The commands use `build:release` to create `woocommerce-payments.zip` at the root of the directory which is then uploaded and used for the QIT tests. +```bash +set -a && source tests/qit/config/local.env && set +a +npm run test:qit-e2e +``` +### Analyzing Results -#### Analysing results -- Once the test run is done, you'll see a result URL along with the test summary. -- Look at any errors that might have been surfaced and associate with PRs that has introduced the same by using `git blame`. +- Once the test run completes, you'll see a result URL along with the test summary. +- Look at any errors that might have been surfaced and associate with PRs that introduced them using `git blame`. - Ping the author for fixing the error, or fix it yourself if it is straightforward enough. +- For failed tests, check the artifacts directory for screenshots and error context. diff --git a/tests/qit/e2e-runner.sh b/tests/qit/e2e-runner.sh deleted file mode 100755 index ef5854e7e6c..00000000000 --- a/tests/qit/e2e-runner.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env bash - -# Enable strict error handling and safe field splitting for reliability -set -euo pipefail -IFS=$'\n\t' - -# E2E test runner for WooPayments using QIT -cwd=$(pwd) -WCP_ROOT="$cwd" -QIT_ROOT="$cwd/tests/qit" - -# Read local.env and build --env arguments for QIT -if [[ -f "$QIT_ROOT/config/local.env" ]]; then - while IFS='=' read -r key value; do - # Skip comments and empty lines - [[ "$key" =~ ^[[:space:]]*# ]] && continue - [[ -z "$key" ]] && continue - - # Remove leading/trailing whitespace and quotes from value - value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^["'\'']//' -e 's/["'\'']$//') - - # Export for build scripts that might need it - export "${key}=${value}" - done < "$QIT_ROOT/config/local.env" -fi - -echo "Running E2E tests..." - -# Change to project root directory to build plugin -cd "$WCP_ROOT" - -# Compute a signature of sources relevant to the release build and -# skip rebuilding if nothing has changed since the last build. -compute_build_signature() { - # Hash tracked files that affect the release artifact. This includes - # sources packaged in the zip and build/config files that affect the output. - git ls-files -z -- \ - assets \ - i18n \ - includes \ - languages \ - lib \ - src \ - templates \ - client \ - tasks/release.js \ - webpack \ - webpack.config.js \ - babel.config.js \ - package.json \ - package-lock.json \ - composer.json \ - composer.lock \ - woocommerce-payments.php \ - changelog.txt \ - readme.txt \ - SECURITY.md \ - 2>/dev/null \ - | xargs -0 shasum -a 256 2>/dev/null \ - | shasum -a 256 \ - | awk '{print $1}' - - # Explicitly return 0 to avoid pipefail issues - return 0 -} - -BUILD_HASH_FILE="$WCP_ROOT/woocommerce-payments.zip.hash" - -CURRENT_SIG="$(compute_build_signature)" - -# If WCP_FORCE_BUILD is set, always rebuild -if [[ -n "${WCP_FORCE_BUILD:-}" ]]; then - echo "WCP_FORCE_BUILD set; forcing build of WooPayments plugin..." - npm run build:release - echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" -elif [[ -f "woocommerce-payments.zip" && -f "$BUILD_HASH_FILE" ]]; then - LAST_SIG="$(cat "$BUILD_HASH_FILE" 2>/dev/null || true)" - if [[ "$CURRENT_SIG" == "$LAST_SIG" && -n "$CURRENT_SIG" ]]; then - echo "No relevant changes detected since last build; skipping build." - else - echo "Changes detected; rebuilding WooPayments plugin..." - npm run build:release - echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" - fi -else - echo "Building WooPayments plugin..." - npm run build:release - echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" -fi - -# QIT CLI is installed via composer as a dev dependency -QIT_CMD="./vendor/bin/qit" - -# Build environment arguments for local development -env_args=() - -# Add Jetpack environment variables if available -if [[ -n "${E2E_JP_SITE_ID:-}" ]]; then - env_args+=( --env "E2E_JP_SITE_ID=${E2E_JP_SITE_ID}" ) -fi -if [[ -n "${E2E_JP_BLOG_TOKEN:-}" ]]; then - env_args+=( --env "E2E_JP_BLOG_TOKEN=${E2E_JP_BLOG_TOKEN}" ) -fi -if [[ -n "${E2E_JP_USER_TOKEN:-}" ]]; then - env_args+=( --env "E2E_JP_USER_TOKEN=${E2E_JP_USER_TOKEN}" ) -fi - -# 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_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 tests completed!" diff --git a/tests/qit/e2e/.eslintrc.js b/tests/qit/e2e/.eslintrc.js deleted file mode 100644 index 486b1448ffc..00000000000 --- a/tests/qit/e2e/.eslintrc.js +++ /dev/null @@ -1,26 +0,0 @@ -module.exports = { - env: { - node: true, - }, - globals: { - page: 'readonly', - browser: 'readonly', - context: 'readonly', - }, - rules: { - // Disable Jest-specific rules that conflict with Playwright - 'jest/no-done-callback': 'off', - 'jest/expect-expect': 'off', - // Allow QIT-specific imports that ESLint can't resolve - 'import/no-unresolved': [ 'error', { ignore: [ '/qitHelpers' ] } ], - }, - overrides: [ - { - files: [ '*.spec.js', '*.test.js' ], - rules: { - // Playwright test specific overrides - 'jest/no-done-callback': 'off', - }, - }, - ], -}; diff --git a/tests/qit/e2e/utils/helpers.ts b/tests/qit/e2e/utils/helpers.ts deleted file mode 100644 index fb35b165177..00000000000 --- a/tests/qit/e2e/utils/helpers.ts +++ /dev/null @@ -1,321 +0,0 @@ -/* 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/qit.json b/tests/qit/qit.json new file mode 100644 index 00000000000..1456413a181 --- /dev/null +++ b/tests/qit/qit.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://qit.woo.com/json-schema/qit", + "sut": { + "type": "plugin", + "slug": "woocommerce-payments", + "source": { + "type": "local", + "path": "woocommerce-payments.zip" + } + }, + "environments": { + "default": { + "plugins": [ + { "slug": "woocommerce", "from": "wporg", "version": "stable" }, + { "slug": "jetpack", "from": "wporg", "version": "stable" } + ] + } + }, + "test_types": { + "e2e": { + "default": { + "environment": "default", + "test_packages": [ + "tests/qit/test-package" + ] + } + } + } +} diff --git a/tests/qit/qit.yml b/tests/qit/qit.yml deleted file mode 100644 index 57092a4dd9e..00000000000 --- a/tests/qit/qit.yml +++ /dev/null @@ -1,21 +0,0 @@ -# QIT Configuration for WooPayments -# This configuration defines how QIT runs custom E2E tests for WooPayments - -# Extension to test (System Under Test) -woo_extension: woocommerce-payments - -# Test against various WC versions for compatibility -woo: "stable" -wp: "stable" -php_version: "8.3" - -# Dependencies and additional plugins for compatibility testing -plugin: - - "woocommerce" - - "jetpack" - -# Mount bootstrap directory for easier access in setup scripts. -# This mounts ./e2e/bootstrap (relative to this qit.yml file) to /qit/bootstrap -# inside the QIT test container (read-only for safety). -volumes: - - "./tests/qit/e2e/bootstrap:/qit/bootstrap:ro" diff --git a/tests/qit/test-package/.gitignore b/tests/qit/test-package/.gitignore new file mode 100644 index 00000000000..614bb70e8c3 --- /dev/null +++ b/tests/qit/test-package/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Test output +test-results/ +results/ +playwright-report/ +allure-results/ + +# Package lock (QIT installs dependencies dynamically) +package-lock.json diff --git a/tests/qit/test-package/.nvmrc b/tests/qit/test-package/.nvmrc new file mode 100644 index 00000000000..209e3ef4b62 --- /dev/null +++ b/tests/qit/test-package/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php b/tests/qit/test-package/bootstrap/class-wp-cli-qit-dev-command.php similarity index 100% rename from tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php rename to tests/qit/test-package/bootstrap/class-wp-cli-qit-dev-command.php diff --git a/tests/qit/test-package/bootstrap/global-setup.sh b/tests/qit/test-package/bootstrap/global-setup.sh new file mode 100755 index 00000000000..60fa71c3b45 --- /dev/null +++ b/tests/qit/test-package/bootstrap/global-setup.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# ------------------------------------------------------------------ +# Global Setup – executed INSIDE the WP container +# ------------------------------------------------------------------ +# Put your plugin/extension into a _minimal ready state_ here. +# – Creates sandbox credentials +# – Disables onboarding banners +# – Turns off tracking, etc. +# This runs **once** per test run (even if your package is only in +# `global_setup`) and should finish fast. + +set -euo pipefail + +echo "[globalSetup] Starting global configuration..." +# Example: +# wp option update my_plugin_onboarding_complete yes +echo "[globalSetup] Done." \ No newline at end of file diff --git a/tests/qit/test-package/bootstrap/global-teardown.sh b/tests/qit/test-package/bootstrap/global-teardown.sh new file mode 100755 index 00000000000..01a1678933e --- /dev/null +++ b/tests/qit/test-package/bootstrap/global-teardown.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# ------------------------------------------------------------------ +# Global Teardown – executed INSIDE the WP container +# ------------------------------------------------------------------ +# Runs once at the very end. Clean up anything created in globalSetup. + +set -euo pipefail + +echo "[globalTeardown] Cleaning up ..." +# Example: +# wp option delete my_plugin_sandbox_token +echo "[globalTeardown] Done." \ No newline at end of file diff --git a/tests/qit/e2e/bootstrap/qit-jetpack-connection.php b/tests/qit/test-package/bootstrap/qit-jetpack-connection.php similarity index 87% rename from tests/qit/e2e/bootstrap/qit-jetpack-connection.php rename to tests/qit/test-package/bootstrap/qit-jetpack-connection.php index 308bcbd25ff..07f04cd16ce 100644 --- a/tests/qit/e2e/bootstrap/qit-jetpack-connection.php +++ b/tests/qit/test-package/bootstrap/qit-jetpack-connection.php @@ -23,8 +23,7 @@ } // Load the QIT command class. -// Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to tests/qit/e2e/bootstrap. -$command_file = '/qit/bootstrap/class-wp-cli-qit-dev-command.php'; +$command_file = './bootstrap/class-wp-cli-qit-dev-command.php'; if ( ! file_exists( $command_file ) ) { WP_CLI::error( 'QIT command file not found: ' . $command_file ); diff --git a/tests/qit/e2e/bootstrap/qit-jetpack-status.php b/tests/qit/test-package/bootstrap/qit-jetpack-status.php similarity index 78% rename from tests/qit/e2e/bootstrap/qit-jetpack-status.php rename to tests/qit/test-package/bootstrap/qit-jetpack-status.php index 1596ab4adc2..3fc13ffa9df 100644 --- a/tests/qit/e2e/bootstrap/qit-jetpack-status.php +++ b/tests/qit/test-package/bootstrap/qit-jetpack-status.php @@ -12,8 +12,7 @@ } // Load the QIT command class. -// Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to tests/qit/e2e/bootstrap. -$command_file = '/qit/bootstrap/class-wp-cli-qit-dev-command.php'; +$command_file = './bootstrap/class-wp-cli-qit-dev-command.php'; if ( ! file_exists( $command_file ) ) { WP_CLI::error( 'QIT command file not found: ' . $command_file ); diff --git a/tests/qit/e2e/bootstrap/setup.sh b/tests/qit/test-package/bootstrap/setup.sh similarity index 72% rename from tests/qit/e2e/bootstrap/setup.sh rename to tests/qit/test-package/bootstrap/setup.sh index c5730003eef..b8a74859551 100755 --- a/tests/qit/e2e/bootstrap/setup.sh +++ b/tests/qit/test-package/bootstrap/setup.sh @@ -1,16 +1,17 @@ #!/bin/bash +# QIT Bootstrap Setup for WooPayments E2E Tests +# +# This script runs before tests to configure the plugin environment. set -euo pipefail IFS=$'\n\t' -# QIT Bootstrap Setup for WooPayments E2E Tests -# This script runs before tests to configure the plugin environment - echo "Setting up WooPayments for E2E testing..." -# Ensure environment is marked as development so dev-only CLI commands are available +# 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 +# Install WordPress importer and import sample products. 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 @@ -30,7 +31,7 @@ else fi fi -# Ensure WooCommerce core pages exist and capture IDs +# Ensure WooCommerce core pages exist and configure checkout/cart. echo "Ensuring WooCommerce core pages exist..." wp wc --user=admin tool run install_pages >/dev/null 2>&1 || true @@ -45,14 +46,14 @@ 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 +# 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 + # 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..." @@ -64,7 +65,7 @@ if [ -n "${CHECKOUT_PAGE_ID}" ] && [ -n "${CART_PAGE_ID}" ]; then --post_name="checkout-wcb" \ --porcelain) else - echo "WooCommerce Blocks checkout page already exists (ID: $CHECKOUT_WCB_PAGE_ID)" + echo "WooCommerce Blocks checkout page already exists (ID: $CHECKOUT_WCB_PAGE_ID)." fi wp post update "$CART_PAGE_ID" --post_content="$CART_SHORTCODE" @@ -75,12 +76,12 @@ if [ -n "${CHECKOUT_PAGE_ID}" ] && [ -n "${CART_PAGE_ID}" ]; then fi fi -# Double check option points to the classic checkout page +# Ensure 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 +# Configure WooCommerce checkout settings. wp option update woocommerce_currency "USD" wp option update woocommerce_enable_guest_checkout "yes" wp option update woocommerce_force_ssl_checkout "no" @@ -88,76 +89,82 @@ wp option set woocommerce_checkout_company_field "optional" --quiet 2>/dev/null 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 test users. +echo "Creating test users..." +wp user create customer customer@woocommercecoree2etestsuite.com \ + --role=customer \ + --user_pass=password \ + --first_name="Jane" \ + --last_name="Smith" \ + --quiet 2>/dev/null || wp user update customer --user_pass=password --quiet -# Create a test customer -wp user create testcustomer test@example.com \ +wp user create subscriptions-customer subscriptions-customer@woocommercecoree2etestsuite.com \ --role=customer \ - --user_pass=testpass123 \ - --first_name="Test" \ + --user_pass=password \ + --first_name="Sub" \ --last_name="Customer" \ - --quiet + --quiet 2>/dev/null || wp user update subscriptions-customer --user_pass=password --quiet -echo "Setting up WooPayments configuration..." +wp user create editor editor@woocommercecoree2etestsuite.com \ + --role=editor \ + --user_pass=password \ + --first_name="Ed" \ + --last_name="Itor" \ + --quiet 2>/dev/null || wp user update editor --user_pass=password --quiet -# Enable WooPayments settings (same as main E2E tests) -echo "Creating/updating WooPayments settings" -wp option set woocommerce_woocommerce_payments_settings --format=json '{"enabled":"yes"}' +echo "Test users created (customer, subscriptions-customer, editor)." -# Check required environment variables for basic Jetpack authentication -if [ -n "${E2E_JP_SITE_ID:-}" ] && [ -n "${E2E_JP_BLOG_TOKEN:-}" ] && [ -n "${E2E_JP_USER_TOKEN:-}" ]; then - echo "Configuring WCPay with Jetpack authentication..." +# Create test coupons. +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 - # Set up Jetpack connection and refresh account data from server - # Environment variables are automatically available to PHP via getenv() - # Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to ./e2e/bootstrap - wp eval-file /qit/bootstrap/qit-jetpack-connection.php +echo "Test coupons created (free)." - echo "✅ WooPayments connection configured - account data fetched from server" +# Configure WooPayments. +echo "Setting up WooPayments configuration..." +# 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 + +# Enable WooPayments settings. +echo "Enabling WooPayments settings..." +wp option set woocommerce_woocommerce_payments_settings --format=json '{"enabled":"yes"}' + +# Check required environment variables for Jetpack authentication. +if [ -n "${E2E_JP_SITE_ID:-}" ] && [ -n "${E2E_JP_BLOG_TOKEN:-}" ] && [ -n "${E2E_JP_USER_TOKEN:-}" ]; then + echo "Configuring WooPayments with Jetpack authentication..." + wp eval-file ./bootstrap/qit-jetpack-connection.php + echo "WooPayments connection configured - account data fetched from server." else - echo "No Jetpack credentials configured - WooPayments will show Connect screen" - echo "WooPayments will show Connect screen" + echo "No Jetpack credentials configured - WooPayments will show Connect screen." echo "" - echo "For basic connectivity testing, set in tests/qit/config/local.env:" + echo "For connectivity testing, set in tests/qit/config/local.env:" echo " E2E_JP_SITE_ID=123456789" echo " E2E_JP_BLOG_TOKEN=123.ABC.QIT" echo " E2E_JP_USER_TOKEN=123.ABC.QIT.1" echo "" fi -# Always check the setup status +# Display current setup status. echo "" echo "Current WooPayments setup status:" -# Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to ./e2e/bootstrap -wp eval-file /qit/bootstrap/qit-jetpack-status.php +wp eval-file ./bootstrap/qit-jetpack-status.php -# Enable development/test mode for better testing experience +# Enable development/test mode for better testing experience. wp option set wcpay_dev_mode 1 --quiet 2>/dev/null || true - -# Disable proxy mode (we want direct production API access) 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" +echo "WooPayments E2E setup complete." diff --git a/tests/qit/e2e/config/default.ts b/tests/qit/test-package/config/default.ts similarity index 98% rename from tests/qit/e2e/config/default.ts rename to tests/qit/test-package/config/default.ts index b04f99d4e66..3c728ed2642 100644 --- a/tests/qit/e2e/config/default.ts +++ b/tests/qit/test-package/config/default.ts @@ -1,7 +1,9 @@ /** * Internal dependencies */ -import { users } from './users.json'; +import usersData from './users.json' with { type: 'json' }; + +const users = usersData.users; export const config = { users: { diff --git a/tests/qit/e2e/config/users.json b/tests/qit/test-package/config/users.json similarity index 100% rename from tests/qit/e2e/config/users.json rename to tests/qit/test-package/config/users.json diff --git a/tests/qit/e2e/fixtures/auth.ts b/tests/qit/test-package/fixtures/auth.ts similarity index 98% rename from tests/qit/e2e/fixtures/auth.ts rename to tests/qit/test-package/fixtures/auth.ts index 0fa22f4940c..f06626fba30 100644 --- a/tests/qit/e2e/fixtures/auth.ts +++ b/tests/qit/test-package/fixtures/auth.ts @@ -8,7 +8,7 @@ import { Page, StorageState, } from '@playwright/test'; -import qit from '/qitHelpers'; +import qit from '@qit/helpers'; /** * Internal dependencies diff --git a/tests/qit/test-package/package.json b/tests/qit/test-package/package.json new file mode 100644 index 00000000000..a3cc0ff5f66 --- /dev/null +++ b/tests/qit/test-package/package.json @@ -0,0 +1,24 @@ +{ + "name": "woocommerce-payments-e2e-tests", + "version": "1.0.0", + "description": "WooCommerce Payments E2E Test Package for QIT", + "private": true, + "type": "module", + "engines": { + "node": ">=20.10.0" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/node": "^20.0.0", + "allure-playwright": "^3.0.0", + "playwright-ctrf-json-reporter": "^0.0.26" + }, + "dependencies": { + "@qit/helpers": "file:./qit-helpers" + }, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug" + } +} diff --git a/tests/qit/test-package/playwright.config.js b/tests/qit/test-package/playwright.config.js new file mode 100644 index 00000000000..911433e3c34 --- /dev/null +++ b/tests/qit/test-package/playwright.config.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for WooCommerce Payments QIT E2E tests + * + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig( { + testDir: './tests', + + /* Run tests sequentially for stability */ + fullyParallel: false, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !! process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Opt out of parallel tests - run one at a time */ + workers: 1, + + /* Reporter configuration for QIT */ + reporter: [ + [ 'list' ], + [ 'html', { open: 'never', outputFolder: './results/html' } ], + [ + 'playwright-ctrf-json-reporter', + { + outputDir: './results', + outputFile: 'ctrf.json', + }, + ], + [ + 'allure-playwright', + { + resultsDir: './results/allure', + }, + ], + [ + 'blob', + { + outputDir: './results/blob', + }, + ], + ], + + /* Shared settings for all projects */ + use: { + /* Base URL from QIT environment */ + baseURL: process.env.QIT_SITE_URL || 'http://localhost:8080', + + /* Collect trace/screenshots/video only on failure */ + screenshot: 'only-on-failure', + video: 'retain-on-failure', + trace: 'retain-on-failure', + + /* Browser viewport */ + viewport: { width: 1280, height: 720 }, + }, + + /* Test timeout */ + timeout: 120 * 1000, // 2 minutes per test + + /* Expect timeout */ + expect: { + timeout: 20 * 1000, // 20 seconds for assertions + }, + + /* Configure projects for subpackages */ + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + testMatch: /.*\.spec\.ts$/, + }, + { + name: 'shopper', + testDir: './tests/woopayments/shopper', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + // Additional projects for merchant and subscriptions subpackages + // will be added when those tests are migrated. + ], +} ); diff --git a/tests/qit/test-package/qit-helpers/index.js b/tests/qit/test-package/qit-helpers/index.js new file mode 100644 index 00000000000..dad4201548a --- /dev/null +++ b/tests/qit/test-package/qit-helpers/index.js @@ -0,0 +1,474 @@ +import { expect } from '@playwright/test'; +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; + +const { + QIT_SITE_URL, + QIT_DOMAIN, + QIT_INTERNAL_DOMAIN, + QIT_INTERNAL_NGINX, + FORCE_COLOR +} = process.env; + +let _cachedDomain = null; + +const forceColorEnv = (FORCE_COLOR || '').toLowerCase(); +const forcedOff = forceColorEnv === '0' || forceColorEnv === 'false'; +const forcedOn = forceColorEnv && !forcedOff; +const canColor = forcedOn || (!forcedOff && process.stdout.isTTY); + +/** + * Makes an HTTP or HTTPS POST request using Axios with automatic redirect handling. + * @param {string} hostname - The hostname or full URL of the server. + * @param {string} path - The path of the endpoint. + * @param {Object} data - The data to send in the POST request. + * @returns {Promise} - Resolves with the response object. + */ +async function makePostRequest(hostname, path, data) { + try { + let finalHost = hostname; + if (!finalHost) { + finalHost = await getDomain(); + } + + // Minimal check for protocol + const hasProtocol = finalHost.startsWith('http://') || finalHost.startsWith('https://'); + const url = hasProtocol ? `${finalHost}${path}` : `http://${finalHost}${path}`; + + const response = await axios.post(url, data, { + headers: { 'Content-Type': 'application/json' }, + maxRedirects: 5, + timeout: 600000, // 10 min + validateStatus: () => true, // accept any status + }); + + return { + status: response.status, + body: response.data, + }; + } catch (error) { + console.error('[qit] Request Error:', error.message); + throw error; + } +} + +/** + * Attempts to figure out a working domain by: + * 1) Checking QIT_SITE_URL first (new env variable for test packages) + * 2) Checking QIT_DOMAIN, + * 3) If that fails, checking QIT_INTERNAL_DOMAIN, + * 4) Caches whichever works so we don't keep retesting. + */ +async function getDomain() { + // If we already found a working domain, return it. + if (_cachedDomain) { + return _cachedDomain; + } + + // Helper inline: tries GET /wp-json on the given domain, returns true if OK, otherwise throws + async function ping(domain) { + const hasProtocol = domain.startsWith('http://') || domain.startsWith('https://'); + const urlBase = hasProtocol ? domain : `http://${domain}`; + const checkUrl = `${urlBase}/wp-json/`; + const resp = await axios.get(checkUrl, { timeout: 3000 }); // 3s + if (resp.status !== 200) { + throw new Error(`Ping got non-200 status: ${resp.status}`); + } + } + + // 0) Try QIT_SITE_URL if set (preferred for test packages) + if (QIT_SITE_URL) { + try { + await ping(QIT_SITE_URL); + _cachedDomain = QIT_SITE_URL; // it worked, cache it + return _cachedDomain; + } catch (err) { + console.warn( + `[qit] QIT_SITE_URL ("${QIT_SITE_URL}") failed to respond; falling back to other domains. Reason:`, + err.message + ); + } + } + + // 1) Try QIT_DOMAIN if set + if (QIT_DOMAIN) { + try { + await ping(QIT_DOMAIN); + _cachedDomain = QIT_DOMAIN; // it worked, cache it + return _cachedDomain; + } catch (err) { + console.warn( + `[qit] QIT_DOMAIN ("${QIT_DOMAIN}") failed to respond; falling back to QIT_INTERNAL_DOMAIN. Reason:`, + err.message + ); + } + } + + // 2) If QIT_DOMAIN missing or failed, try QIT_INTERNAL_DOMAIN + if (QIT_INTERNAL_DOMAIN) { + try { + await ping(QIT_INTERNAL_DOMAIN); + _cachedDomain = QIT_INTERNAL_DOMAIN; // success, cache it + return _cachedDomain; + } catch (err) { + console.warn( + `[qit] QIT_INTERNAL_DOMAIN ("${QIT_INTERNAL_DOMAIN}") failed to respond. Reason:`, + err.message + ); + } + } else { + console.warn('[qit] No QIT_INTERNAL_DOMAIN set.'); + } + + + // 3) Test QIT_INTERNAL_NGINX + if (QIT_INTERNAL_NGINX) { + try { + await ping(QIT_INTERNAL_NGINX); + _cachedDomain = QIT_INTERNAL_NGINX; // success, cache it + return _cachedDomain; + } catch (err) { + console.warn( + `[qit] QIT_INTERNAL_NGINX ("${QIT_INTERNAL_NGINX}") failed to respond. Reason:`, + err.message + ); + } + } else { + console.warn('[qit] No QIT_INTERNAL_NGINX set.'); + } + + // 4) If we get here, nothing worked + throw new Error('No working domain found in QIT_SITE_URL, QIT_DOMAIN, QIT_INTERNAL_DOMAIN, or QIT_INTERNAL_NGINX.'); +} + +const qit = { + activeBrowser: null, + verbose: false, // Todo: Make this configurable from "-v" or "-vvv" + canColor, + async loginAsAdmin(page) { + // Check if the admin cookies are already set. + const adminCookies = qit.getEnv('admin_cookies'); + let usedCookies = false; + + if (adminCookies && Object.keys(adminCookies).length > 0) { + const cookies = JSON.parse(adminCookies); + await page.context().addCookies(cookies); + usedCookies = true; + } + + // Navigate to wp-admin to ensure we're on the admin dashboard. + await page.goto('/wp-admin/'); + await page.waitForLoadState('networkidle'); + + try { + if (!usedCookies) { + throw new Error('Admin cookies not found'); + } + + // Check if the "Dashboard" heading is visible. + await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible(); + } catch (error) { + // Regardless of whether cookies were used, clear cookies and perform the login flow again. + await page.context().clearCookies(); // Clear existing cookies. + await page.goto('about:blank'); // Reset the page. + + // Perform the login flow. + await this._performLoginFlow(page, 'admin', 'password', true); + + // Re-check if the "Dashboard" heading is visible. + await page.goto('/wp-admin/'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible(); + } + }, + // Define the login flow as a separate function for reuse. + async _performLoginFlow(page, username, password, saveCookies = false) { + await page.goto('/wp-admin/'); + await page.getByLabel('Username or Email Address').fill(username); + await page.getByLabel('Password', {exact: true}).fill(password); + await page.getByRole('button', {name: 'Log In'}).click(); + await page.waitForLoadState('networkidle'); + + if (saveCookies) { + // Save the new cookies. + const cookies = await page.context().cookies(); + qit.setEnv('admin_cookies', JSON.stringify(cookies)); + } + }, + async loginAs(page, username, password) { + await this._performLoginFlow(page, username, password); + + // Optionally navigate to the dashboard or a specific page. + await page.goto('/wp-admin/'); + await page.waitForLoadState('networkidle'); + }, + async individualLogging(action) { + // Internal function. "action" can be either "start" or "stop". + try { + const response = await makePostRequest(await getDomain(), '/wp-json/qit/v1/individual-log', {qit_individual_log: action}); + + if (response.body && typeof response.body.output !== 'undefined') { + return response.body.output; + } else { + console.error('Invalid or missing "output" in response body:', response.body); + throw new Error('Invalid response: "output" not found'); + } + } catch (error) { + console.error('Error making POST request:', error); + throw error; + } + }, + async runStreamedCommand(command) { + const domain = await getDomain(); + const url = `http://${domain}/wp-json/qit/v1/exec/stream`; + + if (this.verbose) { + console.log(`[Node] About to stream command: ${command}`); + } + + return new Promise(async (resolve, reject) => { + let allOutput = ''; + let exitCode = 1; // Default until found + let leftover = ''; + + // Helper to process a single line of text + const processLine = (line) => { + // 1) Always append the raw line (plus newline) to allOutput + allOutput += line + '\n'; + + // 2) See if there's an exit marker in the line + const exitMatch = line.match(/__QIT_STREAM_EXIT__CODE__START__(\d+)__END/); + if (exitMatch) { + exitCode = parseInt(exitMatch[1], 10); + // remove placeholder + line = line.replace(/__QIT_STREAM_EXIT__CODE__START__(\d+)__END/, ''); + } + + // 3) Parse out any __QIT_STDOUT__ / __QIT_STDERR__ sections + const segments = line.split(/(__QIT_STDOUT__|__QIT_STDERR__)/); + + let mode = 'normal'; + for (let seg of segments) { + if (seg === '__QIT_STDOUT__') { + mode = 'stdout'; + continue; + } + if (seg === '__QIT_STDERR__') { + mode = 'stderr'; + continue; + } + if (!seg) { + continue; + } + + // 4) Print depending on mode + if (mode === 'stderr') { + if (canColor) { + console.error(`\x1b[33m${seg}\x1b[0m`); // Yellow + } else { + console.error(seg); + } + } else { + // Normal text + console.log(seg); + } + } + }; + + try { + const response = await axios.post( + url, + { qit_command: command, verbose: this.verbose }, + { + responseType: 'stream', + timeout: 10 * 60 * 1000, // 10 minutes + maxBodyLength: 100 * 1024 * 1024, // 100 MB + maxContentLength: 100 * 1024 * 1024, + validateStatus: () => true, // accept all statuses + } + ); + + // Handle streamed data + response.data.on('data', (chunk) => { + // Convert to string and prepend leftover from prior chunk + let text = leftover + chunk.toString(); + leftover = ''; + + // Split by newline + const lines = text.split('\n'); + + // If the last chunk didn't end with a newline, hold it for next event + if (!text.endsWith('\n')) { + leftover = lines.pop(); + } + + // Process each complete line + for (const line of lines) { + processLine(line); + } + }); + + response.data.on('end', () => { + // If there's a leftover partial line, process it one last time + if (leftover) { + processLine(leftover); + leftover = ''; + } + + if (this.verbose) { + console.log('[Node] Streaming ended.\n'); + } + + // Resolve with final code & full logs + resolve({ status: exitCode, output: allOutput }); + }); + + response.data.on('error', (err) => { + console.error('[Node] Streaming error:', err); + reject(err); + }); + + } catch (outerErr) { + console.error(`[Node] Error starting streaming request: ${outerErr.message}`); + reject(outerErr); + } + }); + }, + /** + * Attaches a screenshot to the test context. + * + * @param {string} name - The name of the screenshot. + * @param {Object.} context - An array where keys are strings, and values are a flat array of strings. + * @param {import('playwright').Page} page - The Playwright page object. + * @param {import('playwright').TestInfo} testInfo - The Playwright test info object. + * @returns {Promise} + */ + async attachScreenshot(name, context, page, testInfo) { + // Use relative path for test media (Playwright runs on host, not in container) + const testMediaDir = path.join(process.cwd(), 'test-media'); + + if (!fs.existsSync(testMediaDir)) { + fs.mkdirSync(testMediaDir, {recursive: true}); + } + + const safeName = name.replace(/[^a-zA-Z0-9-]/g, '_'); + const basename = `${safeName}-${Date.now()}`; + + const screenshotPath = path.join(testMediaDir, `${basename}.jpg`); + + // Write "context" as a JSON file with the same name. + const contextPath = path.join(testMediaDir, `${basename}.json`); + fs.writeFileSync(contextPath, JSON.stringify(context, null, 2), 'utf8'); + + try { + await page.screenshot({path: screenshotPath, type: 'jpeg', fullPage: true, timeout: 90000}); + await testInfo.attach(safeName, {path: screenshotPath}); + } catch (error) { + console.error('Error capturing or attaching screenshot:', error); + } + }, + async exec(command, silent = false) { + const response = await makePostRequest(await getDomain(), '/wp-json/qit/v1/exec', { + qit_command: command + }); + + if (!silent) { + console.log(response.body.output); + } + + // Check if something we expect is undefined + if (typeof response.body.status === 'undefined' || typeof response.body.stdout === 'undefined' || typeof response.body.stderr === 'undefined') { + console.error(command); + console.error(response); + throw new Error('Invalid response: "status", "stdout" or "stderr" not found.'); + } + + if (response.body.status !== 0) { + // Command failed; throw an error with the output + throw new Error(`Command failed with status ${response.body.status}: ${response.body.stdout} ${response.body.stderr}`); + } + + return { + status: response.body.status, + stdout: response.body.stdout, + stderr: response.body.stderr, + }; + }, + // Execute "wp" CLI commands asynchronously. It takes everything after "wp" as a parameter + // and executes it. Returns the output as a string. Throws an error if the command execution fails. + async wp(command, silent = true) { + // If command doesn't start with 'wp', add it + const wpCommand = command.trim().startsWith('wp ') ? command : `wp ${command}`; + return this.exec(wpCommand, silent); + }, + getEnv(key = null) { + try { + const filePath = '/tmp/qit_env_helper.json'; + const data = fs.readFileSync(filePath, 'utf8'); + const envs = JSON.parse(data); + + if (key) { + return envs[key]; + } else { + return envs; + } + } catch (error) { + return {}; + } + }, + setEnv(key, value) { + if (typeof key !== 'string' || typeof value !== 'string') { + throw new Error('Key and value must be strings'); + } + + try { + const filePath = '/tmp/qit_env_helper.json'; + let envs = {}; + + try { + const data = fs.readFileSync(filePath, 'utf8'); // synchronous read + envs = JSON.parse(data); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + envs[key] = value; + fs.writeFileSync(filePath, JSON.stringify(envs, null, 2), 'utf8'); // synchronous write + } catch (error) { + console.error(`Error writing to the environment file: ${error.message}`); + throw error; + } + }, + unsetEnv(key) { + if (typeof key !== 'string') { + throw new Error('Key must be a string'); + } + + try { + const filePath = '/tmp/qit_env_helper.json'; + let envs = {}; + + try { + const data = fs.readFileSync(filePath, 'utf8'); // Synchronous read + envs = JSON.parse(data); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // If file doesn't exist, envs remains an empty object + } + + delete envs[key]; + + fs.writeFileSync(filePath, JSON.stringify(envs, null, 2), 'utf8'); // Synchronous write + } catch (error) { + console.error(`Error updating the environment file: ${error.message}`); + throw error; + } + } +}; + +export default qit; \ No newline at end of file diff --git a/tests/qit/test-package/qit-helpers/package.json b/tests/qit/test-package/qit-helpers/package.json new file mode 100644 index 00000000000..756955026cf --- /dev/null +++ b/tests/qit/test-package/qit-helpers/package.json @@ -0,0 +1,22 @@ +{ + "name": "@qit/helpers", + "version": "1.0.0", + "description": "Helper utilities for QIT test packages", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@playwright/test": "^1.54.2", + "axios": "^1.6.0" + }, + "keywords": [ + "qit", + "testing", + "playwright", + "woocommerce" + ], + "author": "QIT Team", + "license": "ISC" +} \ No newline at end of file diff --git a/tests/qit/test-package/qit-test.json b/tests/qit/test-package/qit-test.json new file mode 100644 index 00000000000..17e0bcee3c9 --- /dev/null +++ b/tests/qit/test-package/qit-test.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://qit.woo.com/json-schema/test-package", + "package": "woocommerce-payments/woopayments-e2e-tests", + "test_type": "e2e", + "description": "WooCommerce Payments E2E tests for checkout flows, payment methods, and Jetpack integration", + "tags": ["woopayments", "checkout", "jetpack", "payments"], + "requires": { + "network": true, + "secrets": [ + "E2E_JP_SITE_ID", + "E2E_JP_BLOG_TOKEN", + "E2E_JP_USER_TOKEN" + ], + "plugins": { + "woocommerce": "woocommerce", + "jetpack": "jetpack" + } + }, + "test": { + "phases": { + "globalSetup": [ + "./bootstrap/setup.sh" + ], + "setup": [ + "npm ci", + "npx playwright install chromium --with-deps" + ], + "run": [ + "npx playwright test" + ], + "teardown": [], + "globalTeardown": [] + }, + "results": { + "ctrf-json": "./results/ctrf.json", + "blob-dir": "./results/blob", + "allure-dir": "./results/allure" + } + }, + "subpackages": { + "woocommerce-payments/shopper": { + "description": "Shopper checkout flow tests for WooPayments", + "tags": ["woopayments", "shopper", "checkout", "critical"], + "test": { + "phases": { + "run": ["npx playwright test --project=shopper"] + } + } + } + }, + "timeout": 1800 +} diff --git a/tests/qit/e2e/specs/basic.spec.ts b/tests/qit/test-package/tests/basic.spec.ts similarity index 90% rename from tests/qit/e2e/specs/basic.spec.ts rename to tests/qit/test-package/tests/basic.spec.ts index 708ea2beef8..cb473c43ab1 100644 --- a/tests/qit/e2e/specs/basic.spec.ts +++ b/tests/qit/test-package/tests/basic.spec.ts @@ -8,10 +8,9 @@ test.describe( () => { test( 'Load the home page', async ( { page } ) => { await page.goto( '/' ); + // Verify the page loaded by checking that a site title exists const title = page.locator( 'h1.site-title' ); - await expect( title ).toHaveText( - /WooCommerce Core E2E Test Suite/i - ); + await expect( title ).toBeVisible(); } ); test.describe( 'Sign in as admin', () => { diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-cart-coupon.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-failures.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-failures.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-failures.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-failures.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase-site-editor.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase-with-upe-methods.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-purchase.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-purchase.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-checkout-save-card-and-purchase.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts similarity index 97% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts index 99af71a2030..83feec676e1 100644 --- a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts +++ b/tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-checkout-failures.spec.ts @@ -26,11 +26,11 @@ const failures = [ }, { card: config.cards[ 'invalid-exp-date' ], - error: 'Your card’s expiration year is in the past.', + error: /Your card.s expiration year is in the past\./, }, { card: config.cards[ 'invalid-cvv-number' ], - error: 'Your card’s security code is incomplete.', + error: /Your card.s security code is incomplete\./, }, { card: config.cards[ 'declined-funds' ], diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-checkout-purchase.spec.ts diff --git a/tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts b/tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts similarity index 100% rename from tests/qit/e2e/specs/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts rename to tests/qit/test-package/tests/woopayments/shopper/shopper-wc-blocks-saved-card-checkout-and-usage.spec.ts diff --git a/tests/qit/test-package/tsconfig.json b/tests/qit/test-package/tsconfig.json new file mode 100644 index 00000000000..11aca0a5899 --- /dev/null +++ b/tests/qit/test-package/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noEmit": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tests/qit/e2e/utils/devtools.ts b/tests/qit/test-package/utils/devtools.ts similarity index 98% rename from tests/qit/e2e/utils/devtools.ts rename to tests/qit/test-package/utils/devtools.ts index 1eedd24630c..fa774b82702 100644 --- a/tests/qit/e2e/utils/devtools.ts +++ b/tests/qit/test-package/utils/devtools.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import qit from '/qitHelpers'; +import qit from '@qit/helpers'; /** * The legacy E2E environment relied on the WooPayments Dev Tools plugin UI to toggle diff --git a/tests/qit/test-package/utils/helpers.ts b/tests/qit/test-package/utils/helpers.ts new file mode 100644 index 00000000000..4cbe52e4809 --- /dev/null +++ b/tests/qit/test-package/utils/helpers.ts @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { test, Page, Browser, BrowserContext, expect } from '@playwright/test'; + +/** + * 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; + } ); +}; diff --git a/tests/qit/e2e/utils/merchant.ts b/tests/qit/test-package/utils/merchant.ts similarity index 99% rename from tests/qit/e2e/utils/merchant.ts rename to tests/qit/test-package/utils/merchant.ts index bc29152783a..e46b82a8b02 100644 --- a/tests/qit/e2e/utils/merchant.ts +++ b/tests/qit/test-package/utils/merchant.ts @@ -2,7 +2,7 @@ * External dependencies */ import { Page, expect } from '@playwright/test'; -import qit from '/qitHelpers'; +import qit from '@qit/helpers'; import { config } from '../config/default'; diff --git a/tests/qit/e2e/utils/shopper-navigation.ts b/tests/qit/test-package/utils/shopper-navigation.ts similarity index 100% rename from tests/qit/e2e/utils/shopper-navigation.ts rename to tests/qit/test-package/utils/shopper-navigation.ts diff --git a/tests/qit/e2e/utils/shopper.ts b/tests/qit/test-package/utils/shopper.ts similarity index 98% rename from tests/qit/e2e/utils/shopper.ts rename to tests/qit/test-package/utils/shopper.ts index 4936af84f9f..42ad96646eb 100644 --- a/tests/qit/e2e/utils/shopper.ts +++ b/tests/qit/test-package/utils/shopper.ts @@ -677,17 +677,23 @@ export const placeOrderWithCurrency = async ( }; export const setSavePaymentMethod = async ( page: Page, save = true ) => { + // If on WC Blocks checkout, wait for it to finish loading before interacting. + // The blocks checkout shows "Loading..." text while updating. + const blocksOrderSummary = page.locator( + '.wc-block-components-order-summary' + ); + if ( ( await blocksOrderSummary.count() ) > 0 ) { + await expect( blocksOrderSummary ).not.toContainText( 'Loading', { + timeout: 15000, + } ); + } + 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(); - } + // Use setChecked instead of check/uncheck for better reliability with React components. + await checkbox.setChecked( save ); }; export const emptyCart = async ( page: Page ) => {