Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions includes/Checker/Abstract_Check_Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}

/**
Expand Down
18 changes: 17 additions & 1 deletion includes/Checker/Runtime_Environment_Setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.
*
Expand Down
17 changes: 17 additions & 0 deletions plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down Expand Up @@ -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.
*
Expand Down
105 changes: 105 additions & 0 deletions tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
/**
* Tests for the Abstract_Check_Runner cleanup-to-finally behavior.
*
* Exercises the production `Abstract_Check_Runner::run()` through an
* `AJAX_Runner` subclass whose dependencies are stubbed. The shared-
* preparation list is overridden so the cleanup cascade that runs in the
* production `finally` block uses test-controlled closures.
*
* @package plugin-check
*/

use WordPress\Plugin_Check\Checker\Abstract_Check_Runner;
use WordPress\Plugin_Check\Test_Utils\Traits\With_Mock_Filesystem;

class Abstract_Check_Runner_Tests extends WP_UnitTestCase {

use With_Mock_Filesystem;

public function set_up() {
parent::set_up();
$this->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 );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
* Test-helper preparation used by Abstract_Check_Runner_Tests.
*
* Stable class whose `prepare()` returns a closure that runs the test's
* recorded cleanup callbacks. Acts as the class-args key inside
* `get_shared_preparations()`.
*
* @package plugin-check
*/

/**
* Stable preparation class whose `prepare()` delegates to the test's
* recorded cleanup closures.
*/
class Abstract_Check_Runner_Tests_Preparation {

/**
* Closures captured by reference from the runner.
*
* @var array
*/
public $cleanups;

public function __construct( array &$cleanups ) {
$this->cleanups = &$cleanups;
}

public function prepare() {
$cleanups = $this->cleanups;

return function () use ( $cleanups ) {
foreach ( $cleanups as $cleanup ) {
( $cleanup )();
}
};
}
}
94 changes: 94 additions & 0 deletions tests/phpunit/tests/Checker/Abstract_Check_Runner_Tests_Runner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php
/**
* Test-harness AJAX_Runner subclass used by Abstract_Check_Runner_Tests.
*
* Stubs the production dependencies that Abstract_Check_Runner::run()
* touches (checks execution and shared preparations) so the real `run()`
* can be exercised against test-controlled inputs.
*
* @package plugin-check
*/

use WordPress\Plugin_Check\Checker\AJAX_Runner;

/**
* Concrete AJAX_Runner that stubs `get_checks_instance()` and
* `get_shared_preparations()` so we can drive Abstract_Check_Runner::run()
* against a synthetic check and a synthetic cleanup cascade.
*/
class Abstract_Check_Runner_Tests_Runner extends AJAX_Runner {

/** @var callable|null Stub for checks execution. May throw. */
public $do_run_callback;

/** @var array Cleanup closures whose execution is asserted in tests. */
public $recorded_cleanups = array();

protected function get_plugin_param() {
return 'plugin-check';
}

protected function get_check_slugs_param() {
return array( 'empty-check' );
}

protected function get_check_exclude_slugs_param() {
return array();
}

protected function get_include_experimental_param() {
return false;
}

protected function get_categories_param() {
return array();
}

protected function get_slug_param() {
return '';
}

protected function get_mode_param() {
return 'new';
}

/**
* Stub checks execution.
*
* Returns a plain object (not a Checks subclass, which is final) exposing
* `run_checks()`. The production `run()` only calls this method, so the
* returned value does not need to satisfy any interface.
*
* @return object
*/
protected function get_checks_instance() {
$runner = $this;

return new class( $runner ) {

/** @var Abstract_Check_Runner_Tests_Runner */
private $runner;

public function __construct( $runner ) {
$this->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 ),
),
);
}
}
Loading
Loading