From 3d5374ec009afda477942e396d4cf189f0950762 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 29 Sep 2026 18:00:52 -0400 Subject: [PATCH 1/6] test: cover the workspace, checkout and lost password flows end to end with playwright Signed-off-by: YvesCesar --- .gitignore | 3 ++ package-lock.json | 59 ++++++++++++++++++++++++++++++ package.json | 15 ++++++++ playwright.config.ts | 25 +++++++++++++ readme.txt | 11 ++++++ tests/E2E/Inc/Account.spec.ts | 18 +++++++++ tests/E2E/Inc/Checkout.spec.ts | 31 ++++++++++++++++ tests/E2E/Inc/CpfCnpj.spec.ts | 29 +++++++++++++++ tests/E2E/Inc/Registration.spec.ts | 41 +++++++++++++++++++++ tests/E2E/docker-compose.yml | 55 ++++++++++++++++++++++++++++ tests/E2E/support/seed.php | 10 +++++ tests/E2E/support/setup.sh | 18 +++++++++ tests/E2E/support/store.ts | 28 ++++++++++++++ 13 files changed, 343 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/E2E/Inc/Account.spec.ts create mode 100644 tests/E2E/Inc/Checkout.spec.ts create mode 100644 tests/E2E/Inc/CpfCnpj.spec.ts create mode 100644 tests/E2E/Inc/Registration.spec.ts create mode 100644 tests/E2E/docker-compose.yml create mode 100644 tests/E2E/support/seed.php create mode 100644 tests/E2E/support/setup.sh create mode 100644 tests/E2E/support/store.ts 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..f69a578 --- /dev/null +++ b/tests/E2E/Inc/Checkout.spec.ts @@ -0,0 +1,31 @@ +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(); + await expect( page ).toHaveURL( /\/checkout\/$/ ); + } ); + + 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..f7ca19f --- /dev/null +++ b/tests/E2E/Inc/CpfCnpj.spec.ts @@ -0,0 +1,29 @@ +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( 'hides the field outside Brazil', async ( { page } ) => { + await fillTheBillingAddress( page, '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(); +} From 1cfaeebb0397d3e68c69a04ef1257589efa8bbe6 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 29 Sep 2026 18:00:52 -0400 Subject: [PATCH 2/6] ci: run the playwright suite on every pull request Signed-off-by: YvesCesar --- .github/workflows/playwright.yml | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/playwright.yml 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 From 70950246115c0d766393f547e84b5a7aff96a315 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 29 Sep 2026 18:14:36 -0400 Subject: [PATCH 3/6] test: check that the cpf/cnpj field shows in brazil before checking it hides elsewhere Signed-off-by: YvesCesar --- tests/E2E/Inc/Checkout.spec.ts | 1 - tests/E2E/Inc/CpfCnpj.spec.ts | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/E2E/Inc/Checkout.spec.ts b/tests/E2E/Inc/Checkout.spec.ts index f69a578..1ed0af7 100644 --- a/tests/E2E/Inc/Checkout.spec.ts +++ b/tests/E2E/Inc/Checkout.spec.ts @@ -18,7 +18,6 @@ test.describe( 'Agreeing to the policy at checkout', () => { await placeOrder( page ); await expect( page.getByText( 'You must agree to the policies before completing the purchase.' ) ).toBeVisible(); - await expect( page ).toHaveURL( /\/checkout\/$/ ); } ); test( 'places the order with the consent', async ( { page } ) => { diff --git a/tests/E2E/Inc/CpfCnpj.spec.ts b/tests/E2E/Inc/CpfCnpj.spec.ts index f7ca19f..dc5ef85 100644 --- a/tests/E2E/Inc/CpfCnpj.spec.ts +++ b/tests/E2E/Inc/CpfCnpj.spec.ts @@ -7,8 +7,11 @@ test.describe( 'Asking Brazilian customers for their CPF or CNPJ', () => { await addThePlanToTheCart( page ); } ); - test( 'hides the field outside Brazil', async ( { page } ) => { - await fillTheBillingAddress( page, 'Portugal' ); + 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(); } ); From 67a36c06e6fc1f23e271a9ab4efb054faeea8528 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 30 Sep 2026 09:42:19 -0400 Subject: [PATCH 4/6] test: enforce the test layout and hold the line coverage Signed-off-by: YvesCesar --- .github/workflows/phpunit.yml | 40 ++++++++++++++ .gitignore | 1 + .octocov.yml | 13 +++++ composer.json | 4 ++ phpunit-coverage.xml.dist | 27 +++++++++ readme.txt | 7 +++ tests/Support/ThemeFiles.php | 35 ++++++++++++ tests/Unit/StructureTest.php | 101 ++++++++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+) create mode 100644 .octocov.yml create mode 100644 phpunit-coverage.xml.dist create mode 100644 tests/Support/ThemeFiles.php create mode 100644 tests/Unit/StructureTest.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 44eb499..02cd580 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -60,3 +60,43 @@ jobs: - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 - run: composer test + + coverage: + name: Coverage + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + env: + WP_TESTS_DB_HOST: 127.0.0.1 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: root + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + extensions: mysqli + coverage: xdebug + tools: composer + + - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 + + - run: composer coverage + + - uses: k1LoW/octocov-action@a167dc0dee441b7ffc45e1b862ab55ec0d87f278 # v1.5.2 diff --git a/.gitignore b/.gitignore index 9851b06..10fdae2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ languages/*.mo /vendor/ /vendor-bin/*/vendor/ +/tests/.coverage/ /.phpunit.result.cache /node_modules/ /tests/E2E/.results/ diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 0000000..dcb8e46 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,13 @@ +coverage: + paths: + - tests/.coverage/clover.xml + acceptable: current >= 90% && current >= prev + +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} + +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} diff --git a/composer.json b/composer.json index 44c3d60..9a29348 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,10 @@ ], "test:unit": "phpunit", "test:integration": "phpunit -c phpunit-integration.xml.dist", + "coverage": [ + "@putenv XDEBUG_MODE=coverage", + "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml" + ], "ci": [ "@lint", "@cs", diff --git a/phpunit-coverage.xml.dist b/phpunit-coverage.xml.dist new file mode 100644 index 0000000..75ae9ca --- /dev/null +++ b/phpunit-coverage.xml.dist @@ -0,0 +1,27 @@ + + + + + tests/Unit + + + tests/Integration + + + + + functions.php + inc + src + woocommerce + + + diff --git a/readme.txt b/readme.txt index f8f5ba1..f4d0e3a 100644 --- a/readme.txt +++ b/readme.txt @@ -58,6 +58,13 @@ Every check is a Composer script: - `composer stan`: PHPStan - `composer test`: PHPUnit - `composer ci`: all of the above, in this order +- `composer coverage`: PHPUnit with a coverage report for octocov + +Every PHP file in `inc/`, `src/` and `woocommerce/` needs a test named after it, +at the same path under `tests/Unit` or `tests/Integration` (and under `tests/E2E` +for a browser test). `tests/Unit/StructureTest.php` enforces this and also fails +on a test whose file no longer exists. In CI, octocov fails the run when line +coverage is below 90% or below the last report of `main` (`.octocov.yml`). `composer install` brings in WordPress, the WordPress test suite and WooCommerce, so the tests only need a MySQL/MariaDB database they are allowed to wipe on every diff --git a/tests/Support/ThemeFiles.php b/tests/Support/ThemeFiles.php new file mode 100644 index 0000000..c7af470 --- /dev/null +++ b/tests/Support/ThemeFiles.php @@ -0,0 +1,35 @@ +isFile() && str_ends_with( $file->getPathname(), $suffix ) ) { + $paths[] = substr( $file->getPathname(), strlen( self::root() ) + 1 ); + } + } + + sort( $paths ); + + return $paths; + } +} diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php new file mode 100644 index 0000000..2637e94 --- /dev/null +++ b/tests/Unit/StructureTest.php @@ -0,0 +1,101 @@ +assert_one_exists( self::tests_covering( $file ), $file . ' is not covered by' ); + } + + public static function provide_theme_files() { + $files = array_merge( + ThemeFiles::under( 'inc', '.php' ), + ThemeFiles::under( 'src', '.php' ), + ThemeFiles::under( 'woocommerce', '.php' ) + ); + + foreach ( $files as $file ) { + yield $file => array( $file ); + } + } + + /** + * @dataProvider provide_test_files + */ + public function test_a_test_covers_a_file_of_the_theme( $file ) { + $this->assert_one_exists( self::files_covered_by( $file ), $file . ' does not cover' ); + } + + public static function provide_test_files() { + $files = array_merge( + ThemeFiles::under( 'tests/Unit', 'Test.php' ), + ThemeFiles::under( 'tests/Integration', 'Test.php' ), + ThemeFiles::under( 'tests/E2E', '.spec.ts' ) + ); + + foreach ( $files as $file ) { + if ( 'tests/Unit/StructureTest.php' !== $file ) { + yield $file => array( $file ); + } + } + } + + private static function tests_covering( $file ) { + if ( str_starts_with( $file, 'src/' ) ) { + $name = substr( $file, strlen( 'src/' ), -strlen( '.php' ) ); + + return array( + 'tests/Unit/' . $name . 'Test.php', + 'tests/Integration/' . $name . 'Test.php', + ); + } + + return array( 'tests/Integration/' . self::test_path( substr( $file, 0, -strlen( '.php' ) ) ) . 'Test.php' ); + } + + private static function files_covered_by( $file ) { + if ( str_starts_with( $file, 'tests/E2E/' ) ) { + return array( self::theme_path( substr( $file, strlen( 'tests/E2E/' ), -strlen( '.spec.ts' ) ) ) . '.php' ); + } + + if ( str_starts_with( $file, 'tests/Unit/' ) ) { + return array( 'src/' . substr( $file, strlen( 'tests/Unit/' ), -strlen( 'Test.php' ) ) . '.php' ); + } + + $name = substr( $file, strlen( 'tests/Integration/' ), -strlen( 'Test.php' ) ); + + return array( + 'src/' . $name . '.php', + self::theme_path( $name ) . '.php', + ); + } + + private function assert_one_exists( array $files, $subject ) { + $found = array_filter( + $files, + static function ( $file ) { + return file_exists( ThemeFiles::root() . '/' . $file ); + } + ); + + $this->assertNotEmpty( $found, sprintf( '%s %s.', $subject, implode( ' or ', $files ) ) ); + } + + private static function test_path( $theme_path ) { + return implode( '/', array_map( static fn ( $part ) => str_replace( ' ', '', ucwords( str_replace( '-', ' ', $part ) ) ), explode( '/', $theme_path ) ) ); + } + + private static function theme_path( $test_path ) { + $parts = explode( '/', $test_path ); + $file = array_pop( $parts ); + + return implode( '/', array_merge( array_map( 'strtolower', $parts ), array( strtolower( (string) preg_replace( '/(? Date: Wed, 30 Sep 2026 09:56:56 -0400 Subject: [PATCH 5/6] test: map test folders back to hyphenated theme folders and e2e specs to src Signed-off-by: YvesCesar --- readme.txt | 7 ++++--- tests/Unit/StructureTest.php | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/readme.txt b/readme.txt index f4d0e3a..337b45d 100644 --- a/readme.txt +++ b/readme.txt @@ -61,9 +61,10 @@ Every check is a Composer script: - `composer coverage`: PHPUnit with a coverage report for octocov Every PHP file in `inc/`, `src/` and `woocommerce/` needs a test named after it, -at the same path under `tests/Unit` or `tests/Integration` (and under `tests/E2E` -for a browser test). `tests/Unit/StructureTest.php` enforces this and also fails -on a test whose file no longer exists. In CI, octocov fails the run when line +at the same path: under `tests/Integration` for `inc/` and `woocommerce/`, which +need WordPress, and under `tests/Unit` or `tests/Integration` for `src/`. A browser +test under `tests/E2E` follows the same names. `tests/Unit/StructureTest.php` +enforces this and also fails on a test whose file no longer exists. In CI, octocov fails the run when line coverage is below 90% or below the last report of `main` (`.octocov.yml`). `composer install` brings in WordPress, the WordPress test suite and WooCommerce, diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php index 2637e94..712c2d8 100644 --- a/tests/Unit/StructureTest.php +++ b/tests/Unit/StructureTest.php @@ -62,7 +62,12 @@ private static function tests_covering( $file ) { private static function files_covered_by( $file ) { if ( str_starts_with( $file, 'tests/E2E/' ) ) { - return array( self::theme_path( substr( $file, strlen( 'tests/E2E/' ), -strlen( '.spec.ts' ) ) ) . '.php' ); + $name = substr( $file, strlen( 'tests/E2E/' ), -strlen( '.spec.ts' ) ); + + return array( + 'src/' . $name . '.php', + self::theme_path( $name ) . '.php', + ); } if ( str_starts_with( $file, 'tests/Unit/' ) ) { @@ -93,9 +98,6 @@ private static function test_path( $theme_path ) { } private static function theme_path( $test_path ) { - $parts = explode( '/', $test_path ); - $file = array_pop( $parts ); - - return implode( '/', array_merge( array_map( 'strtolower', $parts ), array( strtolower( (string) preg_replace( '/(? strtolower( (string) preg_replace( '/(? Date: Fri, 2 Oct 2026 11:22:13 -0400 Subject: [PATCH 6/6] test: bind the e2e wordpress port to localhost only Signed-off-by: YvesCesar --- tests/E2E/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/E2E/docker-compose.yml b/tests/E2E/docker-compose.yml index 209911e..b53a410 100644 --- a/tests/E2E/docker-compose.yml +++ b/tests/E2E/docker-compose.yml @@ -22,7 +22,7 @@ services: WORDPRESS_DB_PASSWORD: root WORDPRESS_DB_NAME: wordpress ports: - - "8889:80" + - "127.0.0.1:8889:80" healthcheck: test: ["CMD", "test", "-f", "/var/www/html/wp-config.php"] interval: 1s