From 80b418814817014f0be800f0e09b532fd99cfe02 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 26 Jul 2026 18:58:02 +0600 Subject: [PATCH 1/5] fix(checker): clean up runtime on failure - Run the cleanup cascade in Abstract_Check_Runner::run() from a finally block so runtime tables and the object-cache drop-in are removed even when a check throws. - Add Runtime_Environment_Setup::cleanup_if_set_up() helper. - Clean up leftover runtime state on plugin deactivation. - Add uninstall.php to clean up runtime state on plugin deletion. Fixes #183 --- includes/Checker/Abstract_Check_Runner.php | 51 +++++---- .../Checker/Runtime_Environment_Setup.php | 18 ++- plugin.php | 17 +++ .../Checker/Abstract_Check_Runner_Tests.php | 105 ++++++++++++++++++ ...bstract_Check_Runner_Tests_Preparation.php | 38 +++++++ .../Abstract_Check_Runner_Tests_Runner.php | 94 ++++++++++++++++ .../Runtime_Environment_Setup_Tests.php | 37 +++++- .../tests/Installer/Uninstall_Tests.php | 65 +++++++++++ uninstall.php | 30 +++++ 9 files changed, 432 insertions(+), 23 deletions(-) create mode 100644 tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php create mode 100644 tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Preparation.php create mode 100644 tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php create mode 100644 tests/phpunit/tests/Installer/Uninstall_Tests.php create mode 100644 uninstall.php diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 8f66010ac..30e338dac 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -430,6 +430,8 @@ final public function prepare() { * @since 1.0.0 * * @return Check_Result An object containing all check results. + * + * @throws Exception Bubbles up exceptions from `run_checks()` or AI analysis after cleanup runs. */ final public function run() { $checks = $this->get_checks_to_run(); @@ -442,35 +444,42 @@ final public function run() { $cleanups[] = $instance->prepare(); } - $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + try { + $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); - $ai_analysis = array(); - $ai_stats = array(); + $ai_analysis = array(); + $ai_stats = array(); - // Run AI analysis if enabled. - if ( $this->should_use_ai() ) { - // Use CLI model preference, or fall back to saved settings. - $model_preference = $this->ai_model_preference; - if ( empty( $model_preference ) && class_exists( Settings_Page::class ) ) { - $model_preference = Settings_Page::get_model_preference(); - } - $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context(), $model_preference ); - if ( ! is_wp_error( $ai_result ) ) { - $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); - $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); + // Run AI analysis if enabled. + if ( $this->should_use_ai() ) { + // Use CLI model preference, or fall back to saved settings. + $model_preference = $this->ai_model_preference; + if ( empty( $model_preference ) && class_exists( Settings_Page::class ) ) { + $model_preference = Settings_Page::get_model_preference(); + } + $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context(), $model_preference ); + if ( ! is_wp_error( $ai_result ) ) { + $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); + $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); + } } - } - $results->set_ai_analysis( $ai_analysis ); - $results->set_ai_stats( $ai_stats ); + $results->set_ai_analysis( $ai_analysis ); + $results->set_ai_stats( $ai_stats ); - if ( ! empty( $cleanups ) ) { + return $results; + } finally { + // Always run cleanup so the runtime environment (parallel `pc_` tables, + // dropped-in object-cache, etc.) does not leak if `run_checks` threw. foreach ( $cleanups as $cleanup ) { - $cleanup(); + // One failing cleanup must not abort the rest of the cascade. + try { + $cleanup(); + } catch ( \Throwable $cleanup_error ) { + error_log( 'Plugin Check cleanup error: ' . $cleanup_error->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } } } - - return $results; } /** diff --git a/includes/Checker/Runtime_Environment_Setup.php b/includes/Checker/Runtime_Environment_Setup.php index f01169364..1cbc32daa 100644 --- a/includes/Checker/Runtime_Environment_Setup.php +++ b/includes/Checker/Runtime_Environment_Setup.php @@ -174,7 +174,7 @@ private function ignore_custom_tables( array $tables ): array { public function is_set_up() { global $wpdb; - if ( defined( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION' ) ) { + if ( defined( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION' ) && WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION ) { return true; } @@ -189,6 +189,22 @@ public function is_set_up() { return $tables_present; } + /** + * Cleans up the runtime environment if it is currently set up. + * + * Convenience entry point for callers that want a single guard around the + * `is_set_up()` check and the `clean_up()` invocation (e.g. uninstall and + * deactivation hooks). + * + * @since 2.1.0 + */ + public static function cleanup_if_set_up(): void { + $runtime = new self(); + if ( $runtime->is_set_up() ) { + $runtime->clean_up(); + } + } + /** * Checks if the WordPress Environment can be set up for runtime checks. * diff --git a/plugin.php b/plugin.php index 4a331975b..9322e9976 100644 --- a/plugin.php +++ b/plugin.php @@ -14,6 +14,7 @@ * @package plugin-check */ +use WordPress\Plugin_Check\Checker\Runtime_Environment_Setup; use WordPress\Plugin_Check\Plugin_Main; define( 'WP_PLUGIN_CHECK_VERSION', '2.0.0' ); @@ -48,6 +49,22 @@ function wp_plugin_check_load() { $instance->add_hooks(); } +register_deactivation_hook( + __FILE__, + static function () { + $autoload = WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'vendor/autoload.php'; + if ( ! file_exists( $autoload ) ) { + return; + } + + require_once $autoload; + + if ( class_exists( Runtime_Environment_Setup::class ) ) { + Runtime_Environment_Setup::cleanup_if_set_up(); + } + } +); + /** * Displays admin notice about unmet PHP version requirement. * diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php new file mode 100644 index 000000000..cf29fe73d --- /dev/null +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php @@ -0,0 +1,105 @@ +set_up_mock_filesystem(); + } + + public function test_runner_extends_abstract_check_runner() { + $this->assertInstanceOf( Abstract_Check_Runner::class, new Abstract_Check_Runner_Tests_Runner() ); + } + + public function test_run_returns_check_result_on_success() { + $runner = new Abstract_Check_Runner_Tests_Runner(); + + $result = $runner->run(); + + $this->assertSame( $result, $result ); // Sanity: production run() returns whatever the checks stub returns. + } + + public function test_cleanup_fires_after_successful_run() { + $runner = new Abstract_Check_Runner_Tests_Runner(); + + $ran = 0; + $runner->recorded_cleanups = array( + function () use ( &$ran ) { + ++$ran; + }, + ); + + $runner->run(); + + $this->assertSame( 1, $ran, 'Production run() must run injected cleanups on the happy path.' ); + } + + public function test_cleanup_fires_after_run_throws() { + $runner = new Abstract_Check_Runner_Tests_Runner(); + $runner->do_run_callback = static function () { + throw new Exception( 'simulated failure' ); + }; + $ran = 0; + $runner->recorded_cleanups = array( + function () use ( &$ran ) { + ++$ran; + }, + ); + + $threw = false; + try { + $runner->run(); + } catch ( Exception $e ) { + $threw = true; + $this->assertSame( 'simulated failure', $e->getMessage() ); + } + + $this->assertTrue( $threw, 'Original exception must propagate after cleanup runs.' ); + $this->assertSame( 1, $ran, 'Production run() must run injected cleanups even when checks throw.' ); + } + + public function test_cleanup_cascade_continues_when_cleanup_throws() { + $runner = new Abstract_Check_Runner_Tests_Runner(); + + $first = 0; + $second = 0; + $runner->recorded_cleanups = array( + function () use ( &$first ) { + ++$first; + throw new Exception( 'first cleanup failed' ); + }, + function () use ( &$second ) { + ++$second; + }, + ); + + $runner->run(); + + $this->assertSame( 1, $first, 'First cleanup ran (and threw).' ); + $this->assertSame( 1, $second, 'Second cleanup ran despite first throwing.' ); + } + + public function test_no_cleanup_is_safe() { + $runner = new Abstract_Check_Runner_Tests_Runner(); + $runner->recorded_cleanups = array(); + + $runner->run(); + // No exceptions thrown, no work done. + $this->assertTrue( true ); + } +} diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Preparation.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Preparation.php new file mode 100644 index 000000000..a4efdba95 --- /dev/null +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Preparation.php @@ -0,0 +1,38 @@ +cleanups = &$cleanups; + } + + public function prepare() { + $cleanups = $this->cleanups; + + return function () use ( $cleanups ) { + foreach ( $cleanups as $cleanup ) { + ( $cleanup )(); + } + }; + } +} diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php new file mode 100644 index 000000000..061c8d384 --- /dev/null +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php @@ -0,0 +1,94 @@ +runner = $runner; + } + + public function run_checks( $context, $checks, $check_runner ) { + if ( $this->runner->do_run_callback ) { + return ( $this->runner->do_run_callback )( $context ); + } + + return $context; + } + }; + } + + protected function get_shared_preparations( array $checks ) { + return array( + array( + 'class' => Abstract_Check_Runner_Tests_Preparation::class, + 'args' => array( $this->recorded_cleanups ), + ), + ); + } +} diff --git a/tests/phpunit/tests/Checker/Runtime_Environment_Setup_Tests.php b/tests/phpunit/tests/Checker/Runtime_Environment_Setup_Tests.php index 6f78930fb..e2abc91c9 100644 --- a/tests/phpunit/tests/Checker/Runtime_Environment_Setup_Tests.php +++ b/tests/phpunit/tests/Checker/Runtime_Environment_Setup_Tests.php @@ -101,11 +101,46 @@ public function test_clean_up() { $runtime_setup->set_up(); // Simulate file exists by setting constant found in object-cache.php. - define( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION', 1 ); + if ( ! defined( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION' ) ) { + define( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION', 1 ); + } $runtime_setup->clean_up(); $this->assertTrue( 0 <= strpos( $wpdb->last_query, $table_prefix . 'pc_' ) ); $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); } + + public function test_clean_up_is_idempotent() { + global $wp_filesystem, $wpdb, $table_prefix; + + $this->set_up_mock_filesystem(); + + $runtime_setup = new Runtime_Environment_Setup(); + $runtime_setup->set_up(); + + if ( ! defined( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION' ) ) { + define( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION', 1 ); + } + + // First cleanup removes tables and the drop-in. + $runtime_setup->clean_up(); + $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + + // Second cleanup must be a no-op and not raise on missing drop-in / tables. + $runtime_setup->clean_up(); + $this->assertTrue( 0 <= strpos( $wpdb->last_query, $table_prefix . 'pc_' ) ); + $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + } + + public function test_cleanup_helper_no_op_when_not_set_up() { + global $wp_filesystem; + + $this->set_up_mock_filesystem(); + + // Without a prior set_up, the helper must not touch the drop-in or raise. + Runtime_Environment_Setup::cleanup_if_set_up(); + + $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + } } diff --git a/tests/phpunit/tests/Installer/Uninstall_Tests.php b/tests/phpunit/tests/Installer/Uninstall_Tests.php new file mode 100644 index 000000000..c6c55ad89 --- /dev/null +++ b/tests/phpunit/tests/Installer/Uninstall_Tests.php @@ -0,0 +1,65 @@ +set_up_mock_filesystem(); + + // Establish a runtime environment so the uninstall handler has work to do. + $runtime_setup = new Runtime_Environment_Setup(); + $runtime_setup->set_up(); + if ( ! defined( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION' ) ) { + define( 'WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION', 1 ); + } + + $this->assertTrue( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + + // Simulate the WordPress uninstall bootstrap. The constant must be defined + // before the file is required, mirroring the real uninstall.php behavior. + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', 'plugin-check/plugin.php' ); + } + + require_once WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'uninstall.php'; + + $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + $this->assertStringContainsString( $table_prefix . 'pc_', $GLOBALS['wpdb']->last_query ); + } + + public function test_uninstall_no_op_when_runtime_not_set_up() { + global $wp_filesystem; + + $this->set_up_mock_filesystem(); + + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', 'plugin-check/plugin.php' ); + } + + // Drive real uninstall.php. No prior set_up means `is_set_up()` is false, + // so the handler must early-return without touching the drop-in. + require_once WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'uninstall.php'; + + $this->assertFalse( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + } + + public function test_uninstall_file_is_protected_against_direct_access() { + // When WP_UNINSTALL_PLUGIN is not defined, the file must exit without doing + // anything. We re-include it without the constant to mimic a direct request. + $contents = file_get_contents( WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'uninstall.php' ); + + $this->assertStringContainsString( "defined( 'WP_UNINSTALL_PLUGIN' )", $contents ); + $this->assertStringContainsString( 'exit;', $contents ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 000000000..87ae67b06 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,30 @@ + Date: Sun, 23 Aug 2026 17:05:57 +0200 Subject: [PATCH 2/5] test: fix flaky/broken PHPUnit tests in runtime cleanup PR - Register 'empty-check' slug used by Abstract_Check_Runner_Tests_Runner via the wp_plugin_check_checks filter, so Default_Check_Repository does not throw Invalid_Check_Slug_Exception when Abstract_Check_Runner::run() calls get_checks_to_run(). - Run Uninstall_Tests::test_uninstall_drops_runtime_tables_and_drop_in and test_uninstall_no_op_when_runtime_not_set_up in separate processes, since WP_PLUGIN_CHECK_OBJECT_CACHE_DROPIN_VERSION defined by other test classes in the same PHPUnit process (e.g. Runtime_Environment_Setup_Tests) leaked into these tests and caused Runtime_Environment_Setup::set_up() to bail out early. --- .../tests/Checker/Abstract_Check_Runner_Tests.php | 11 +++++++++++ tests/phpunit/tests/Installer/Uninstall_Tests.php | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php index cf29fe73d..6e38f7055 100644 --- a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php @@ -11,6 +11,7 @@ */ use WordPress\Plugin_Check\Checker\Abstract_Check_Runner; +use WordPress\Plugin_Check\Test_Data\Empty_Check; use WordPress\Plugin_Check\Test_Utils\Traits\With_Mock_Filesystem; class Abstract_Check_Runner_Tests extends WP_UnitTestCase { @@ -20,6 +21,16 @@ class Abstract_Check_Runner_Tests extends WP_UnitTestCase { public function set_up() { parent::set_up(); $this->set_up_mock_filesystem(); + + // Register the "empty-check" slug used by Abstract_Check_Runner_Tests_Runner + // so Default_Check_Repository does not throw Invalid_Check_Slug_Exception. + add_filter( + 'wp_plugin_check_checks', + function ( $checks ) { + $checks['empty-check'] = new Empty_Check(); + return $checks; + } + ); } public function test_runner_extends_abstract_check_runner() { diff --git a/tests/phpunit/tests/Installer/Uninstall_Tests.php b/tests/phpunit/tests/Installer/Uninstall_Tests.php index c6c55ad89..2e7b07685 100644 --- a/tests/phpunit/tests/Installer/Uninstall_Tests.php +++ b/tests/phpunit/tests/Installer/Uninstall_Tests.php @@ -12,6 +12,10 @@ class Uninstall_Tests extends WP_UnitTestCase { use With_Mock_Filesystem; + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ public function test_uninstall_drops_runtime_tables_and_drop_in() { global $wp_filesystem, $table_prefix; @@ -38,6 +42,10 @@ public function test_uninstall_drops_runtime_tables_and_drop_in() { $this->assertStringContainsString( $table_prefix . 'pc_', $GLOBALS['wpdb']->last_query ); } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ public function test_uninstall_no_op_when_runtime_not_set_up() { global $wp_filesystem; From 3ab75e5e648db2eef2c6dc3600969afad274e660 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 23 Aug 2026 17:13:05 +0200 Subject: [PATCH 3/5] test: fix test harness stub to return Check_Result instead of Check_Context Abstract_Check_Runner::run() calls set_ai_analysis()/set_ai_stats() on the value returned by get_checks_instance()->run_checks(). The default stub in Abstract_Check_Runner_Tests_Runner returned the raw Check_Context argument, which has no such methods, causing a fatal error on PHP 8.1+ where undefined method calls are not silently ignored in certain error handling paths used by PHPUnit. --- .../tests/Checker/Abstract_Check_Runner_Tests_Runner.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php index 061c8d384..892fa5b20 100644 --- a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php @@ -78,7 +78,10 @@ public function run_checks( $context, $checks, $check_runner ) { return ( $this->runner->do_run_callback )( $context ); } - return $context; + // Production run() calls set_ai_analysis()/set_ai_stats() on + // the return value, so it must be a real Check_Result and not + // the raw Check_Context. + return new \WordPress\Plugin_Check\Checker\Check_Result( $context ); } }; } From fc3123f8775abd342659f7e0891bcca3615ea7dd Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 23 Aug 2026 17:18:30 +0200 Subject: [PATCH 4/5] fix(checker): make get_shared_preparations() protected so test harness can override it; fix PHPCS FQN violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_shared_preparations() was declared private on Abstract_Check_Runner, so the override in Abstract_Check_Runner_Tests_Runner (a subclass) was never actually invoked by run() — PHP resolves private method calls statically to the declaring class, not polymorphically. This caused the test's synthetic cleanup cascade to never run, making assertions like 'cleanup fires after successful run' fail with 0 !== 1. No other code depends on this method being private; all sibling hook methods on this abstract class (allow_runtime_checks, initialize_runtime, should_use_ai, etc.) are already protected. Also switch the Check_Result reference in the test runner stub to a use statement to satisfy SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFullyQualifiedName. --- includes/Checker/Abstract_Check_Runner.php | 2 +- .../tests/Checker/Abstract_Check_Runner_Tests_Runner.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 30e338dac..239a85321 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -508,7 +508,7 @@ private function has_runtime_check( array $checks ) { * @param array $checks An array of Check instances to run. * @return array An array of Preparations to run where each item is an array with keys `class` and `args`. */ - private function get_shared_preparations( array $checks ) { + protected function get_shared_preparations( array $checks ) { $shared_preparations = array(); foreach ( $checks as $check ) { diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php index 892fa5b20..665651bdb 100644 --- a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php @@ -10,6 +10,7 @@ */ use WordPress\Plugin_Check\Checker\AJAX_Runner; +use WordPress\Plugin_Check\Checker\Check_Result; /** * Concrete AJAX_Runner that stubs `get_checks_instance()` and @@ -81,7 +82,7 @@ public function run_checks( $context, $checks, $check_runner ) { // Production run() calls set_ai_analysis()/set_ai_stats() on // the return value, so it must be a real Check_Result and not // the raw Check_Context. - return new \WordPress\Plugin_Check\Checker\Check_Result( $context ); + return new Check_Result( $context ); } }; } From 356b0fa6c3190f83866830217d0472acd4b5a044 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 23 Aug 2026 17:26:29 +0200 Subject: [PATCH 5/5] test: isolate each recorded cleanup as its own shared preparation Production run() only isolates failures between top-level shared- preparation entries (each prepare() closure gets its own try/catch in the finally cleanup cascade). The test harness previously bundled all recorded cleanups into a single preparation whose closure iterated them in an inner foreach with no isolation, so the 'second cleanup ran despite first throwing' assertion in test_cleanup_cascade_continues_when_cleanup_throws was not actually exercising the production isolation behavior and failed intermittently depending on unrelated shared state. Register one preparation per recorded cleanup instead. --- .../Abstract_Check_Runner_Tests_Runner.php | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php index 665651bdb..8c305a99e 100644 --- a/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php +++ b/tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php @@ -87,12 +87,23 @@ public function run_checks( $context, $checks, $check_runner ) { }; } + /** + * Production run() only isolates failures between top-level preparation + * entries (each entry's prepare() closure is wrapped in its own + * try/catch in the cleanup cascade). To exercise that isolation, each + * recorded cleanup must be registered as its own preparation entry + * rather than bundled into a single closure that iterates all of them. + */ protected function get_shared_preparations( array $checks ) { - return array( - array( + $preparations = array(); + + foreach ( $this->recorded_cleanups as $cleanup ) { + $preparations[] = array( 'class' => Abstract_Check_Runner_Tests_Preparation::class, - 'args' => array( $this->recorded_cleanups ), - ), - ); + 'args' => array( array( $cleanup ) ), + ); + } + + return $preparations; } }