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/.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 7479820..10fdae2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,8 @@ languages/*.mo /vendor/ /vendor-bin/*/vendor/ +/tests/.coverage/ /.phpunit.result.cache /node_modules/ +/tests/E2E/.results/ +/tests/E2E/.report/ 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/package-lock.json b/package-lock.json index db9e321..5a26419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,25 @@ "dependencies": { "bootstrap": "5.3.0", "lineicons": "1.0.3" + }, + "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/@popperjs/core": { @@ -46,6 +65,35 @@ "resolved": "https://registry.npmjs.org/lineicons/-/lineicons-1.0.3.tgz", "integrity": "sha512-UN62u6Rxu9tG3ShwzciZSmD5BgVhpHWy7i5B7BidqFWr/nY7mTr0W+FQwA0KtC9vzjQWry8Vwxp7K1xvaXwnSQ==", "license": "MIT" + }, + "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 index 6123708..5f164bd 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,17 @@ "private": true, "license": "GPL-2.0-or-later", "scripts": { - "build:vendor": "rm -rf assets/vendor && mkdir -p assets/vendor/bootstrap assets/vendor/lineicons && cp node_modules/bootstrap/dist/css/bootstrap-grid.min.css assets/vendor/bootstrap/ && cp -r node_modules/lineicons/web-font/lineicons.css node_modules/lineicons/web-font/fonts assets/vendor/lineicons/" + "build:vendor": "rm -rf assets/vendor && mkdir -p assets/vendor/bootstrap assets/vendor/lineicons && cp node_modules/bootstrap/dist/css/bootstrap-grid.min.css assets/vendor/bootstrap/ && cp -r node_modules/lineicons/web-font/lineicons.css node_modules/lineicons/web-font/fonts assets/vendor/lineicons/", + "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" }, "dependencies": { "bootstrap": "5.3.0", "lineicons": "1.0.3" + }, + "devDependencies": { + "@playwright/test": "^1.63.0" } } 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/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..337b45d 100644 --- a/readme.txt +++ b/readme.txt @@ -58,6 +58,14 @@ 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/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, so the tests only need a MySQL/MariaDB database they are allowed to wipe on every @@ -73,6 +81,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..b53a410 --- /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: + - "127.0.0.1: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(); +} 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..712c2d8 --- /dev/null +++ b/tests/Unit/StructureTest.php @@ -0,0 +1,103 @@ +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/' ) ) { + $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/' ) ) { + 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 ) { + return implode( '/', array_map( static fn ( $part ) => strtolower( (string) preg_replace( '/(?