From 107212be9f5fb7aca117bf07578d04b0803bac0c Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Thu, 13 Aug 2026 15:38:18 +0200 Subject: [PATCH] Assert which mistake was reported, not that one was assert_the_library_reported_incorrect_usage() accepted any report naming this library, so four Loader cases and both Scheduler catch arms passed on a report from an unrelated cause -- and deleting the Config_Exception arm from resolve_conflicts() left the suite green. The trait records the message now and offers an assertion that matches it, so LoaderTest's private copy of that recorder goes away. expect_incorrect_usage() also removes a listener it already installed: run_halted_request() calls it, so two halted requests in one scenario stranded a listener bound to a finished test object on doing_it_wrong_run for the rest of the process. The late-boot fallback is asserted for the conflict step as well as the load, through a should_load filter reading state at the load pass's last gate. A fallback that iterated only the last element of sequence() passed everything. --- tests/_support/Traits/WithIncorrectUsage.php | 92 ++++++- tests/unit/Boot/SchedulerTest.php | 256 +++++++++++++++++-- tests/unit/LoaderTest.php | 113 +++----- 3 files changed, 355 insertions(+), 106 deletions(-) diff --git a/tests/_support/Traits/WithIncorrectUsage.php b/tests/_support/Traits/WithIncorrectUsage.php index 14905fd..3bdaa58 100644 --- a/tests/_support/Traits/WithIncorrectUsage.php +++ b/tests/_support/Traits/WithIncorrectUsage.php @@ -5,6 +5,8 @@ * @package Nexcess\PluginAbsorber */ +declare( strict_types=1 ); + namespace Nexcess\PluginAbsorber\Tests\Support\Traits; /** @@ -20,6 +22,12 @@ * The expectation is registered from the report itself, so an unexpected report still fails the test: * anything the library reports is recorded here and asserted over. * + * The *message* is recorded alongside the name, because "something was reported" is a weak thing to + * assert on its own: a failed registry read, a missing hook prefix and the gate a test is actually + * about all satisfy it equally. A test about one particular failure asserts through + * `assert_the_library_reported_incorrect_usage_saying()` instead, which is the only assertion here + * that can tell two report sites apart. + * * Requires `Codeception\TestCase\WPTestCase`, for `setExpectedIncorrectUsage()`. * * @since 1.0.0 @@ -32,6 +40,13 @@ trait WithIncorrectUsage { */ private $incorrect_usage_reports = []; + /** + * Every `_doing_it_wrong()` message seen since the listener went on, in the same order. + * + * @var string[] + */ + private $incorrect_usage_messages = []; + /** * @var callable|null */ @@ -40,26 +55,39 @@ trait WithIncorrectUsage { /** * Start accepting — and recording — incorrect-usage reports. * + * Safe to call more than once: whatever this trait already put on the hook comes off first. + * `Scenario\Bootstrap_Test_Case::run_halted_request()` calls this once per halted request, so a + * scenario running two of them would otherwise strand the first listener on `doing_it_wrong_run` + * for the rest of the process, bound to a test object that has already finished and writing into + * properties nothing will ever read. + * + * What was already recorded stays. A caller that expected a report before the second call still + * has to be able to assert on it afterwards. + * * @since 1.0.0 * * @return void */ protected function expect_incorrect_usage(): void { - $reports = &$this->incorrect_usage_reports; + $this->remove_incorrect_usage_listener(); - $listener = function ( $function_name ) use ( &$reports ): void { + $reports = &$this->incorrect_usage_reports; + $messages = &$this->incorrect_usage_messages; + + $listener = function ( $function_name, $message = '' ) use ( &$reports, &$messages ): void { if ( ! is_string( $function_name ) ) { return; } - $reports[] = $function_name; + $reports[] = $function_name; + $messages[] = is_string( $message ) ? $message : ''; $this->setExpectedIncorrectUsage( $function_name ); }; $this->incorrect_usage_listener = $listener; - add_action( 'doing_it_wrong_run', $listener ); + add_action( 'doing_it_wrong_run', $listener, 10, 2 ); } /** @@ -84,22 +112,64 @@ protected function assert_the_library_reported_incorrect_usage(): void { } /** - * Take the listener back off. Call from tearDown. + * The same, and that one of the reports says which failure it was raised for. * - * Removed by identity rather than by clearing the hook, which WordPress and the rest of the suite - * are also on. + * The assertion above is deliberately loose, which makes it the wrong one for a test about a + * particular gate: every other reason this library reports — an unreadable registry, a bootstrap + * with no hook prefix, a step that threw — satisfies it just as well, so the test would go on + * passing after the gate it was written for stopped running at all. + * + * @since 1.0.0 + * + * @param string $needle Text one report has to carry, distinctive enough to name only that report + * site. + * @param string $why Why that report and no other is the one under test. + * + * @return void + */ + protected function assert_the_library_reported_incorrect_usage_saying( string $needle, string $why ): void { + $this->assert_the_library_reported_incorrect_usage(); + + $this->assertStringContainsString( + $needle, + implode( PHP_EOL, $this->incorrect_usage_messages ), + $why + ); + } + + /** + * Take the listener back off, and forget what it recorded. Call from tearDown. + * + * Idempotent, and safe on a test that never expected a report at all. * * @since 1.0.0 * * @return void */ protected function stop_expecting_incorrect_usage(): void { - if ( $this->incorrect_usage_listener !== null ) { - remove_action( 'doing_it_wrong_run', $this->incorrect_usage_listener ); + $this->remove_incorrect_usage_listener(); + + $this->incorrect_usage_reports = []; + $this->incorrect_usage_messages = []; + } - $this->incorrect_usage_listener = null; + /** + * Unhook whatever this trait last installed, if anything. + * + * Removed by identity rather than by clearing the hook, which WordPress and the rest of the suite + * are also on. + * + * @since 1.0.0 + * + * @return void + */ + private function remove_incorrect_usage_listener(): void { + if ( $this->incorrect_usage_listener === null ) { + return; } - $this->incorrect_usage_reports = []; + remove_action( 'doing_it_wrong_run', $this->incorrect_usage_listener ); + + $this->incorrect_usage_listener = null; } } diff --git a/tests/unit/Boot/SchedulerTest.php b/tests/unit/Boot/SchedulerTest.php index cfbc78b..3ec0821 100644 --- a/tests/unit/Boot/SchedulerTest.php +++ b/tests/unit/Boot/SchedulerTest.php @@ -3,11 +3,14 @@ * @package Nexcess\PluginAbsorber */ +declare( strict_types=1 ); + namespace Nexcess\PluginAbsorber\Tests\Unit\Boot; use Codeception\TestCase\WPTestCase; use Generator; use LogicException; +use lucatume\WPBrowser\Traits\UopzFunctions; use Nexcess\PluginAbsorber\Absorber; use Nexcess\PluginAbsorber\Boot\Scheduler; use Nexcess\PluginAbsorber\Config; @@ -23,6 +26,7 @@ use Nexcess\PluginAbsorber\Tests\Support\Config_State; use Nexcess\PluginAbsorber\Tests\Support\Spy_Presenter; use Nexcess\PluginAbsorber\Tests\Support\Spy_Rewriter; +use Nexcess\PluginAbsorber\Tests\Support\TestException; use Nexcess\PluginAbsorber\Tests\Support\Test_Container; use Nexcess\PluginAbsorber\Tests\Support\Traits\WithBundledPlugins; use Nexcess\PluginAbsorber\Tests\Support\Traits\WithContainer; @@ -49,6 +53,7 @@ * @since 1.0.0 */ class SchedulerTest extends WPTestCase { + use UopzFunctions; use WithBundledPlugins; use WithContainer; use WithIncorrectUsage; @@ -56,6 +61,14 @@ class SchedulerTest extends WPTestCase { use WithRequestMethod; use WithUsers; + /** + * The standalone the conflict cases put into the real `active_plugins`, and the basename every + * sub-plugin registered here says it absorbed. + * + * @var string + */ + private const STANDALONE = 'give-recurring/give-recurring.php'; + /** * @var int */ @@ -142,6 +155,14 @@ public function tearDown(): void { $this->stop_expecting_incorrect_usage(); $this->remove_bundled_plugin_files(); $this->clear_notices(); + + // The conflict cases write the real option and core's own deactivate_plugins() rewrites it, so + // it is cleared here rather than at the end of a test body: a failed assertion would otherwise + // leave a standalone active for every test behind it that reads through the real + // Plugin\Checker. + delete_option( 'active_plugins' ); + delete_site_option( 'active_sitewide_plugins' ); + Absorber_State::reset(); Config_State::reset(); $this->tear_down_container(); @@ -162,26 +183,41 @@ public function test_the_load_step_runs_early_in_plugins_loaded(): void { $this->assertSame( 6, self::load_priority() ); } - /** - * A standalone that survives the conflict defines its guard constant as it loads, and the load - * pass has to see that — so resolution runs first and cannot share a priority with it. - * - * Being first makes this the number a host is measured against, so it is the one that decides how - * much room a host has to configure the library in. Priority 5 leaves 0 through 4, which covers - * booting at 0 as documented and the priority-1 habit LearnDash and MemberDash already have. - */ /** * The outermost guarantee, and the reason it lives here rather than in each pass: whatever a step * reaches — a collaborator a host's factory could not build, a gate, a probe, a pass that got past * its own guard — `plugins_loaded` fires on every request a site serves, and a throw out of it is - * a white screen on all of them. Each step is reported and abandoned on its own, so the step - * behind it still runs. + * a white screen on all of them. Each step is reported and abandoned on its own, so the other one + * still does its work. + * + * That second half is asserted rather than described: a request is set up where both steps have + * something to do — a standalone in the way under the policy that only talks, and a bundled file to + * require — so the step that did not throw is the one whose effect is still there afterwards. + * + * The report is asserted by its wording, because the conflict step has two catch arms and both end + * "no conflict was resolved". Only "the conflict pass threw" belongs to the backstop under test + * here; the arm that names an unreadable registry is the duplicate-slug case further down. * * @dataProvider throwing_steps * - * @param string $id Binding the step resolves, bound to a factory that throws. + * @param string $id Binding the step resolves, bound to a factory that throws. + * @param string $reported Wording only this step's backstop produces. + * @param bool $notice_queued Whether the conflict step got as far as queuing its notice. + * @param int $loads How many bundled files the load pass required. */ - public function test_a_step_that_throws_cannot_end_the_request( string $id ): void { + public function test_a_step_that_throws_cannot_end_the_request( + string $id, + string $reported, + bool $notice_queued, + int $loads + ): void { + set_current_screen( 'dashboard' ); + + $this->bind_active_standalone(); + $this->register_conflicted_sub_plugin(); + + $this->become_plugin_administrator(); + $this->expect_incorrect_usage(); Absorber::boot(); @@ -199,19 +235,51 @@ static function (): object { do_action( 'plugins_loaded' ); - $this->assert_the_library_reported_incorrect_usage(); + $this->assert_the_library_reported_incorrect_usage_saying( + $reported, + 'The report has to name the step that was abandoned, or either step could have produced it.' + ); + $this->assert_the_library_reported_incorrect_usage_saying( + 'the host factory needed a database connection', + 'And carry what actually went wrong, so the developer is not left guessing.' + ); + + $this->assertSame( + $notice_queued, + array_key_exists( 'give-recurring:conflict', $this->queued_notices() ), + 'The step that did not throw has to have run.' + ); + $this->assertSame( $loads, $this->bundled_plugin_loads(), 'And the other one must not have.' ); } /** * Both steps, because a guard on one of them leaves the other able to end the request. * - * @return Generator + * @return Generator */ public static function throwing_steps(): Generator { - yield 'the conflict step' => [ Gatekeeper::class ]; - yield 'the load step' => [ Loader::class ]; + yield 'the conflict step' => [ + Gatekeeper::class, + 'The conflict pass threw, so no conflict was resolved', + false, + 1, + ]; + yield 'the load step' => [ + Loader::class, + 'The load pass threw, so no sub-plugin was loaded', + true, + 0, + ]; } + /** + * A standalone that survives the conflict defines its guard constant as it loads, and the load + * pass has to see that — so resolution runs first and cannot share a priority with it. + * + * Being first makes this the number a host is measured against, so it is the one that decides how + * much room a host has to configure the library in. Priority 5 leaves 0 through 4, which covers + * booting at 0 as documented and the priority-1 habit LearnDash and MemberDash already have. + */ public function test_the_conflict_step_runs_before_the_load_step(): void { $this->assertSame( 5, self::resolve_priority() ); $this->assertLessThan( self::load_priority(), self::resolve_priority() ); @@ -406,7 +474,19 @@ public function test_a_duplicate_slug_is_reported_rather_than_fataling_the_confl $this->queued_notices(), 'A read that failed has no list to resolve from, so nothing may be resolved.' ); - $this->assert_the_library_reported_incorrect_usage(); + + // The arm under test, by the only words that separate it from the Throwable backstop beside it + // — which would catch the same exception and say "the conflict pass threw" instead. Without + // this the arm could be deleted outright and the test would go on passing. + $this->assert_the_library_reported_incorrect_usage_saying( + 'The registered sub-plugins could not be read, so no conflict was resolved', + 'An unreadable registry is the one failure here a developer can act on directly, and it is' + . ' reported as itself rather than as any throw.' + ); + $this->assert_the_library_reported_incorrect_usage_saying( + 'Two sub-plugins are registered under the slug "give-recurring"', + 'The report has to carry what the registrar refused, or it names no mistake to correct.' + ); } public function test_it_wires_the_load_step_at_the_load_priority(): void { @@ -658,6 +738,137 @@ public static function late_boot_priorities(): Generator { yield 'the default a host omits' => [ 10 - self::load_priority() ]; } + /** + * The inline fallback runs the *whole* sequence, in hook order, and this is the request that turns + * on it: a host booting at `plugins_loaded` 10 with the standalone still active. Lose the conflict + * step and the load pass requires the bundled copy on top of the standalone WordPress included from + * wp-settings.php — the re-declaration fatal this library exists to prevent, and the one failure + * none of the guards around these steps can report. + * + * Asserted as an order, not as two end states. A fallback that iterated only the last element of + * `sequence()` would still leave the bundled plugin loaded, and one that iterated only the first + * would still leave the standalone deactivated; what neither can produce is a standalone already + * gone, and a merge notice already queued, at the moment the load pass reached its last gate. + * + * Nothing here is doubled: the real `active_plugins` option, the real plugin checker, and core's own + * `deactivate_plugins()`. `headers_sent()` is pinned instead of left to the runtime, where under + * CLI it answers for whatever the test runner has printed rather than for this request. Pinned + * *true* because that is the state this path documents — the fallback reports through + * `_doing_it_wrong()` immediately before running, which prints on a debugging site — and the + * resolver's answer to sent headers is to fall through rather than redirect into a warning and a + * blank page. It is also what leaves the load pass observable behind the conflict step in one + * dispatch, since a redirect is where production stops. + */ + public function test_a_late_boot_resolves_the_conflict_before_it_loads(): void { + set_current_screen( 'dashboard' ); + + $this->become_plugin_administrator(); + + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->setFunctionReturn( 'headers_sent', true ); + + $redirects = []; + $halt_message = 'The conflict step redirected where it must not have.'; + + // Recorded and thrown, never merely recorded: production's next line is `exit`, which uopz + // cannot stub and `preventExit()` must never stand in for. The step swallows the throw like any + // other, so it is the assertion below that reports a redirect -- the throw only keeps the + // process alive long enough to make it. + $this->setFunctionReturn( + 'wp_safe_redirect', + static function ( $to ) use ( &$redirects, $halt_message ) { + $redirects[] = is_string( $to ) ? $to : ''; + + throw new TestException( $halt_message ); + }, + true + ); + + $constant = $this->make_guard_constant(); + $path = $this->make_bundled_plugin_file( $constant ); + + $standalone_at_load = null; + $notices_at_load = []; + + // Read at the load pass's last gate rather than after the dispatch: the ordering is the claim, + // and two end states read afterwards cannot tell "resolved, then loaded" from the reverse. + $this->add_tracked_filter( + 'give/plugin_absorber/should_load', + function ( $should_load ) use ( &$standalone_at_load, &$notices_at_load ) { + $standalone_at_load = in_array( self::STANDALONE, $this->active_plugins(), true ); + $notices_at_load = $this->queued_notices(); + + return $should_load; + } + ); + + $this->expect_incorrect_usage(); + + $this->add_tracked_action( + 'plugins_loaded', + static function () use ( $path, $constant ): void { + Absorber::register( + [ + 'slug' => 'give-recurring', + 'bundled_plugin_file' => $path, + 'plugin_loaded_constant' => $constant, + 'standalone_plugin_basename' => self::STANDALONE, + ] + ); + + Absorber::boot(); + } + ); + + do_action( 'plugins_loaded' ); + + $this->assertNotContains( + self::STANDALONE, + $this->active_plugins(), + 'A late boot must resolve the conflict, not only load.' + ); + $this->assertArrayHasKey( + 'give-recurring:merge', + $this->queued_notices(), + 'A deactivation nobody asked for has to be explained.' + ); + $this->assertSame( 1, $this->bundled_plugin_loads(), 'And the load pass still has to run behind it.' ); + + $this->assertFalse( + $standalone_at_load, + 'The standalone had to be gone by the time the load pass reached its last gate.' + ); + $this->assertArrayHasKey( + 'give-recurring:merge', + $notices_at_load, + 'And the merge notice already queued, which only the conflict step running first can do.' + ); + + $this->assertSame( [], $redirects, 'A step running inline, after its own report, must not redirect.' ); + + // The recorder has to be shown to work: a stub that never installed leaves the log empty + // whether or not the resolver redirected. + try { + wp_safe_redirect( 'https://example.test/wp-admin/index.php' ); + + $this->fail( 'The stubbed redirect must throw rather than return.' ); + } catch ( TestException $exception ) { + $this->assertSame( $halt_message, $exception->getMessage() ); + } + + $this->assertSame( + [ 'https://example.test/wp-admin/index.php' ], + $redirects, + 'The recorder must catch a redirect that really happened.' + ); + + $this->assert_the_library_reported_incorrect_usage_saying( + 'must run before plugins_loaded priority 5', + 'The late boot is the mistake to report, not whatever a step ran into on the way.' + ); + } + /** * The other side of the same boundary: every priority the window is still open at must *wire* the * sequence rather than run it. @@ -1107,12 +1318,21 @@ private function register_conflicted_sub_plugin(): void { 'slug' => 'give-recurring', 'bundled_plugin_file' => $this->make_bundled_plugin_file( $constant ), 'plugin_loaded_constant' => $constant, - 'standalone_plugin_basename' => 'give-recurring/give-recurring.php', + 'standalone_plugin_basename' => self::STANDALONE, 'conflict_policy' => Conflict_Policy::NOTICE_ONLY, ] ); } + /** + * What WordPress holds as active, as the real option holds it. + * + * @return array + */ + private function active_plugins(): array { + return (array) get_option( 'active_plugins', [] ); + } + /** * Register a sub-plugin whose bundled file records that it was loaded. * diff --git a/tests/unit/LoaderTest.php b/tests/unit/LoaderTest.php index 4b658a2..57eec04 100644 --- a/tests/unit/LoaderTest.php +++ b/tests/unit/LoaderTest.php @@ -3,6 +3,8 @@ * @package Nexcess\PluginAbsorber */ +declare( strict_types=1 ); + namespace Nexcess\PluginAbsorber\Tests\Unit; use Codeception\TestCase\WPTestCase; @@ -52,18 +54,6 @@ class LoaderTest extends WPTestCase { */ private $constants = []; - /** - * Every `_doing_it_wrong()` message seen since the recorder went on. - * - * @var string[] - */ - private $incorrect_usage_messages = []; - - /** - * @var callable|null - */ - private $incorrect_usage_message_listener = null; - /** * Every value the should_load filter was called with. * @@ -99,7 +89,6 @@ public function tearDown(): void { $this->constants = []; $this->stop_expecting_incorrect_usage(); - $this->stop_recording_incorrect_usage_messages(); $this->clear_notices(); $this->clear_activations(); Absorber_State::reset(); @@ -248,10 +237,12 @@ public function test_it_skips_when_the_guard_constant_is_already_defined(): void public function test_it_skips_when_the_bundled_file_is_missing(): void { $this->expect_incorrect_usage(); + $path = $this->missing_bundled_plugin_file(); + Absorber::register( [ 'slug' => 'give-recurring', - 'bundled_plugin_file' => $this->missing_bundled_plugin_file(), + 'bundled_plugin_file' => $path, 'plugin_loaded_constant' => $this->make_guard_constant(), ] ); @@ -259,7 +250,14 @@ public function test_it_skips_when_the_bundled_file_is_missing(): void { $this->loader()->load_all(); $this->assertSame( 0, $this->bundled_plugin_loads() ); - $this->assert_the_library_reported_incorrect_usage(); + + // The file gate's own sentence, and the path it refused, rather than "something was reported": + // a load that skipped for any other reason at all — an unreadable registry, a bootstrap with + // no hook prefix — reports too, and would satisfy a looser assertion just as well. + $this->assert_the_library_reported_incorrect_usage_saying( + sprintf( '"give-recurring" is missing or unreadable: %s', $path ), + 'The report has to name the file gate and the path it refused.' + ); } /** @@ -273,10 +271,12 @@ public function test_it_skips_when_the_bundled_file_is_missing(): void { public function test_a_missing_bundled_file_reports_to_the_developer_not_the_site_owner(): void { $this->expect_incorrect_usage(); + $path = $this->missing_bundled_plugin_file(); + Absorber::register( [ 'slug' => 'give-recurring', - 'bundled_plugin_file' => $this->missing_bundled_plugin_file(), + 'bundled_plugin_file' => $path, 'plugin_loaded_constant' => $this->make_guard_constant(), 'dependency_notice_message' => static fn() => 'GiveWP 3.0 or later is required.', ] @@ -285,7 +285,13 @@ public function test_a_missing_bundled_file_reports_to_the_developer_not_the_sit $this->loader()->load_all(); $this->assertSame( [], $this->queued_notices() ); - $this->assert_the_library_reported_incorrect_usage(); + + // An empty queue is the same shape a load that never ran leaves behind, so what the developer + // was told instead has to be the file gate's own report and not any other. + $this->assert_the_library_reported_incorrect_usage_saying( + sprintf( '"give-recurring" is missing or unreadable: %s', $path ), + 'The missing file has to be reported to the developer, not merely reported.' + ); } /** @@ -305,7 +311,10 @@ public function test_it_skips_when_the_bundled_path_is_a_directory(): void { $this->loader()->load_all(); $this->assertSame( 0, $this->bundled_plugin_loads() ); - $this->assert_the_library_reported_incorrect_usage(); + $this->assert_the_library_reported_incorrect_usage_saying( + sprintf( '"give-recurring" is missing or unreadable: %s', sys_get_temp_dir() ), + 'The report has to name the directory the file gate refused.' + ); } /** @@ -333,7 +342,10 @@ public function test_it_skips_when_the_bundled_file_is_unreadable(): void { $this->loader()->load_all(); $this->assertSame( 0, $this->bundled_plugin_loads() ); - $this->assert_the_library_reported_incorrect_usage(); + $this->assert_the_library_reported_incorrect_usage_saying( + sprintf( '"give-recurring" is missing or unreadable: %s', $path ), + 'The report has to name the unreadable file, not merely have happened.' + ); } /** @@ -672,7 +684,6 @@ public function test_a_duplicate_slug_is_reported_rather_than_fataling_the_reque $this->register(); $this->expect_incorrect_usage(); - $this->record_incorrect_usage_messages(); $this->loader()->load_all(); @@ -682,8 +693,11 @@ public function test_a_duplicate_slug_is_reported_rather_than_fataling_the_reque $this->bundled_plugin_loads(), 'A read that failed has no list to load from, so nothing may load.' ); - $this->assert_the_library_reported_incorrect_usage(); - $this->assert_a_reported_message_contains( + $this->assert_the_library_reported_incorrect_usage_saying( + 'The registered sub-plugins could not be read, so none were loaded', + 'The read is what failed, and the report has to say so rather than name some other gate.' + ); + $this->assert_the_library_reported_incorrect_usage_saying( 'give-recurring', 'The report has to name the slug, or it could have been raised for any other reason.' ); @@ -800,61 +814,6 @@ private function assert_the_should_load_recorder_works(): void { ); } - /** - * Keep the *message* of every incorrect-usage report, which the shared trait deliberately does not. - * - * `WithIncorrectUsage` pins that the library reported something against itself, which is all most - * tests need. A test about one particular failure needs more: a report raised for an unrelated - * reason — no hook prefix, no container — would otherwise satisfy it just as well. - * - * @return void - */ - private function record_incorrect_usage_messages(): void { - $messages = &$this->incorrect_usage_messages; - - $listener = static function ( $function_name, $message ) use ( &$messages ): void { - $messages[] = is_string( $message ) ? $message : ''; - }; - - $this->incorrect_usage_message_listener = $listener; - - add_action( 'doing_it_wrong_run', $listener, 10, 2 ); - } - - /** - * @param string $needle Text one report has to carry. - * @param string $message Why it has to. - * - * @return void - */ - private function assert_a_reported_message_contains( string $needle, string $message ): void { - $this->assertNotSame( [], $this->incorrect_usage_messages, 'Nothing was reported at all.' ); - $this->assertStringContainsString( - $needle, - implode( PHP_EOL, $this->incorrect_usage_messages ), - $message - ); - } - - /** - * Take the recorder back off. Call from tearDown, for the same reason the trait's own removal is - * there: a failing assertion would otherwise leave it listening for the rest of the process. - * - * Removed by identity rather than by clearing the hook, which WordPress and the rest of the suite - * are also on. - * - * @return void - */ - private function stop_recording_incorrect_usage_messages(): void { - if ( $this->incorrect_usage_message_listener !== null ) { - remove_action( 'doing_it_wrong_run', $this->incorrect_usage_message_listener ); - - $this->incorrect_usage_message_listener = null; - } - - $this->incorrect_usage_messages = []; - } - /** * Define a guard constant for the duration of one test, undone in tearDown. *