diff --git a/.ddev/commands/web/infection b/.ddev/commands/web/infection
new file mode 100755
index 00000000..ae063d41
--- /dev/null
+++ b/.ddev/commands/web/infection
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+## Description: Run Infection mutation testing for the MageForge module
+## Usage: infection [infection-options]
+## Example: ddev infection
+## Example: ddev infection --show-mutations
+## Example: ddev infection --filter=src/Service/Hyva/
+
+cd /var/www/html
+
+if [[ ! -x vendor/bin/infection ]]; then
+ echo "infection not found. Installing module dev dependencies..."
+ composer install --no-interaction
+fi
+
+if ! php -m | grep -qi '^pcov$'; then
+ echo "PCOV extension not loaded. Run 'ddev restart' to rebuild the web image" >&2
+ echo "(.ddev/web-build/Dockerfile.pcov installs it)." >&2
+ exit 1
+fi
+
+echo "Mutation report: reports/infection/infection.html"
+# The initial PHPUnit run needs PCOV for coverage (globally off, see
+# .ddev/php/pcov.ini); opcache.jit=off avoids the JIT warning PCOV triggers.
+exec vendor/bin/infection \
+ --threads=max \
+ --initial-tests-php-options="-d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0" \
+ "$@"
diff --git a/.ddev/commands/web/phpunit b/.ddev/commands/web/phpunit
index 28b3c5b6..924fd720 100755
--- a/.ddev/commands/web/phpunit
+++ b/.ddev/commands/web/phpunit
@@ -3,10 +3,12 @@
set -euo pipefail
## Description: Run PHPUnit tests for the MageForge module
-## Usage: phpunit [options]
+## Usage: phpunit [--coverage] [phpunit-options]
## Example: ddev phpunit
## Example: ddev phpunit --testdox
## Example: ddev phpunit tests/Unit/Service/Hyva/
+## Example: ddev phpunit --coverage
+## Example: ddev phpunit --coverage tests/Unit/Service/Hyva/
cd /var/www/html
@@ -15,4 +17,30 @@ if [[ ! -x vendor/bin/phpunit ]]; then
composer install --no-interaction
fi
-vendor/bin/phpunit "$@"
+coverage=false
+args=()
+for arg in "$@"; do
+ if [[ ${arg} == "--coverage" ]]; then
+ coverage=true
+ else
+ args+=("${arg}")
+ fi
+done
+
+if [[ ${coverage} == true ]]; then
+ if ! php -m | grep -qi '^pcov$'; then
+ echo "PCOV extension not loaded. Run 'ddev restart' to rebuild the web image" >&2
+ echo "(.ddev/web-build/Dockerfile.pcov installs it)." >&2
+ exit 1
+ fi
+ echo "Coverage reports: reports/coverage/index.html (HTML), reports/clover.xml (Clover)"
+ # pcov.enabled=1: opt in per-run (globally off, see .ddev/php/pcov.ini).
+ # opcache.jit=off: avoid the "JIT is incompatible" warning PCOV would trigger.
+ exec php -d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit \
+ --coverage-text \
+ --coverage-html reports/coverage \
+ --coverage-clover reports/clover.xml \
+ "${args[@]}"
+fi
+
+exec vendor/bin/phpunit "${args[@]}"
diff --git a/.ddev/php/pcov.ini b/.ddev/php/pcov.ini
new file mode 100644
index 00000000..c027cda1
--- /dev/null
+++ b/.ddev/php/pcov.ini
@@ -0,0 +1,4 @@
+; PCOV stays disabled by default: its zend_execute_ex hook forces opcache to
+; disable JIT and print a warning on every PHP invocation.
+; `ddev phpunit --coverage` enables it per-run via -d pcov.enabled=1.
+pcov.enabled=0
diff --git a/.ddev/web-build/Dockerfile.pcov b/.ddev/web-build/Dockerfile.pcov
new file mode 100644
index 00000000..246eb21d
--- /dev/null
+++ b/.ddev/web-build/Dockerfile.pcov
@@ -0,0 +1,3 @@
+# PCOV provides fast code coverage for PHPUnit (used by `ddev phpunit --coverage`).
+# It is disabled by default via .ddev/php/pcov.ini and enabled per-run.
+RUN sudo apt-get update && sudo apt-get install -y -o Dpkg::Options::="--force-confold" --no-install-recommends --no-install-suggests "php${DDEV_PHP_VERSION}-pcov"
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index baf97f7a..8c9e9252 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -5,14 +5,19 @@ on: [pull_request]
permissions:
contents: read
-env:
- PHP_VERSION: "8.4"
-
jobs:
phpunit:
- name: Unit Tests
+ name: Unit Tests (PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ["8.3", "8.4"]
+ include:
+ - php-version: "8.4"
+ coverage: true
+
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -20,18 +25,101 @@ jobs:
- name: Set up PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
with:
- php-version: ${{ env.PHP_VERSION }}
+ php-version: ${{ matrix.php-version }}
tools: composer:v2
+ coverage: ${{ matrix.coverage && 'pcov' || 'none' }}
- name: Cache Composer packages
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.composer/cache/files
- key: ${{ runner.os }}-composer-phpunit-${{ hashFiles('composer.json') }}
- restore-keys: ${{ runner.os }}-composer-phpunit
+ key: ${{ runner.os }}-composer-phpunit-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-phpunit-${{ matrix.php-version }}
- name: Install dev dependencies
run: composer install --no-interaction --no-progress
- name: Run PHPUnit
+ if: ${{ !matrix.coverage }}
run: vendor/bin/phpunit
+
+ - name: Run PHPUnit with coverage
+ if: ${{ matrix.coverage }}
+ # mkdir: the text report is written with a bare file_put_contents and
+ # relies on the Clover/HTML writers having created reports/ first.
+ run: |
+ mkdir -p reports
+ vendor/bin/phpunit \
+ --coverage-text=reports/coverage.txt \
+ --coverage-html reports/coverage \
+ --coverage-clover reports/clover.xml
+
+ - name: Publish coverage summary
+ if: ${{ matrix.coverage && always() }}
+ run: |
+ if [[ -f reports/coverage.txt ]]; then
+ {
+ echo '## Code Coverage'
+ echo '```'
+ cat reports/coverage.txt
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Upload coverage report
+ if: ${{ matrix.coverage && always() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: coverage-report
+ path: |
+ reports/coverage
+ reports/clover.xml
+ if-no-files-found: ignore
+
+ mutation:
+ name: Mutation Tests (Infection)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
+ with:
+ php-version: "8.4"
+ tools: composer:v2
+ coverage: pcov
+
+ - name: Cache Composer packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.composer/cache/files
+ key: ${{ runner.os }}-composer-phpunit-8.4-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-phpunit-8.4
+
+ - name: Install dev dependencies
+ run: composer install --no-interaction --no-progress
+
+ - name: Run Infection
+ run: vendor/bin/infection --threads=max --logger-github --no-progress
+
+ - name: Publish mutation summary
+ if: always()
+ run: |
+ if [[ -f reports/infection/summary.log ]]; then
+ {
+ echo '## Mutation Testing'
+ echo '```'
+ cat reports/infection/summary.log
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Upload mutation report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: mutation-report
+ path: reports/infection
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 1a5f69b8..bf161553 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
# PHPUnit
/.phpunit.cache/
+/reports/
.vscode
/magento/
diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml
index 9c95b89d..f6ab98bf 100644
--- a/.trunk/trunk.yaml
+++ b/.trunk/trunk.yaml
@@ -30,6 +30,12 @@ lint:
- linters: [checkov]
paths:
- .ddev/**
+ # DDEV web-build files are Dockerfile *fragments* concatenated into DDEV's
+ # generated Dockerfile: they have no FROM, and sudo is the documented DDEV
+ # mechanism since the build runs as the non-root web user.
+ - linters: [hadolint]
+ paths:
+ - .ddev/web-build/**
enabled:
- pinact@4.1.0
- svgo@4.0.1
diff --git a/composer.json b/composer.json
index 14317943..0a07d0cf 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,7 @@
},
"require-dev": {
"carthage-software/mago": "^1.30",
+ "infection/infection": "^0.34.0",
"magento/magento-coding-standard": "^40",
"phpunit/phpunit": "^11.0"
},
@@ -37,6 +38,7 @@
"sort-packages": true,
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
+ "infection/extension-installer": true,
"magento/composer-dependency-version-audit-plugin": true
}
},
@@ -45,6 +47,9 @@
"fmt": "mago fmt",
"fmt:check": "mago fmt --dry-run",
"phpcs": "phpcs -p -s",
- "phpcbf": "phpcbf -p"
+ "phpcbf": "phpcbf -p",
+ "test": "phpunit",
+ "test:coverage": "phpunit --coverage-text --coverage-html reports/coverage --coverage-clover reports/clover.xml",
+ "test:mutation": "infection --threads=max --initial-tests-php-options='-d pcov.enabled=1'"
}
}
diff --git a/infection.json5 b/infection.json5
new file mode 100644
index 00000000..d6aad063
--- /dev/null
+++ b/infection.json5
@@ -0,0 +1,16 @@
+{
+ "$schema": "vendor/infection/infection/resources/schema.json",
+ "source": {
+ "directories": ["src"],
+ "excludes": ["registration.php"],
+ },
+ "timeout": 10,
+ "logs": {
+ "text": "reports/infection/infection.log",
+ "html": "reports/infection/infection.html",
+ "summary": "reports/infection/summary.log",
+ },
+ "mutators": {
+ "@default": true,
+ },
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index e067aaea..3f29a067 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -3,15 +3,29 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
- cacheDirectory=".phpunit.cache">
+ cacheDirectory=".phpunit.cache"
+ executionOrder="random"
+ failOnRisky="true"
+ failOnWarning="true"
+ failOnEmptyTestSuite="true"
+ beStrictAboutOutputDuringTests="true"
+ beStrictAboutChangesToGlobalState="true"
+ displayDetailsOnPhpunitDeprecations="true"
+ displayDetailsOnTestsThatTriggerDeprecations="true"
+ displayDetailsOnTestsThatTriggerErrors="true"
+ displayDetailsOnTestsThatTriggerNotices="true"
+ displayDetailsOnTestsThatTriggerWarnings="true">
tests/Unit
-
+
src
+
+ src/registration.php
+