diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index bf67592d8..94241454e 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -13,3 +13,34 @@ on:
jobs:
test:
uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main
+ with:
+ matrix: |
+ {
+ "include": [
+ {
+ "php": "8.4",
+ "wp": "latest",
+ "mysql": "mysql-8.0",
+ "os": "macos-15"
+ },
+ {
+ "php": "8.4",
+ "wp": "latest",
+ "dbtype": "sqlite",
+ "os": "macos-15"
+ },
+ {
+ "php": "8.4",
+ "wp": "latest",
+ "mysql": "mysql-8.0",
+ "os": "windows-2025"
+ },
+ {
+ "php": "8.4",
+ "wp": "latest",
+ "dbtype": "sqlite",
+ "os": "windows-2025"
+ }
+ ],
+ "exclude": []
+ }
diff --git a/bin/install-package-tests b/bin/install-package-tests
index c05768e9b..1c448f4ff 100755
--- a/bin/install-package-tests
+++ b/bin/install-package-tests
@@ -16,8 +16,8 @@ is_numeric() {
esac
}
# Prompt color vars.
-C_RED="\033[31m"
-C_BLUE="\033[34m"
+C_RED="\033[0;31m"
+C_BLUE="\033[0;34m"
NO_FORMAT="\033[0m"
HOST=localhost
diff --git a/composer.json b/composer.json
index 57f53f159..2e04fa2f3 100644
--- a/composer.json
+++ b/composer.json
@@ -25,7 +25,7 @@
"wp-cli/config-command": "^1 || ^2",
"wp-cli/core-command": "^1 || ^2",
"wp-cli/eval-command": "^1 || ^2",
- "wp-cli/wp-cli": "^2.12",
+ "wp-cli/wp-cli": "^2.13",
"wp-coding-standards/wpcs": "dev-develop",
"yoast/phpunit-polyfills": "^1.0 || ^2.0 || ^3.0 || ^4.0"
},
diff --git a/features/behat-steps.feature b/features/behat-steps.feature
index 6ad8c2b6f..3ffa4eb22 100644
--- a/features/behat-steps.feature
+++ b/features/behat-steps.feature
@@ -28,7 +28,8 @@ Feature: Test that WP-CLI Behat steps work as expected
Then the test-dir directory should not exist
Scenario: Test "Given an empty cache" step
- Given an empty cache
+ Given a WP installation
+ And an empty cache
Then the {SUITE_CACHE_DIR} directory should exist
Scenario: Test "Given a file" step
@@ -44,7 +45,8 @@ Feature: Test that WP-CLI Behat steps work as expected
"""
Scenario: Test "Given a cache file" step
- Given an empty cache
+ Given a WP installation
+ And an empty cache
And a test-cache.txt cache file:
"""
Cached content
@@ -68,7 +70,7 @@ Feature: Test that WP-CLI Behat steps work as expected
"""
Scenario: Test "When I run" step with basic command
- When I run `echo "test output"`
+ When I run `php -r "echo 'test output' . PHP_EOL;"`
Then STDOUT should be:
"""
test output
@@ -81,7 +83,7 @@ Feature: Test that WP-CLI Behat steps work as expected
Then the return code should be 1
Scenario: Test "save STDOUT as" variable
- When I run `echo "saved value"`
+ When I run `php -r "echo 'saved value' . PHP_EOL;"`
Then save STDOUT as {MY_VAR}
When I run `echo {MY_VAR}`
@@ -131,7 +133,7 @@ Feature: Test that WP-CLI Behat steps work as expected
Then STDERR should be empty
Scenario: Test "STDOUT should match" regex
- When I run `echo "test-123"`
+ When I run `php -r "echo 'test-123' . PHP_EOL;"`
Then STDOUT should match /^test-\d+$/
Scenario: Test "STDOUT should not match" regex
@@ -252,7 +254,7 @@ Feature: Test that WP-CLI Behat steps work as expected
"""
Scenario: Test STDOUT strictly be
- When I run `echo "exact"`
+ When I run `php -r "echo 'exact' . PHP_EOL;"`
Then STDOUT should strictly be:
"""
exact
@@ -318,7 +320,7 @@ Feature: Test that WP-CLI Behat steps work as expected
| foo | 1.0 |
Scenario: Test JSON output
- When I run `echo '{"name":"test","value":"example.com"}'`
+ When I run `php -r "echo '{\"name\":\"test\",\"value\":\"example.com\"}' . PHP_EOL;"`
Then STDOUT should be JSON containing:
"""
{"name":"test"}
@@ -410,13 +412,15 @@ Feature: Test that WP-CLI Behat steps work as expected
@require-download
Scenario: Test download step
- Given an empty cache
+ Given a WP installation
+ And an empty cache
And download:
| path | url |
| {SUITE_CACHE_DIR}/test.txt | https://www.iana.org/robots.txt |
Then the {SUITE_CACHE_DIR}/test.txt file should exist
- @require-wp @require-composer
+ # Skipped on Windows because of curl getaddrinfo() errors.
+ @require-wp @require-composer @skip-windows
Scenario: Test WP installation with Composer
Given a WP installation with Composer
Then the composer.json file should exist
@@ -424,13 +428,15 @@ Feature: Test that WP-CLI Behat steps work as expected
When I run `wp core version`
Then STDOUT should not be empty
- @require-wp @require-composer
+ # Skipped on Windows because of curl getaddrinfo() errors.
+ @require-wp @require-composer @skip-windows
Scenario: Test WP installation with Composer and custom vendor directory
Given a WP installation with Composer and a custom vendor directory 'custom-vendor'
Then the composer.json file should exist
And the custom-vendor directory should exist
- @require-wp @require-composer
+ # Skipped on Windows because of curl getaddrinfo() errors.
+ @require-wp @require-composer @skip-windows
Scenario: Test dependency on current wp-cli
Given a WP installation with Composer
And a dependency on current wp-cli
@@ -439,8 +445,7 @@ Feature: Test that WP-CLI Behat steps work as expected
wp-cli/wp-cli
"""
-
-
+ @require-linux
Scenario: Test STDOUT should be empty
When I run `echo -n ""`
Then STDOUT should be empty
@@ -448,7 +453,7 @@ Feature: Test that WP-CLI Behat steps work as expected
Scenario: Test running command from subdirectory
Given an empty directory
And an empty testdir directory
- When I run `pwd` from 'testdir'
+ When I run `php -r "echo getcwd();"` from 'testdir'
Then STDOUT should contain:
"""
testdir
@@ -521,7 +526,7 @@ Feature: Test that WP-CLI Behat steps work as expected
"""
Scenario: Test version comparison operators
- When I run `echo "5.6.2"`
+ When I run `php -r "echo '5.6.2' . PHP_EOL;"`
Then STDOUT should be a version string > 5.6.1
And STDOUT should be a version string >= 5.6.2
And STDOUT should be a version string < 5.6.3
@@ -590,7 +595,7 @@ Feature: Test that WP-CLI Behat steps work as expected
Scenario: Test variable naming conventions
- When I run `echo "value1"`
+ When I run `php -r "echo 'value1' . PHP_EOL;"`
Then save STDOUT as {VAR_NAME}
When I run `echo {VAR_NAME}`
@@ -621,7 +626,7 @@ Feature: Test that WP-CLI Behat steps work as expected
Scenario: Test built-in variables
Given an empty directory
- When I run `pwd`
+ When I run `php -r "echo getcwd();"`
Then STDOUT should contain:
"""
{RUN_DIR}
@@ -648,7 +653,7 @@ Feature: Test that WP-CLI Behat steps work as expected
"""
Scenario: Test STDERR capture
- When I try `bash -c 'echo "stdout"; echo "stderr" >&2'`
+ When I try `php -r "echo 'stdout' . PHP_EOL; fwrite(STDERR, 'stderr' . PHP_EOL);"`
Then STDOUT should contain:
"""
stdout
@@ -689,3 +694,4 @@ Feature: Test that WP-CLI Behat steps work as expected
Then STDOUT should be CSV containing:
| user_login | user_email |
| admin | admin@example.com |
+ | user2 | user2@example.com |
diff --git a/features/steps.feature b/features/steps.feature
index a1575f40f..0120fda4e 100644
--- a/features/steps.feature
+++ b/features/steps.feature
@@ -61,13 +61,14 @@ Feature: Make sure "Given", "When", "Then" steps work as expected
Scenario: Special variables
When I run `echo {INVOKE_WP_CLI_WITH_PHP_ARGS-} cli info`
- Then STDOUT should match /wp cli info/
+ Then STDOUT should match /(wp|wp\.bat) cli info/
And STDERR should be empty
When I run `echo {WP_VERSION-latest}`
Then STDOUT should match /\d\.\d/
And STDERR should be empty
+ @skip-windows
Scenario: Nested special variables
Given an empty directory
When I run `echo {INVOKE_WP_CLI_WITH_PHP_ARGS--dopen_basedir={RUN_DIR}} cli info`
@@ -77,6 +78,16 @@ Feature: Make sure "Given", "When", "Then" steps work as expected
When I run `echo {INVOKE_WP_CLI_WITH_PHP_ARGS--dopen_basedir={RUN_DIR}} eval 'echo "{RUN_DIR}";'`
Then STDOUT should match /^WP_CLI_PHP_ARGS=-dopen_basedir=(.*)(.*) ?wp eval echo "\1";/
+ @require-windows
+ Scenario: Nested special variables
+ Given an empty directory
+ When I run `echo {INVOKE_WP_CLI_WITH_PHP_ARGS--dopen_basedir={RUN_DIR}} cli info`
+ Then STDOUT should match /^WP_CLI_PHP_ARGS="?-dopen_basedir=.*"? ?wp\.bat? cli info/
+ And STDERR should be empty
+
+ When I run `echo {INVOKE_WP_CLI_WITH_PHP_ARGS--dopen_basedir={RUN_DIR}} eval 'echo "{RUN_DIR}";'`
+ Then STDOUT should match /^WP_CLI_PHP_ARGS="?-dopen_basedir=(.*)(.*)"? ?wp\.bat? eval 'echo "\1";'/
+
@require-mysql-or-mariadb
Scenario: SQL related variables
When I run `echo {MYSQL_BINARY}`
diff --git a/features/testing.feature b/features/testing.feature
index 47b0377dd..3392d9ac6 100644
--- a/features/testing.feature
+++ b/features/testing.feature
@@ -11,6 +11,10 @@ Feature: Test that WP-CLI loads.
Scenario: WP Cron is disabled by default
Given a WP install
+ And the wp-config.php file should contain:
+ """
+ if ( defined( 'DISABLE_WP_CRON' ) === false ) { define( 'DISABLE_WP_CRON', true ); }
+ """
And a test_cron.php file:
"""
#'
strictRules:
strictCalls: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index b7619f503..97c0c774b 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -11,7 +11,6 @@
>
- tests/
tests/tests
diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php
index ebf91353d..df444013c 100644
--- a/src/Context/FeatureContext.php
+++ b/src/Context/FeatureContext.php
@@ -133,6 +133,13 @@ class FeatureContext implements Context {
*/
private $running_procs = [];
+ /**
+ * Array of temporary file paths created for background processes on Windows. Used to clean them up at the end of the scenario.
+ *
+ * @var array
+ */
+ private $temp_files = [];
+
/**
* Array of variables available as {VARIABLE_NAME}. Some are always set: CORE_CONFIG_SETTINGS, DB_USER, DB_PASSWORD, DB_HOST, SRC_DIR, CACHE_DIR, WP_VERSION-version-latest.
* Some are step-dependent: RUN_DIR, SUITE_CACHE_DIR, COMPOSER_LOCAL_REPOSITORY, PHAR_PATH. One is set on use: INVOKE_WP_CLI_WITH_PHP_ARGS-args.
@@ -317,9 +324,9 @@ public static function get_vendor_dir(): ?string {
// We try to detect the vendor folder in the most probable locations.
$vendor_locations = [
// wp-cli/wp-cli-tests is a dependency of the current working dir.
- getcwd() . '/vendor',
+ getcwd() . DIRECTORY_SEPARATOR . 'vendor',
// wp-cli/wp-cli-tests is the root project.
- dirname( __DIR__, 2 ) . '/vendor',
+ dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'vendor',
// wp-cli/wp-cli-tests is a dependency.
dirname( __DIR__, 4 ),
];
@@ -358,7 +365,7 @@ public static function get_framework_dir(): ?string {
// wp-cli/wp-cli is the root project.
dirname( $vendor_folder ),
// wp-cli/wp-cli is a dependency.
- "{$vendor_folder}/wp-cli/wp-cli",
+ $vendor_folder . DIRECTORY_SEPARATOR . 'wp-cli' . DIRECTORY_SEPARATOR . 'wp-cli',
];
$framework_folder = '';
@@ -395,18 +402,20 @@ public static function get_bin_path(): ?string {
}
$bin_paths = [
- self::get_vendor_dir() . '/bin',
- self::get_framework_dir() . '/bin',
+ self::get_vendor_dir() . DIRECTORY_SEPARATOR . 'bin',
+ self::get_framework_dir() . DIRECTORY_SEPARATOR . 'bin',
];
+ $bin = Utils\is_windows() ? 'wp.bat' : 'wp';
+
foreach ( $bin_paths as $path ) {
- if ( is_file( "{$path}/wp" ) && is_executable( "{$path}/wp" ) ) {
- $bin_path = $path;
- break;
+ $full_bin_path = $path . DIRECTORY_SEPARATOR . $bin;
+ if ( is_file( $full_bin_path ) && ( Utils\is_windows() || is_executable( $full_bin_path ) ) ) {
+ return $path;
}
}
- return $bin_path;
+ return null;
}
/**
@@ -423,24 +432,32 @@ private static function get_process_env_variables(): array {
// Ensure we're using the expected `wp` binary.
$bin_path = self::get_bin_path();
- self::debug( "WP-CLI binary path: {$bin_path}" );
- if ( ! file_exists( "{$bin_path}/wp" ) ) {
- self::debug( "WARNING: No file named 'wp' found in the provided/detected binary path." );
+ if ( ! $bin_path ) {
+ throw new RuntimeException( 'Could not find WP-CLI binary path.' );
}
- if ( ! is_executable( "{$bin_path}/wp" ) ) {
- self::debug( "WARNING: File named 'wp' found in the provided/detected binary path is not executable." );
+ self::debug( "WP-CLI binary path: {$bin_path}" );
+
+ $bin = Utils\is_windows() ? 'wp.bat' : 'wp';
+ $full_bin_path = $bin_path . DIRECTORY_SEPARATOR . $bin;
+
+ if ( ! is_executable( $full_bin_path ) ) {
+ self::debug( "WARNING: File named '{$bin}' found in the provided/detected binary path is not executable." );
}
- $path_separator = Utils\is_windows() ? ';' : ':';
- $env = [
- 'PATH' => $bin_path . $path_separator . getenv( 'PATH' ),
- 'BEHAT_RUN' => 1,
- 'HOME' => sys_get_temp_dir() . '/wp-cli-home',
- 'TEST_RUN_DIR' => self::$behat_run_dir,
+ $path_separator = Utils\is_windows() ? ';' : ':';
+ $php_binary_path = dirname( PHP_BINARY );
+ $env = [
+ 'PATH' => $php_binary_path . $path_separator . $bin_path . $path_separator . getenv( 'PATH' ),
+ 'BEHAT_RUN' => 1,
+ 'HOME' => sys_get_temp_dir() . '/wp-cli-home',
+ 'COMPOSER_HOME' => sys_get_temp_dir() . '/wp-cli-composer-home',
+ 'TEST_RUN_DIR' => self::$behat_run_dir,
];
+ $env = array_merge( $_ENV, $env );
+
if ( self::running_with_code_coverage() ) {
$has_coverage_driver = ( new Runtime() )->hasXdebug() || ( new Runtime() )->hasPCOV();
@@ -558,13 +575,11 @@ private static function download_sqlite_plugin( $dir ): void {
mkdir( $dir );
}
- Process::create(
- Utils\esc_cmd(
- 'curl -sSfL %1$s > %2$s',
- $download_url,
- $download_location
- )
- )->run_check();
+ $response = Utils\http_request( 'GET', $download_url, null, [], [ 'filename' => $download_location ] );
+
+ if ( 200 !== $response->status_code ) {
+ throw new RuntimeException( "Could not download SQLite plugin (HTTP code {$response->status_code})" );
+ }
$zip = new \ZipArchive();
$new_zip_file = $download_location;
@@ -593,6 +608,12 @@ private static function configure_sqlite( $dir ): void {
$db_copy = $dir . '/wp-content/mu-plugins/sqlite-database-integration/db.copy';
$db_dropin = $dir . '/wp-content/db.php';
+ $db_copy_contents = file_get_contents( $db_copy );
+
+ if ( false === $db_copy_contents ) {
+ throw new RuntimeException( "Could not read db.copy file at: {$db_copy}" );
+ }
+
/* similar to https://github.com/WordPress/sqlite-database-integration/blob/3306576c9b606bc23bbb26c15383fef08e03ab11/activate.php#L95 */
$file_contents = str_replace(
array(
@@ -605,7 +626,7 @@ private static function configure_sqlite( $dir ): void {
'sqlite-database-integration/load.php',
'/mu-plugins/',
),
- file_get_contents( $db_copy )
+ $db_copy_contents
);
file_put_contents( $db_dropin, $file_contents );
@@ -652,7 +673,13 @@ public static function prepare( BeforeSuiteScope $scope ): void {
self::log_run_times_before_suite( $scope );
}
self::$behat_run_dir = getcwd();
- self::$mysql_binary = Utils\get_mysql_binary_path();
+
+ // TODO: Improve Windows support upstream in Utils\get_mysql_binary_path().
+ if ( Utils\is_windows() ) {
+ self::$mysql_binary = 'mysql.exe';
+ } else {
+ self::$mysql_binary = Utils\get_mysql_binary_path();
+ }
$result = Process::create( 'wp cli info', null, self::get_process_env_variables() )->run_check();
echo "{$result->stdout}\n";
@@ -698,10 +725,9 @@ public function beforeScenario( BeforeScenarioScope $scope ): void {
self::get_behat_internal_variables()
);
- $mysql_binary = Utils\get_mysql_binary_path();
$sql_dump_command = Utils\get_sql_dump_command();
- $this->variables['MYSQL_BINARY'] = $mysql_binary;
+ $this->variables['MYSQL_BINARY'] = self::$mysql_binary;
$this->variables['SQL_DUMP_COMMAND'] = $sql_dump_command;
// Used in the names of the RUN_DIR and SUITE_CACHE_DIR directories.
@@ -748,6 +774,10 @@ public function afterScenario( AfterScenarioScope $scope ): void {
self::terminate_proc( $status['pid'] );
}
+ // Clean up temporary files created for background processes on Windows.
+ $this->cleanup_temp_files( ...$this->temp_files );
+ $this->temp_files = [];
+
if ( self::$log_run_times ) {
self::log_run_times_after_scenario( $scope );
}
@@ -759,6 +789,12 @@ public function afterScenario( AfterScenarioScope $scope ): void {
* @param int $master_pid
*/
private static function terminate_proc( $master_pid ): void {
+ $master_pid = (int) $master_pid;
+
+ if ( Utils\is_windows() ) {
+ shell_exec( "taskkill /F /T /PID $master_pid > NUL 2>&1" );
+ return;
+ }
$output = shell_exec( "ps -o ppid,pid,command | grep $master_pid" );
@@ -767,13 +803,17 @@ private static function terminate_proc( $master_pid ): void {
$parent = $matches[1];
$child = $matches[2];
- if ( (int) $parent === (int) $master_pid ) {
+ if ( (int) $parent === $master_pid ) {
self::terminate_proc( (int) $child );
}
}
}
- if ( ! posix_kill( (int) $master_pid, 9 ) ) {
+ if ( ! function_exists( 'posix_kill' ) ) {
+ return;
+ }
+
+ if ( ! posix_kill( $master_pid, 9 ) ) {
$errno = posix_get_last_error();
// Ignore "No such process" error as that's what we want.
if ( 3 /*ESRCH*/ !== $errno ) {
@@ -905,7 +945,12 @@ protected static function bootstrap_feature_context(): void {
return;
}
- $composer = json_decode( file_get_contents( $project_composer ) );
+ $composer_contents = file_get_contents( $project_composer );
+ if ( false === $composer_contents ) {
+ return;
+ }
+
+ $composer = json_decode( $composer_contents );
if ( empty( $composer->autoload->files ) ) {
return;
}
@@ -964,18 +1009,25 @@ private function replace_invoke_wp_cli_with_php_args( $str ) {
$phar_begin = '#!/usr/bin/env php';
$phar_begin_len = strlen( $phar_begin );
$bin_dir = getenv( 'WP_CLI_BIN_DIR' );
- if ( false !== $bin_dir && file_exists( $bin_dir . '/wp' ) && file_get_contents( $bin_dir . '/wp', false, null, 0, $phar_begin_len ) === $phar_begin ) {
- $phar_path = $bin_dir . '/wp';
+ $bin = Utils\is_windows() ? 'wp.bat' : 'wp';
+ if (
+ false !== $bin_dir &&
+ // A .bat file will never start with a shebang.
+ ! Utils\is_windows() &&
+ file_exists( $bin_dir . DIRECTORY_SEPARATOR . $bin ) &&
+ (string) file_get_contents( $bin_dir . DIRECTORY_SEPARATOR . $bin, false, null, 0, $phar_begin_len ) === $phar_begin
+ ) {
+ $phar_path = $bin_dir . DIRECTORY_SEPARATOR . $bin;
} else {
$src_dir = dirname( __DIR__, 2 );
- $bin_path = $src_dir . '/bin/wp';
- $vendor_bin_path = $src_dir . '/vendor/bin/wp';
+ $bin_path = $src_dir . '/bin/' . $bin;
+ $vendor_bin_path = $src_dir . '/vendor/bin/' . $bin;
if ( file_exists( $bin_path ) && is_executable( $bin_path ) ) {
$shell_path = $bin_path;
} elseif ( file_exists( $vendor_bin_path ) && is_executable( $vendor_bin_path ) ) {
$shell_path = $vendor_bin_path;
} else {
- $shell_path = 'wp';
+ $shell_path = $bin;
}
}
}
@@ -1080,7 +1132,7 @@ private static function get_event_file( $scope, &$line ): ?string {
*/
public function create_run_dir(): void {
if ( ! isset( $this->variables['RUN_DIR'] ) ) {
- self::$run_dir = sys_get_temp_dir() . '/' . uniqid( 'wp-cli-test-run-' . self::$temp_dir_infix . '-', true );
+ self::$run_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid( 'wp-cli-test-run-' . self::$temp_dir_infix . '-', true );
$this->variables['RUN_DIR'] = self::$run_dir;
mkdir( $this->variables['RUN_DIR'] );
}
@@ -1113,14 +1165,18 @@ public function build_phar( $version = 'same' ): void {
$this->composer_command( 'dump-autoload --working-dir=' . dirname( self::get_vendor_dir() ) );
}
- $this->proc(
- Utils\esc_cmd(
- 'php -dphar.readonly=0 %1$s %2$s --version=%3$s && chmod +x %2$s',
- $make_phar_path,
- $this->variables['PHAR_PATH'],
- $version
- )
- )->run_check();
+ $command = Utils\esc_cmd(
+ 'php -dphar.readonly=0 %1$s %2$s --version=%3$s',
+ $make_phar_path,
+ $this->variables['PHAR_PATH'],
+ $version
+ );
+
+ if ( ! Utils\is_windows() ) {
+ $command .= Utils\esc_cmd( ' && chmod +x %s', $this->variables['PHAR_PATH'] );
+ }
+
+ $this->proc( $command )->run_check();
// Revert the suffix change again
if ( $is_bundle && self::running_with_code_coverage() ) {
@@ -1146,13 +1202,15 @@ public function download_phar( $version = 'same' ): void {
. uniqid( 'wp-cli-download-', true )
. '.phar';
- Process::create(
- Utils\esc_cmd(
- 'curl -sSfL %1$s > %2$s && chmod +x %2$s',
- $download_url,
- $this->variables['PHAR_PATH']
- )
- )->run_check();
+ $response = Utils\http_request( 'GET', $download_url, null, [], [ 'filename' => $this->variables['PHAR_PATH'] ] );
+
+ if ( 200 !== $response->status_code ) {
+ throw new RuntimeException( "Could not download WP-CLI PHAR (HTTP code {$response->status_code})" );
+ }
+
+ if ( ! Utils\is_windows() ) {
+ chmod( $this->variables['PHAR_PATH'], 0755 );
+ }
}
/**
@@ -1297,24 +1355,66 @@ public function proc( $command, $assoc_args = [], $path = '' ): Process {
* @param string $cmd
*/
public function background_proc( $cmd ): void {
- $descriptors = [
- 0 => STDIN,
- 1 => [ 'pipe', 'w' ],
- 2 => [ 'pipe', 'w' ],
- ];
+ if ( Utils\is_windows() ) {
+ // On Windows, leaving pipes open can cause hangs.
+ // Redirect output to files and close stdin.
+ $stdout_file = tempnam( sys_get_temp_dir(), 'behat-stdout-' );
+ $stderr_file = tempnam( sys_get_temp_dir(), 'behat-stderr-' );
+ $descriptors = [
+ 0 => [ 'pipe', 'r' ],
+ 1 => [ 'file', $stdout_file, 'a' ],
+ 2 => [ 'file', $stderr_file, 'a' ],
+ ];
+ } else {
+ $descriptors = [
+ 0 => STDIN,
+ 1 => [ 'pipe', 'w' ],
+ 2 => [ 'pipe', 'w' ],
+ ];
+ }
$proc = proc_open( $cmd, $descriptors, $pipes, $this->variables['RUN_DIR'], self::get_process_env_variables() );
+ if ( Utils\is_windows() ) {
+ fclose( $pipes[0] );
+ }
+
sleep( 1 );
$status = proc_get_status( $proc );
if ( ! $status['running'] ) {
- $stderr = is_resource( $pipes[2] ) ? ( ': ' . stream_get_contents( $pipes[2] ) ) : '';
+ if ( Utils\is_windows() ) {
+ $stderr = (string) file_get_contents( $stderr_file );
+ $stderr = $stderr ? ': ' . $stderr : '';
+ // Clean up temporary files.
+ $this->cleanup_temp_files( $stdout_file, $stderr_file );
+ } else {
+ $stderr = is_resource( $pipes[2] ) ? ( ': ' . stream_get_contents( $pipes[2] ) ) : '';
+ }
throw new RuntimeException( sprintf( "Failed to start background process '%s'%s.", $cmd, $stderr ) );
}
$this->running_procs[] = $proc;
+
+ // Track temporary files for cleanup at the end of the scenario.
+ if ( Utils\is_windows() ) {
+ $this->temp_files[] = $stdout_file;
+ $this->temp_files[] = $stderr_file;
+ }
+ }
+
+ /**
+ * Clean up temporary files safely.
+ *
+ * @param string ...$files File paths to clean up.
+ */
+ private function cleanup_temp_files( ...$files ): void {
+ foreach ( $files as $file ) {
+ if ( file_exists( $file ) ) {
+ unlink( $file );
+ }
+ }
}
/**
@@ -1331,7 +1431,27 @@ public function move_files( $src, $dest ): void {
* @param string $dir
*/
public static function remove_dir( $dir ): void {
- Process::create( Utils\esc_cmd( 'rm -rf %s', $dir ) )->run_check();
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ /**
+ * @var \SplFileInfo $file
+ */
+ foreach ( $iterator as $file ) {
+ if ( $file->isDir() ) {
+ rmdir( $file->getPathname() );
+ } else {
+ unlink( $file->getPathname() );
+ }
+ }
+
+ rmdir( $dir );
}
/**
@@ -1341,10 +1461,24 @@ public static function remove_dir( $dir ): void {
* @param string $dest_dir
*/
public static function copy_dir( $src_dir, $dest_dir ): void {
- $shell_command = ( 'Darwin' === PHP_OS )
- ? Utils\esc_cmd( 'cp -R %s/* %s', $src_dir, $dest_dir )
- : Utils\esc_cmd( 'cp -r %s/* %s', $src_dir, $dest_dir );
- Process::create( $shell_command )->run_check();
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $src_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ /**
+ * @var \SplFileInfo $item
+ */
+ foreach ( $iterator as $item ) {
+ $dest_path = $dest_dir . '/' . $iterator->getSubPathname();
+ if ( $item->isDir() ) {
+ if ( ! is_dir( $dest_path ) ) {
+ mkdir( $dest_path, 0777, true );
+ }
+ } else {
+ copy( $item->getPathname(), $dest_path );
+ }
+ }
}
/**
@@ -1379,13 +1513,13 @@ public function download_wp( $subdir = '', $version = '' ): void {
$dest_dir = $this->variables['RUN_DIR'] . "/$subdir";
if ( $subdir ) {
- mkdir( $dest_dir );
+ mkdir( $dest_dir, 0777, true /*recursive*/ );
}
self::copy_dir( self::$cache_dir, $dest_dir );
if ( ! is_dir( $dest_dir . '/wp-content/mu-plugins' ) ) {
- mkdir( $dest_dir . '/wp-content/mu-plugins' );
+ mkdir( $dest_dir . '/wp-content/mu-plugins', 0777, true /*recursive*/ );
}
// Disable emailing.
@@ -1454,7 +1588,7 @@ public function install_wp( $subdir = '', $version = '' ): void {
$subdir = $this->replace_variables( $subdir );
// Disable WP Cron by default to avoid bogus HTTP requests in CLI context.
- $config_extra_php = "if ( ! defined( 'DISABLE_WP_CRON' ) ) { define( 'DISABLE_WP_CRON', true ); }\n";
+ $config_extra_php = "if ( defined( 'DISABLE_WP_CRON' ) === false ) { define( 'DISABLE_WP_CRON', true ); }\n";
if ( 'sqlite' !== self::$db_type ) {
$this->create_db();
@@ -1496,7 +1630,8 @@ public function install_wp( $subdir = '', $version = '' ): void {
if ( 'sqlite' !== self::$db_type ) {
$mysqldump_binary = Utils\get_sql_dump_command();
$mysqldump_binary = Utils\force_env_on_nix_systems( $mysqldump_binary );
- $support_column_statistics = exec( "{$mysqldump_binary} --help | grep 'column-statistics'" );
+ $help_output = shell_exec( "{$mysqldump_binary} --help" );
+ $support_column_statistics = ( null !== $help_output && false !== strpos( $help_output, 'column-statistics' ) );
$ssl_flag = 'mariadb' === self::$db_type ? ' --ssl-verify-server-cert' : '';
$command = "{$mysqldump_binary} --no-defaults{$ssl_flag} --no-tablespaces";
if ( $support_column_statistics ) {
@@ -1531,7 +1666,7 @@ public function install_wp_with_composer( $vendor_directory = 'vendor' ): void {
$this->composer_command( 'require johnpbloch/wordpress-core-installer johnpbloch/wordpress-core --optimize-autoloader' );
// Disable WP Cron by default to avoid bogus HTTP requests in CLI context.
- $config_extra_php = "if ( ! defined( 'DISABLE_WP_CRON' ) ) { define( 'DISABLE_WP_CRON', true ); }\n";
+ $config_extra_php = "if ( defined( 'DISABLE_WP_CRON' ) === false ) { define( 'DISABLE_WP_CRON', true ); }\n";
$config_extra_php .= "require_once dirname(__DIR__) . '/" . $vendor_directory . "/autoload.php';\n";
@@ -1606,7 +1741,14 @@ public function start_php_server( $subdir = '' ): void {
*/
private function composer_command( $cmd ): void {
if ( ! isset( $this->variables['COMPOSER_PATH'] ) ) {
- $this->variables['COMPOSER_PATH'] = exec( 'which composer' );
+ $command = Utils\is_windows() ? 'where composer' : 'which composer';
+ $path = exec( $command );
+ if ( false === $path || empty( $path ) ) {
+ throw new RuntimeException( 'Could not find composer.' );
+ }
+ // In case of multiple paths, pick the first one.
+ $path = strtok( $path, PHP_EOL );
+ $this->variables['COMPOSER_PATH'] = $path;
}
$this->proc( $this->variables['COMPOSER_PATH'] . ' --no-interaction ' . $cmd )->run_check();
}
diff --git a/src/Context/GivenStepDefinitions.php b/src/Context/GivenStepDefinitions.php
index 0aa0ded0a..ffccdb5f8 100644
--- a/src/Context/GivenStepDefinitions.php
+++ b/src/Context/GivenStepDefinitions.php
@@ -615,7 +615,10 @@ public function given_a_download( TableNode $table ): void {
continue;
}
- Process::create( Utils\esc_cmd( 'curl -sSL %s > %s', $row['url'], $path ) )->run_check();
+ $response = Utils\http_request( 'GET', $row['url'], null, [], [ 'filename' => $path ] );
+ if ( 200 !== $response->status_code ) {
+ throw new RuntimeException( "Could not download file (HTTP code {$response->status_code})" );
+ }
}
}
diff --git a/src/Context/Support.php b/src/Context/Support.php
index c68e2879c..93b6c82a4 100644
--- a/src/Context/Support.php
+++ b/src/Context/Support.php
@@ -244,7 +244,7 @@ protected function check_that_csv_string_contains_values( $actual_csv, $expected
static function ( $str ) {
return str_getcsv( $str, ',', '"', '\\' );
},
- explode( PHP_EOL, $actual_csv )
+ explode( "\n", $actual_csv )
);
if ( empty( $actual_csv ) ) {
diff --git a/src/Context/ThenStepDefinitions.php b/src/Context/ThenStepDefinitions.php
index bc2cb3f5b..d74158d74 100644
--- a/src/Context/ThenStepDefinitions.php
+++ b/src/Context/ThenStepDefinitions.php
@@ -455,9 +455,11 @@ public function then_stdout_stderr_should_be_a_specific_version_string( $stream,
public function then_a_specific_file_folder_should_exist( $path, $type, $strictly, $action, $expected = null ): void {
$path = $this->replace_variables( $path );
+ $is_absolute = preg_match( '#^[a-zA-Z]:\\\\#', $path ) || ( strlen( $path ) > 0 && ( '/' === $path[0] || '\\' === $path[0] ) );
+
// If it's a relative path, make it relative to the current test dir.
- if ( '/' !== $path[0] ) {
- $path = $this->variables['RUN_DIR'] . "/$path";
+ if ( ! $is_absolute ) {
+ $path = $this->variables['RUN_DIR'] . DIRECTORY_SEPARATOR . $path;
}
$exists = static function ( $path ) use ( $type ) {
diff --git a/tests/tests/TestBehatTags.php b/tests/tests/TestBehatTags.php
index 5b3ca49c1..13b222860 100644
--- a/tests/tests/TestBehatTags.php
+++ b/tests/tests/TestBehatTags.php
@@ -19,22 +19,79 @@ protected function set_up(): void {
$this->temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-test-behat-tags-', true );
mkdir( $this->temp_dir );
- mkdir( $this->temp_dir . '/features' );
+ mkdir( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' );
}
protected function tear_down(): void {
-
if ( $this->temp_dir && file_exists( $this->temp_dir ) ) {
- foreach ( glob( $this->temp_dir . '/features/*' ) as $feature_file ) {
- unlink( $feature_file );
- }
- rmdir( $this->temp_dir . '/features' );
- rmdir( $this->temp_dir );
+ $this->remove_dir( $this->temp_dir );
}
parent::tear_down();
}
+ /**
+ * Recursively removes a directory and its contents.
+ *
+ * @param string $dir The directory to remove.
+ */
+ private function remove_dir( $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( $file->isDir() ) {
+ rmdir( $file->getPathname() );
+ } else {
+ unlink( $file->getPathname() );
+ }
+ }
+
+ rmdir( $dir );
+ }
+
+ /**
+ * Runs the behat-tags.php script in a cross-platform way.
+ *
+ * @param string $env Environment variable string to set (e.g., 'WP_VERSION=4.5').
+ * @return string|false The output of the script.
+ */
+ private function run_behat_tags_script( $env = '' ) {
+ $behat_tags = dirname( dirname( __DIR__ ) ) . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'behat-tags.php';
+
+ // Use the `-n` flag to disable loading of `php.ini` and ensure a clean environment.
+ $php_run = escapeshellarg( PHP_BINARY ) . ' -n ' . escapeshellarg( $behat_tags );
+
+ $features_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'features';
+
+ $command = '';
+ if ( Utils\is_windows() ) {
+ // `set` is internal to `cmd.exe`. Do not escape the values, as `set` doesn't understand quotes from escapeshellarg.
+ // Note: `set "VAR=VALUE"` is more robust than `set VAR=VALUE`.
+ $command = 'set "BEHAT_FEATURES_FOLDER=' . $features_dir . '" && ';
+ if ( ! empty( $env ) ) {
+ $command .= 'set "' . $env . '" && ';
+ }
+ } else {
+ // On Unix-like systems, this sets the variable for the duration of the command.
+ $command = 'BEHAT_FEATURES_FOLDER=' . escapeshellarg( $features_dir );
+ if ( ! empty( $env ) ) {
+ $command .= ' ' . $env;
+ }
+ $command .= ' ';
+ }
+
+ $command = 'cd ' . escapeshellarg( $this->temp_dir ) . ' && ' . $command . $php_run;
+ $output = exec( $command );
+
+ return $output;
+ }
/**
* @dataProvider data_behat_tags_wp_version_github_token
*
@@ -50,12 +107,10 @@ public function test_behat_tags_wp_version_github_token( $env, $expected ): void
putenv( 'WP_VERSION' );
putenv( 'GITHUB_TOKEN' );
- $behat_tags = dirname( dirname( __DIR__ ) ) . '/utils/behat-tags.php';
-
$contents = '@require-wp-4.6 @require-wp-4.8 @require-wp-4.9 @less-than-wp-4.6 @less-than-wp-4.8 @less-than-wp-4.9';
- file_put_contents( $this->temp_dir . '/features/wp_version.feature', $contents );
+ file_put_contents( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'wp_version.feature', $contents );
- $output = exec( "cd {$this->temp_dir}; $env php $behat_tags" );
+ $output = $this->run_behat_tags_script( $env );
$expected .= '&&~@broken';
if ( in_array( $env, array( 'WP_VERSION=trunk', 'WP_VERSION=nightly' ), true ) ) {
@@ -109,8 +164,6 @@ public function test_behat_tags_php_version(): void {
putenv( 'GITHUB_TOKEN' );
- $behat_tags = dirname( dirname( __DIR__ ) ) . '/utils/behat-tags.php';
-
$php_version = substr( PHP_VERSION, 0, 3 );
$contents = '';
$expected = '';
@@ -163,9 +216,9 @@ public function test_behat_tags_php_version(): void {
break;
}
- file_put_contents( $this->temp_dir . '/features/php_version.feature', $contents );
+ file_put_contents( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'php_version.feature', $contents );
- $output = exec( "cd {$this->temp_dir}; php $behat_tags" );
+ $output = $this->run_behat_tags_script();
$this->assertSame( '--tags=' . $expected, $output );
putenv( false === $env_github_token ? 'GITHUB_TOKEN' : "GITHUB_TOKEN=$env_github_token" );
@@ -177,9 +230,7 @@ public function test_behat_tags_extension(): void {
putenv( 'GITHUB_TOKEN' );
- $behat_tags = dirname( dirname( __DIR__ ) ) . '/utils/behat-tags.php';
-
- file_put_contents( $this->temp_dir . '/features/extension.feature', '@require-extension-imagick @require-extension-curl' );
+ file_put_contents( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'extension.feature', '@require-extension-imagick @require-extension-curl' );
$expecteds = array();
@@ -200,15 +251,18 @@ public function test_behat_tags_extension(): void {
break;
}
- if ( ! extension_loaded( 'imagick' ) ) {
+ // Check which extensions are loaded in the clean `php -n` environment to build the correct expectation.
+ $imagick_loaded_in_script = (bool) exec( 'cd ' . escapeshellarg( $this->temp_dir ) . ' && ' . escapeshellarg( PHP_BINARY ) . ' -n -r "echo (int)extension_loaded(\'imagick\');"' );
+ if ( ! $imagick_loaded_in_script ) {
$expecteds[] = '~@require-extension-imagick';
}
- if ( ! extension_loaded( 'curl' ) ) {
+ $curl_loaded_in_script = (bool) exec( 'cd ' . escapeshellarg( $this->temp_dir ) . ' && ' . escapeshellarg( PHP_BINARY ) . ' -n -r "echo (int)extension_loaded(\'curl\');"' );
+ if ( ! $curl_loaded_in_script ) {
$expecteds[] = '~@require-extension-curl';
}
$expected = '--tags=' . implode( '&&', array_merge( array( '~@github-api', '~@broken' ), $expecteds ) );
- $output = exec( "cd {$this->temp_dir}; php $behat_tags" );
+ $output = $this->run_behat_tags_script();
$this->assertSame( $expected, $output );
putenv( false === $env_github_token ? 'GITHUB_TOKEN' : "GITHUB_TOKEN=$env_github_token" );
@@ -217,8 +271,13 @@ public function test_behat_tags_extension(): void {
public function test_behat_tags_db_version(): void {
$db_type = getenv( 'WP_CLI_TEST_DBTYPE' );
- $behat_tags = dirname( dirname( __DIR__ ) ) . '/utils/behat-tags.php';
+ $behat_tags = dirname( dirname( __DIR__ ) ) . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'behat-tags.php';
+
+ // Just to get the get_db_version() function. Prevents unexpected output.
+ ob_start();
require $behat_tags;
+ ob_end_clean();
+
// @phpstan-ignore-next-line
$db_version = get_db_version();
$minimum_db_version = $db_version . '.1';
@@ -249,10 +308,67 @@ public function test_behat_tags_db_version(): void {
break;
}
- file_put_contents( $this->temp_dir . '/features/extension.feature', $contents );
+ file_put_contents( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'extension.feature', $contents );
$expected = '--tags=' . implode( '&&', array_merge( array( '~@github-api', '~@broken' ), $expecteds ) );
- $output = exec( "cd {$this->temp_dir}; php $behat_tags" );
+ $output = $this->run_behat_tags_script();
$this->assertSame( $expected, $output );
}
+
+ public function test_behat_tags_os(): void {
+ $env_github_token = getenv( 'GITHUB_TOKEN' );
+ $db_type = getenv( 'WP_CLI_TEST_DBTYPE' );
+
+ putenv( 'GITHUB_TOKEN' );
+
+ file_put_contents( $this->temp_dir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'os.feature', '@require-windows @skip-windows @require-macos @skip-macos @require-linux @skip-linux' );
+
+ $expecteds = array();
+
+ switch ( $db_type ) {
+ case 'mariadb':
+ $expecteds[] = '~@require-mysql';
+ $expecteds[] = '~@require-sqlite';
+ break;
+ case 'sqlite':
+ $expecteds[] = '~@require-mariadb';
+ $expecteds[] = '~@require-mysql';
+ $expecteds[] = '~@require-mysql-or-mariadb';
+ break;
+ case 'mysql':
+ default:
+ $expecteds[] = '~@require-mariadb';
+ $expecteds[] = '~@require-sqlite';
+ break;
+ }
+
+ $is_windows = 'Windows' === PHP_OS_FAMILY;
+ $is_macos = 'Darwin' === PHP_OS_FAMILY;
+ $is_linux = 'Linux' === PHP_OS_FAMILY;
+
+ if ( ! $is_windows ) {
+ $expecteds[] = '~@require-windows';
+ }
+ if ( $is_windows ) {
+ $expecteds[] = '~@skip-windows';
+ }
+ if ( ! $is_macos ) {
+ $expecteds[] = '~@require-macos';
+ }
+ if ( $is_macos ) {
+ $expecteds[] = '~@skip-macos';
+ }
+ if ( ! $is_linux ) {
+ $expecteds[] = '~@require-linux';
+ }
+ if ( $is_linux ) {
+ $expecteds[] = '~@skip-linux';
+ }
+
+ $expected = '--tags=' . implode( '&&', array_merge( array( '~@github-api', '~@broken' ), $expecteds ) );
+ $output = $this->run_behat_tags_script();
+ $this->assertSame( $expected, $output );
+
+ putenv( false === $env_github_token ? 'GITHUB_TOKEN' : "GITHUB_TOKEN=$env_github_token" );
+ }
}
diff --git a/utils/behat-tags.php b/utils/behat-tags.php
index 4da5af53a..e433e4197 100644
--- a/utils/behat-tags.php
+++ b/utils/behat-tags.php
@@ -23,10 +23,17 @@ function version_tags(
return array();
}
- exec(
- "grep '@{$prefix}-[0-9\.]*' -h -o {$features_folder}/*.feature | uniq",
- $existing_tags
- );
+ $existing_tags = array();
+ $feature_files = glob( $features_folder . DIRECTORY_SEPARATOR . '*.feature' );
+ if ( ! empty( $feature_files ) ) {
+ foreach ( $feature_files as $feature_file ) {
+ $contents = (string) file_get_contents( $feature_file );
+ if ( preg_match_all( '/@' . $prefix . '-[0-9\.]+/', $contents, $matches ) ) {
+ $existing_tags = array_merge( $existing_tags, $matches[0] );
+ }
+ }
+ $existing_tags = array_unique( $existing_tags );
+ }
$skip_tags = array();
@@ -171,7 +178,8 @@ function get_db_version() {
switch ( $db_type ) {
case 'mariadb':
- $skip_tags = array_merge(
+ $db_version = get_db_version();
+ $skip_tags = array_merge(
$skip_tags,
[ '@require-mysql', '@require-sqlite' ],
version_tags( 'require-mariadb', $db_version, '<', $features_folder ),
@@ -185,7 +193,8 @@ function get_db_version() {
break;
case 'mysql':
default:
- $skip_tags = array_merge(
+ $db_version = get_db_version();
+ $skip_tags = array_merge(
$skip_tags,
[ '@require-mariadb', '@require-sqlite' ],
version_tags( 'require-mysql', $db_version, '<', $features_folder ),
@@ -197,10 +206,16 @@ function get_db_version() {
# Require PHP extension, eg 'imagick'.
function extension_tags( $features_folder = 'features' ) {
$extension_tags = array();
- exec(
- "grep '@require-extension-[A-Za-z_]*' -h -o {$features_folder}/*.feature | uniq",
- $extension_tags
- );
+ $feature_files = glob( $features_folder . DIRECTORY_SEPARATOR . '*.feature' );
+ if ( ! empty( $feature_files ) ) {
+ foreach ( $feature_files as $feature_file ) {
+ $contents = (string) file_get_contents( $feature_file );
+ if ( preg_match_all( '/@require-extension-[A-Za-z_]*/', $contents, $matches ) ) {
+ $extension_tags = array_merge( $extension_tags, $matches[0] );
+ }
+ }
+ $extension_tags = array_unique( $extension_tags );
+ }
$skip_tags = array();
@@ -215,7 +230,75 @@ function extension_tags( $features_folder = 'features' ) {
return $skip_tags;
}
+/**
+ * An array of tags for excluding tests based on the operating system.
+ *
+ * @param string $features_folder The folder where the feature files are located.
+ * @return array
+ */
+function os_tags( $features_folder = 'features' ) {
+ $os_tags = array();
+ $feature_files = glob( $features_folder . DIRECTORY_SEPARATOR . '*.feature' );
+ if ( ! empty( $feature_files ) ) {
+ foreach ( $feature_files as $feature_file ) {
+ $contents = (string) file_get_contents( $feature_file );
+ if ( preg_match_all( '/@(require-(windows|macos|linux)|skip-(windows|macos|linux))/', $contents, $matches ) ) {
+ $os_tags = array_merge( $os_tags, $matches[0] );
+ }
+ }
+ $os_tags = array_unique( $os_tags );
+ }
+
+ if ( empty( $os_tags ) ) {
+ return array();
+ }
+
+ $skip_tags = array();
+
+ $is_windows = 'Windows' === PHP_OS_FAMILY;
+ $is_macos = 'Darwin' === PHP_OS_FAMILY;
+ $is_linux = 'Linux' === PHP_OS_FAMILY;
+
+ foreach ( $os_tags as $tag ) {
+ switch ( $tag ) {
+ case '@require-windows':
+ if ( ! $is_windows ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ case '@require-macos':
+ if ( ! $is_macos ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ case '@require-linux':
+ if ( ! $is_linux ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ case '@skip-windows':
+ if ( $is_windows ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ case '@skip-macos':
+ if ( $is_macos ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ case '@skip-linux':
+ if ( $is_linux ) {
+ $skip_tags[] = $tag;
+ }
+ break;
+ }
+ }
+
+ return $skip_tags;
+}
+
$skip_tags = array_merge( $skip_tags, extension_tags( $features_folder ) );
+$skip_tags = array_merge( $skip_tags, os_tags( $features_folder ) );
if ( ! empty( $skip_tags ) ) {
echo '--tags=~' . implode( '&&~', $skip_tags );