Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .readme-partials/USING.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ Here's how to run your tests against the latest trunk version of WordPress:
WP_VERSION=trunk composer behat
```

#### WordPress Archive

Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary
WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either
a path to a local archive or an HTTP(S) URL.

This is useful to test against a WordPress build that has not been released, such as the ZIP file
produced by the WordPress core build process.

```bash
WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat
```

The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/`
(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts)
work. Archives are extracted once and then cached, keyed by their contents.

`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`)
are filtered out, since the version of a development build cannot be compared meaningfully. It
defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the
archive holds a specific release:

```bash
WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat
```

Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep
downloading that version from WordPress.org and ignore the archive.

#### WP-CLI Binary

You can run the tests against a specific WP-CLI binary, instead of using the one that has been built in your project's `vendor/bin` folder.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,35 @@ Here's how to run your tests against the latest trunk version of WordPress:
WP_VERSION=trunk composer behat
```

#### WordPress Archive
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary
WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either
a path to a local archive or an HTTP(S) URL.

This is useful to test against a WordPress build that has not been released, such as the ZIP file
produced by the WordPress core build process.

```bash
WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat
```

The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/`
(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts)
work. Archives are extracted once and then cached, keyed by their contents.

`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`)
are filtered out, since the version of a development build cannot be compared meaningfully. It
defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the
archive holds a specific release:

```bash
WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat
```

Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep
downloading that version from WordPress.org and ignore the archive.

#### WP-CLI Binary

You can run the tests against a specific WP-CLI binary, instead of using the one that has been built in your project's `vendor/bin` folder.
Expand Down
8 changes: 8 additions & 0 deletions bin/run-behat-tests
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ else
fi
fi

# When installing from an arbitrary archive, there is no version to resolve against
# WordPress.org. Default to "trunk" so that no @require-wp tags are filtered out, as
# such an archive is usually a development build. Set WP_VERSION explicitly alongside
# WP_CLI_TEST_CORE_ZIP when the archive holds a specific release.
if [ -n "${WP_CLI_TEST_CORE_ZIP-}" ] && [ -z "${WP_VERSION-}" ]; then
export WP_VERSION=trunk
fi

# Turn WP_VERSION into an actual number to make sure our tags work correctly.
if [ "${WP_VERSION-latest}" = "latest" ]; then
export WP_VERSION=$(curl -s https://api.wordpress.org/core/version-check/1.7/ | jq -r ".offers[0].current")
Expand Down
240 changes: 235 additions & 5 deletions src/Context/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use SebastianBergmann\Environment\Runtime;
use RuntimeException;
use DirectoryIterator;
use WP_CLI\Extractor;
use WP_CLI\Process;
use WP_CLI\ProcessRun;
use WP_CLI\Utils;
Expand Down Expand Up @@ -71,6 +72,22 @@ class FeatureContext implements Context {
*/
private static $cache_dir;

/**
* Path to the local WordPress ZIP archive configured via WP_CLI_TEST_CORE_ZIP. Resolved once per suite.
* Null while unresolved, false when no archive is configured.
*
* @var string|false|null
*/
private static $core_zip = null;

/**
* The raw WP_CLI_TEST_CORE_ZIP value that self::$core_zip was resolved from, so that a
* change of the environment variable is picked up instead of served from the memoized value.
*
* @var ?string
*/
private static $core_zip_source = null;

/**
* The directory that holds the install cache, and which is copied to RUN_DIR during a "Given a WP installation" step. Recreated on each suite run.
*
Expand Down Expand Up @@ -681,16 +698,225 @@ private static function configure_sqlite( $dir ): void {
file_put_contents( $db_dropin, $file_contents );
}

/**
* Resolve the WordPress archive to install from, as configured through the
* `WP_CLI_TEST_CORE_ZIP` environment variable.
*
* The variable accepts either a path to a local ZIP file or an HTTP(S) URL.
* Remote archives are downloaded once per suite run.
*
* @return ?string Path to a local ZIP file, or null if the variable is not set.
*/
private static function get_core_zip(): ?string {
$source = getenv( 'WP_CLI_TEST_CORE_ZIP' );
$source = false === $source ? '' : $source;
$resolved = self::$core_zip;

if ( null !== $resolved && self::$core_zip_source === $source ) {
return false === $resolved ? null : $resolved;
}

self::$core_zip_source = $source;

$core_zip = $source;

if ( '' === $core_zip ) {
self::$core_zip = false;
return null;
}

if ( preg_match( '#^https?://#i', $core_zip ) ) {
$core_zip = self::download_core_zip( $core_zip );
}

if ( ! is_file( $core_zip ) || ! is_readable( $core_zip ) ) {
throw new RuntimeException( "Could not read the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" );
}

$realpath = realpath( $core_zip );
$resolved = false !== $realpath ? $realpath : $core_zip;

self::$core_zip = $resolved;

return $resolved;
}

/**
* Download a remote WordPress archive to a local file.
*
* @param string $url
* @return string Path to the downloaded file.
*/
private static function download_core_zip( $url ): string {
$download_location = sys_get_temp_dir() . '/wp-cli-test-core-zip-' . substr( md5( $url ), 0, 12 ) . '.zip';

$response = Utils\http_request(
'GET',
$url,
null,
[],
[
'filename' => $download_location,
'timeout' => 600,
]
);

if ( 200 !== $response->status_code ) {
throw new RuntimeException( "Could not download WordPress archive from {$url} (HTTP code {$response->status_code})" );
}

return $download_location;
}

/**
* Get the directory that a given WordPress version is cached in.
*
* Without an explicit version, this is derived from the contents of the archive configured
* through `WP_CLI_TEST_CORE_ZIP`, if any, and from `WP_VERSION` otherwise.
*
* @param string $version
* @return string
*/
public static function get_core_cache_dir( $version = '' ): string {
// An explicit version always takes precedence over a configured archive.
$core_zip = $version ? null : self::get_core_zip();

if ( $core_zip ) {
$hash = md5_file( $core_zip );

if ( false === $hash ) {
throw new RuntimeException( "Could not hash the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" );
}

return sys_get_temp_dir() . '/wp-cli-test-core-download-cache-zip-' . substr( $hash, 0, 12 );
}

$wp_version = $version ?: getenv( 'WP_VERSION' );

return sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . ( $wp_version ? "-$wp_version" : '' );
}

/**
* Extract a WordPress ZIP archive into a destination directory.
*
* Supports archives that wrap WordPress in a single top-level directory --
* `wordpress/` for wordpress.org releases, `build/` for some WordPress core
* build artifacts -- as well as archives that contain WordPress at the root.
*
* @param string $zip_file
* @param string $dest_dir
*/
public static function extract_wp_zip( $zip_file, $dest_dir ): void {
$temp_dir = sys_get_temp_dir() . '/wp-cli-test-core-zip-extract-' . uniqid( '', true );

$zip = new \ZipArchive();
$opened = $zip->open( $zip_file );

if ( true !== $opened ) {
// Note that ZipArchive::getStatusString() cannot be used to describe this failure,
// as it errors out on an archive that failed to open on PHP < 8.0.
throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, Extractor::zip_error_msg( (int) $opened ) ) );
}

try {
self::validate_zip_entries( $zip, $zip_file );

if ( ! $zip->extractTo( $temp_dir ) ) {
throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $zip->getStatusString() ) );
}
} finally {
$zip->close();
}

try {
$source_dir = self::find_wp_root( $temp_dir );

if ( null === $source_dir ) {
throw new RuntimeException( "The archive {$zip_file} does not look like a WordPress archive: no wp-includes/version.php found at its root or one level below." );
}

self::remove_dir( $dest_dir );

// Both directories live in the system temp folder, so a rename is
// normally possible and avoids copying thousands of files.
if ( ! @rename( $source_dir, $dest_dir ) ) {
// copy_dir() copies into an existing directory, so create it first.
if ( ! is_dir( $dest_dir ) && ! mkdir( $dest_dir, 0777, true ) && ! is_dir( $dest_dir ) ) {
throw new RuntimeException( "Could not create the WordPress destination directory: {$dest_dir}" );
}

self::copy_dir( $source_dir, $dest_dir );
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
self::remove_dir( $temp_dir );
}
}

/**
* Reject archives holding entries that point outside of the directory they are extracted into.
*
* ZipArchive::extractTo() normalizes such entries rather than following them, but an archive
* containing them is malformed for our purposes on any PHP version.
*
* @param \ZipArchive $zip
* @param string $zip_file
*/
private static function validate_zip_entries( \ZipArchive $zip, $zip_file ): void {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the PHP ZipArchive class.
$num_files = $zip->numFiles;

for ( $i = 0; $i < $num_files; $i++ ) {
$name = $zip->getNameIndex( $i );

if ( false === $name ) {
continue;
}

$segments = explode( '/', str_replace( '\\', '/', $name ) );

// An empty first segment means the entry is an absolute path.
if ( in_array( '..', $segments, true ) || '' === $segments[0] || preg_match( '#^[a-zA-Z]:$#', $segments[0] ) ) {
throw new RuntimeException( "The archive {$zip_file} contains an entry that would be extracted outside of its destination: {$name}" );
}
}
}

/**
* Find the WordPress root within an extracted archive.
*
* @param string $dir
* @return ?string The directory holding wp-includes/version.php, or null if there is none.
*/
private static function find_wp_root( $dir ): ?string {
if ( is_readable( $dir . '/wp-includes/version.php' ) ) {
return $dir;
}

foreach ( new DirectoryIterator( $dir ) as $item ) {
if ( ! $item->isDir() || $item->isDot() ) {
continue;
}

$candidate = $item->getPathname();

if ( is_readable( $candidate . '/wp-includes/version.php' ) ) {
return $candidate;
}
}

return null;
}

/**
* We cache the results of `wp core download` to improve test performance.
* Ideally, we'd cache at the HTTP layer for more reliable tests.
*
* @param string $version
*/
private static function cache_wp_files( $version = '' ): void {
$core_zip = $version ? null : self::get_core_zip();
$wp_version = $version ?: getenv( 'WP_VERSION' );
$wp_version_suffix = $wp_version ? "-$wp_version" : '';
$cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix;
$cache_dir = self::get_core_cache_dir( $version );
self::$sqlite_cache_dir = sys_get_temp_dir() . '/wp-cli-test-sqlite-integration-cache';

if ( 'sqlite' === getenv( 'WP_CLI_TEST_DBTYPE' ) ) {
Expand All @@ -711,6 +937,12 @@ private static function cache_wp_files( $version = '' ): void {
return;
}

if ( $core_zip ) {
self::extract_wp_zip( $core_zip, $cache_dir );
self::$cache_dir = $cache_dir;
return;
}

$cmd = Utils\esc_cmd( 'wp core download --force --path=%s', $cache_dir );
if ( $wp_version ) {
$cmd .= Utils\esc_cmd( ' --version=%s', $wp_version );
Expand Down Expand Up @@ -1586,9 +1818,7 @@ public function add_line_to_wp_config( &$wp_config_code, $line ): void {
* @param string $version
*/
public function download_wp( $subdir = '', $version = '' ): void {
$wp_version = $version ?: getenv( 'WP_VERSION' );
$wp_version_suffix = $wp_version ? "-$wp_version" : '';
$expected_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix;
$expected_cache_dir = self::get_core_cache_dir( $version );

if ( ! self::$cache_dir || self::$cache_dir !== $expected_cache_dir ) {
self::cache_wp_files( $version );
Expand Down
Loading
Loading