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
23 changes: 3 additions & 20 deletions src/src/Commands/Environment/ExtensionSummary.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,7 @@ private function render_themes_table( OutputInterface $output, E2EEnvInfo $info
*/
private function get_installed_themes( E2EEnvInfo $info ): array {
try {
$reflection = new \ReflectionClass( $this->e2e_environment );
$docker_property = $reflection->getProperty( 'docker' );
$docker_property->setAccessible( true );
$docker = $docker_property->getValue( $this->e2e_environment );

$output = $docker->run_inside_docker(
$output = $this->e2e_environment->get_docker()->run_inside_docker(
$info,
[ 'wp', 'theme', 'list', '--fields=name,status,version', '--format=json' ],
[ 'WP_CLI_ALLOW_ROOT' => 'true' ],
Expand Down Expand Up @@ -149,13 +144,7 @@ private function create_source_map( array $extensions ): array {
*/
private function get_plugin_status( E2EEnvInfo $info, string $plugin_slug ): string {
try {
// Access the docker property through reflection since it's protected
$reflection = new \ReflectionClass( $this->e2e_environment );
$docker_property = $reflection->getProperty( 'docker' );
$docker_property->setAccessible( true );
$docker = $docker_property->getValue( $this->e2e_environment );

$output = $docker->run_inside_docker(
$output = $this->e2e_environment->get_docker()->run_inside_docker(
$info,
[ 'wp', 'plugin', 'is-active', $plugin_slug ],
[ 'WP_CLI_ALLOW_ROOT' => 'true' ],
Expand All @@ -177,13 +166,7 @@ private function get_plugin_status( E2EEnvInfo $info, string $plugin_slug ): str
*/
private function get_plugin_version( E2EEnvInfo $info, string $plugin_slug ): string {
try {
// Access the docker property through reflection since it's protected
$reflection = new \ReflectionClass( $this->e2e_environment );
$docker_property = $reflection->getProperty( 'docker' );
$docker_property->setAccessible( true );
$docker = $docker_property->getValue( $this->e2e_environment );

$output = $docker->run_inside_docker(
$output = $this->e2e_environment->get_docker()->run_inside_docker(
$info,
[ 'wp', 'plugin', 'get', $plugin_slug, '--field=version' ],
[ 'WP_CLI_ALLOW_ROOT' => 'true' ],
Expand Down
4 changes: 3 additions & 1 deletion src/src/Environment/EnvUpChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ protected function check_site( string $site_url ): bool {
curl_setopt( $ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' );
curl_exec( $ch );
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );

// Not closing the cURL handle: doing so is a no-op since PHP 8.0 and deprecated in 8.5; on 7.4 the handle is freed when $ch goes out of scope.
unset( $ch );

// 502 = Bad Gateway. This can happen for a short period, especially when using Jurassic Tube.
if ( $http_code === 502 && $retries < 3 ) { // @phpstan-ignore-line
Expand Down
4 changes: 4 additions & 0 deletions src/src/Environment/Environments/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public function __construct(

abstract public function get_name(): string;

public function get_docker(): Docker {
return $this->docker;
}

public function init( EnvInfo $env_info ): void {
$this->env_info = $env_info;

Expand Down
19 changes: 14 additions & 5 deletions src/src/OptionReuseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace QIT_CLI;

use ReflectionProperty;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputOption;

Expand Down Expand Up @@ -33,10 +32,20 @@ protected function reuseOption( string $command_name, string $option_name ): sel
);
}

// Using reflection to access the 'mode' private property of the option.
$reflected_option = new ReflectionProperty( InputOption::class, 'mode' );
$reflected_option->setAccessible( true );
$mode = $reflected_option->getValue( $option );
// Reconstruct the option's "mode" bitmask from the public API.
if ( ! $option->acceptValue() ) {
$mode = InputOption::VALUE_NONE;
} else {
$mode = $option->isValueRequired() ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL;

if ( $option->isArray() ) {
$mode |= InputOption::VALUE_IS_ARRAY;
}
}

if ( $option->isNegatable() ) {
$mode |= InputOption::VALUE_NEGATABLE;
}

if ( $mode === InputOption::VALUE_NONE ) {
$default = null;
Expand Down
23 changes: 17 additions & 6 deletions src/src/RequestBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,22 @@ public function request(): string {
$result = curl_exec( $curl );
$curl_error = curl_error( $curl );

// Extract header size and separate headers from body.
$header_size = curl_getinfo( $curl, CURLINFO_HEADER_SIZE );
$headers = substr( $result, 0, $header_size );
$body = substr( $result, $header_size );
if ( $result === false ) {
// Network error: there is no response to parse. The error handling below
// reports it via $curl_error and the unexpected status code (0).
$headers = '';
$body = '';
} else {
// Extract header size and separate headers from body.
$header_size = curl_getinfo( $curl, CURLINFO_HEADER_SIZE );
$headers = substr( $result, 0, $header_size );
$body = substr( $result, $header_size );
}

$response_status_code = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
curl_close( $curl );

// Not closing the cURL handle: doing so is a no-op since PHP 8.0 and deprecated in 8.5; on 7.4 the handle is freed when $curl goes out of scope.
unset( $curl );

if ( ! in_array( $response_status_code, $this->expected_status_codes, true ) ) {
if ( $proxied && $result === false ) {
Expand Down Expand Up @@ -546,7 +555,9 @@ public static function download_file( string $url, string $file_path ): void {
$output->writeln( sprintf( 'Downloaded %s in %f seconds.', $url, microtime( true ) - $start ) );
}
$curl_error = curl_error( $curl );
curl_close( $curl );

// Not closing the cURL handle: doing so is a no-op since PHP 8.0 and deprecated in 8.5; on 7.4 the handle is freed when $curl goes out of scope.
unset( $curl );
fclose( $fp );

if ( $curl_error ) {
Expand Down
87 changes: 87 additions & 0 deletions src/tests/unit/OptionReuseTraitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

use QIT_CLI\App;
use QIT_CLI\OptionReuseTrait;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;

class OptionReuseTraitTest extends \QIT_CLI_Tests\QITTestCase {
/** @var Command */
protected $source_command;

public function setUp(): void {
parent::setUp();

// Register the source command once: the Application from the DI container is a
// singleton shared across test methods, so re-adding would rely on Application::add()
// silently replacing the existing registration.
$application = App::make( Application::class );

if ( ! $application->has( 'test:option-reuse-source' ) ) {
$source_command = new Command( 'test:option-reuse-source' );
$source_command->addOption( 'flag', null, InputOption::VALUE_NONE, 'A flag option' );
$source_command->addOption( 'required_value', 'r', InputOption::VALUE_REQUIRED, 'A required value option', 'the-default' );
$source_command->addOption( 'optional_value', null, InputOption::VALUE_OPTIONAL, 'An optional value option', 'optional-default' );
$source_command->addOption( 'array_value', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'An array value option', [ 'a', 'b' ] );
$source_command->addOption( 'negatable_flag', null, InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE, 'A negatable flag option' );
$application->add( $source_command );
}

$this->source_command = $application->find( 'test:option-reuse-source' );
}

protected function make_consumer_command(): Command {
return new class( 'test:option-reuse-consumer' ) extends Command {
use OptionReuseTrait;

public function reuse( string $command_name, string $option_name ): void {
$this->reuseOption( $command_name, $option_name );
}
};
}

public function test_reused_options_behave_like_the_originals() {
$consumer = $this->make_consumer_command();

foreach ( array_keys( $this->source_command->getDefinition()->getOptions() ) as $option_name ) {
$consumer->reuse( 'test:option-reuse-source', $option_name );
}

foreach ( $this->source_command->getDefinition()->getOptions() as $option_name => $original ) {
$reused = $consumer->getDefinition()->getOption( $option_name );

$this->assertSame( $original->acceptValue(), $reused->acceptValue(), "acceptValue mismatch for --$option_name" );
$this->assertSame( $original->isValueRequired(), $reused->isValueRequired(), "isValueRequired mismatch for --$option_name" );
$this->assertSame( $original->isValueOptional(), $reused->isValueOptional(), "isValueOptional mismatch for --$option_name" );
$this->assertSame( $original->isArray(), $reused->isArray(), "isArray mismatch for --$option_name" );
$this->assertSame( $original->isNegatable(), $reused->isNegatable(), "isNegatable mismatch for --$option_name" );
$this->assertSame( $original->getDefault(), $reused->getDefault(), "getDefault mismatch for --$option_name" );
$this->assertSame( $original->getShortcut(), $reused->getShortcut(), "getShortcut mismatch for --$option_name" );
$this->assertSame( $original->getDescription(), $reused->getDescription(), "getDescription mismatch for --$option_name" );
}
}

public function test_reusing_unknown_option_throws() {
$consumer = $this->make_consumer_command();

$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Failed to reuse option "does_not_exist" from command "test:option-reuse-source"' );

$consumer->reuse( 'test:option-reuse-source', 'does_not_exist' );
}

public function test_reusing_option_from_real_command() {
// 'env:up' is the real source of reused options in RunE2ECommand.
$consumer = $this->make_consumer_command();
$consumer->reuse( 'env:up', 'php_version' );

$original = App::make( Application::class )->find( 'env:up' )->getDefinition()->getOption( 'php_version' );
$reused = $consumer->getDefinition()->getOption( 'php_version' );

$this->assertSame( $original->acceptValue(), $reused->acceptValue() );
$this->assertSame( $original->isValueRequired(), $reused->isValueRequired() );
$this->assertSame( $original->isArray(), $reused->isArray() );
$this->assertSame( $original->getDefault(), $reused->getDefault() );
}
}
46 changes: 46 additions & 0 deletions src/tests/unit/Php85DeprecationGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/**
* Guards against re-introducing calls that are deprecated on PHP 8.5.
*
* These deprecations only surface at runtime on PHP 8.5+, so CI running on
* older PHP versions would not catch them. This static scan does.
*
* - curl_close() is a no-op since PHP 8.0 and deprecated since 8.5. Handles
* are freed when they go out of scope; use unset() if eager release is needed.
* - ReflectionProperty/ReflectionMethod::setAccessible() is a no-op since
* PHP 8.1 and deprecated since 8.5. Prefer public accessors over reflection.
*/
class Php85DeprecationGuardTest extends \PHPUnit\Framework\TestCase {
public function test_src_has_no_php85_deprecated_calls() {
$src_dir = realpath( __DIR__ . '/../../src' );
$this->assertNotFalse( $src_dir, 'Could not resolve src directory.' );

$deprecated_patterns = [
'curl_close' => '/\bcurl_close\s*\(/',
'setAccessible' => '/->\s*setAccessible\s*\(/',
];

$violations = [];

$iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $src_dir, FilesystemIterator::SKIP_DOTS ) );
foreach ( $iterator as $file ) {
if ( $file->getExtension() !== 'php' ) {
continue;
}

$contents = file_get_contents( $file->getPathname() );

// An unreadable file would otherwise be scanned as an empty string and silently pass the guard.
$this->assertNotFalse( $contents, 'Could not read source file: ' . $file->getPathname() );

foreach ( $deprecated_patterns as $name => $pattern ) {
if ( preg_match( $pattern, $contents ) ) {
$violations[] = sprintf( '%s in %s', $name, $file->getPathname() );
}
}
}

$this->assertSame( [], $violations, "Found calls that are deprecated on PHP 8.5:\n" . implode( "\n", $violations ) );
}
}
12 changes: 8 additions & 4 deletions src/tests/unit/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,25 @@
define( 'UNIT_TESTS', true );

function qit_tests_reset_config_dir() {
exec( 'rm -rf /tmp/.woo-qit-tests' );
// Must match the QIT_HOME set below. On macOS, sys_get_temp_dir() is not "/tmp",
// so hardcoding "/tmp/.woo-qit-tests" here would leak state between test runs.
$config_dir = sys_get_temp_dir() . '/.woo-qit-tests';

if ( ! mkdir( '/tmp/.woo-qit-tests' ) ) {
exec( sprintf( 'rm -rf %s', escapeshellarg( $config_dir ) ) );

if ( ! mkdir( $config_dir ) ) {
throw new RuntimeException( 'Could not create config dir for tests..' );
}

if ( ! mkdir( '/tmp/.woo-qit-tests/environments' ) ) {
if ( ! mkdir( $config_dir . '/environments' ) ) {
throw new RuntimeException( 'Could not create environments dir for tests.' );
}

if ( ! file_exists( __DIR__ . '/data/environments/e2e.zip' ) ) {
throw new RuntimeException( 'Could not find e2e environment for tests.' );
}

if ( ! copy( __DIR__ . '/data/environments/e2e.zip', '/tmp/.woo-qit-tests/environments/e2e.zip' ) ) {
if ( ! copy( __DIR__ . '/data/environments/e2e.zip', $config_dir . '/environments/e2e.zip' ) ) {
throw new RuntimeException( 'Could not copy e2e environment for tests.' );
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/tests/unit/compare_snapshots.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,9 @@ function callOllama( string $endpoint, string $model, string $system, string $us
$response = curl_exec( $ch );
if ( $response === false ) {
$err = curl_error( $ch );
curl_close( $ch );

return "**Error calling Ollama**: $err";
}
curl_close( $ch );

$decoded = json_decode( $response, true );
if ( ! is_array( $decoded ) ) {
Expand Down
2 changes: 0 additions & 2 deletions src/tests/unit/data/pull-sync-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ function post_request( $url ) {
$http_code = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
$curl_error = curl_error( $curl );

curl_close( $curl );

if ( $response === false ) {
throw new RuntimeException( 'cURL error: ' . $curl_error );
}
Expand Down
Loading