diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..5a02be1 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,46 @@ +name: Playwright + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + playwright: + name: Playwright + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + coverage: none + tools: composer + + - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - run: npx playwright install --with-deps chromium + + - run: npm run env:start + + - run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: tests/E2E/.report + include-hidden-files: true + retention-days: 7 diff --git a/.gitignore b/.gitignore index 70181d4..9851b06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ languages/*.mo /vendor/ /vendor-bin/*/vendor/ /.phpunit.result.cache +/node_modules/ +/tests/E2E/.results/ +/tests/E2E/.report/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..afaa9d9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "libresign-wordpress-theme", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libresign-wordpress-theme", + "license": "GPL-2.0-or-later", + "devDependencies": { + "@playwright/test": "^1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5de0679 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "libresign-wordpress-theme", + "private": true, + "license": "GPL-2.0-or-later", + "description": "Browser tests for the LibreSign WordPress theme", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "env:start": "docker compose -f tests/E2E/docker-compose.yml up -d --wait && docker compose -f tests/E2E/docker-compose.yml run --rm cli", + "env:stop": "docker compose -f tests/E2E/docker-compose.yml down -v" + }, + "devDependencies": { + "@playwright/test": "^1.63.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c79df90 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig( { + testDir: 'tests/E2E', + outputDir: 'tests/E2E/.results', + workers: 1, + fullyParallel: false, + forbidOnly: Boolean( process.env.CI ), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [ + [ 'github' ], + [ + 'html', + { open: 'never', outputFolder: 'tests/E2E/.report' }, + ], + ] + : [ [ 'list' ] ], + use: { + ...devices[ 'Desktop Chrome' ], + baseURL: 'http://localhost:8889', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +} ); diff --git a/readme.txt b/readme.txt index 1cf7b36..f8f5ba1 100644 --- a/readme.txt +++ b/readme.txt @@ -73,6 +73,17 @@ On the local SaaS stack: docker exec -w /var/www/html/wp-content/themes/libresign \ wordpress-docker-wordpress-1 composer test +The browser tests in `tests/E2E` cover creating a workspace together with a plan, +the CPF/CNPJ field and the policy consent at checkout, and the lost password page. +They need Docker, Node.js and a `composer install`, since the stack mounts +WooCommerce from `vendor/test-plugins`: + + npm ci + npx playwright install chromium + npm run env:start # WordPress on :8889 + npm run test:e2e + npm run env:stop + == Development notes == - Site fragment sync/render bootstrap: `inc/footer-fragment.php` diff --git a/tests/E2E/Inc/Account.spec.ts b/tests/E2E/Inc/Account.spec.ts new file mode 100644 index 0000000..b7e6113 --- /dev/null +++ b/tests/E2E/Inc/Account.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test.describe( 'Recovering the password', () => { + test( 'opens the lost password form on its own page', async ( { page } ) => { + await page.goto( '/lost-password/' ); + + await expect( page.getByRole( 'heading', { name: 'Lost password', level: 1 } ) ).toBeVisible(); + await expect( page.getByLabel( /Email or username/ ) ).toBeVisible(); + await expect( page.getByRole( 'link', { name: 'Back to sign in' } ) ).toHaveAttribute( 'href', /\/my-account\/$/ ); + } ); + + test( 'offers the lost password form from the account page', async ( { page } ) => { + await page.goto( '/my-account/' ); + await page.getByRole( 'link', { name: 'Lost your password?' } ).click(); + + await expect( page.getByRole( 'button', { name: 'Send reset link' } ) ).toBeVisible(); + } ); +} ); diff --git a/tests/E2E/Inc/Checkout.spec.ts b/tests/E2E/Inc/Checkout.spec.ts new file mode 100644 index 0000000..1ed0af7 --- /dev/null +++ b/tests/E2E/Inc/Checkout.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +import { addThePlanToTheCart, fillTheBillingAddress, placeOrder } from '../support/store'; + +const CONSENT = 'I agree to the terms and privacy policy before placing the order.'; + +test.describe( 'Agreeing to the policy at checkout', () => { + test.beforeEach( async ( { page } ) => { + await addThePlanToTheCart( page ); + await fillTheBillingAddress( page, 'Brazil', '529.982.247-25' ); + } ); + + test( 'links the terms and privacy policy', async ( { page } ) => { + await expect( page.getByRole( 'link', { name: 'terms and privacy policy' } ) ).toHaveAttribute( 'href', 'https://libresign.coop/privacy-policy' ); + } ); + + test( 'refuses the order without the consent', async ( { page } ) => { + await placeOrder( page ); + + await expect( page.getByText( 'You must agree to the policies before completing the purchase.' ) ).toBeVisible(); + } ); + + test( 'places the order with the consent', async ( { page } ) => { + await page.getByLabel( CONSENT ).check(); + await placeOrder( page ); + + await expect( page ).toHaveURL( /\/order-received\// ); + await expect( page.getByText( 'Thank you. Your order has been received.' ) ).toBeVisible(); + } ); +} ); diff --git a/tests/E2E/Inc/CpfCnpj.spec.ts b/tests/E2E/Inc/CpfCnpj.spec.ts new file mode 100644 index 0000000..dc5ef85 --- /dev/null +++ b/tests/E2E/Inc/CpfCnpj.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +import { addThePlanToTheCart, fillTheBillingAddress, placeOrder } from '../support/store'; + +test.describe( 'Asking Brazilian customers for their CPF or CNPJ', () => { + test.beforeEach( async ( { page } ) => { + await addThePlanToTheCart( page ); + } ); + + test( 'shows the field only in Brazil', async ( { page } ) => { + await fillTheBillingAddress( page, 'Brazil' ); + await expect( page.getByLabel( 'CPF or CNPJ' ) ).toBeVisible(); + + await page.locator( '#billing-country' ).selectOption( { label: 'Portugal' } ); + + await expect( page.getByLabel( 'CPF or CNPJ' ) ).toBeHidden(); + } ); + + test( 'requires the field in Brazil', async ( { page } ) => { + await fillTheBillingAddress( page, 'Brazil' ); + await placeOrder( page ); + + await expect( page.getByText( 'Please enter your CPF or CNPJ.' ) ).toBeVisible(); + } ); + + test( 'refuses an invalid CPF', async ( { page } ) => { + await fillTheBillingAddress( page, 'Brazil', '529.982.247-24' ); + await placeOrder( page ); + + await expect( page.getByText( 'Please enter a valid CPF or CNPJ.' ) ).toBeVisible(); + } ); +} ); diff --git a/tests/E2E/Inc/Registration.spec.ts b/tests/E2E/Inc/Registration.spec.ts new file mode 100644 index 0000000..0e8ad81 --- /dev/null +++ b/tests/E2E/Inc/Registration.spec.ts @@ -0,0 +1,41 @@ +import { expect, Page, test } from '@playwright/test'; + +import { addThePlanToTheCart } from '../support/store'; + +async function fillTheWorkspaceForm( page: Page ): Promise< void > { + await page.goto( '/my-account/' ); + await page.waitForFunction( () => 'zxcvbn' in window ); + await page.locator( '#reg_email' ).fill( `workspace-${ Date.now() }@example.org` ); + await page.locator( '#reg_password' ).pressSequentially( 'a strong password 123' ); +} + +test.describe( 'Creating a workspace together with a plan', () => { + test( 'sends a visitor to choose a plan first', async ( { page } ) => { + await page.goto( '/my-account/' ); + + await expect( page.getByRole( 'heading', { name: 'Create your workspace' } ) ).toBeHidden(); + await page.getByRole( 'link', { name: 'Choose a plan' } ).click(); + + await expect( page ).toHaveURL( /\/shop\/$/ ); + } ); + + test( 'creates the workspace and continues to checkout', async ( { page } ) => { + await addThePlanToTheCart( page ); + await fillTheWorkspaceForm( page ); + + await page.locator( '#libresign_workspace_terms' ).check(); + await page.getByRole( 'button', { name: 'Continue to checkout' } ).click(); + + await expect( page ).toHaveURL( /\/checkout\/$/ ); + } ); + + test( 'refuses the workspace without the terms consent', async ( { page } ) => { + await addThePlanToTheCart( page ); + await fillTheWorkspaceForm( page ); + + await page.locator( '#libresign_workspace_terms' ).evaluate( ( checkbox ) => checkbox.removeAttribute( 'required' ) ); + await page.getByRole( 'button', { name: 'Continue to checkout' } ).click(); + + await expect( page.getByText( 'You must accept the terms to create your workspace.' ) ).toBeVisible(); + } ); +} ); diff --git a/tests/E2E/docker-compose.yml b/tests/E2E/docker-compose.yml new file mode 100644 index 0000000..209911e --- /dev/null +++ b/tests/E2E/docker-compose.yml @@ -0,0 +1,55 @@ +name: libresign-theme-e2e + +services: + mariadb: + image: mariadb:11 + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wordpress + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + retries: 20 + + wordpress: + image: wordpress:7.1-php8.3-apache + depends_on: + mariadb: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: mariadb + WORDPRESS_DB_USER: root + WORDPRESS_DB_PASSWORD: root + WORDPRESS_DB_NAME: wordpress + ports: + - "8889:80" + healthcheck: + test: ["CMD", "test", "-f", "/var/www/html/wp-config.php"] + interval: 1s + retries: 60 + volumes: + - wordpress:/var/www/html + - ../..:/var/www/html/wp-content/themes/libresign:ro + - ../../vendor/test-plugins/woocommerce:/var/www/html/wp-content/plugins/woocommerce:ro + + cli: + image: wordpress:cli-php8.3 + profiles: ["cli"] + user: "33:33" + depends_on: + wordpress: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: mariadb + WORDPRESS_DB_USER: root + WORDPRESS_DB_PASSWORD: root + WORDPRESS_DB_NAME: wordpress + volumes_from: + - wordpress + volumes: + - ./support/setup.sh:/setup.sh:ro + - ./support/seed.php:/seed.php:ro + command: ["sh", "/setup.sh"] + +volumes: + wordpress: diff --git a/tests/E2E/support/seed.php b/tests/E2E/support/seed.php new file mode 100644 index 0000000..93f3518 --- /dev/null +++ b/tests/E2E/support/seed.php @@ -0,0 +1,10 @@ +set_name( 'Basic' ); +$plan->set_slug( 'basic' ); +$plan->set_regular_price( '55' ); +$plan->set_virtual( true ); +$plan->save(); diff --git a/tests/E2E/support/setup.sh b/tests/E2E/support/setup.sh new file mode 100644 index 0000000..cb5174d --- /dev/null +++ b/tests/E2E/support/setup.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +wp core install --url=http://localhost:8889 --title=LibreSign --admin_user=admin --admin_password=password --admin_email=admin@example.org --skip-email +wp rewrite structure '/%postname%/' +wp plugin activate woocommerce +wp theme activate libresign + +wp option update woocommerce_coming_soon no +wp option update woocommerce_default_country BR:RJ +wp option update woocommerce_enable_myaccount_registration yes +wp option update woocommerce_registration_generate_username yes +wp option update woocommerce_registration_generate_password no +wp option update woocommerce_cheque_settings '{"enabled":"yes","title":"Check payments"}' --format=json + +if [ -z "$(wp post list --post_type=product --name=basic --format=ids)" ]; then + wp eval-file /seed.php +fi diff --git a/tests/E2E/support/store.ts b/tests/E2E/support/store.ts new file mode 100644 index 0000000..13cc757 --- /dev/null +++ b/tests/E2E/support/store.ts @@ -0,0 +1,28 @@ +import { expect, Page } from '@playwright/test'; + +export async function addThePlanToTheCart( page: Page ): Promise< void > { + await page.goto( '/product/basic/' ); + await page.getByRole( 'button', { name: 'Add to cart' } ).click(); + await expect( page.getByRole( 'alert' ) ).toContainText( 'has been added to your cart' ); +} + +export async function fillTheBillingAddress( page: Page, country: string, cpfCnpj = '' ): Promise< void > { + await page.goto( '/checkout/' ); + await page.locator( '#email' ).fill( 'customer@example.org' ); + await page.locator( '#billing-country' ).selectOption( { label: country } ); + await page.locator( '#billing-first_name' ).fill( 'Ana' ); + await page.locator( '#billing-last_name' ).fill( 'Lima' ); + await page.locator( '#billing-address_1' ).fill( 'Rua do Ouvidor, 50' ); + await page.locator( '#billing-city' ).fill( 'Rio de Janeiro' ); + await page.locator( '#billing-postcode' ).fill( '20040-030' ); + if ( 'Brazil' === country ) { + await page.locator( '#billing-state' ).selectOption( { label: 'Rio de Janeiro' } ); + } + if ( '' !== cpfCnpj ) { + await page.getByLabel( 'CPF or CNPJ' ).fill( cpfCnpj ); + } +} + +export function placeOrder( page: Page ) { + return page.getByRole( 'button', { name: 'Place Order' } ).click(); +}