diff --git a/README.md b/README.md index 2b08c29..f8e7d05 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ whose bindings were discarded. - [Conflict handling](docs/conflict-handling.md) — the policies, when they run, and the guard's limits. - [Filters](docs/filters.md) — the runtime overrides for policies and notice text. - [Notices](docs/notices.md) — where the queue lives, who may see it, and how to render it yourself. +- [Tests](tests/README.md) — running the suite, the fixtures and traits it offers, and every scenario + it drives the library through. ## License diff --git a/tests/README.md b/tests/README.md index c834a4b..6e332a5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -289,3 +289,250 @@ $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 scenario suite + +`tests/unit/Scenario/` 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 scenarios that are *about* a host binding its own collaborators. + +Everything else in `tests/unit/` mirrors `src/` and tests one class with its +neighbours doubled. This folder is the exception, and it is named for what its +files describe rather than for a class: a **scenario** is one host bootstrap, +one or more requests, and assertions about what WordPress holds afterwards. + +`Scenario/Bootstrap_Test_Case.php` is the abstract parent every scenario file +extends. It is not collected as a test — the runner takes `*Test.php`, and it is +deliberately not one. + +### What a scenario may call + +It reaches for no entry point a host does not have. The bootstrap is +`Config::set_hook_prefix()`, `Config::set_container()`, `Absorber::register()` +and `Absorber::boot()`, and everything after that arrives through the hooks +`boot()` wired: + +| Helper | What it really does | +|---|---| +| `boot()` | `Config::set_container()` with a **bare** container, then `Absorber::boot()` | +| `run_request()` | `do_action( 'plugins_loaded' )`, and fails the test if it redirects | +| `run_halted_request()` | the same, for a request that must end in a redirect; returns where the user was sent | +| `render_admin_notices()` | `do_action( 'all_admin_notices' )`, and returns what was printed | +| `register()` | `Absorber::register()`, backed by a bundled fixture file that really exists | + +The container is handed over bare rather than through `WithContainer`, because +`boot()` running the provider over it is one of the steps under test. Calling +the steps directly — `Loader::load_all()`, `Resolver::resolve_all()` — would +skip the half where the bugs are: an admin-only `add_action()` that never ran, a +step wired into a dispatch window that had already closed, a resolution ordered +behind the load pass. + +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. `preventExit()` is never used — it would let a request +carry on past the line production never returns from, which turns a failure into +a pass. + +### Four preconditions + +None of it means anything unless all four hold, and setUp establishes all four: + +- **An interactive admin GET** — `set_current_screen( 'plugins' )` plus + `set_request_method( 'GET' )`. `Conflict\Gatekeeper` turns away anything else, + so without both of these every policy scenario would pass while resolving + nothing at all. +- **A user who can `activate_plugins`** — `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** — 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. + +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. 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 the activation record are `get_site_option()` values, which +are network options there. Every precondition above resolves differently on the +second leg. + +### The cases + +Every scenario shares one shape, so it is drawn once here rather than six times +below. A host bootstraps, and from then on the library is only ever reached +through hooks: + +```mermaid +sequenceDiagram + autonumber + participant Host as Host plugin + participant Cfg as Config + participant Abs as Absorber + participant WP as WordPress + + Host->>Cfg: set_hook_prefix(), set_container() + Host->>Abs: register( config ) — buffered, resolves nothing + Host->>Abs: boot() + Abs->>Abs: Provider binds what the container lacks + Abs->>WP: Scheduler wires the hooks + Note over WP: plugins_loaded priority 5 — conflict resolution + Note over WP: plugins_loaded priority 6 — the load pass + Note over WP: all_admin_notices — the queue renders +``` + +#### `Scenario/LoadTest.php` — a bundled plugin nothing is fighting over + +No standalone is in the way in any of these, so priority 5 finds nothing to do +and what is under test is the chain priority 6 walks, in the order it walks it: + +```mermaid +flowchart LR + A[enabled] --> B[not already loaded] + B --> C[dependencies met] + C --> D[file exists] + D --> E[should_load filter] + E --> F[require_once] + F --> G[activation callback] +``` + +Each gate skips to the next sub-plugin on the first failure, and the activation +callback runs only after a require that actually happened. + +**A fresh load defines the guard and activates exactly once.** Nothing else +claims the plugin, and the host supplied an activation callback. The file is +required once, the guard constant is defined, the callback runs, and the +once-ever record is written — and a second request repeats none of it. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant F as Bundled file + participant Act as Activator + + WP->>L: plugins_loaded priority 6 + L->>L: every gate passes + L->>F: require_once + F-->>F: define( guard constant ) + L->>Act: maybe_run() + Act-->>Act: writes the record + Note over WP,Act: second request, nothing re-registered + WP->>L: plugins_loaded priority 6 + L->>L: the guard is defined — stand down +``` + +**The bundled copy stands down when the guard is already defined.** A must-use +copy, a second host bundling the same code, or the owner's own snippet has +already defined the constant. Nothing is required — and nothing is queued +either, because a plugin the admin can watch working has nothing to explain. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant F as Bundled file + + Note over L: the guard constant is already defined + WP->>L: plugins_loaded priority 6 + L->>L: gate two fails — already loaded + L--xF: no require + Note right of L: no notice: this is the ordinary success case +``` + +**A sub-plugin toggled off loads nothing.** The host's `enabled` callback +returns false, and then true. Nothing loads and nothing is said while it is off; +the load happens on the request after it is switched on, which is what proves +the toggle was the only thing stopping it — a missing file would have left the +same empty counter. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant H as Host callback + participant F as Bundled file + + WP->>L: plugins_loaded priority 6 + L->>H: enabled? + H-->>L: false + L--xF: no require + Note over H: the host flips the toggle + WP->>L: plugins_loaded priority 6 + L->>H: enabled? + H-->>L: true + L->>F: require_once +``` + +**The `should_load` filter can veto a load.** The host's last word before the +require, on the hook name its own prefix builds. No require, no guard constant, +and no notice — a host that vetoed the load does not need telling about it. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant Flt as prefix/plugin_absorber/should_load + participant F as Bundled file + + WP->>L: plugins_loaded priority 6 + L->>L: enabled, not loaded, deps, file all pass + L->>Flt: apply_filters( true, sub_plugin ) + Flt-->>L: false + L--xF: no require +``` + +**Two sub-plugins load in one request, in registration order.** A host bundles +plugins that depend on one another, and the order it registers them in is the +only say it gets — not slug order, and not filesystem order. The order is read +back from each activation callback, which runs immediately after its own +require, so it is the order the files were really required in. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant F1 as First file + participant F2 as Second file + + WP->>L: plugins_loaded priority 6 + L->>F1: require_once + F1-->>L: activation callback records it first + L->>F2: require_once + F2-->>L: activation callback records it second +``` + +**An unmet dependency blocks the load and queues the explanation.** All the way +to the screen from the other end: `dependency_check` returns false, so nothing +loads, the host's own sentence is queued, and the next admin page draws it as an +error and consumes it — the owner is told once, not on every page load for ever. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant L as Loader + participant Q as Notice queue + participant F as Bundled file + + WP->>L: plugins_loaded priority 6 + L->>L: dependency_check returns false + L--xF: no require + L->>Q: queue_dependency_notice() + Note over WP,Q: the same request reaches the admin screen + WP->>Q: all_admin_notices + Q-->>WP: draws it as notice-error + Q->>Q: clears the queue +``` diff --git a/tests/_support/Traits/WithBundledPlugins.php b/tests/_support/Traits/WithBundledPlugins.php index ca1b73a..6c53ff3 100644 --- a/tests/_support/Traits/WithBundledPlugins.php +++ b/tests/_support/Traits/WithBundledPlugins.php @@ -125,7 +125,26 @@ protected function reset_bundled_plugin_loads(): void { } /** - * Remove every fixture this test wrote. Call from tearDown. + * Remove every fixture written by a test that never reached its own cleanup. + * + * PHPUnit runs an `@after` method whether the test passed, failed or errored, which a line at the + * end of a test body does not survive: a failed assertion aborts the test where it stands, so that + * is exactly the line that does not run on the day it matters. Tests that clear other state + * alongside these files still call `remove_bundled_plugin_files()` from their own tearDown, and + * the second call is a no-op over an emptied list. + * + * @since 1.0.0 + * + * @after + * + * @return void + */ + protected function remove_bundled_plugin_files_after_test(): void { + $this->remove_bundled_plugin_files(); + } + + /** + * Remove every fixture this test wrote. Safe to call more than once. * * @since 1.0.0 * diff --git a/tests/unit/Scenario/Bootstrap_Test_Case.php b/tests/unit/Scenario/Bootstrap_Test_Case.php new file mode 100644 index 0000000..76a7a4a --- /dev/null +++ b/tests/unit/Scenario/Bootstrap_Test_Case.php @@ -0,0 +1,528 @@ + + */ + private $added_hooks = []; + + /** + * The plugins_loaded count as the harness left it. + * + * @var int + */ + private $plugins_loaded_count = 0; + + /** + * The request URI as the harness left it, put back in tearDown. + * + * @var string|null + */ + private $request_uri; + + /** + * @return void + */ + public function setUp(): void { + parent::setUp(); + + Absorber_State::reset(); + Config_State::reset(); + + // The first half of the bootstrap. The container is the second, and each scenario 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 scenario would pass while resolving + // nothing at all. + set_current_screen( 'plugins' ); + $this->set_request_method( 'GET' ); + + // Two capabilities, and one user who holds both: Conflict\Gatekeeper asks for + // manage_network_plugins on multisite and activate_plugins everywhere else, while + // Notices\Presenter asks for activate_plugins wherever it runs. + $this->become_plugin_administrator(); + + // Where a resolved conflict sends the user is read off the current request rather than off the + // referrer, so the URI is stated rather than inherited: $_SERVER outlives whichever test wrote + // to it last, and a destination assertion against a URI this file never set would be right by + // accident. The plugins list, to match the screen set above. + $this->request_uri = $_SERVER['REQUEST_URI'] ?? null; + $_SERVER['REQUEST_URI'] = '/wp-admin/plugins.php'; + + $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 scenario starts — and boot() would rightly report that it is too late + // to wire. Rewind the counter so a scenario sees the timing a host bootstrap sees; the + // late-boot scenario dispatches the hook itself to close the window again. + $this->plugins_loaded_count = did_action( 'plugins_loaded' ); + unset( $GLOBALS['wp_actions']['plugins_loaded'] ); + } + + /** + * @return void + */ + public function tearDown(): void { + // In tearDown rather than at the end of each scenario body: a failed assertion would otherwise + // leave an admin screen, a pinned request method and URI, and a rewound hook counter standing + // for every test that runs afterwards in this process. + $GLOBALS['wp_actions']['plugins_loaded'] = $this->plugins_loaded_count; + + $this->restore_request_method(); + + if ( $this->request_uri === null ) { + unset( $_SERVER['REQUEST_URI'] ); + } else { + $_SERVER['REQUEST_URI'] = $this->request_uri; + } + + set_current_screen( 'front' ); + + foreach ( $this->constants as $constant ) { + $this->unsetConstant( $constant ); + } + $this->constants = []; + + // Only what these scenarios added by hand. What boot() wired comes off in + // Absorber_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(); + + // Before the two resets below, not after: the queue's option name is read off the bound + // `Notices\Store`, and clearing the container first would leave the row standing. + $this->clear_state(); + + Absorber_State::reset(); + Config_State::reset(); + parent::tearDown(); + } + + /** + * The second half of the bootstrap, and the point every scenario starts from. + * + * The container is handed over bare: `Absorber::boot()` is what runs the provider over it, so a + * scenario that pre-registered the bindings would be asserting against a container the library + * never had to teach. + * + * @since 1.0.0 + * + * @param Test_Container|null $container Container to bootstrap with, when a scenario has bound its + * own implementations into one. + * + * @return void + */ + protected function boot( ?Test_Container $container = null ): void { + Config::set_container( $container ?? new Test_Container() ); + + Absorber::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. + * + * @since 1.0.0 + * + * @return void + */ + protected function run_request(): void { + $halted = false; + $message = self::halted_at_exit_message(); + + $this->pin_headers_as_unsent(); + + // The stub raises the flag itself. Catching the TestException here would never fire: every + // plugins_loaded step wraps itself in `catch ( Throwable )` so that a hook this library owns + // can never white-screen a site, so the throw is swallowed inside the step and a `try`/`catch` + // around the dispatch would pass whether the request redirected or not — which is the failure + // this helper exists to prevent. Reading the halt back out of the library's own + // `_doing_it_wrong()` report would work, but only for as long as the report keeps its present + // wording, and the report is not the fact under test. Only this stub can be reached by a + // redirect, so only this stub is asked. + $this->setFunctionReturn( + 'wp_safe_redirect', + static function () use ( &$halted, $message ) { + $halted = true; + + throw new TestException( $message ); + }, + true + ); + + try { + do_action( 'plugins_loaded' ); + } finally { + // In a finally block so a failed assertion cannot strand the stubs 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' ); + $this->unsetFunctionReturn( 'headers_sent' ); + } + + $this->assertFalse( $halted, 'The request must not redirect and end here.' ); + } + + /** + * One page view that must end where production calls exit(), and where it sent the user. + * + * `WithHaltedRedirects::capture_redirect()` is the right tool one level down, where a redirect + * throws out of the call under test and a `catch` can see it. It is the wrong tool here, and + * quietly so: every `plugins_loaded` step wraps itself in `catch ( Throwable )` — the promise that + * a hook this library owns can never white-screen a site — and a stubbed redirect is a throw like + * any other, swallowed and reported before it could leave the step. A `catch` around the dispatch + * would pass whether or not the request redirected. + * + * So the stub records the halt itself, and both halves are still asserted: the flag, which only a + * redirect can raise, and the library's own `_doing_it_wrong()` report, which says the step ended + * the way a swallowed throw ends it. Reading the flag *out of* that report instead would tie the + * assertion to the report's present wording, and the wording is not the fact under test. + * + * @since 1.0.0 + * + * @return string + */ + protected function run_halted_request(): string { + $halted = false; + $location = ''; + $message = self::halted_at_exit_message(); + + $this->pin_headers_as_unsent(); + + // Emptying the hook is a statement about this request and not about the process, so what was + // on it is put back afterwards. Cloned rather than aliased: the stub empties the object in + // place, and a reference to it would restore nothing. + $wired = isset( $GLOBALS['wp_filter']['plugins_loaded'] ) && $GLOBALS['wp_filter']['plugins_loaded'] instanceof WP_Hook + ? clone $GLOBALS['wp_filter']['plugins_loaded'] + : null; + + $this->setFunctionReturn( + 'wp_safe_redirect', + static function ( $to ) use ( &$halted, &$location, $message ) { + $halted = true; + $location = is_string( $to ) ? $to : ''; + + // What `exit` means, modelled: nothing else in this request runs, the load pass wired + // one priority behind included. uopz cannot stub `exit`, and `preventExit()` would let + // the request carry on past the line production never returns from — so a scenario + // that asserts nothing loaded after a redirect would be asserting it about a request + // production never serves. + remove_all_actions( 'plugins_loaded' ); + + throw new TestException( $message ); + }, + true + ); + + // The step reports the swallowed throw through _doing_it_wrong(), and WPTestCase fails a test + // that receives one it did not expect. + $this->expect_incorrect_usage(); + + try { + do_action( 'plugins_loaded' ); + } finally { + $this->unsetFunctionReturn( 'wp_safe_redirect' ); + $this->unsetFunctionReturn( 'headers_sent' ); + + if ( $wired !== null ) { + $GLOBALS['wp_filter']['plugins_loaded'] = $wired; + } + } + + $this->assertTrue( $halted, 'The request had to stop where production calls exit().' ); + $this->assert_the_library_reported_incorrect_usage(); + + return $location; + } + + /** + * An admin page load, as far as this library is concerned: the hook `Notices\Presenter` draws the + * queue on. + * + * Dispatched rather than calling `Absorber::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. + * + * @since 1.0.0 + * + * @return string + */ + protected 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 scenario names one, because loading the file defines it + * with a real `define()` that lasts for the whole PHP process. + * + * @since 1.0.0 + * + * @param array $overrides Config values to override. + * @param string|null $constant Guard constant to use, when the scenario needs to define + * it. + * + * @return string + */ + protected 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; + + Absorber::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 scenario, 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. + * + * @since 1.0.0 + * + * @param string $constant Constant to define. + * + * @return string + */ + protected 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. + * + * @since 1.0.0 + * + * @param string $hook Hook to add to. + * @param callable $callback Callback to add. + * @param int $priority Priority to add it at. + * + * @return void + */ + protected 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. + * + * @since 1.0.0 + * + * @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 + */ + protected 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 ); + } + + /** + * @since 1.0.0 + * + * @return array + */ + protected function active_plugins(): array { + return (array) get_option( 'active_plugins', [] ); + } + + /** + * Everything a scenario recorded as having activated a sub-plugin once ever. + * + * Composed here rather than read off the `Activator`, which keeps its option name private on + * purpose: nothing outside that class assembles the name, and nothing outside it has a reader to + * offer. `Notices\Store` is the opposite case and is read through `WithNoticeQueue` instead of + * being composed a second time here. + * + * @since 1.0.0 + * + * @return array + */ + protected function activation_record(): array { + // get_site_option() is get_option() outside multisite, so one read covers both install types. + $done = get_site_option( Config::get_option_name( 'activations' ), [] ); + + return is_array( $done ) ? $done : []; + } + + /** + * Everything this suite writes outside its own fixtures, cleared before and after each scenario. + * + * @since 1.0.0 + * + * @return void + */ + protected function clear_state(): void { + $this->clear_notices(); + + delete_site_option( Config::get_option_name( 'activations' ) ); + delete_option( 'active_plugins' ); + delete_site_option( 'active_sitewide_plugins' ); + } + + /** + * State what every scenario here assumes: a request that has sent nothing yet. + * + * `Conflict\Resolver` reads `headers_sent()` to decide whether it may redirect at all, and under + * CLI the real answer is not about this request but about the test runner: PHP records headers as + * sent the moment anything reaches the output layer, so a single line printed before the suite + * starts settles it for the whole process. From PHP 8.4 on, the deprecation notices Codeception's + * own vendor tree emits while booting are that line — which is why leaving this to the runtime + * passed on 7.4 and failed on 8.5. + * + * Both halves matter, so both request helpers pin it. Unstubbed, `run_halted_request()` fails + * because the resolver takes the sent-headers branch and never redirects — and, far worse, + * `run_request()` *passes* for the same reason, since a request that cannot redirect satisfies + * "this one must not redirect" without anything having been tested. + * + * Undone in the same `finally` that takes the redirect stub back off, so nothing outside a + * dispatched request is answered for. + * + * @since 1.0.0 + * + * @return void + */ + private function pin_headers_as_unsent(): void { + $this->setFunctionReturn( 'headers_sent', false ); + } +} diff --git a/tests/unit/Scenario/LoadTest.php b/tests/unit/Scenario/LoadTest.php new file mode 100644 index 0000000..0490a89 --- /dev/null +++ b/tests/unit/Scenario/LoadTest.php @@ -0,0 +1,193 @@ +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 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_SCENARIO_ALREADY_LOADED_GUARD' ); + + $this->register( [], $constant ); + + $this->boot(); + $this->run_request(); + + $this->assertSame( 0, $this->bundled_plugin_loads() ); + $this->assertSame( [], $this->queued_notices(), '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->queued_notices(), '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->queued_notices(), '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: 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->queued_notices() + ); + + $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->queued_notices(), 'Rendering consumes the queue.' ); + } +}