Skip to content

Commit cc7e495

Browse files
authored
Add support for installing WordPress from an arbitrary ZIP archive (#351)
1 parent 9c4782b commit cc7e495

5 files changed

Lines changed: 574 additions & 5 deletions

File tree

.readme-partials/USING.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ Here's how to run your tests against the latest trunk version of WordPress:
119119
WP_VERSION=trunk composer behat
120120
```
121121
122+
#### WordPress Archive
123+
124+
Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary
125+
WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either
126+
a path to a local archive or an HTTP(S) URL.
127+
128+
This is useful to test against a WordPress build that has not been released, such as the ZIP file
129+
produced by the WordPress core build process.
130+
131+
```bash
132+
WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat
133+
```
134+
135+
The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/`
136+
(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts)
137+
work. Archives are extracted once and then cached, keyed by their contents.
138+
139+
`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`)
140+
are filtered out, since the version of a development build cannot be compared meaningfully. It
141+
defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the
142+
archive holds a specific release:
143+
144+
```bash
145+
WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat
146+
```
147+
148+
Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep
149+
downloading that version from WordPress.org and ignore the archive.
150+
122151
#### WP-CLI Binary
123152
124153
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.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,35 @@ Here's how to run your tests against the latest trunk version of WordPress:
130130
WP_VERSION=trunk composer behat
131131
```
132132
133+
#### WordPress Archive
134+
135+
Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary
136+
WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either
137+
a path to a local archive or an HTTP(S) URL.
138+
139+
This is useful to test against a WordPress build that has not been released, such as the ZIP file
140+
produced by the WordPress core build process.
141+
142+
```bash
143+
WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat
144+
```
145+
146+
The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/`
147+
(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts)
148+
work. Archives are extracted once and then cached, keyed by their contents.
149+
150+
`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`)
151+
are filtered out, since the version of a development build cannot be compared meaningfully. It
152+
defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the
153+
archive holds a specific release:
154+
155+
```bash
156+
WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat
157+
```
158+
159+
Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep
160+
downloading that version from WordPress.org and ignore the archive.
161+
133162
#### WP-CLI Binary
134163
135164
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.

bin/run-behat-tests

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ else
8989
fi
9090
fi
9191

92+
# When installing from an arbitrary archive, there is no version to resolve against
93+
# WordPress.org. Default to "trunk" so that no @require-wp tags are filtered out, as
94+
# such an archive is usually a development build. Set WP_VERSION explicitly alongside
95+
# WP_CLI_TEST_CORE_ZIP when the archive holds a specific release.
96+
if [ -n "${WP_CLI_TEST_CORE_ZIP-}" ] && [ -z "${WP_VERSION-}" ]; then
97+
export WP_VERSION=trunk
98+
fi
99+
92100
# Turn WP_VERSION into an actual number to make sure our tags work correctly.
93101
if [ "${WP_VERSION-latest}" = "latest" ]; then
94102
export WP_VERSION=$(curl -s https://api.wordpress.org/core/version-check/1.7/ | jq -r ".offers[0].current")

src/Context/FeatureContext.php

Lines changed: 235 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use SebastianBergmann\Environment\Runtime;
2222
use RuntimeException;
2323
use DirectoryIterator;
24+
use WP_CLI\Extractor;
2425
use WP_CLI\Process;
2526
use WP_CLI\ProcessRun;
2627
use WP_CLI\Utils;
@@ -71,6 +72,22 @@ class FeatureContext implements Context {
7172
*/
7273
private static $cache_dir;
7374

75+
/**
76+
* Path to the local WordPress ZIP archive configured via WP_CLI_TEST_CORE_ZIP. Resolved once per suite.
77+
* Null while unresolved, false when no archive is configured.
78+
*
79+
* @var string|false|null
80+
*/
81+
private static $core_zip = null;
82+
83+
/**
84+
* The raw WP_CLI_TEST_CORE_ZIP value that self::$core_zip was resolved from, so that a
85+
* change of the environment variable is picked up instead of served from the memoized value.
86+
*
87+
* @var ?string
88+
*/
89+
private static $core_zip_source = null;
90+
7491
/**
7592
* 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.
7693
*
@@ -681,16 +698,225 @@ private static function configure_sqlite( $dir ): void {
681698
file_put_contents( $db_dropin, $file_contents );
682699
}
683700

701+
/**
702+
* Resolve the WordPress archive to install from, as configured through the
703+
* `WP_CLI_TEST_CORE_ZIP` environment variable.
704+
*
705+
* The variable accepts either a path to a local ZIP file or an HTTP(S) URL.
706+
* Remote archives are downloaded once per suite run.
707+
*
708+
* @return ?string Path to a local ZIP file, or null if the variable is not set.
709+
*/
710+
private static function get_core_zip(): ?string {
711+
$source = getenv( 'WP_CLI_TEST_CORE_ZIP' );
712+
$source = false === $source ? '' : $source;
713+
$resolved = self::$core_zip;
714+
715+
if ( null !== $resolved && self::$core_zip_source === $source ) {
716+
return false === $resolved ? null : $resolved;
717+
}
718+
719+
self::$core_zip_source = $source;
720+
721+
$core_zip = $source;
722+
723+
if ( '' === $core_zip ) {
724+
self::$core_zip = false;
725+
return null;
726+
}
727+
728+
if ( preg_match( '#^https?://#i', $core_zip ) ) {
729+
$core_zip = self::download_core_zip( $core_zip );
730+
}
731+
732+
if ( ! is_file( $core_zip ) || ! is_readable( $core_zip ) ) {
733+
throw new RuntimeException( "Could not read the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" );
734+
}
735+
736+
$realpath = realpath( $core_zip );
737+
$resolved = false !== $realpath ? $realpath : $core_zip;
738+
739+
self::$core_zip = $resolved;
740+
741+
return $resolved;
742+
}
743+
744+
/**
745+
* Download a remote WordPress archive to a local file.
746+
*
747+
* @param string $url
748+
* @return string Path to the downloaded file.
749+
*/
750+
private static function download_core_zip( $url ): string {
751+
$download_location = sys_get_temp_dir() . '/wp-cli-test-core-zip-' . substr( md5( $url ), 0, 12 ) . '.zip';
752+
753+
$response = Utils\http_request(
754+
'GET',
755+
$url,
756+
null,
757+
[],
758+
[
759+
'filename' => $download_location,
760+
'timeout' => 600,
761+
]
762+
);
763+
764+
if ( 200 !== $response->status_code ) {
765+
throw new RuntimeException( "Could not download WordPress archive from {$url} (HTTP code {$response->status_code})" );
766+
}
767+
768+
return $download_location;
769+
}
770+
771+
/**
772+
* Get the directory that a given WordPress version is cached in.
773+
*
774+
* Without an explicit version, this is derived from the contents of the archive configured
775+
* through `WP_CLI_TEST_CORE_ZIP`, if any, and from `WP_VERSION` otherwise.
776+
*
777+
* @param string $version
778+
* @return string
779+
*/
780+
public static function get_core_cache_dir( $version = '' ): string {
781+
// An explicit version always takes precedence over a configured archive.
782+
$core_zip = $version ? null : self::get_core_zip();
783+
784+
if ( $core_zip ) {
785+
$hash = md5_file( $core_zip );
786+
787+
if ( false === $hash ) {
788+
throw new RuntimeException( "Could not hash the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" );
789+
}
790+
791+
return sys_get_temp_dir() . '/wp-cli-test-core-download-cache-zip-' . substr( $hash, 0, 12 );
792+
}
793+
794+
$wp_version = $version ?: getenv( 'WP_VERSION' );
795+
796+
return sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . ( $wp_version ? "-$wp_version" : '' );
797+
}
798+
799+
/**
800+
* Extract a WordPress ZIP archive into a destination directory.
801+
*
802+
* Supports archives that wrap WordPress in a single top-level directory --
803+
* `wordpress/` for wordpress.org releases, `build/` for some WordPress core
804+
* build artifacts -- as well as archives that contain WordPress at the root.
805+
*
806+
* @param string $zip_file
807+
* @param string $dest_dir
808+
*/
809+
public static function extract_wp_zip( $zip_file, $dest_dir ): void {
810+
$temp_dir = sys_get_temp_dir() . '/wp-cli-test-core-zip-extract-' . uniqid( '', true );
811+
812+
$zip = new \ZipArchive();
813+
$opened = $zip->open( $zip_file );
814+
815+
if ( true !== $opened ) {
816+
// Note that ZipArchive::getStatusString() cannot be used to describe this failure,
817+
// as it errors out on an archive that failed to open on PHP < 8.0.
818+
throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, Extractor::zip_error_msg( (int) $opened ) ) );
819+
}
820+
821+
try {
822+
self::validate_zip_entries( $zip, $zip_file );
823+
824+
if ( ! $zip->extractTo( $temp_dir ) ) {
825+
throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $zip->getStatusString() ) );
826+
}
827+
} finally {
828+
$zip->close();
829+
}
830+
831+
try {
832+
$source_dir = self::find_wp_root( $temp_dir );
833+
834+
if ( null === $source_dir ) {
835+
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." );
836+
}
837+
838+
self::remove_dir( $dest_dir );
839+
840+
// Both directories live in the system temp folder, so a rename is
841+
// normally possible and avoids copying thousands of files.
842+
if ( ! @rename( $source_dir, $dest_dir ) ) {
843+
// copy_dir() copies into an existing directory, so create it first.
844+
if ( ! is_dir( $dest_dir ) && ! mkdir( $dest_dir, 0777, true ) && ! is_dir( $dest_dir ) ) {
845+
throw new RuntimeException( "Could not create the WordPress destination directory: {$dest_dir}" );
846+
}
847+
848+
self::copy_dir( $source_dir, $dest_dir );
849+
}
850+
} finally {
851+
self::remove_dir( $temp_dir );
852+
}
853+
}
854+
855+
/**
856+
* Reject archives holding entries that point outside of the directory they are extracted into.
857+
*
858+
* ZipArchive::extractTo() normalizes such entries rather than following them, but an archive
859+
* containing them is malformed for our purposes on any PHP version.
860+
*
861+
* @param \ZipArchive $zip
862+
* @param string $zip_file
863+
*/
864+
private static function validate_zip_entries( \ZipArchive $zip, $zip_file ): void {
865+
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the PHP ZipArchive class.
866+
$num_files = $zip->numFiles;
867+
868+
for ( $i = 0; $i < $num_files; $i++ ) {
869+
$name = $zip->getNameIndex( $i );
870+
871+
if ( false === $name ) {
872+
continue;
873+
}
874+
875+
$segments = explode( '/', str_replace( '\\', '/', $name ) );
876+
877+
// An empty first segment means the entry is an absolute path.
878+
if ( in_array( '..', $segments, true ) || '' === $segments[0] || preg_match( '#^[a-zA-Z]:$#', $segments[0] ) ) {
879+
throw new RuntimeException( "The archive {$zip_file} contains an entry that would be extracted outside of its destination: {$name}" );
880+
}
881+
}
882+
}
883+
884+
/**
885+
* Find the WordPress root within an extracted archive.
886+
*
887+
* @param string $dir
888+
* @return ?string The directory holding wp-includes/version.php, or null if there is none.
889+
*/
890+
private static function find_wp_root( $dir ): ?string {
891+
if ( is_readable( $dir . '/wp-includes/version.php' ) ) {
892+
return $dir;
893+
}
894+
895+
foreach ( new DirectoryIterator( $dir ) as $item ) {
896+
if ( ! $item->isDir() || $item->isDot() ) {
897+
continue;
898+
}
899+
900+
$candidate = $item->getPathname();
901+
902+
if ( is_readable( $candidate . '/wp-includes/version.php' ) ) {
903+
return $candidate;
904+
}
905+
}
906+
907+
return null;
908+
}
909+
684910
/**
685911
* We cache the results of `wp core download` to improve test performance.
686912
* Ideally, we'd cache at the HTTP layer for more reliable tests.
687913
*
688914
* @param string $version
689915
*/
690916
private static function cache_wp_files( $version = '' ): void {
917+
$core_zip = $version ? null : self::get_core_zip();
691918
$wp_version = $version ?: getenv( 'WP_VERSION' );
692-
$wp_version_suffix = $wp_version ? "-$wp_version" : '';
693-
$cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix;
919+
$cache_dir = self::get_core_cache_dir( $version );
694920
self::$sqlite_cache_dir = sys_get_temp_dir() . '/wp-cli-test-sqlite-integration-cache';
695921

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

940+
if ( $core_zip ) {
941+
self::extract_wp_zip( $core_zip, $cache_dir );
942+
self::$cache_dir = $cache_dir;
943+
return;
944+
}
945+
714946
$cmd = Utils\esc_cmd( 'wp core download --force --path=%s', $cache_dir );
715947
if ( $wp_version ) {
716948
$cmd .= Utils\esc_cmd( ' --version=%s', $wp_version );
@@ -1586,9 +1818,7 @@ public function add_line_to_wp_config( &$wp_config_code, $line ): void {
15861818
* @param string $version
15871819
*/
15881820
public function download_wp( $subdir = '', $version = '' ): void {
1589-
$wp_version = $version ?: getenv( 'WP_VERSION' );
1590-
$wp_version_suffix = $wp_version ? "-$wp_version" : '';
1591-
$expected_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix;
1821+
$expected_cache_dir = self::get_core_cache_dir( $version );
15921822

15931823
if ( ! self::$cache_dir || self::$cache_dir !== $expected_cache_dir ) {
15941824
self::cache_wp_files( $version );

0 commit comments

Comments
 (0)