From 46ce957f6f92fea20c6e1c11a6db56d9745a079d Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 30 Oct 2026 20:35:29 +0100 Subject: [PATCH 1/3] Reuse plugins and themes downloaded from WordPress.org across scenarios Co-Authored-By: Claude Opus 5.5 --- composer.json | 3 + features/behat-steps.feature | 65 +++++++++++++++++ src/Context/FeatureContext.php | 103 +++++++++++++++++++++++++++ src/Context/GivenStepDefinitions.php | 30 ++++++-- 4 files changed, 194 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 2061c6ec..d34ebdf6 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,9 @@ "wp-coding-standards/wpcs": "dev-develop", "yoast/phpunit-polyfills": "^2.0 || ^3.0 || ^4.0" }, + "require-dev": { + "wp-cli/extension-command": "^1 || ^2 || ^3" + }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, diff --git a/features/behat-steps.feature b/features/behat-steps.feature index 1620e011..16d3d8ad 100644 --- a/features/behat-steps.feature +++ b/features/behat-steps.feature @@ -468,6 +468,71 @@ Feature: Test that WP-CLI Behat steps work as expected When I launch in the background `wp eval 'sleep(2); echo "done";'` # Background process should not block, continuing immediately + @require-wp + Scenario: Install plugins and themes from WordPress.org + Given a WP installation + And these installed themes: + """ + twentysixteen + """ + And these installed and active plugins: + """ + wordpress-importer --version=0.8 + """ + Then the wp-content/themes/twentysixteen/style.css file should exist + + When I run `wp plugin get wordpress-importer --fields=status,version --format=csv` + Then STDOUT should contain: + """ + status,active + version,0.8 + """ + + @require-wp + Scenario: Install cached plugins and themes without requests to WordPress.org + Given a WP installation + And these installed themes: + """ + twentysixteen + """ + And these installed plugins: + """ + wordpress-importer --version=0.8 + """ + And a non-existent wp-content/themes/twentysixteen directory + And a non-existent wp-content/plugins/wordpress-importer directory + And a wp-content/mu-plugins/block-http-requests.php file: + """ + $lines Slugs, optionally followed by `--version=` and `--force`, or anything `wp plugin install` accepts. + * @param bool $activate Whether to activate them. + */ + public function install_extensions( $type, array $lines, $activate ): void { + $target_dir = $this->get_extension_dir( $type ); + $slugs = []; + $uncached = []; + $others = []; + + foreach ( $lines as $line ) { + if ( ! preg_match( '/^([a-z0-9][a-z0-9._-]*)(?:\s+--version=(\S+))?(\s+--force)?$/i', $line, $matches ) || null === $target_dir ) { + $others[] = $line; + continue; + } + + $slug = $matches[1]; + $flags = ( isset( $matches[2] ) && '' !== $matches[2] ? ' --version=' . escapeshellarg( $matches[2] ) : '' ) . ( empty( $matches[3] ) ? '' : ' --force' ); + $slugs[] = $slug; + + $cache_path = self::get_extension_cache_path( $type, $slug, $flags ); + if ( is_dir( $cache_path ) ) { + self::remove_dir( "$target_dir/$slug" ); + mkdir( "$target_dir/$slug" ); + self::copy_dir( $cache_path, "$target_dir/$slug" ); + } else { + $uncached[ $flags ][] = $slug; + } + } + + foreach ( $uncached as $flags => $uncached_slugs ) { + $this->proc( "wp $type install " . implode( ' ', array_map( 'escapeshellarg', $uncached_slugs ) ) . $flags )->run_check(); + + foreach ( $uncached_slugs as $slug ) { + if ( is_dir( "$target_dir/$slug" ) ) { + self::store_in_extension_cache( "$target_dir/$slug", self::get_extension_cache_path( $type, $slug, (string) $flags ) ); + } + } + } + + if ( $others ) { + $this->proc( "wp $type install " . implode( ' ', $others ) . ( $activate ? ' --activate' : '' ) )->run_check(); + } + + if ( $activate && $slugs ) { + $this->proc( "wp $type activate " . implode( ' ', array_map( 'escapeshellarg', $slugs ) ) )->run_check(); + } + } + + /** + * Get the directory plugins or themes are installed into, or null if it is not known. + * + * @param string $type Either 'plugin' or 'theme'. + */ + private function get_extension_dir( $type ): ?string { + if ( ! isset( $this->variables['RUN_DIR'] ) ) { + return null; + } + + $run_dir = $this->variables['RUN_DIR']; + $candidates = 'plugin' === $type + ? [ "$run_dir/my-plugins", "$run_dir/wp-content/plugins", "$run_dir/WordPress/wp-content/plugins" ] + : [ "$run_dir/my-content/themes", "$run_dir/wp-content/themes", "$run_dir/WordPress/wp-content/themes" ]; + + foreach ( $candidates as $candidate ) { + if ( is_dir( $candidate ) ) { + return $candidate; + } + } + + return null; + } + + /** + * @param string $type + * @param string $slug + * @param string $flags Install flags that change what gets installed. + */ + private static function get_extension_cache_path( $type, $slug, $flags ): string { + $key = implode( ':', [ $type, $slug, $flags, (string) getenv( 'WP_VERSION' ), PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION ] ); + + return sys_get_temp_dir() . '/wp-cli-test-extension-cache/' . $type . '-' . $slug . '-' . substr( md5( $key ), 0, 12 ); + } + + /** + * @param string $source_dir + * @param string $cache_path + */ + private static function store_in_extension_cache( $source_dir, $cache_path ): void { + $temp_path = $cache_path . '-' . uniqid( '', true ); + mkdir( $temp_path, 0777, true ); + + self::copy_dir( $source_dir, $temp_path ); + + // Another process may have filled the cache in the meantime. + if ( is_dir( $cache_path ) || ! rename( $temp_path, $cache_path ) ) { + self::remove_dir( $temp_path ); + } + } + /** * Create a wp-config.php file. * diff --git a/src/Context/GivenStepDefinitions.php b/src/Context/GivenStepDefinitions.php index 03a87493..21d51e09 100644 --- a/src/Context/GivenStepDefinitions.php +++ b/src/Context/GivenStepDefinitions.php @@ -405,7 +405,12 @@ public function given_a_wp_multisite_installation( $type = 'subdirectory' ): voi } /** - * Installs and activates one or more plugins. + * Installs and optionally activates one or more plugins or themes. + * + * Plugins and themes from WordPress.org are downloaded once and copied from a local cache + * afterwards. Each line holds a slug, optionally followed by `--version=` and `--force`. Other + * lines, like a URL or a path to a ZIP file, are installed with `wp plugin install` or + * `wp theme install` every time. * * ``` * Scenario: My example scenario @@ -415,19 +420,30 @@ public function given_a_wp_multisite_installation( $type = 'subdirectory' ): voi * akismet * wordpress-importer * """ + * + * Scenario: My other scenario + * Given a WP installation + * And these installed themes: + * """ + * twentysixteen + * twentyseventeen --version=1.0 + * """ * ``` * * @access public * - * @Given these installed and active plugins: + * @Given /^these installed (and active )?(plugins|themes):$/ * - * @param string $stream + * @param string $active + * @param string $type + * @param PyStringNode $stream */ - public function given_these_installed_and_active_plugins( $stream ): void { - $plugins = implode( ' ', array_map( 'trim', explode( PHP_EOL, (string) $stream ) ) ); - $plugins = $this->replace_variables( $plugins ); + public function given_these_installed_plugins_or_themes( $active, $type, PyStringNode $stream ): void { + $lines = array_filter( array_map( 'trim', explode( "\n", $this->replace_variables( (string) $stream ) ) ) ); + $type = 'plugins' === $type ? 'plugin' : 'theme'; + $activate = '' !== trim( $active ); - $this->proc( "wp plugin install $plugins --activate" )->run_check(); + $this->install_extensions( $type, $lines, $activate ); } /** From 2f16c14679ec09b40438e0b5c9e4a3f1f531c5b3 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 30 Oct 2026 21:31:35 +0100 Subject: [PATCH 2/3] Install the parent of a cached child theme Co-Authored-By: Claude Opus 5.5 --- features/behat-steps.feature | 11 ++++++++++- src/Context/FeatureContext.php | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/features/behat-steps.feature b/features/behat-steps.feature index 16d3d8ad..09856fc0 100644 --- a/features/behat-steps.feature +++ b/features/behat-steps.feature @@ -494,12 +494,15 @@ Feature: Test that WP-CLI Behat steps work as expected And these installed themes: """ twentysixteen + oceanly-green """ And these installed plugins: """ wordpress-importer --version=0.8 """ And a non-existent wp-content/themes/twentysixteen directory + And a non-existent wp-content/themes/oceanly-green directory + And a non-existent wp-content/themes/oceanly directory And a non-existent wp-content/plugins/wordpress-importer directory And a wp-content/mu-plugins/block-http-requests.php file: """ @@ -512,7 +515,11 @@ Feature: Test that WP-CLI Behat steps work as expected ); """ - When these installed and active themes: + When these installed themes: + """ + oceanly-green + """ + And these installed and active themes: """ twentysixteen """ @@ -532,6 +539,8 @@ Feature: Test that WP-CLI Behat steps work as expected status,active version,0.8 """ + And the wp-content/themes/oceanly-green/style.css file should exist + And the wp-content/themes/oceanly/style.css file should exist @require-wp Scenario: Test custom wp-content directory diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index ef39375c..14785b3b 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -1899,6 +1899,12 @@ public function install_extensions( $type, array $lines, $activate ): void { self::remove_dir( "$target_dir/$slug" ); mkdir( "$target_dir/$slug" ); self::copy_dir( $cache_path, "$target_dir/$slug" ); + + // Installing a child theme also installs its parent. + $parent = 'theme' === $type ? self::get_parent_theme( "$target_dir/$slug" ) : ''; + if ( '' !== $parent && ! is_dir( "$target_dir/$parent" ) ) { + $this->install_extensions( 'theme', [ $parent ], false ); + } } else { $uncached[ $flags ][] = $slug; } @@ -1911,6 +1917,11 @@ public function install_extensions( $type, array $lines, $activate ): void { if ( is_dir( "$target_dir/$slug" ) ) { self::store_in_extension_cache( "$target_dir/$slug", self::get_extension_cache_path( $type, $slug, (string) $flags ) ); } + + $parent = 'theme' === $type && is_dir( "$target_dir/$slug" ) ? self::get_parent_theme( "$target_dir/$slug" ) : ''; + if ( '' !== $parent && is_dir( "$target_dir/$parent" ) && ! is_dir( self::get_extension_cache_path( 'theme', $parent, '' ) ) ) { + self::store_in_extension_cache( "$target_dir/$parent", self::get_extension_cache_path( 'theme', $parent, '' ) ); + } } } @@ -1947,6 +1958,22 @@ private function get_extension_dir( $type ): ?string { return null; } + /** + * Get the parent of a theme from the `Template` header of its stylesheet, or '' if it has none. + * + * @param string $theme_dir + */ + private static function get_parent_theme( $theme_dir ): string { + $stylesheet = "$theme_dir/style.css"; + if ( ! is_file( $stylesheet ) ) { + return ''; + } + + $header = (string) file_get_contents( $stylesheet, false, null, 0, 8192 ); + + return preg_match( '/^[ \t\/*#@]*Template:(.*)$/mi', $header, $matches ) ? trim( $matches[1] ) : ''; + } + /** * @param string $type * @param string $slug From e013cfa02505717c973fc059572dbf04f404219e Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 31 Oct 2026 03:45:17 +0100 Subject: [PATCH 3/3] Run the checks for the cached child theme fix Co-Authored-By: Claude Opus 5.5