From 3d5d17c96691a16923fd2552dd5c245e8cb9de3a Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Wed, 12 Aug 2026 15:43:45 +0200 Subject: [PATCH] Drive the whole library end to end against real WordPress Fifteen requests, each one dispatched through the hooks a host would fire rather than by calling the steps directly, so the priorities, the gatekeeper and the boot barrier are exercised instead of bypassed. The container being mandatory is what makes the last two tests possible: a host binds its own registrar, checker, deactivator, notice queue, activator, gatekeeper and resolver before boot, and every one is asserted to be the object the request actually used -- with the defaults asserted not to have run beside it, since active_plugins untouched and both options unwritten is the only way to tell "the binding was used" from "the binding was ignored and nothing happened". Each load-path test writes its own bundled fixture, because require_once caches by resolved path for the whole process and a shared file would make every later test pass without loading anything. The trait that writes them gained the old suite's @after cleanup and kept ours, so a fixture is removed whether or not the test remembers. --- .../2026-08-12-container-required-rework.md | 4 +- tests/README.md | 67 +- tests/_support/Traits/WithBundledPlugins.php | 17 +- tests/unit/EndToEndTest.php | 955 ++++++++++++++++++ tests/unit/Load/RunnerTest.php | 2 + 5 files changed, 1040 insertions(+), 5 deletions(-) create mode 100644 tests/unit/EndToEndTest.php diff --git a/docs/superpowers/plans/2026-08-12-container-required-rework.md b/docs/superpowers/plans/2026-08-12-container-required-rework.md index 7b41c1e..e27f91b 100644 --- a/docs/superpowers/plans/2026-08-12-container-required-rework.md +++ b/docs/superpowers/plans/2026-08-12-container-required-rework.md @@ -295,7 +295,9 @@ level 9, so most breakage surfaces from `composer test:analysis` without standin ### Task 15 — `15-e2e-suite` -- [ ] The end-to-end bootstrap now builds a container and calls `Provider::register()` before `Loader::boot()`. +- [x] The end-to-end bootstrap now hands `Config::set_container()` a bare container and lets `Loader::boot()` + run `Provider::register()` over it, which is the sequence a host runs. A test about a rebinding host binds + its own implementations into that container first. ### Task 16 — `16-readme-release` diff --git a/tests/README.md b/tests/README.md index f2246d1..6c7c165 100644 --- a/tests/README.md +++ b/tests/README.md @@ -116,9 +116,21 @@ $this->assertSame( 1, $this->bundled_plugin_loads() ); Every call writes a *new* file under a unique name, and every guard constant is unique too. Neither is tidiness: `require_once` dedupes by resolved path for the lifetime of the PHP process, so a shared fixture lets a later test pass -without loading anything, and the fixture defines its constant for real, so a -reused name makes a later sub-plugin read as already loaded. Call -`remove_bundled_plugin_files()` from tearDown. +without loading anything — including if the load logic were deleted outright — +and the fixture defines its constant for real, so a reused name makes a later +sub-plugin read as already loaded. + +The generated file does two things and nothing else: it increments a load +counter in `$GLOBALS`, and it defines the constant inside a `defined()` check, +the shape a real bundled sub-plugin has. The counter is what separates "loaded +twice" and "never loaded" from "loaded once"; the constant alone cannot tell +those three apart. + +`remove_bundled_plugin_files()` cleans up on the trait's own `@after` hook, and +the tests that clear other state alongside it call it from tearDown as well. +Never leave it to the end of a test body: a failed assertion aborts the test +where it stands, so that is exactly the line that does not run on the day it +matters. A fixture helper cannot be called `make()`, `makeEmpty()`, `construct()`, or `constructEmpty()`: those are public methods on `Codeception\Test\Unit`, which @@ -289,3 +301,52 @@ $this->assert_the_library_reported_incorrect_usage(); An unexpected report still fails the test, because everything the listener sees is recorded and asserted to belong to this library. Call `stop_expecting_incorrect_usage()` from tearDown. + +## The end-to-end suite + +`tests/unit/EndToEndTest.php` drives the library the way a host plugin does, +against real WordPress state: the real `active_plugins` option, a real +`deactivate_plugins()` that really writes it, and real site options behind the +notice queue and the activation record. Nothing about the library is doubled, +except in the two tests that are *about* a host binding its own collaborators. + +It reaches for no entry point a host does not have. The bootstrap is +`Config::set_hook_prefix()`, `Config::set_container()`, `Loader::register()` and +`Loader::boot()`, and everything after that arrives through the hooks boot() +wired — a request is `do_action( 'plugins_loaded' )`, an admin page load is +`do_action( 'all_admin_notices' )`. The container is handed over bare rather +than through `WithContainer`, because `boot()` running the provider over it is +one of the steps under test. + +Only two functions are stubbed: `wp_safe_redirect`, which throws so the request +halts where production calls `exit`, and `wp_get_referer`, which is a request +header no test can send and which decides where that redirect would have gone. + +Four preconditions have to hold before any of it means anything, and setUp +establishes all four: + +- **An interactive admin GET** — `set_current_screen( 'plugins' )` plus + `$_SERVER['REQUEST_METHOD'] = 'GET'`. `Conflict\Gatekeeper` turns away + anything else, so without both of these every policy test would pass while + resolving nothing at all. +- **A user who can `activate_plugins`** — `WithUsers::become_plugin_administrator()`. + The gatekeeper checks the capability before anything is resolved, and the + queue checks the same one before it renders, so as nobody the suite would be + asserting that a no-op is a no-op. +- **The hook prefix** — `Config::set_hook_prefix()`. Both plugins_loaded steps + report and return without one, and the queue and activation option names are + derived from it. +- **A rewound `plugins_loaded` counter** — the harness dispatched the hook + before any test ran, so `boot()` would rightly report that it is too late to + wire and run everything inline. tearDown puts the count back. + +The screen, the request method and that counter are all process-global, so all +three are restored in teardown; leaving any of them set turns an unrelated later +test into an admin request. + +Run both legs — `slic run unit` and `slic run unit --env multisite`. Multisite +is not a formality here: `deactivate_plugins()` is network-aware, +`activate_plugins` maps through `manage_network_plugins` so the administrator +who passes on singlesite is not the one who passes on multisite, and the queue +and activation record are `get_site_option()` values, which are network options +there. Every precondition above resolves differently on the second leg. diff --git a/tests/_support/Traits/WithBundledPlugins.php b/tests/_support/Traits/WithBundledPlugins.php index 6d96ef2..96f0bd9 100644 --- a/tests/_support/Traits/WithBundledPlugins.php +++ b/tests/_support/Traits/WithBundledPlugins.php @@ -30,6 +30,14 @@ trait WithBundledPlugins { /** * Write a bundled plugin that counts its own loads and defines its guard constant. * + * The counter is what separates "loaded twice" and "never loaded" from "loaded once". The + * constant alone cannot tell those three apart, because it ends up defined exactly once either + * way. + * + * The constant is defined inside a `defined()` check, because the file stands in for a real + * plugin: a bundled copy that redeclared a constant the standalone had already defined would + * raise a notice, and the guard is what a plugin actually ships. + * * @since 1.0.0 * * @param string $constant Guard constant the file defines, as a bundled plugin's own header would. @@ -100,7 +108,14 @@ protected function reset_bundled_plugin_loads(): void { } /** - * Remove every fixture this test wrote. Call from tearDown. + * Remove every fixture this test wrote. + * + * Runs itself, as PHPUnit's own `@after` hook, and is safe to call from tearDown as well — which + * is where the tests that clear other state alongside it do call it. A test body must never be + * the only thing that removes these: a failed assertion aborts the test where it stands, so a + * cleanup line at the end of the body is exactly the one that does not run on the day it matters. + * + * @after * * @since 1.0.0 * diff --git a/tests/unit/EndToEndTest.php b/tests/unit/EndToEndTest.php new file mode 100644 index 0000000..8b8daf1 --- /dev/null +++ b/tests/unit/EndToEndTest.php @@ -0,0 +1,955 @@ +fatal error.'; + + /** + * The notice core is about to print, as `wp_admin_notice_markup` hands it over. + * + * @var string + */ + private const MARKUP = '

' . self::CORE_TEXT . '

'; + + /** + * Guard constants a test defined through uopz, undone in tearDown. + * + * @var string[] + */ + private $constants = []; + + /** + * Hook callbacks these tests added, as [ hook, callback, priority ] triples. + * + * Tracked so tearDown can take back exactly what a test put there. `remove_all_filters()` would + * strip the hook bare instead, discarding every callback WordPress and the rest of the suite have + * on it for the remainder of the process. + * + * @var array + */ + private $added_hooks = []; + + /** + * @var string|null + */ + private $request_method = null; + + /** + * The plugins_loaded count as the harness left it. + * + * @var int + */ + private $plugins_loaded_count = 0; + + public function setUp(): void { + parent::setUp(); + + Loader_State::reset(); + Config_State::reset(); + + // The first half of the bootstrap. The container is the second, and each test builds its own + // so that a host binding its implementations first has somewhere to bind them. + Config::set_hook_prefix( self::HOOK_PREFIX ); + + // Conflict resolution runs only on an interactive admin GET, since plugins_loaded fires on + // every request. Without both of these every policy test below would pass while resolving + // nothing at all. + set_current_screen( 'plugins' ); + $this->request_method = $_SERVER['REQUEST_METHOD'] ?? null; + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Deactivating a standalone and consuming the notice queue are both gated on + // activate_plugins, which on multisite maps through manage_network_plugins. + $this->become_plugin_administrator(); + + // A referrer is a header no test can send. False is the ordinary case — a link followed from + // somewhere outside the admin — and it sends the user to the plugins list. + $this->setFunctionReturn( 'wp_get_referer', false ); + + $this->clear_state(); + $this->reset_bundled_plugin_loads(); + + // The harness has to boot WordPress before it can run anything, so plugins_loaded has already + // fired by the time any test starts — and boot() would rightly report that it is too late to + // wire. Rewind the counter so a test sees the timing a host bootstrap sees; the late-boot test + // dispatches the hook itself to close the window again. + $this->plugins_loaded_count = did_action( 'plugins_loaded' ); + unset( $GLOBALS['wp_actions']['plugins_loaded'] ); + } + + public function tearDown(): void { + // In tearDown rather than at the end of each test body: a failed assertion would otherwise + // leave an admin screen, a pinned request method, a half-built activation-error request and a + // rewound hook counter standing for every test that runs afterwards in this process. + $GLOBALS['wp_actions']['plugins_loaded'] = $this->plugins_loaded_count; + + if ( $this->request_method === null ) { + unset( $_SERVER['REQUEST_METHOD'] ); + } else { + $_SERVER['REQUEST_METHOD'] = $this->request_method; + } + + unset( $_GET['plugin'], $_GET['_error_nonce'] ); + set_current_screen( 'front' ); + + foreach ( $this->constants as $constant ) { + $this->unsetConstant( $constant ); + } + $this->constants = []; + + // Only what these tests added by hand. What boot() wired comes off in Loader_State::reset(). + foreach ( $this->added_hooks as [ $hook, $callback, $priority ] ) { + remove_filter( $hook, $callback, $priority ); + } + $this->added_hooks = []; + + $this->stop_expecting_incorrect_usage(); + $this->remove_bundled_plugin_files(); + $this->clear_state(); + Loader_State::reset(); + Config_State::reset(); + parent::tearDown(); + } + + /** + * The happy path, and the one every other scenario is a deviation from: nothing else claims the + * plugin, so the bundled copy loads, defines the guard the standalone would have defined, and + * gets the one-time setup that `register_activation_hook()` never gives it. + */ + public function test_a_fresh_load_defines_the_guard_and_activates_exactly_once(): void { + $activated = []; + + $constant = $this->register( + [ + 'activation_callback' => static function ( Sub_Plugin $sub_plugin ) use ( &$activated ): void { + $activated[] = $sub_plugin->get_slug(); + }, + ] + ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 1, $this->bundled_plugin_loads() ); + $this->assertTrue( defined( $constant ), 'The bundled copy defines the guard the standalone would have.' ); + $this->assertSame( [ self::SLUG ], $activated ); + $this->assertSame( [ self::SLUG => true ], $this->activation_record() ); + + // The next page view, with nothing re-registered and nothing re-booted. The constant the file + // really defined stands the load down, and the record really written stands the callback down. + $this->run_request(); + + $this->assertSame( 1, $this->bundled_plugin_loads() ); + $this->assertSame( [ self::SLUG ], $activated, 'Activation runs once for the life of the site.' ); + } + + /** + * The default policy, against core's own `deactivate_plugins()` and the real `active_plugins` + * option rather than a stub of either. + */ + public function test_deactivate_deactivates_notifies_and_redirects(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + ] + ); + + $this->boot(); + + $location = $this->run_halted_request(); + + $this->assertNotContains( self::STANDALONE, $this->active_plugins() ); + $this->assertArrayHasKey( self::SLUG . ':merge', $this->notice_queue() ); + + // The destination, not merely that one was asked for: a redirect somewhere else entirely + // would satisfy "the request ended in a redirect" without sending anyone anywhere useful. + $this->assertSame( admin_url( 'plugins.php' ), $location ); + + // The request really ended in the resolver. The bundled copy loads on the next one, which is + // what the standalone's own guard constant forces in production. + $this->assertSame( 0, $this->bundled_plugin_loads() ); + } + + /** + * All the way to the screen. The merge notice is the one this library raises exactly once and + * never re-queues, so the admin page load after the deactivation has to draw it — and consume it, + * or the owner reads the same deactivation report for ever. + */ + public function test_the_merge_notice_renders_on_the_next_admin_screen_and_clears(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + $this->boot(); + $this->run_halted_request(); + + $rendered = $this->render_admin_notices(); + + $this->assertStringContainsString( self::SLUG, $rendered ); + $this->assertStringContainsString( 'has been deactivated', $rendered ); + $this->assertStringContainsString( + 'notice-warning', + $rendered, + 'A conflict the library has already handled is a warning, not an error.' + ); + $this->assertSame( [], $this->notice_queue(), 'Rendering consumes the queue.' ); + } + + /** + * The failure mode a merge notice queued on every request would produce: a redirect loop, or an + * admin screen that reports the same deactivation for ever. Nothing is re-registered between the + * two requests — a duplicate slug throws — because this is the next page view, not a second + * bootstrap. + */ + public function test_the_request_after_a_deactivation_does_not_loop(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $constant = $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + $this->boot(); + $this->run_halted_request(); + + $this->assertArrayHasKey( self::SLUG . ':merge', $this->notice_queue() ); + + // The owner has been told. Emptying the queue is what makes a second notice visible at all: + // re-queuing writes the same `slug:merge` key, so a queue left as it is would look identical + // whether or not the resolver ran again. + delete_site_option( Queue::option_name() ); + + // This one must not halt, and run_request() fails the test if it does — which is the + // redirect loop, asserted rather than described. + $this->run_request(); + + $this->assertSame( [], $this->notice_queue(), 'Nothing is left to resolve, so nothing is left to say.' ); + $this->assertSame( 1, $this->bundled_plugin_loads(), 'With the standalone gone the bundled copy takes over.' ); + $this->assertTrue( defined( $constant ) ); + } + + /** + * DEFER hands the request to the standalone, and WordPress includes an active plugin from + * wp-settings.php long before plugins_loaded — so by the time the resolver runs, the standalone + * has already defined the guard constant. Defining it up front is what makes this the scenario + * the policy actually describes rather than a resolver that merely declined to act. + */ + public function test_defer_leaves_the_standalone_active_and_loads_nothing(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $constant = $this->define_guard( 'ABSORBER_E2E_DEFERRED_GUARD' ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEFER, + ], + $constant + ); + + $this->boot(); + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins() ); + $this->assertSame( 0, $this->bundled_plugin_loads(), 'The standalone won; the guard stands the bundled copy down.' ); + $this->assertSame( [], $this->notice_queue() ); + } + + public function test_notice_only_notifies_without_deactivating(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::NOTICE_ONLY, + 'conflict_notice_message' => static fn() => 'Deactivate the standalone when you get a chance.', + ] + ); + + $this->boot(); + + // A policy that only talks must not end the request, which is what run_request() asserts. + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins() ); + $this->assertSame( + [ self::SLUG . ':conflict' => 'Deactivate the standalone when you get a chance.' ], + $this->notice_queue() + ); + } + + /** + * The gate that survives every policy and every rebinding: whoever cannot activate a plugin must + * not be able to deactivate one by loading an admin page. Nothing is consumed by refusing — the + * standalone is still there to detect on the next request, from someone who can act on it, which + * is what the second half asserts. + */ + public function test_a_user_who_cannot_activate_plugins_resolves_nothing(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + wp_set_current_user( $this->create_user( 'subscriber' ) ); + + $this->boot(); + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins(), 'A subscriber must not deactivate anything.' ); + $this->assertSame( [], $this->notice_queue(), 'A user who could never read the notice must not consume it.' ); + + $this->become_plugin_administrator(); + + $this->run_halted_request(); + + $this->assertNotContains( self::STANDALONE, $this->active_plugins() ); + $this->assertArrayHasKey( self::SLUG . ':merge', $this->notice_queue() ); + } + + /** + * The conflict the load guard cannot prevent: the owner reinstalls the standalone and presses + * Activate, WordPress includes it on top of the bundled copy, and the re-declaration is a real + * fatal that core's sandbox reports as "the plugin triggered a fatal error" — true, and useless. + * + * Driven through `Loader::boot()` and core's own filter dispatch rather than by calling the queue + * directly, because the wiring is half of what has to work: an admin-only `add_filter()` that + * never ran leaves the useless sentence on the screen. + */ + public function test_a_reactivation_attempt_yields_the_friendly_message(): void { + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_notice_message' => static fn() => 'Recurring is already bundled with the host plugin.', + ] + ); + + $this->boot(); + + // The request core redirects to once the sandboxed activation has fataled. + $_GET['plugin'] = self::STANDALONE; + $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . self::STANDALONE ); + + $rewritten = apply_filters( 'wp_admin_notice_markup', self::MARKUP, self::CORE_TEXT, [] ); + + $this->assertIsString( $rewritten, 'The filter must hand back markup, whatever it did with it.' ); + + $filtered = is_string( $rewritten ) ? $rewritten : ''; + + $this->assertStringContainsString( 'Recurring is already bundled with the host plugin.', $filtered ); + $this->assertStringNotContainsString( self::CORE_TEXT, $filtered ); + + // The notice box stays core's to draw — its classes, its dismiss button, its wrapper. Only + // the sentence inside belongs to this library. + $this->assertStringStartsWith( '

', $filtered ); + } + + /** + * The guard is not only about the standalone. A must-use copy, a second host plugin bundling the + * same code, or the site owner's own snippet all define the same constant, and any of them means + * the code is already in memory. + */ + public function test_the_bundled_copy_stands_down_when_the_guard_is_already_defined(): void { + $constant = $this->define_guard( 'ABSORBER_E2E_ALREADY_LOADED_GUARD' ); + + $this->register( [], $constant ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 0, $this->bundled_plugin_loads() ); + $this->assertSame( [], $this->notice_queue(), 'A plugin the admin can see running has nothing to explain.' ); + } + + /** + * The toggle is read on every request rather than resolved at registration, so flipping it and + * running the next request is what proves the first load was skipped for the toggle and not for + * something else entirely — a missing file, say, which would leave the same empty counter. + */ + public function test_a_sub_plugin_toggled_off_loads_nothing(): void { + $enabled = false; + + $constant = $this->register( + [ + 'enabled' => static function () use ( &$enabled ) { + return $enabled; + }, + ] + ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 0, $this->bundled_plugin_loads() ); + $this->assertFalse( defined( $constant ) ); + $this->assertSame( [], $this->notice_queue(), 'A sub-plugin nobody asked for has nothing to report.' ); + + $enabled = true; + + $this->run_request(); + + $this->assertSame( 1, $this->bundled_plugin_loads(), 'The toggle is the only thing that was stopping it.' ); + } + + /** + * The host's last word before the require, on the hook name its own prefix builds. + */ + public function test_the_should_load_filter_can_veto_a_load(): void { + $constant = $this->register(); + + $this->add_tracked_filter( + Config::get_hook_name( 'should_load' ), + static function ( $should_load, $sub_plugin ) { + return $sub_plugin instanceof Sub_Plugin && $sub_plugin->get_slug() === self::SLUG + ? false + : $should_load; + }, + 10, + 2 + ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 0, $this->bundled_plugin_loads() ); + $this->assertFalse( defined( $constant ) ); + $this->assertSame( [], $this->notice_queue(), 'A host that vetoed the load does not need telling about it.' ); + } + + /** + * Registration order, not slug order and not filesystem order: a host bundles plugins that + * depend on one another, and the order it registers them in is the only say it gets. + */ + public function test_two_sub_plugins_load_in_one_request_in_registration_order(): void { + $loaded = []; + + // Recorded from the activation callback, which runs immediately after each require — so this + // is the order the files were really required in, not the order they were registered in. + $record = static function ( Sub_Plugin $sub_plugin ) use ( &$loaded ): void { + $loaded[] = $sub_plugin->get_slug(); + }; + + $first = $this->register( [ 'activation_callback' => $record ] ); + $second = $this->register( + [ + 'slug' => 'absorber-fee-recovery', + 'activation_callback' => $record, + ] + ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 2, $this->bundled_plugin_loads() ); + $this->assertTrue( defined( $first ) ); + $this->assertTrue( defined( $second ) ); + $this->assertSame( [ self::SLUG, 'absorber-fee-recovery' ], $loaded ); + } + + /** + * All the way to the screen again, from the other end: the load is skipped, the host's own + * explanation is queued, the render draws it as an error, and the render consumes the queue so + * the owner is told once rather than on every admin page load for ever. + */ + public function test_an_unmet_dependency_blocks_the_load_and_queues_the_explanation(): void { + $this->register( + [ + 'dependency_check' => static fn() => false, + 'dependency_notice_message' => static fn() => 'GiveWP 3.0 or later is required.', + ] + ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 0, $this->bundled_plugin_loads() ); + $this->assertSame( + [ self::SLUG . ':dependency' => 'GiveWP 3.0 or later is required.' ], + $this->notice_queue() + ); + + $rendered = $this->render_admin_notices(); + + $this->assertStringContainsString( 'GiveWP 3.0 or later is required.', $rendered ); + $this->assertStringContainsString( 'notice-error', $rendered, 'A plugin that did not load at all is an error.' ); + $this->assertSame( [], $this->notice_queue(), 'Rendering consumes the queue.' ); + } + + /** + * Booting from plugins_loaded at the default priority is the commonest hook mistake there is, and + * an add_action() at a priority the running dispatch has already passed is accepted and then never + * fires. The library reports the mistake and runs the sequence inline, so the site the host + * shipped still gets its bundled plugins. + */ + public function test_a_host_that_boots_too_late_still_gets_its_sub_plugins(): void { + $this->expect_incorrect_usage(); + + $constant = $this->register(); + + $this->add_tracked_action( + 'plugins_loaded', + function (): void { + $this->boot(); + } + ); + + $this->run_request(); + + $this->assertSame( 1, $this->bundled_plugin_loads(), 'A late boot must still load.' ); + $this->assertTrue( defined( $constant ) ); + $this->assert_the_library_reported_incorrect_usage(); + } + + /** + * The whole point of a required container: a host binds its own implementation of an interface + * before boot, and that is the object the library uses for the rest of the request. + * + * One request covers the conflict step and the load pass because the referrer is the plugins + * list, where the redirector says to stay put. The defaults are asserted *not* to have run + * alongside the doubles — a library that resolved a second copy of the queue or the deactivator + * behind the host's back would satisfy every positive assertion here. + */ + public function test_a_host_binding_reaches_every_step_of_the_request(): void { + $registrar = new Spy_Registrar(); + $notices = new Spy_Queue(); + $activator = new Spy_Activator(); + + $checker = new class() implements Plugin_Checker_Interface { + /** + * @var string[] + */ + public $basenames = []; + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_active( string $basename ): bool { + $this->basenames[] = $basename; + + return true; + } + }; + + $deactivator = new class() implements Plugin_Deactivator_Interface { + /** + * @var string[] + */ + public $basenames = []; + + /** + * @param string $basename Plugin basename. + * + * @return void + */ + public function deactivate( string $basename ): void { + $this->basenames[] = $basename; + } + }; + + // Really active, so that the default deactivator would have emptied this option had it been + // the one reached. Nothing else in this test would notice the difference. + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $container = new Test_Container(); + $container->singleton( + Registrar_Interface::class, + static function () use ( $registrar ): Registrar_Interface { + return $registrar; + } + ); + $container->singleton( + Plugin_Checker_Interface::class, + static function () use ( $checker ): Plugin_Checker_Interface { + return $checker; + } + ); + $container->singleton( + Plugin_Deactivator_Interface::class, + static function () use ( $deactivator ): Plugin_Deactivator_Interface { + return $deactivator; + } + ); + $container->singleton( + Queue_Interface::class, + static function () use ( $notices ): Queue_Interface { + return $notices; + } + ); + $container->singleton( + Activator_Interface::class, + static function () use ( $activator ): Activator_Interface { + return $activator; + } + ); + + // From the plugins list the redirector says to stay put, so the request runs on into the load + // pass instead of ending in the resolver. + $this->setFunctionReturn( 'wp_get_referer', admin_url( 'plugins.php' ) ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + 'activation_callback' => static fn() => null, + ] + ); + + $this->boot( $container ); + $this->run_request(); + + $this->assertSame( [ self::SLUG ], array_keys( $registrar->sub_plugins ), 'The host registrar holds the registration.' ); + $this->assertSame( [ self::STANDALONE ], $checker->basenames, 'The host checker answers whether the standalone is active.' ); + $this->assertSame( [ self::STANDALONE ], $deactivator->basenames, 'The host deactivator is the one asked to turn it off.' ); + $this->assertSame( [ self::SLUG ], $notices->merge_notices, 'The host queue is told what happened.' ); + $this->assertSame( [ self::SLUG ], $activator->slugs, 'The host activator runs the one-time setup.' ); + $this->assertSame( 1, $this->bundled_plugin_loads() ); + + $this->assertContains( self::STANDALONE, $this->active_plugins(), 'The default deactivator must not have run too.' ); + $this->assertSame( [], $this->notice_queue(), 'The default queue must not have been resolved alongside it.' ); + $this->assertSame( [], $this->activation_record(), 'The default activator must not have recorded anything.' ); + } + + /** + * The same guarantee for the two the conflict step resolves itself. A host owns what a conflict + * means — but not who may have one resolved, which is why the gate is asked first and separately, + * and is asserted here to have been asked at all. + */ + public function test_a_host_binding_replaces_the_gatekeeper_and_the_resolver(): void { + $gatekeeper = new Spy_Gatekeeper( true ); + $resolver = new Spy_Resolver(); + + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $container = new Test_Container(); + $container->singleton( + Gatekeeper::class, + static function () use ( $gatekeeper ): Gatekeeper { + return $gatekeeper; + } + ); + $container->singleton( + Resolver_Interface::class, + static function () use ( $resolver ): Resolver_Interface { + return $resolver; + } + ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + ] + ); + + $this->boot( $container ); + $this->run_request(); + + $this->assertSame( 1, $gatekeeper->may_resolve_calls, 'The conflict step has to ask the gate.' ); + $this->assertSame( 1, $resolver->resolve_calls ); + $this->assertContains( + self::STANDALONE, + $this->active_plugins(), + 'A host resolver that does nothing means nothing is deactivated.' + ); + $this->assertSame( [], $this->notice_queue() ); + $this->assertSame( 1, $this->bundled_plugin_loads(), 'The load pass still runs after it.' ); + } + + /** + * The second half of the bootstrap, and the point every test above starts from. + * + * The container is handed over bare: `Loader::boot()` is what runs the provider over it, so a + * test that pre-registered the bindings would be asserting against a container the library never + * had to teach. + * + * @param Test_Container|null $container Container to bootstrap with, when a test has bound its + * own implementations into one. + * + * @return void + */ + private function boot( ?Test_Container $container = null ): void { + Config::set_container( $container ?? new Test_Container() ); + + Loader::boot(); + } + + /** + * One page view, which must not end in a redirect. + * + * `wp_safe_redirect()` is stubbed even here, where nothing should reach it. The real one is + * followed by `exit`, which would take the whole test process down rather than fail one test — + * so the stub throws, and a request that redirected when it should not have fails right here + * instead of silently passing somewhere else. + * + * @return void + */ + private function run_request(): void { + $message = self::halted_at_exit_message(); + + $this->setFunctionReturn( + 'wp_safe_redirect', + static function () use ( $message ) { + throw new TestException( $message ); + }, + true + ); + + try { + do_action( 'plugins_loaded' ); + } catch ( TestException $exception ) { + $this->fail( 'The request must not redirect and end here. ' . $exception->getMessage() ); + } finally { + // In a finally block so a failed assertion cannot strand the stub for the rest of the + // process, where a later test's redirect would throw for no reason it can see. + $this->unsetFunctionReturn( 'wp_safe_redirect' ); + } + } + + /** + * One page view that must end where production calls exit(), and where it sent the user. + * + * @return string + */ + private function run_halted_request(): string { + return $this->capture_redirect( + static function (): void { + do_action( 'plugins_loaded' ); + } + ); + } + + /** + * An admin page load, as far as this library is concerned: the hook it renders the queue on. + * + * Dispatched rather than calling `Loader::render_notices()`, because the admin-only `add_action()` + * is half of what has to work — a queue nothing renders is a queue nothing clears either. + * + * @return string + */ + private function render_admin_notices(): string { + ob_start(); + + do_action( 'all_admin_notices' ); + + return (string) ob_get_clean(); + } + + /** + * Register one sub-plugin, backed by a bundled file that exists. + * + * Called before the container is set, which is legal and deliberate: registration is buffered, so + * a host that builds its config array before it builds its container still works. The guard + * constant is unique per call unless the test names one, because loading the file defines it with + * a real `define()` that lasts for the whole PHP process. + * + * @param array $overrides Config values to override. + * @param string|null $constant Guard constant to use, when the test needs to define it. + * + * @return string + */ + private function register( array $overrides = [], ?string $constant = null ): string { + $constant = $constant ?? $this->make_guard_constant(); + $slug = isset( $overrides['slug'] ) && is_string( $overrides['slug'] ) && $overrides['slug'] !== '' + ? $overrides['slug'] + : self::SLUG; + + Loader::register( + array_merge( + [ + 'slug' => $slug, + 'bundled_plugin_file' => $this->make_bundled_plugin_file( $constant ), + 'plugin_loaded_constant' => $constant, + ], + $overrides + ) + ); + + return $constant; + } + + /** + * Define a guard constant for the duration of one test, undone in tearDown. + * + * uopz is what makes this reversible: a plain `define()` lasts for the whole PHP process, and a + * guard left standing makes every later test read its sub-plugin as already loaded. + * + * @param string $constant Constant to define. + * + * @return string + */ + private function define_guard( string $constant ): string { + $this->constants[] = $constant; + + $this->setConstant( $constant, '1.0.0' ); + + return $constant; + } + + /** + * Add an action tearDown can take back by identity rather than by clearing the whole hook. + * + * @param string $hook Hook to add to. + * @param callable $callback Callback to add. + * @param int $priority Priority to add it at. + * + * @return void + */ + private function add_tracked_action( string $hook, callable $callback, int $priority = 10 ): void { + $this->added_hooks[] = [ $hook, $callback, $priority ]; + + add_action( $hook, $callback, $priority ); + } + + /** + * The same, for a filter. Spelled separately even though WordPress keeps actions and filters in + * one registry, so a reader is never left wondering whether a filter was wired by an add_action() + * on purpose. + * + * @param string $hook Hook to add to. + * @param callable $callback Callback to add. + * @param int $priority Priority to add it at. + * @param int $accepted_args How many arguments the callback takes. + * + * @return void + */ + private function add_tracked_filter( + string $hook, + callable $callback, + int $priority = 10, + int $accepted_args = 1 + ): void { + $this->added_hooks[] = [ $hook, $callback, $priority ]; + + add_filter( $hook, $callback, $priority, $accepted_args ); + } + + /** + * Everything this suite writes outside its own fixtures, cleared before and after each test. + * + * @return void + */ + private function clear_state(): void { + delete_site_option( Queue::option_name() ); + delete_site_option( Activator::option_name() ); + delete_option( 'active_plugins' ); + delete_site_option( 'active_sitewide_plugins' ); + } + + /** + * @return array + */ + private function active_plugins(): array { + return (array) get_option( 'active_plugins', [] ); + } + + /** + * The queue is an option and not a transient: with a persistent object cache a transient never + * reaches the database, and a `wp_cache_flush()` would destroy a merge notice raised exactly once. + * `get_site_option()` is `get_option()` outside multisite, so one read covers both install types. + * + * @return array + */ + private function notice_queue(): array { + $queue = get_site_option( Queue::option_name(), [] ); + + return is_array( $queue ) ? $queue : []; + } + + /** + * @return array + */ + private function activation_record(): array { + $done = get_site_option( Activator::option_name(), [] ); + + return is_array( $done ) ? $done : []; + } +} diff --git a/tests/unit/Load/RunnerTest.php b/tests/unit/Load/RunnerTest.php index dc2e9c9..df5128f 100644 --- a/tests/unit/Load/RunnerTest.php +++ b/tests/unit/Load/RunnerTest.php @@ -482,6 +482,8 @@ static function () use ( $registrar ): Registrar_Interface { * when the second one's guard constant never gets defined. */ public function test_one_bundled_file_behind_two_registrations_loads_once(): void { + // The file's own guard constant is one neither registration names, so both of them still + // reach require_once and only the path dedupe can stop the second load. $path = $this->make_bundled_plugin_file( $this->make_guard_constant() ); foreach ( [ 'give-recurring', 'give-fee-recovery' ] as $slug ) {