diff --git a/AGENTS.md b/AGENTS.md index 321587a..59aa15f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,9 +193,10 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `src/Absorber.php` | Static facade: registration, `boot()`, the accessors, and the two notice trampolines. Holds no collaborator's state. | | `src/Provider.php` | Binds every collaborator; the only file that names a default implementation. | | `src/Boot/Scheduler.php` | Hook wiring and boot timing: the sequence, the priorities, and the fallback for a host that boots too late. | -| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback. | +| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback, and the `loaded`/`skipped` announcements. | | `src/Sub_Plugin.php` | Value object; validates config and answers what it can without a container-bound collaborator. | | `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. | +| `src/Skip_Reason.php` | The five reasons the `skipped` action carries, one per gate in the load pass. | | `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | | `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. | | `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. | @@ -262,6 +263,13 @@ is_readable()`, not `file_exists()`: that last is true for a directory and for a permission, and `require_once` fatals on both. Only the dependency gate queues a notice; an unreadable file is a broken build in the host plugin and reports through `_doing_it_wrong()`. +**Each gate's reason is a `Skip_Reason` constant, not a constant on `Loader`.** What the five values +belong to is the `skipped` action rather than the pass that fires it: `Loader` is bound by class name +and a host may replace it outright, and a replacement announcing the same vocabulary should not have +to import the implementation it swapped out to name one — the reason `Conflict_Policy` sits at the +root rather than on `Conflict\Resolver`. Unlike `Conflict_Policy` it carries no behaviour: the library +only ever emits a reason, never receives one, so an `is_valid()` there would have no caller. + The activation callback is the last of those and runs through `Activator_Interface`, which `Loader` takes as a constructor argument like the writer and the registry reader. Last, because a bundled plugin is included rather @@ -403,8 +411,11 @@ runnable inline as well as wirable. `{$hook_prefix}/plugin_absorber/conflict_notice_message`, `{$hook_prefix}/plugin_absorber/dependency_notice_message` and `{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`) -- Actions: `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site - in the library) +- Actions: `{$hook_prefix}/plugin_absorber/loaded` and `{$hook_prefix}/plugin_absorber/skipped` + (`Loader`, one per sub-plugin the load pass finished with; the skip reasons are `Skip_Reason` + constants), + `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site in the + library) - Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`), `{$option_prefix}_plugin_absorber_notices` (`Notices\Store`) @@ -529,9 +540,12 @@ against real WordPress state. `Bootstrap_Test_Case.php` is the abstract parent o somebody else's code — `enabled`, `dependency_check`, `activation_callback`, `conflict_policy`, the notice messages, the `should_load` filter, the bundled file a `require` runs top to bottom, the standalone's own deactivation hook, and every listener on the actions this library fires. - `Traits\Reports_Errors` is the exception that catches its own: reporting a failure may not raise a - second one, so a throw from an `error` listener is swallowed there rather than handed back up to - the step that was already failing. The one failure none of this can catch is a re-declaration + Two of those catch their own rather than falling to the enclosing one. `Traits\Reports_Errors` + swallows what an `error` listener throws, because reporting a failure may not raise a second one. + `Loader::announce()` swallows what a `loaded` or `skipped` listener throws, because by then the + require has happened and the activation callback has run — left to the per-sub-plugin catch, a + listener's bug would be reported as the sub-plugin having been abandoned, on the channel a host + built its health check on. The one failure none of this can catch is a re-declaration fatal, which PHP does not raise as a `Throwable`; the guard constant, checked before the require, is what prevents that one. - **The guard constant and the standalone basename are two separate keys.** No constant does double diff --git a/README.md b/README.md index 2ad38a3..81d3f96 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ two sub-plugins, every optional key. releases. - [Conflict handling][conflicts] — the policies, when they run, and the guard's limits. - [Filters][filters] — the runtime overrides for policies and notice text. -- [Actions][actions] — the failures the library announces, and what each one carries. +- [Actions][actions] — what the library reports as it loads, skips and fails. - [Notices][notices] — where the queue lives, who may see it, and how to render it yourself. - [Extending][extending] — swapping out a piece of the library. - [Tests][tests] — running the suite, the fixtures and traits it offers, and every scenario it drives diff --git a/docs/actions.md b/docs/actions.md index 8e01318..7cff5d6 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -5,10 +5,60 @@ you override. `{prefix}` is the value passed to `Config::set_hook_prefix()`. | Action | Arguments | Fires when | |---|---|---| +| `{prefix}/plugin_absorber/loaded` | `Sub_Plugin $sub_plugin` | A bundled file was required, and its activation callback has already run. | +| `{prefix}/plugin_absorber/skipped` | `Sub_Plugin $sub_plugin`, `string $reason` | A gate turned a sub-plugin away. | | `{prefix}/plugin_absorber/error` | `string $message`, `Sub_Plugin\|null $sub_plugin` | Something went wrong that a developer has to fix. | -Everything announced here also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on. -This is the channel that is not — reach for it for a log line, a health check, or a support tool. +`error` also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on; the action is the +channel that is not, which is what makes it worth listening to on a production site. `loaded` and +`skipped` are announcements rather than failures and go nowhere else. Reach for any of them for a log +line, a health check, or a support tool. + +## Loading + +`loaded` is the answer to "is this sub-plugin here?" without a `defined()` check of your own, and it +is where code that builds on a sub-plugin belongs: + +```php +add_action( 'give/plugin_absorber/loaded', function ( $sub_plugin ) { + if ( $sub_plugin->get_slug() === 'give-recurring' ) { + My_Recurring_Bridge::init(); + } +} ); +``` + +It fires **after** the activation callback, so on a first-ever load the tables and options that +callback creates are already there. + +## Skip reasons + +The second argument is one of five values. Compare against the constant, not the string: + +| Constant | Value | Meaning | +|---|---|---| +| `Skip_Reason::DISABLED` | `disabled` | The `enabled` key, or the callable behind it, said no. | +| `Skip_Reason::ALREADY_LOADED` | `already_loaded` | The guard constant was already defined — usually a standalone copy that loaded first. | +| `Skip_Reason::DEPENDENCIES_UNMET` | `dependencies_unmet` | `dependency_check` said the requirements are not met. The one skip that also queues a [notice](notices.md). | +| `Skip_Reason::FILE_UNREADABLE` | `file_unreadable` | `bundled_plugin_file` does not name a readable file. | +| `Skip_Reason::FILTERED` | `filtered` | The [`should_load` filter](filters.md#the-load-gate) returned something falsy. | + +```php +use Nexcess\PluginAbsorber\Skip_Reason; + +add_action( 'give/plugin_absorber/skipped', function ( $sub_plugin, $reason ) { + if ( $reason === Skip_Reason::ALREADY_LOADED ) { + my_log( sprintf( '%s deferred to a standalone copy.', $sub_plugin->get_slug() ) ); + } +}, 10, 2 ); +``` + +The values are fixed API and will not change. New reasons may be added, so treat one you do not +recognise as a plain skip rather than as an error. + +`loaded` and `skipped` do not add up to everything registered, so do not count on them to. A +sub-plugin whose `enabled`, `dependency_check` or `should_load` callable throws, or whose bundled +file throws as it is required, announces `error` and neither of these; and a `DEACTIVATE` conflict +redirects before the load pass runs at all, so on that request no sub-plugin announces anything. ## Errors @@ -24,14 +74,24 @@ add_action( 'give/plugin_absorber/error', function ( $message, $sub_plugin ) { }, 10, 2 ); ``` -**A bootstrap with no hook prefix cannot be announced.** The prefix is what names this action, so -the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to -`_doing_it_wrong()` alone. +Two things to know: + +- **`file_unreadable` fires both.** A bundled file that is not there is a build to fix *and* a + sub-plugin that will not be present, so it is announced as an `error` and as a `skipped`. Listen + to both and you will see it twice. +- **A bootstrap with no hook prefix cannot be announced.** The prefix is what names these hooks, so + the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to + `_doing_it_wrong()` alone. ## Your listener cannot take the site down -`error` fires from inside `plugins_loaded`, and from inside the handlers that keep a failing -sub-plugin from white-screening the site, so a listener that throws is caught rather than allowed -out. A throw from it costs nothing at all and is itself reported through `_doing_it_wrong()`. That -is a backstop, not a licence — a listener here runs on every request the site serves, so keep it -cheap and keep it quiet. +These fire from inside `plugins_loaded` and from inside the handlers that keep a failing sub-plugin +from white-screening the site, so a listener that throws is caught rather than allowed out. A throw +from `loaded` or `skipped` costs nothing: by the time `loaded` fires the file is required and the +activation callback has run, and a `skipped` announcement is the last thing that happens to that +sub-plugin either way. The throw is reported on `error` as what it is — a listener, named by the +hook it is on — rather than as the sub-plugin having failed, so a health check watching `error` does +not read your own bug as a broken load. A throw from `error` costs nothing either and is reported +through `_doing_it_wrong()` alone, since announcing it again is how a listener that always throws +would recurse. That is a backstop, not a licence — a listener here runs on every request the site +serves, so keep it cheap and keep it quiet. diff --git a/src/Loader.php b/src/Loader.php index 625ca11..6cb1266 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -97,9 +97,13 @@ public function load_all(): void { // A re-declaration is the one failure this cannot catch, because PHP does not raise it as // a Throwable -- which is what the guard constant, checked before any of this, is for. // - // Reporting the failure cannot add one of its own: report_error() swallows whatever a - // listener on the error action throws, so the announcement of a sub-plugin that died - // cannot be what kills the request. + // The loaded and skipped actions run host code too, but they catch their own throws + // rather than falling to this one: by the time `loaded` fires the require has happened, + // the guard constant is defined and the activation callback has run, so a listener's + // throw arriving here would report a sub-plugin that is loaded and healthy as one that + // was abandoned -- on the channel a host built its log line and its health check on. + // Reporting a failure cannot add one of its own either way: report_error() swallows + // whatever a listener on the error action throws. try { $this->load( $sub_plugin ); } catch ( Throwable $thrown ) { @@ -129,6 +133,8 @@ public function load_all(): void { */ private function load( Sub_Plugin $sub_plugin ): void { if ( ! $sub_plugin->is_enabled() ) { + $this->announce_skip( $sub_plugin, Skip_Reason::DISABLED ); + return; } @@ -137,11 +143,14 @@ private function load( Sub_Plugin $sub_plugin ): void { // means "the plugin is already running" -- warning that requirements are unmet for a // plugin the admin can see working would be worse than useless. if ( $sub_plugin->is_already_loaded() ) { + $this->announce_skip( $sub_plugin, Skip_Reason::ALREADY_LOADED ); + return; } if ( ! $sub_plugin->are_dependencies_met() ) { $this->notices->queue_dependency_notice( $sub_plugin ); + $this->announce_skip( $sub_plugin, Skip_Reason::DEPENDENCIES_UNMET ); return; } @@ -154,9 +163,10 @@ private function load( Sub_Plugin $sub_plugin ): void { $file = $sub_plugin->get_bundled_plugin_file(); if ( ! is_file( $file ) || ! is_readable( $file ) ) { - // The sub-plugin travels with the sentence, because this failure belongs to exactly one - // registration: a listener told only that a bundled file is missing would have to parse - // the path back out to know which of them to act on. + // Both channels, because this gate is two things at once: a build the host has to fix, + // which is what `error` carries, and a sub-plugin that is not going to be there, which is + // what anything watching `skipped` is counting. A host listening to both sees this one + // twice, and that is said plainly in the docs rather than solved by picking one. self::report_error( self::class, sprintf( @@ -166,6 +176,7 @@ private function load( Sub_Plugin $sub_plugin ): void { ), $sub_plugin ); + $this->announce_skip( $sub_plugin, Skip_Reason::FILE_UNREADABLE ); return; } @@ -176,6 +187,8 @@ private function load( Sub_Plugin $sub_plugin ): void { $should_load = apply_filters( Config::get_hook_name( 'should_load' ), true, $sub_plugin ); if ( ! $should_load ) { + $this->announce_skip( $sub_plugin, Skip_Reason::FILTERED ); + return; } @@ -191,5 +204,73 @@ private function load( Sub_Plugin $sub_plugin ): void { // for a sub-plugin that was skipped would be worse: the schema would appear for a plugin // that is not loaded. $this->activator->maybe_run( $sub_plugin ); + + // Behind the activation callback, not in front of it. A listener here is host code that will + // reach into the sub-plugin it was just told about, and on the first-ever load the tables and + // options that code expects are the activation callback's work -- so announcing first would + // hand a host a plugin that is loaded but not yet set up. It also keeps a throwing listener + // off the callback: from here announce() catches the throw with the require and the + // activation already done, where from in front of it the throw would skip the callback + // entirely and leave it to be retried, silently, on every request for ever. + $this->announce( Config::get_hook_name( 'loaded' ), [ $sub_plugin ], $sub_plugin ); + } + + /** + * Say that a gate turned this sub-plugin away, and which gate it was. + * + * The hook name is built in one place for the reason `Config` gives for owning the prefix at all. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin that was skipped. + * @param string $reason One of the `Skip_Reason` constants. + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return void + */ + private function announce_skip( Sub_Plugin $sub_plugin, string $reason ): void { + $this->announce( Config::get_hook_name( 'skipped' ), [ $sub_plugin, $reason ], $sub_plugin ); + } + + /** + * Fire one lifecycle action, and keep what a listener throws out of the load pass's own report. + * + * The throw is caught here rather than a frame up, because `load_all()`'s per-sub-plugin catch + * has exactly one sentence and it is "threw while loading, so it was abandoned". For a listener + * on `loaded` that sentence is false in both halves: the require happened, the guard constant is + * defined and the activation callback has already run, so the sub-plugin is loaded and nothing + * about it was abandoned. A host listening to both channels would get `loaded` and `error` for + * the same sub-plugin in the same pass, and the log line, health check and support tool that + * `error` exists for would each read a successful load as a failed one. The same is true of a + * listener on `skipped`, which reports a skip that really did happen. + * + * So the failure is announced as what it is -- somebody's listener, named by the hook it is on -- + * in the sentence `Traits\Reports_Errors` already uses for a listener on the error action. It + * still costs nothing behind it: the sub-plugin is finished with either way, and the loop moves + * on to the next. + * + * @since 1.0.0 + * + * @param string $hook Fully qualified hook name. + * @param array $arguments Arguments to pass to the listeners. + * @param Sub_Plugin $sub_plugin Sub-plugin the announcement is about. + * + * @return void + */ + private function announce( string $hook, array $arguments, Sub_Plugin $sub_plugin ): void { + try { + do_action_ref_array( $hook, $arguments ); + } catch ( Throwable $thrown ) { + self::report_error( + self::class, + sprintf( + 'A listener on %s threw, and was abandoned: %s', + $hook, + $thrown->getMessage() + ), + $sub_plugin + ); + } } } diff --git a/src/Skip_Reason.php b/src/Skip_Reason.php new file mode 100644 index 0000000..38e7559 --- /dev/null +++ b/src/Skip_Reason.php @@ -0,0 +1,76 @@ + + */ + private $loaded_calls = []; + + /** + * Every `skipped` firing, as a [ slug, reason ] pair. + * + * The reason travels with the slug rather than in a list of its own, because a pass over two + * sub-plugins is the case where "one skip, for this reason" and "two skips, one of them for this + * reason" have to be told apart. + * + * @var array + */ + private $skipped_calls = []; + /** * Every `error` firing, as the message and whatever sub-plugin came with it. * @@ -82,6 +102,8 @@ public function setUp(): void { $this->clear_activations(); $this->reset_bundled_plugin_loads(); $this->should_load_calls = []; + $this->loaded_calls = []; + $this->skipped_calls = []; $this->error_calls = []; } @@ -764,12 +786,207 @@ static function () use ( $notices ): Writer_Interface { } /** - * The diagnostic channel a production site actually has. `_doing_it_wrong()` prints nothing - * without WP_DEBUG, so until this action existed a host had no way to be told that a bundled - * plugin it ships never made it into memory. + * `loaded` is the answer to "is this sub-plugin here?" without a `defined()` check of the host's + * own, so it has to fire once, for the sub-plugin that really was required. + */ + public function test_it_announces_a_load_once_with_the_sub_plugin(): void { + $this->record_lifecycle_actions(); + + $this->register(); + + $this->loader()->load_all(); + + $this->assertCount( 1, $this->loaded_calls ); + + $loaded = $this->loaded_calls[0]; + + $this->assertInstanceOf( Sub_Plugin::class, $loaded ); + $this->assertSame( 'give-recurring', $loaded->get_slug() ); + + // The other half, and the reason both listeners go on together: a load that also announced a + // skip is a gate chain that ran on past its own `return`, which asserting on one list alone + // would never see. + $this->assertSame( [], $this->skipped_calls ); + } + + /** + * The whole value of the action is that it means "this code is in memory now", so a gate that + * turned the sub-plugin away must not fire it. The second pass is the positive control: an + * empty list also describes a listener that never attached at all. + */ + public function test_it_announces_no_load_for_a_sub_plugin_that_was_skipped(): void { + $this->record_lifecycle_actions(); + + $this->register( [ 'enabled' => false ] ); + + $this->loader()->load_all(); + + $this->assertSame( [], $this->loaded_calls ); + + $this->register( [ 'slug' => 'give-fee-recovery' ] ); + + $this->loader()->load_all(); + + $this->assertCount( 1, $this->loaded_calls, 'The recorder must catch a load that really happened.' ); + } + + /** + * Behind the activation callback, not in front of it. A listener is host code that will reach + * into the sub-plugin it was just told about, and on a first-ever load the tables and options + * that code expects are the activation callback's work. + */ + public function test_it_announces_a_load_after_the_activation_callback(): void { + $order = []; + + $this->register( + [ + 'activation_callback' => static function () use ( &$order ): void { + $order[] = 'activation_callback'; + }, + ] + ); + + add_action( + 'give/plugin_absorber/loaded', + static function () use ( &$order ): void { + $order[] = 'loaded'; + } + ); + + $this->loader()->load_all(); + + $this->assertSame( [ 'activation_callback', 'loaded' ], $order ); + } + + /** + * One reason per gate, and the reason is what a host dispatches on — so each gate is pinned to + * the constant it reports, not merely to having reported something. + * + * @return Generator,1:string}> + */ + public function skipping_gates(): Generator { + yield 'disabled' => [ [ 'enabled' => false ], Skip_Reason::DISABLED ]; + + yield 'dependencies unmet' => [ + [ 'dependency_check' => static fn() => false ], + Skip_Reason::DEPENDENCIES_UNMET, + ]; + } + + /** + * @dataProvider skipping_gates + * + * @param array $overrides Config that trips the gate. + * @param string $reason Reason the gate has to report. + * + * @return void + */ + public function test_it_announces_a_skip_with_the_reason_for_the_gate( array $overrides, string $reason ): void { + $this->record_lifecycle_actions(); + + $this->register( $overrides ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', $reason ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); + } + + /** + * The guard constant has its own case because tripping it means defining a constant, which the + * data provider cannot do reversibly. + */ + public function test_it_announces_a_skip_for_a_sub_plugin_that_is_already_loaded(): void { + $this->record_lifecycle_actions(); + + $constant = $this->define_guard( 'ABSORBER_ANNOUNCED_SKIP_GUARD' ); + + $this->register( [], $constant ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', Skip_Reason::ALREADY_LOADED ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); + } + + /** + * And the filter has its own because tripping it means a host filter rather than a config key. + */ + public function test_it_announces_a_skip_for_a_load_the_filter_vetoed(): void { + $this->record_lifecycle_actions(); + + $this->register(); + + add_filter( 'give/plugin_absorber/should_load', '__return_false' ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', Skip_Reason::FILTERED ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); + } + + /** + * `do_action()` runs host code, so the lifecycle actions are a new way for a request to die + * inside `plugins_loaded`. A listener that throws costs its own sub-plugin and nothing behind it. + * + * And it costs its own sub-plugin nothing either, which is the half worth pinning: by the time + * `loaded` fires the require has happened, the guard constant is defined and the activation + * callback has run. Left to the per-sub-plugin catch in `load_all()`, the throw would be reported + * as "threw while loading, so it was abandoned" — a sentence that is false in both halves, on the + * one channel a host is expected to build a log line and a health check on. It is announced as + * what it is instead: somebody's listener, named by the hook it is on. + */ + public function test_a_throwing_load_listener_does_not_take_the_request_down(): void { + $this->record_error_action(); + $this->record_lifecycle_actions(); + $this->expect_incorrect_usage(); + + add_action( + 'give/plugin_absorber/loaded', + static function (): void { + throw new RuntimeException( 'the telemetry endpoint was unreachable' ); + } + ); + + $this->register( [ 'slug' => 'give-recurring' ] ); + $this->register( [ 'slug' => 'give-fee-recovery' ] ); + + $this->loader()->load_all(); + + // Both files: the listener throws after each require, and the sub-plugin behind the first one + // still has to load. Without the guard neither number is ever read, because the throw ends the + // request. + $this->assertSame( 2, $this->bundled_plugin_loads() ); + + $this->assertCount( 2, $this->error_calls ); + + foreach ( $this->error_calls as $error ) { + $message = is_string( $error['message'] ) ? $error['message'] : ''; + + $this->assertStringContainsString( 'A listener on give/plugin_absorber/loaded threw', $message ); + $this->assertStringContainsString( 'the telemetry endpoint was unreachable', $message ); + $this->assertStringNotContainsString( + 'threw while loading', + $message, + 'The sub-plugin loaded. Reporting it as abandoned would have a health check watching' + . ' this channel call a healthy load a failed one.' + ); + } + + $this->assert_the_library_reported_incorrect_usage_saying( + 'A listener on give/plugin_absorber/loaded threw', + 'The developer channel carries the same sentence the action does, and names the hook so' + . ' the host knows whose listener to go and look at.' + ); + } + + /** + * The file gate is two things at once — a build the host has to fix, and a sub-plugin that is not + * going to be there — so it is the one gate that announces down both channels. */ - public function test_it_announces_a_missing_bundled_file_with_the_sub_plugin_it_belongs_to(): void { + public function test_it_announces_a_missing_bundled_file_as_both_an_error_and_a_skip(): void { $this->record_error_action(); + $this->record_lifecycle_actions(); $this->expect_incorrect_usage(); $path = $this->missing_bundled_plugin_file(); @@ -784,6 +1001,8 @@ public function test_it_announces_a_missing_bundled_file_with_the_sub_plugin_it_ $this->loader()->load_all(); + $this->assertSame( [ [ 'give-recurring', Skip_Reason::FILE_UNREADABLE ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); $this->assertCount( 1, $this->error_calls ); $this->assertStringContainsString( $path, @@ -931,6 +1150,42 @@ private function loader(): Loader { return $this->resolve( Loader::class ); } + /** + * Listen to both lifecycle actions at once. + * + * One helper rather than two, because most of these tests assert on one list *and* on the other + * staying empty — a skip that also fired `loaded`, a load that also fired `skipped` — and a test + * that only attached the listener it names could not see the other half. + * + * The closures take references to the properties and are `static`: uopz is not involved here, but + * the same shape keeps a listener from holding the test object alive on a hook. + * + * @return void + */ + private function record_lifecycle_actions(): void { + $loaded = &$this->loaded_calls; + $skipped = &$this->skipped_calls; + + add_action( + 'give/plugin_absorber/loaded', + static function ( $sub_plugin ) use ( &$loaded ): void { + $loaded[] = $sub_plugin; + } + ); + + add_action( + 'give/plugin_absorber/skipped', + static function ( $sub_plugin, $reason ) use ( &$skipped ): void { + $skipped[] = [ + $sub_plugin instanceof Sub_Plugin ? $sub_plugin->get_slug() : $sub_plugin, + $reason, + ]; + }, + 10, + 2 + ); + } + /** * Listen to the `error` action, keeping both of its arguments. *