diff --git a/.env.testing.slic b/.env.testing.slic index 950565a..f35ee5e 100644 --- a/.env.testing.slic +++ b/.env.testing.slic @@ -1,5 +1,9 @@ # Consumed by both CI and local slic runs. -WP_VERSION=latest +# +# There is deliberately no WP_VERSION here: slic ignores it. In CI the +# WordPress version is set by the workflow's "Pin the WordPress version" step +# (site-cli core update), and locally you get whatever core the slic image +# ships. WP_ROOT_FOLDER=/var/www/html WP_URL=http://plugin-absorber.test WP_DOMAIN=plugin-absorber.test diff --git a/.github/workflows/tests-php.yml b/.github/workflows/tests-php.yml new file mode 100644 index 0000000..a990b21 --- /dev/null +++ b/.github/workflows/tests-php.yml @@ -0,0 +1,156 @@ +# cspell:ignore DotReporter +name: PHP Tests + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +# This workflow only reads the repository. Narrow the token accordingly. +permissions: + contents: read + +# A superseded PR run is a result nobody is waiting on any more. Pushes to a +# long-lived branch are left alone so their history stays complete. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + # A broken WordPress nightly is upstream's problem, not this library's, so + # those legs report without blocking the PR. + continue-on-error: ${{ matrix.wp == 'nightly' }} + + strategy: + fail-fast: false + matrix: + # The ends of the supported range only. A deprecation introduced at any + # PHP version fires on every later one, so 8.5 catches what 8.0-8.4 + # would, and every leg resolves identical dependencies because + # config.platform.php pins resolution to 7.4 regardless of runtime. + php: + - "7.4" + - "8.5" + # The nightly column is early warning for core regressions; when a + # nightly leg is red, its latest counterpart on the same PHP tells you + # whether WordPress or PHP is at fault. + wp: + - "latest" + - "nightly" + + name: "Tests: PHP ${{ matrix.php }} / WP ${{ matrix.wp }}" + + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + # This workflow reaches into slic's own file layout, so tracking its main + # branch would let an upstream reorganisation change CI without a commit + # here. Pin to a tag and bump it deliberately when slic is upgraded. + - name: Checkout slic + uses: actions/checkout@v6 + with: + repository: stellarwp/slic + ref: "2.3.0" + path: slic + fetch-depth: 1 + + # Codeception refuses to start unless register_argc_argv is On. slic's + # php.ini does not set it, so the base image default applies, and that + # differs between PHP versions -- 7.4 is On, 8.5 is Off. This file is + # bind-mounted into the slic container as conf.d/zz-docker.ini, which + # loads after the main php.ini, so appending here wins. The existence + # check is there because a bare append would happily create the file if + # slic ever moves or renames it, leaving the override silently unapplied + # and the 8.5 legs failing later at "cc build" for no visible reason. + - name: Enable register_argc_argv for Codeception + run: | + php_ini="${GITHUB_WORKSPACE}/slic/containers/slic/php.ini" + if [ ! -f "${php_ini}" ]; then + echo "Expected slic php.ini at ${php_ini}, but it does not exist. slic's layout has changed; update this workflow." >&2 + exit 1 + fi + echo "register_argc_argv=On" >> "${php_ini}" + + - name: Set up slic env vars + run: | + echo "SLIC_BIN=${GITHUB_WORKSPACE}/slic/slic" >> $GITHUB_ENV + echo "SLIC_WP_DIR=${GITHUB_WORKSPACE}/slic/_wordpress" >> $GITHUB_ENV + echo "SLIC_WORDPRESS_DOCKERFILE=Dockerfile.base" >> $GITHUB_ENV + + - name: Set run context for slic + run: echo "SLIC=1" >> $GITHUB_ENV + + - name: Start ssh-agent + run: | + eval `ssh-agent -s` + echo "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}" >> $GITHUB_ENV + + - name: Set up slic for CI + run: | + cd ${GITHUB_WORKSPACE}/.. + ${SLIC_BIN} here + ${SLIC_BIN} interactive off + ${SLIC_BIN} build-prompt off + ${SLIC_BIN} build-subdir off + ${SLIC_BIN} xdebug off + ${SLIC_BIN} debug on + ${SLIC_BIN} php-version set ${{ matrix.php }} --skip-rebuild + + - name: Set up the library + run: | + ${SLIC_BIN} use ${{ github.event.repository.name }} + ${SLIC_BIN} composer set-version 2 + ${SLIC_BIN} composer validate + ${SLIC_BIN} composer install + + # The slic image ships a fixed WordPress that varies by PHP version, and + # no environment variable overrides it -- this step is the only lever. + # Without it a leg named "WP latest" silently tests whatever core the + # image happened to bake in. WPLoader installs from this codebase, so + # pinning here is what puts the suite on the version the leg claims. + - name: Pin the WordPress version + run: ${SLIC_BIN} site-cli core update --version=${{ matrix.wp }} --force + + - name: Build codeception + id: build + run: ${SLIC_BIN} cc build + + - name: Run unit tests (singlesite) + run: ${SLIC_BIN} run unit --env singlesite --ext DotReporter + + # Run even when singlesite failed: one run should report both envs rather + # than making you fix one and rediscover the other. It is gated on the + # build having succeeded, because running the suite after a failed + # install or build only stacks a second, misleading failure on top of the + # real one. + - name: Run unit tests (multisite) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: ${SLIC_BIN} run unit --env multisite --ext DotReporter + + # continue-on-error keeps a red nightly leg from blocking the PR, but it + # also makes it report as a pass in the checks list, so the only way to + # notice is to open the run. Leave a trace where it will actually be seen. + - name: Flag a failing nightly leg + if: ${{ failure() && matrix.wp == 'nightly' }} + run: | + echo "::warning title=WordPress nightly failed::PHP ${{ matrix.php }} against WordPress nightly is red. This does not block the PR." + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> PHP ${{ matrix.php }} / WP nightly failed. Non-blocking, but worth a look." >> $GITHUB_STEP_SUMMARY + + - name: Upload test output + if: failure() + uses: actions/upload-artifact@v7 + with: + name: "test-output-php${{ matrix.php }}-wp${{ matrix.wp }}" + path: tests/_output + if-no-files-found: ignore + retention-days: 7 diff --git a/codeception.dist.yml b/codeception.dist.yml index 491d94e..827ad77 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -1,5 +1,5 @@ -actor: Tester bootstrap: _bootstrap.php +namespace: Nexcess\PluginAbsorber\Tests\Support paths: tests: tests output: tests/_output diff --git a/composer.json b/composer.json index 94bc303..c88680b 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,6 @@ }, "autoload-dev": { "psr-4": { - "Nexcess\\PluginAbsorber\\Tests\\": "tests/", "Nexcess\\PluginAbsorber\\Tests\\Support\\": "tests/_support", "Nexcess\\PluginAbsorber\\Tests\\Unit\\": "tests/unit" } diff --git a/docs/superpowers/plans/2026-07-31-plugin-absorber.md b/docs/superpowers/plans/2026-07-31-plugin-absorber.md index dff5526..07fa151 100644 --- a/docs/superpowers/plans/2026-07-31-plugin-absorber.md +++ b/docs/superpowers/plans/2026-07-31-plugin-absorber.md @@ -559,11 +559,13 @@ next PR, which adds the smoke test and the CI workflow together.' **PR 3** · branch `03-ci-tests` from `02-codeception-harness` · 1 source file **Files:** -- Create: `.github/workflows/tests-php.yml`, `tests/_support/Traits/WithUopz.php`, `tests/unit/SmokeTest.php` +- Create: `.github/workflows/tests-php.yml`, `tests/unit/SmokeTest.php`, `tests/_support/TestException.php`, `tests/README.md` **Interfaces:** - Consumes: the `unit` suite from Task 2. -- Produces: `Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz` with `set_function_return( string $function, $value ): void`, `allow_exit( bool $allow ): void`, and automatic teardown via `unset_uopz_returns()`. Every later test that stubs a WordPress function uses this trait. +- Produces: no local uopz trait. Every later test that stubs a WordPress function uses `lucatume\WPBrowser\Traits\UopzFunctions` from wp-browser — `setFunctionReturn( string $function, $value, bool $execute = false )`, with automatic teardown via the trait's own `@after resetUopzAlterations()`. Also produces `Nexcess\PluginAbsorber\Tests\Support\TestException`, thrown from a stubbed function to halt a code path in place of `exit`. + +**Why not a local trait:** a hand-rolled `WithUopz` is duplicated across every StellarWP plugin repo and drifts. `UopzFunctions` is maintained by wp-browser's author, is already in the dependency tree, and exists as far back as the `^3.6.5` floor this library pins. Nothing to keep in sync. - [ ] **Step 1: Cut the branch** @@ -584,13 +586,13 @@ git checkout 02-codeception-harness && git checkout -b 03-ci-tests namespace Nexcess\PluginAbsorber\Tests\Unit; use Codeception\TestCase\WPTestCase; -use Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz; +use lucatume\WPBrowser\Traits\UopzFunctions; /** * @since 1.0.0 */ class SmokeTest extends WPTestCase { - use WithUopz; + use UopzFunctions; public function test_wordpress_is_loaded(): void { $this->assertTrue( function_exists( 'add_action' ) ); @@ -603,144 +605,62 @@ class SmokeTest extends WPTestCase { } public function test_uopz_can_stub_a_function(): void { - $this->set_function_return( 'wp_get_referer', 'https://example.test/wp-admin/plugins.php' ); + $this->setFunctionReturn( 'wp_get_referer', 'https://example.test/wp-admin/plugins.php' ); $this->assertSame( 'https://example.test/wp-admin/plugins.php', wp_get_referer() ); } - - public function test_exit_can_be_neutralised(): void { - $this->allow_exit( false ); - - $reached = false; - - ( static function () { - exit; - } )(); - - $reached = true; - - $this->assertTrue( $reached, 'exit must be a no-op so the resolver redirect path is testable.' ); - } } ``` +There is deliberately no test that `exit` can be neutralised. See Step 4. + - [ ] **Step 3: Run it to verify it fails** Run: `slic run unit` -Expected: FAIL — `Class "Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz" not found`. +Expected: FAIL — `wp_get_referer()` returns the real value, so `test_uopz_can_stub_a_function` fails on the `assertSame`, until `use UopzFunctions` is in place. -- [ ] **Step 4: Write the `WithUopz` trait** +- [ ] **Step 4: Add `TestException` and the tests README** -Follows the established StellarWP shape (`learndash-seats-plus/tests/_support/Traits/WithUopz.php`), trimmed to what this library needs. The slic image sets `uopz.exit=1`, so `exit` is live unless a test opts out. +There is no local uopz trait to write. `use lucatume\WPBrowser\Traits\UopzFunctions;` in the smoke test is the entire change on the stubbing side: it ships with wp-browser, undoes every override via its own `@after resetUopzAlterations()`, and takes an explicit `$execute` flag instead of guessing whether a value is callable. + +`UopzFunctions::preventExit()` exists, but this library does not use it. Neutralising `exit` lets a test keep running past the point where production would have stopped, so a test that should fail can report as passing and CI will not say otherwise. Tasks 8 and 13 instead stub the call immediately before `exit` and throw from it, which stops execution at a point the test controls. ```php - */ - private $uopz_function_returns = []; - - /** - * Whether this test neutralised exit. - * - * @var bool - */ - private $uopz_exit_modified = false; - - /** - * Override a function's return value for the duration of the test. - * - * Pass a closure to have it invoked in place of the function. - * - * @since 1.0.0 - * - * @param string $function_name Function to override. - * @param mixed $return_value Value to return, or a closure to execute. - * - * @return void - */ - protected function set_function_return( string $function_name, $return_value ): void { - $this->skip_if_no_uopz(); - - $this->uopz_function_returns[] = $function_name; - - uopz_set_return( $function_name, $return_value, $return_value instanceof \Closure ); - } - - /** - * Make exit a no-op so redirect branches can be asserted. - * - * @since 1.0.0 - * - * @param bool $allow Whether exit should terminate execution. - * - * @return void - */ - protected function allow_exit( bool $allow ): void { - $this->skip_if_no_uopz(); - - $this->uopz_exit_modified = true; - - uopz_allow_exit( $allow ); - } - - /** - * Skip the test when uopz is unavailable rather than failing confusingly. - * - * @since 1.0.0 - * - * @return void - */ - private function skip_if_no_uopz(): void { - if ( ! extension_loaded( 'uopz' ) ) { - $this->markTestSkipped( 'The uopz extension is required for this test.' ); - } - } - - /** - * @since 1.0.0 - * - * @after - * - * @return void - */ - protected function unset_uopz_returns(): void { - foreach ( $this->uopz_function_returns as $function_name ) { - uopz_unset_return( $function_name ); - } - - $this->uopz_function_returns = []; - - if ( $this->uopz_exit_modified ) { - uopz_allow_exit( true ); - $this->uopz_exit_modified = false; - } - } +class TestException extends Exception { } ``` +Also write `tests/README.md`, documenting how to run the suites, the `setFunctionReturn()` pattern, and the no-mocking-`exit` rule with a worked example. Tasks 8 and 13 follow it rather than rediscovering it. + + - [ ] **Step 5: Run the tests to verify they pass** Run: `slic run unit` -Expected: PASS — 4 tests, 6 assertions. +Expected: PASS — 3 tests, 5 assertions. - [ ] **Step 6: Write the tests CI workflow** -Adapted from `stellarwp/harbor`'s `tests-php.yml`, reduced to one WordPress version and the PHP range this library supports. +Adapted from `stellarwp/harbor`'s `tests-php.yml`, running the ends of the supported PHP range against WordPress `latest` and `nightly` — four legs, with the `nightly` ones non-blocking. Testing only 7.4 and 8.5 is deliberate: a deprecation introduced at any PHP version fires on every later one, so 8.5 catches whatever 8.0–8.4 would, and `config.platform.php` pins dependency resolution to 7.4 on every leg regardless of runtime, so there is no per-version dependency drift to catch either. ```yaml # cspell:ignore DotReporter @@ -752,20 +672,43 @@ on: branches: - main +# This workflow only reads the repository. Narrow the token accordingly. +permissions: + contents: read + +# A superseded PR run is a result nobody is waiting on any more. Pushes to a +# long-lived branch are left alone so their history stays complete. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 + + # A broken WordPress nightly is upstream's problem, not this library's, so + # those legs report without blocking the PR. + continue-on-error: ${{ matrix.wp == 'nightly' }} + strategy: fail-fast: false matrix: + # The ends of the supported range only. A deprecation introduced at any + # PHP version fires on every later one, so 8.5 catches what 8.0-8.4 + # would, and every leg resolves identical dependencies because + # config.platform.php pins resolution to 7.4 regardless of runtime. php: - "7.4" - - "8.0" - - "8.1" - - "8.2" - - "8.3" + - "8.5" + # The nightly column is early warning for core regressions; when a + # nightly leg is red, its latest counterpart on the same PHP tells you + # whether WordPress or PHP is at fault. + wp: + - "latest" + - "nightly" - name: "Tests: PHP ${{ matrix.php }}" + name: "Tests: PHP ${{ matrix.php }} / WP ${{ matrix.wp }}" steps: - name: Checkout the repository @@ -781,6 +724,14 @@ jobs: path: slic fetch-depth: 1 + # Codeception refuses to start unless register_argc_argv is On. slic's + # php.ini does not set it, so the base image default applies, and that + # differs between PHP versions -- 7.4 is On, 8.5 is Off. This file is + # bind-mounted into the slic container as conf.d/zz-docker.ini, which + # loads after the main php.ini, so appending here wins. + - name: Enable register_argc_argv for Codeception + run: echo "register_argc_argv=On" >> ${GITHUB_WORKSPACE}/slic/containers/slic/php.ini + - name: Set up slic env vars run: | echo "SLIC_BIN=${GITHUB_WORKSPACE}/slic/slic" >> $GITHUB_ENV @@ -790,6 +741,11 @@ jobs: - name: Set run context for slic run: echo "SLIC=1" >> $GITHUB_ENV && echo "CI=1" >> $GITHUB_ENV + - name: Start ssh-agent + run: | + eval `ssh-agent -s` + echo "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}" >> $GITHUB_ENV + - name: Set up slic for CI run: | cd ${GITHUB_WORKSPACE}/.. @@ -808,49 +764,75 @@ jobs: ${SLIC_BIN} composer validate ${SLIC_BIN} composer install + # The slic image ships a fixed WordPress that varies by PHP version, and + # WP_VERSION in .env.testing.slic does not change it. Without this step a + # leg named "WP latest" silently tests whatever core the image happened to + # bake in. WPLoader installs from this codebase, so pinning here is what + # actually puts the suite on the version the leg claims. + - name: Pin the WordPress version + run: ${SLIC_BIN} site-cli core update --version=${{ matrix.wp }} --force + - name: Build codeception run: ${SLIC_BIN} cc build - name: Run unit tests (singlesite) run: ${SLIC_BIN} run unit --env singlesite --ext DotReporter + # Run even when singlesite failed: one run should report both envs rather + # than making you fix one and rediscover the other. - name: Run unit tests (multisite) + if: ${{ !cancelled() }} run: ${SLIC_BIN} run unit --env multisite --ext DotReporter + + - name: Upload test output + if: failure() + uses: actions/upload-artifact@v7 + with: + name: "test-output-php${{ matrix.php }}-wp${{ matrix.wp }}" + path: tests/_output + if-no-files-found: ignore + retention-days: 7 ``` - [ ] **Step 7: Commit** ```bash -git add tests/_support/Traits/WithUopz.php tests/unit/SmokeTest.php .github/workflows/tests-php.yml -git commit -m "Add uopz trait, harness smoke test, and PHP tests workflow" +git add tests/unit/SmokeTest.php tests/_support/TestException.php tests/README.md .github/workflows/tests-php.yml +git commit -m "Add harness smoke test, exit policy, and PHP tests workflow" ``` - [ ] **Step 8: Push and confirm CI is actually green** ```bash git push -u origin 03-ci-tests -gh pr create --base 02-codeception-harness --title "First green CI" --body 'What: `WithUopz` trait, a smoke test that proves the harness, and the PHP tests workflow. +gh pr create --base 02-codeception-harness --title "First green CI" --body 'What: a smoke test that proves the harness, the `TestException` and README that set the stubbing rules, and the PHP tests workflow. Usage: class SomeTest extends WPTestCase { - use WithUopz; + use UopzFunctions; // from wp-browser, not a local trait. public function test_something(): void { - $this->set_function_return( "is_plugin_active", true ); - $this->allow_exit( false ); + $this->setFunctionReturn( "is_plugin_active", true ); } } -Why this way: the smoke test asserts WordPress is loaded, uopz is present, and `exit` can be -neutralised — the three assumptions every later test rests on. Proving them here means a later -failure is a real bug rather than a harness problem. The slic image ships `uopz.exit=1`, so `exit` -terminates unless a test opts out; `allow_exit( false )` is what makes the redirect branch in the -Resolver testable at all. +Why this way: the smoke test asserts WordPress is loaded, uopz is present, and a function can +actually be stubbed — the assumptions every later test rests on. Proving them here means a later +failure is a real bug rather than a harness problem. + +No local `WithUopz`: `lucatume\WPBrowser\Traits\UopzFunctions` ships with wp-browser, is maintained +by its author, undoes overrides through its own `@after`, and exists as far back as the `^3.6.5` +floor this library pins. One less copy to drift across repos. -Verify: `slic run unit` — 4 tests. CI runs the suite on PHP 7.4 through 8.3, singlesite and -multisite. Static analysis is not wired yet; it lands after the first src/ file, because PHPStan -errors on an empty directory.' +`exit` is never mocked. Neutralising it lets a test keep running past the point production would +have stopped, so a test that should fail can report as passing. Redirect branches are tested by +stubbing the call immediately before `exit` and throwing `TestException` from it — worked example +in tests/README.md. + +Verify: `slic run unit` — 3 tests. CI runs both envs across PHP 7.4 through 8.5 against WordPress +latest and nightly — four legs, with the nightly ones non-blocking. Static analysis is not +wired yet; it lands after the first src/ file, because PHPStan errors on an empty directory.' gh run watch ``` @@ -1546,13 +1528,13 @@ use Nexcess\PluginAbsorber\Config; use Nexcess\PluginAbsorber\Conflict_Policy; use Nexcess\PluginAbsorber\Exceptions\Config_Exception; use Nexcess\PluginAbsorber\Sub_Plugin; -use Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz; +use lucatume\WPBrowser\Traits\UopzFunctions; /** * @since 1.0.0 */ class SubPluginTest extends WPTestCase { - use WithUopz; + use UopzFunctions; public function setUp(): void { parent::setUp(); @@ -1667,8 +1649,8 @@ class SubPluginTest extends WPTestCase { } public function test_it_never_calls_wordpress_without_a_standalone(): void { - $this->set_function_return( 'is_plugin_active', true ); - $this->set_function_return( 'is_plugin_active_for_network', true ); + $this->setFunctionReturn( 'is_plugin_active', true ); + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); $this->assertFalse( $this->make()->is_standalone_plugin_active(), @@ -1677,8 +1659,8 @@ class SubPluginTest extends WPTestCase { } public function test_it_detects_a_normally_active_standalone(): void { - $this->set_function_return( 'is_plugin_active', true ); - $this->set_function_return( 'is_plugin_active_for_network', false ); + $this->setFunctionReturn( 'is_plugin_active', true ); + $this->setFunctionReturn( 'is_plugin_active_for_network', false ); $sub_plugin = $this->make( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); @@ -1687,8 +1669,8 @@ class SubPluginTest extends WPTestCase { } public function test_it_detects_a_network_active_standalone(): void { - $this->set_function_return( 'is_plugin_active', false ); - $this->set_function_return( 'is_plugin_active_for_network', true ); + $this->setFunctionReturn( 'is_plugin_active', false ); + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); $sub_plugin = $this->make( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); @@ -1697,8 +1679,8 @@ class SubPluginTest extends WPTestCase { } public function test_it_detects_an_inactive_standalone(): void { - $this->set_function_return( 'is_plugin_active', false ); - $this->set_function_return( 'is_plugin_active_for_network', false ); + $this->setFunctionReturn( 'is_plugin_active', false ); + $this->setFunctionReturn( 'is_plugin_active_for_network', false ); $sub_plugin = $this->make( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); @@ -3740,13 +3722,22 @@ use Nexcess\PluginAbsorber\Conflict\Resolver; use Nexcess\PluginAbsorber\Conflict_Policy; use Nexcess\PluginAbsorber\Loader; use Nexcess\PluginAbsorber\Sub_Plugin; -use Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz; +use Nexcess\PluginAbsorber\Tests\Support\TestException; +use lucatume\WPBrowser\Traits\UopzFunctions; /** * @since 1.0.0 */ class ResolverTest extends WPTestCase { - use WithUopz; + use UopzFunctions; + + /** + * Message carried by the exception that stands in for exit(). + * + * Asserted on rather than merely caught, so an unrelated TestException + * cannot make one of these tests pass for the wrong reason. + */ + private const HALTED_AT_EXIT = 'Resolver halted where production calls exit().'; /** * @var array> @@ -3767,9 +3758,7 @@ class ResolverTest extends WPTestCase { $this->deactivations = []; $this->redirects = []; - $this->allow_exit( false ); - - $this->set_function_return( + $this->setFunctionReturn( 'deactivate_plugins', function ( $plugins, $silent = false, $network_wide = null ) { $this->deactivations[] = [ @@ -3777,16 +3766,20 @@ class ResolverTest extends WPTestCase { 'silent' => $silent, 'network_wide' => $network_wide, ]; - } + }, + true ); - $this->set_function_return( + // Throwing here stops the resolver exactly where production calls exit, + // without mocking exit itself. See tests/README.md. + $this->setFunctionReturn( 'wp_safe_redirect', function ( $location ) { $this->redirects[] = $location; - return true; - } + throw new TestException( self::HALTED_AT_EXIT ); + }, + true ); } @@ -3815,8 +3808,25 @@ class ResolverTest extends WPTestCase { } private function standalone_is( bool $active, bool $network_active = false ): void { - $this->set_function_return( 'is_plugin_active', $active ); - $this->set_function_return( 'is_plugin_active_for_network', $network_active ); + $this->setFunctionReturn( 'is_plugin_active', $active ); + $this->setFunctionReturn( 'is_plugin_active_for_network', $network_active ); + } + + /** + * Runs the resolver, absorbing the TestException that stands in for exit(). + * + * Paths that redirect halt inside wp_safe_redirect(); paths that do not run + * to completion. Either way the assertions afterwards see the same state + * production would have left behind. + * + * @return void + */ + private function resolve(): void { + try { + ( new Resolver() )->resolve_all(); + } catch ( TestException $e ) { + $this->assertSame( self::HALTED_AT_EXIT, $e->getMessage() ); + } } /** @@ -3835,9 +3845,9 @@ class ResolverTest extends WPTestCase { public function test_deactivate_deactivates_notifies_and_redirects(): void { $this->standalone_is( true ); $this->register( [ 'conflict_policy' => Conflict_Policy::DEACTIVATE ] ); - $this->set_function_return( 'wp_get_referer', false ); + $this->setFunctionReturn( 'wp_get_referer', false ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertCount( 1, $this->deactivations ); $this->assertSame( 'give-recurring/give-recurring.php', $this->deactivations[0]['plugins'] ); @@ -3848,9 +3858,9 @@ class ResolverTest extends WPTestCase { public function test_deactivate_is_the_default_policy(): void { $this->standalone_is( true ); $this->register(); - $this->set_function_return( 'wp_get_referer', false ); + $this->setFunctionReturn( 'wp_get_referer', false ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertCount( 1, $this->deactivations ); } @@ -3858,9 +3868,9 @@ class ResolverTest extends WPTestCase { public function test_it_passes_the_network_flag_for_a_network_active_standalone(): void { $this->standalone_is( false, true ); $this->register(); - $this->set_function_return( 'wp_get_referer', false ); + $this->setFunctionReturn( 'wp_get_referer', false ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertTrue( $this->deactivations[0]['network_wide'], @@ -3871,9 +3881,9 @@ class ResolverTest extends WPTestCase { public function test_it_omits_the_network_flag_for_a_normally_active_standalone(): void { $this->standalone_is( true, false ); $this->register(); - $this->set_function_return( 'wp_get_referer', false ); + $this->setFunctionReturn( 'wp_get_referer', false ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertFalse( $this->deactivations[0]['network_wide'] ); } @@ -3882,7 +3892,7 @@ class ResolverTest extends WPTestCase { $this->standalone_is( true ); $this->register( [ 'conflict_policy' => Conflict_Policy::DEFER ] ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations ); $this->assertSame( [], $this->redirects ); @@ -3893,7 +3903,7 @@ class ResolverTest extends WPTestCase { $this->standalone_is( true ); $this->register( [ 'conflict_policy' => Conflict_Policy::NOTICE_ONLY ] ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations ); $this->assertSame( [], $this->redirects ); @@ -3912,7 +3922,7 @@ class ResolverTest extends WPTestCase { ] ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations, 'The callable chose DEFER for this slug.' ); } @@ -3921,7 +3931,7 @@ class ResolverTest extends WPTestCase { $this->standalone_is( true ); $this->register( [ 'enabled' => false ] ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations ); } @@ -3930,7 +3940,7 @@ class ResolverTest extends WPTestCase { $this->standalone_is( false, false ); $this->register(); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations ); } @@ -3945,7 +3955,7 @@ class ResolverTest extends WPTestCase { ] ); - ( new Resolver() )->resolve_all(); + $this->resolve(); $this->assertSame( [], $this->deactivations ); } @@ -4272,8 +4282,9 @@ into an update screen. Known limitation, deliberate: `resolve_all()` runs on front-end requests too, matching both reference implementations. Tracked as issue B in the spec. -Verify: `slic run unit` and `slic run unit --env multisite` — 15 tests. `exit` is neutralised with -`uopz_allow_exit( false )`, which is the only way the redirect branch is reachable in a test.' +Verify: `slic run unit` and `slic run unit --env multisite` — 15 tests. `exit` is never mocked: the +stubbed `wp_safe_redirect()` throws `TestException`, which halts the resolver exactly where +production calls `exit` while leaving a failing test free to report as failing.' ``` --- @@ -5012,7 +5023,7 @@ Verify: `slic run unit` — 8 tests, one per gate plus escaping and the Loader t **PR 15** · branch `15-e2e-fixtures` from `14-activation-error-notice` · 1 source file -Exercises the whole matrix from the engineering plan's verification section against real WordPress state — the real `active_plugins` option, real `deactivate_plugins()`, real transients and options. Only `wp_safe_redirect` and `exit` stay stubbed, because they end the request. +Exercises the whole matrix from the engineering plan's verification section against real WordPress state — the real `active_plugins` option, real `deactivate_plugins()`, real transients and options. Only `wp_safe_redirect` and `wp_get_referer` are stubbed; the redirect throws `TestException` so the request halts where production calls `exit`, without mocking `exit` itself. **Files:** - Create: `tests/_data/plugins/absorber-host/absorber-host.php`, `tests/_data/plugins/fake-standalone/fake-standalone.php`, `tests/_support/Traits/WithBundledPlugins.php`, `tests/unit/EndToEndTest.php` @@ -5194,19 +5205,21 @@ use Codeception\TestCase\WPTestCase; use Nexcess\PluginAbsorber\Config; use Nexcess\PluginAbsorber\Conflict_Policy; use Nexcess\PluginAbsorber\Loader; +use Nexcess\PluginAbsorber\Tests\Support\TestException; use Nexcess\PluginAbsorber\Tests\Support\Traits\WithBundledPlugins; -use Nexcess\PluginAbsorber\Tests\Support\Traits\WithUopz; +use lucatume\WPBrowser\Traits\UopzFunctions; /** * @since 1.0.0 */ class EndToEndTest extends WPTestCase { use WithBundledPlugins; - use WithUopz; + use UopzFunctions; - private const STANDALONE = 'fake-standalone/fake-standalone.php'; - private const TRANSIENT = 'absorber_host_plugin_absorber_notices'; - private const OPTION = 'absorber_host_plugin_absorber_activations'; + private const STANDALONE = 'fake-standalone/fake-standalone.php'; + private const TRANSIENT = 'absorber_host_plugin_absorber_notices'; + private const OPTION = 'absorber_host_plugin_absorber_activations'; + private const HALTED_AT_EXIT = 'Request halted where production calls exit().'; public function setUp(): void { parent::setUp(); @@ -5216,10 +5229,16 @@ class EndToEndTest extends WPTestCase { $GLOBALS['absorber_loads'] = 0; - // Real deactivation and real redirects would end the request; everything else is real. - $this->allow_exit( false ); - $this->set_function_return( 'wp_safe_redirect', static fn() => true ); - $this->set_function_return( 'wp_get_referer', false ); + // Only the two calls that would end the request are stubbed; everything + // else — active_plugins, deactivate_plugins(), transients — is real. + $this->setFunctionReturn( + 'wp_safe_redirect', + static function () { + throw new TestException( self::HALTED_AT_EXIT ); + }, + true + ); + $this->setFunctionReturn( 'wp_get_referer', false ); } public function tearDown(): void { @@ -5258,7 +5277,17 @@ class EndToEndTest extends WPTestCase { } private function run_request(): void { - Loader::run_conflict_resolution(); + try { + Loader::run_conflict_resolution(); + } catch ( TestException $e ) { + $this->assertSame( self::HALTED_AT_EXIT, $e->getMessage() ); + + // Production exits inside the redirect, so load_all() never runs on + // this request. Returning here is what makes the assertion that a + // deactivating request does not also load the sub-plugin meaningful. + return; + } + Loader::load_all(); } @@ -5423,8 +5452,9 @@ Usage: `tests/_data/plugins/absorber-host/absorber-host.php` is the worked consu register, set a policy, supply an activation callback, boot. Why this way: these drive the real `active_plugins` option and let `deactivate_plugins()` actually -run, rather than stubbing it as the unit tests do. Only `wp_safe_redirect` and `exit` stay stubbed, -because they end the request. That makes this a genuine integration check of the load guard, +run, rather than stubbing it as the unit tests do. Only `wp_safe_redirect` and `wp_get_referer` are +stubbed; the redirect throws `TestException` so the request halts where production calls `exit`, +without mocking `exit` itself. That makes this a genuine integration check of the load guard, the three policies, and the run-once activation working together. Bundled fixtures are generated per test rather than committed: `require_once` caches by resolved diff --git a/docs/superpowers/specs/2026-07-31-plugin-absorber-design.md b/docs/superpowers/specs/2026-07-31-plugin-absorber-design.md index b93efd7..a113edc 100644 --- a/docs/superpowers/specs/2026-07-31-plugin-absorber-design.md +++ b/docs/superpowers/specs/2026-07-31-plugin-absorber-design.md @@ -212,12 +212,22 @@ Every PR that ships behavior is covered. PRs 1, 2, 5 and 16 carry no tests of th is deliberate: 1 is boilerplate with no logic, 2 *is* the test harness, 5 is PHPStan configuration (verified by CI running it), and 16 is documentation plus the release tag. -`WithUopz` follows the established StellarWP trait (see -`learndash-seats-plus/tests/_support/Traits/WithUopz.php`). uopz is present in the slic image -(`containers/slic/docker-php-ext-uopz.ini`) with `uopz.exit=1`, so `exit` is live by default — -redirect tests call `uopz_allow_exit( false )` per test. - -- **3 — smoke.** WP bootstrapped; `uopz` loaded; `uopz_allow_exit( false )` works. +Function stubbing uses `lucatume\WPBrowser\Traits\UopzFunctions` from wp-browser, which exists as +far back as the `^3.6.5` floor this library pins; there is no local trait to keep in sync with the +other plugin repos. `setFunctionReturn( $function, $value, $execute = false )` takes `true` as its +third argument when `$value` is a closure to run in place of the function, and the trait's own +`@after resetUopzAlterations()` undoes every override, so tests never write uopz teardown. uopz is +present in the slic image (`containers/slic/docker-php-ext-uopz.ini`). + +`exit` is never mocked. `UopzFunctions::preventExit()` exists but is banned: neutralising `exit` +lets a test keep running past the point production would have halted, so a test that should fail +can report as passing and CI will not say otherwise. Redirect tests instead stub the function +called immediately before `exit` — `wp_safe_redirect` — and throw +`Nexcess\PluginAbsorber\Tests\Support\TestException` from it, then catch it and assert on the +message. This is documented in `tests/README.md`. + +- **3 — smoke.** Three tests: WordPress is loaded; `uopz` is available; a function can be stubbed. + There is deliberately no test that `exit` can be neutralised. - **4 — `Config`.** Prefix regex rejects invalid characters (throws); `get_hook_prefix()` throws when unset; version set/get; container set/get/has; `reset()` clears all three. - **6 — `Conflict_Policy`.** Constant values; the three are distinct. @@ -239,7 +249,8 @@ redirect tests call `uopz_allow_exit( false )` per test. skipped when disabled, when dependencies are unmet (and the notice is queued), when the constant is already defined, when the file is missing, and when `…/should_load` returns false; the filter receives `(bool, Sub_Plugin)`; `boot()` called twice wires each hook once, at priorities 1 and 2. -- **12 — `Conflict\Resolver`.** With `uopz_allow_exit( false )`: DEACTIVATE calls +- **12 — `Conflict\Resolver`.** With `wp_safe_redirect` stubbed to throw `TestException` in place + of the redirect-then-`exit` pair: DEACTIVATE calls `deactivate_plugins` **with** the network flag when network-active and **without** when not, queues the merge notice, and redirects; DEFER no-ops; NOTICE_ONLY queues without deactivating; a callable `conflict_policy` selects the branch per sub-plugin; disabled sub-plugins are skipped; diff --git a/engineering-plan.md b/engineering-plan.md index 332dd1e..98c2c98 100644 --- a/engineering-plan.md +++ b/engineering-plan.md @@ -735,8 +735,9 @@ Codeception + `lucatume/wp-browser`, run through **slic** (StellarWP convention; `…/conflict_policy` filter (last wins). - **`Loader`:** `require_once` happens exactly once; skipped when disabled / already-loaded / file missing / the `…/should_load` filter returns false; `boot()` is idempotent (double-boot - wires hooks once). Use `uopz` (as admin-notices' `WithUopz` trait does) to stub `is_plugin_active`, - `deactivate_plugins`, `wp_safe_redirect`. + wires hooks once). Use `UopzFunctions` from wp-browser (`setFunctionReturn()`; the trait's own + `@after` undoes every override) to stub `is_plugin_active`, `deactivate_plugins`, + `wp_safe_redirect`. - **Container / rebinding:** with a di52 container binding a custom `Registrar_Interface`, `Notices_Interface`, or `Conflict\Resolver_Interface`, the resolve helper returns the bound instance and the trampolines delegate to it; with no container, the local defaults are used. @@ -744,6 +745,8 @@ Codeception + `lucatume/wp-browser`, run through **slic** (StellarWP convention; - **`Conflict\Resolver`:** DEACTIVATE calls `deactivate_plugins` + queues the merge notice + computes a redirect; DEFER does nothing; NOTICE_ONLY queues a conflict notice without deactivating; a callable `conflict_policy` (ProPanel-style) selects the branch per sub-plugin. + Never mock the `exit` after the redirect — stub `wp_safe_redirect` to throw `TestException` + instead, so the test stops where production would (see `tests/README.md`). - **`Activation`:** callback runs exactly once ever per slug; a second load does not re-run it; never runs without an `activation_callback`. - **`Notices`:** transient round-trips the redirect; buffer rewrite replaces the default fatal @@ -786,8 +789,8 @@ CI: `.github/workflows/static-analysis.yml` (PHPStan level 5 via `composer test: `should_load` filter). Unit tests for the happy path + already-loaded/disabled/file-missing skips + container-bound vs local resolution. 3. **Conflict handling** — `Conflict\Resolver` (+ `Resolver_Interface`): deactivate/defer/notice_only - + safe redirect; callable `conflict_policy` + `…/conflict_policy` filter. Tests with uopz-stubbed - WP functions. + + safe redirect; callable `conflict_policy` + `…/conflict_policy` filter. Tests with + `UopzFunctions`-stubbed WP functions. 4. **Activation + Notices** — run-once `Activation` (+ `Activation_Interface`); self-contained `Notices` (+ `Notices_Interface`; transient queue + `plugins.php` buffer rewrite). Tests. 5. **README** — intent, `composer require`, Strauss recommendation, `Config`/`Loader` API, diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8f2c274 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,160 @@ +# Tests + +Codeception on top of a real WordPress, run through [slic](https://github.com/stellarwp/slic). + +## Running + +```bash +slic use plugin-absorber +slic composer install +slic cc build + +slic run unit # singlesite +slic run unit --env multisite # multisite +``` + +CI runs both envs on PHP 7.4 and 8.5 — the ends of the supported range — against WordPress +`latest` and `nightly`. Four legs, with the `nightly` ones non-blocking. + +## Stubbing functions + +Use `UopzFunctions` from wp-browser. Do not add a local `WithUopz` trait — this +library deliberately does not maintain one, so there is nothing to keep in sync +with the other plugin repos. + +```php +use Codeception\TestCase\WPTestCase; +use lucatume\WPBrowser\Traits\UopzFunctions; + +class SomeTest extends WPTestCase { + use UopzFunctions; + + public function test_something(): void { + $this->setFunctionReturn( 'is_plugin_active', true ); + } +} +``` + +Overrides are undone automatically after each test by the trait's `@after` hook, +so tests never need their own uopz cleanup. `setFunctionReturn()` calls +`markTestSkipped()` when the uopz extension is missing, so a machine without +uopz reports skips rather than confusing failures. Pass `true` as the third +argument to execute a closure in place of the function rather than returning it: + +```php +$this->setFunctionReturn( 'wp_safe_redirect', static fn( $location ) => true, true ); +``` + +### A stub closure has no class scope + +uopz executes the replacement outside the test object, so neither `$this` nor +`self::` is available inside it. Both are fatal errors, not warnings: + +```php +// Fatal: Using $this when not in object context. +// Fatal: Cannot access "self" when no class scope is active. +$this->setFunctionReturn( + 'deactivate_plugins', + function ( $plugins ) { + $this->deactivations[] = $plugins; + + throw new TestException( self::HALTED_AT_EXIT ); + }, + true +); +``` + +Arguments arrive normally, and `use` works — including by reference. So bind a +reference to the property first, resolve any class constant into a local, and +capture both. Writes through the reference land on the property, so the rest of +the test reads `$this->deactivations` as usual: + +```php +$deactivations = &$this->deactivations; +$halt_message = self::HALTED_AT_EXIT; + +$this->setFunctionReturn( + 'deactivate_plugins', + static function ( $plugins ) use ( &$deactivations, $halt_message ) { + $deactivations[] = $plugins; + + throw new TestException( $halt_message ); + }, + true +); +``` + +Marking the closure `static` costs nothing and makes the constraint obvious to +the next reader, since `$this` was never usable in the first place. + +## Never mock `exit()` + +`UopzFunctions::preventExit()` exists, but do not use it. Neutralising `exit` +lets a test keep running past the point where production would have stopped, so +a test that should fail can report as passing and CI will not tell you. + +Instead, stub the call immediately before `exit` and throw `TestException` from +it. Execution stops at a point the test controls, and the assertion is about +behaviour rather than about uopz. + +Given code under test that ends a request: + +```php +class Deactivator { + public function redirect_back( string $destination ): void { + wp_safe_redirect( $destination ); + + exit; + } +} +``` + +Assert it like this: + +```php +use Nexcess\PluginAbsorber\Tests\Support\TestException; + +public function test_redirects_back(): void { + $redirects = []; + + $this->setFunctionReturn( + 'wp_safe_redirect', + static function ( $location ) use ( &$redirects ) { + $redirects[] = $location; + + throw new TestException( 'Halted where production calls exit().' ); + }, + true + ); + + $subject = new Deactivator(); + $halted = false; + + try { + $subject->redirect_back( 'https://example.test/wp-admin/plugins.php' ); + } catch ( TestException $e ) { + $halted = true; + + $this->assertSame( 'Halted where production calls exit().', $e->getMessage() ); + } + + $this->assertTrue( $halted, 'The redirect must halt where production calls exit().' ); + $this->assertSame( [ 'https://example.test/wp-admin/plugins.php' ], $redirects ); +} +``` + +The `$halted` flag is the part that cannot be dropped. Catching the exception +without asserting that it actually arrived turns "the code under test never +redirected at all" into a silent pass — the same class of failure this section +opens by warning about, moved out of `preventExit()` and into the test body. +Matching on the message as well as the class keeps an unrelated `TestException` +thrown earlier from satisfying the catch for the wrong reason. + +A bare `expectException( TestException::class )` is fine when the test only +cares that the halt happened and asserts nothing about state afterwards. The +try/catch shape exists so assertions can run after the halt; there is no reason +to use both mechanisms in one test. + +`tests/unit/SmokeTest.php` covers this with +`test_a_stub_can_throw_to_halt_a_code_path`, which is the executable proof that +a stub really can throw to stop a code path before it reaches `exit`. diff --git a/tests/_data/.gitkeep b/tests/_data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/_support/TestException.php b/tests/_support/TestException.php new file mode 100644 index 0000000..79a05b7 --- /dev/null +++ b/tests/_support/TestException.php @@ -0,0 +1,22 @@ +assertTrue( function_exists( 'add_action' ) ); + $this->assertTrue( defined( 'ABSPATH' ) ); + } + + public function test_uopz_is_available(): void { + $this->assertTrue( extension_loaded( 'uopz' ), 'uopz is required to stub WordPress functions.' ); + $this->assertTrue( function_exists( 'uopz_set_return' ) ); + } + + public function test_uopz_can_stub_a_function(): void { + $this->setFunctionReturn( 'wp_get_referer', 'https://example.test/wp-admin/plugins.php' ); + + $this->assertSame( 'https://example.test/wp-admin/plugins.php', wp_get_referer() ); + } + + /** + * The no-mocking-exit rule rests entirely on this mechanism working. + * + * Every later test of a redirect branch stubs the call before exit() and throws + * from it, so the exception has to survive being raised inside a uopz-replaced + * function. Proving that here means a later redirect test that fails is a real + * bug rather than a broken technique. See tests/README.md. + * + * @since 1.0.0 + */ + public function test_a_stub_can_throw_to_halt_a_code_path(): void { + // Captured by value: uopz runs the replacement with no class scope, so + // reading self::HALTED_AT_EXIT inside the closure is a fatal error. + $message = self::HALTED_AT_EXIT; + + $this->setFunctionReturn( + 'wp_safe_redirect', + static function () use ( $message ) { + throw new TestException( $message ); + }, + true + ); + + $halted = false; + + try { + wp_safe_redirect( 'https://example.test/wp-admin/plugins.php' ); + } catch ( TestException $e ) { + $halted = true; + + $this->assertSame( self::HALTED_AT_EXIT, $e->getMessage() ); + } + + $this->assertTrue( $halted, 'A stubbed function must be able to throw in place of exit().' ); + } + + /** + * Proves the multisite env is actually a network, on its own tables. + * + * Without this the multisite CI leg only re-runs the singlesite assertions, and + * an env that silently failed to install a network would still report green. + * Checking the prefix alongside is_multisite() also covers the per-env + * tablePrefix that keeps the two envs from clobbering each other's tables. + * + * @since 1.0.0 + */ + public function test_the_env_matches_its_table_prefix(): void { + $prefix = $GLOBALS['wpdb']->base_prefix; + + $this->assertContains( + $prefix, + [ 'test_', 'mstest_' ], + 'Unexpected table prefix. The envs are declared in tests/unit.suite.yml.' + ); + + $this->assertSame( + 'mstest_' === $prefix, + is_multisite(), + sprintf( + 'The %s prefix belongs to the %s env, but is_multisite() disagrees.', + $prefix, + 'mstest_' === $prefix ? 'multisite' : 'singlesite' + ) + ); + } +} diff --git a/tests/unit/_bootstrap.php b/tests/unit/_bootstrap.php new file mode 100644 index 0000000..e40f663 --- /dev/null +++ b/tests/unit/_bootstrap.php @@ -0,0 +1,11 @@ +