From 5a9164532740cce892b6c96e78b0745810fb6b91 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 18 Aug 2026 11:52:36 -0700 Subject: [PATCH 1/4] Prevent plugin installs from clearing unrelated plugins --- .../Steps/class-installpluginstep.php | 47 +------------------ .../Unit/Steps/InstallPluginStepTest.php | 29 ++++++++++++ 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/components/Blueprints/Steps/class-installpluginstep.php b/components/Blueprints/Steps/class-installpluginstep.php index 6f9113c56..ee9b0f3c5 100644 --- a/components/Blueprints/Steps/class-installpluginstep.php +++ b/components/Blueprints/Steps/class-installpluginstep.php @@ -215,34 +215,6 @@ public function after( $title = '' ) { exit( 1 ); } -// List files from the plugin zip -$zip = new ZipArchive(); -if ( $zip->open( $plugin_zip_path ) !== true ) { - fwrite( STDERR, "Failed to open plugin zip file: " . $plugin_zip_path . "\n" ); - exit( 1 ); -} - -fwrite( STDERR, "Plugin zip contents:" . "\n" ); -for ( $i = 0; $i < $zip->numFiles; $i ++ ) { - $filename = $zip->getNameIndex( $i ); - $stats = $zip->statIndex( $i ); - $size = $stats['size']; - $is_dir = substr( $filename, - 1 ) === '/'; -} - -// Extract plugin slug from the zip file -$plugin_slug = ''; -// Check the first directory in the zip file -if ( $zip->numFiles > 0 ) { - $first_entry = $zip->getNameIndex( 0 ); - // Most plugin zips have a top-level directory that is the plugin slug - if ( strpos( $first_entry, '/' ) !== false ) { - $plugin_slug = explode( '/', $first_entry )[0]; - } -} - -$zip->close(); - // Make sure the destination directory is writable $wp_plugin_dir = WP_PLUGIN_DIR; if ( ! is_writable( $wp_plugin_dir ) ) { @@ -258,26 +230,9 @@ public function after( $title = '' ) { $skin = new Blueprint_WP_Upgrader_Skin(); $upgrader = new Plugin_Upgrader( $skin ); -// If we have a plugin slug from the zip, create the target directory first -$target_directory = null; -if ( ! empty( $plugin_slug ) ) { - $target_directory = WP_PLUGIN_DIR . '/' . $plugin_slug; - - // Remove existing directory if it exists - if ( is_dir( $target_directory ) ) { - $GLOBALS['wp_filesystem']->delete( $target_directory, true ); - } - - // Create the directory - $GLOBALS['wp_filesystem']->mkdir( $target_directory ); - - fwrite( STDERR, "Created target directory: " . $target_directory . "\n" ); -} - // Install the plugin $result = $upgrader->install( $plugin_zip_path, array( 'overwrite_package' => true, - 'destination' => $target_directory, ) ); // Check for filesystem errors @@ -304,7 +259,7 @@ public function after( $title = '' ) { } // Installation successful, find the main plugin file. -$plugin_folder_name = ! empty( $plugin_slug ) ? $plugin_slug : ( $upgrader->result['destination_name'] ?? null ); +$plugin_folder_name = $upgrader->result['destination_name'] ?? null; if ( ! $plugin_folder_name ) { fwrite( STDERR, "Could not determine plugin folder name after installation." . "\n" ); exit( 1 ); diff --git a/components/Blueprints/Tests/Unit/Steps/InstallPluginStepTest.php b/components/Blueprints/Tests/Unit/Steps/InstallPluginStepTest.php index 2fb2a759a..7b7df7053 100644 --- a/components/Blueprints/Tests/Unit/Steps/InstallPluginStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/InstallPluginStepTest.php @@ -198,6 +198,35 @@ public function testInstallPluginFromZipWithSubfolder() { $this->assertContains( 'subfolder-name/test-plugin.php', $active_plugins ); } + public function testInstallPluginFromZipWithDotRootPreservesExistingPlugins() { + $target_filesystem = $this->runtime->get_target_filesystem(); + $target_filesystem->put_contents( + 'wp-content/plugins/existing-plugin.php', + self::PLUGIN_FILE_CONTENT + ); + + $zip_file = wp_join_unix_paths( $this->execution_context_path, 'dot-root-plugin.zip' ); + $zip = new ZipArchive(); + if ( $zip->open( $zip_file, ZipArchive::CREATE ) === true ) { + $zip->addEmptyDir( './' ); + $zip->addFromString( './test-plugin.php', self::PLUGIN_FILE_CONTENT ); + $zip->close(); + } + + $step = new InstallPluginStep( + DataReference::create( './dot-root-plugin.zip', [ + ExecutionContextPath::class + ] ), + false + ); + + $tracker = new Tracker(); + $step->run( $this->runtime, $tracker ); + + $this->assertTrue( $target_filesystem->exists( 'wp-content/plugins/existing-plugin.php' ) ); + $this->assertTrue( $target_filesystem->exists( 'wp-content/plugins/dot-root-plugin/test-plugin.php' ) ); + } + public function testInstallPluginFromADirectory() { $this->execution_context->mkdir( 'plugin-directory', [ 'recursive' => true ] From cf42e0e099c05d4ff5bce55cfd3a56f65d5d4687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 1 Sep 2026 17:55:37 +0200 Subject: [PATCH 2/4] Prevent theme installs from clearing unrelated themes --- .../Steps/class-installthemestep.php | 32 +---------------- .../Tests/Unit/Steps/InstallThemeStepTest.php | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/components/Blueprints/Steps/class-installthemestep.php b/components/Blueprints/Steps/class-installthemestep.php index ee9bd305e..6050c39e3 100644 --- a/components/Blueprints/Steps/class-installthemestep.php +++ b/components/Blueprints/Steps/class-installthemestep.php @@ -160,40 +160,10 @@ function show_message( $message ) { } } -// Extract theme slug from the zip file -$theme_slug = ''; -$zip = new ZipArchive(); -if ( $zip->open( $theme_zip_path ) === true ) { - // Check the first directory in the zip file - if ( $zip->numFiles > 0 ) { - $first_entry = $zip->getNameIndex( 0 ); - // Most theme zips have a top-level directory that is the theme slug - if ( strpos( $first_entry, '/' ) !== false ) { - $theme_slug = explode( '/', $first_entry )[0]; - } - } - $zip->close(); -} - -// Target directory for the theme -$target_directory = null; -if ( ! empty( $theme_slug ) ) { - $target_directory = $wp_theme_dir . '/' . $theme_slug; - - // Remove existing directory if it exists - if ( is_dir( $target_directory ) ) { - $GLOBALS['wp_filesystem']->delete( $target_directory, true ); - } - - // Create the directory - $GLOBALS['wp_filesystem']->mkdir( $target_directory ); -} - // Use the Theme_Upgrader class to install the theme $upgrader = new Theme_Upgrader(); $result = $upgrader->install( $theme_zip_path, array( 'overwrite_package' => true, - 'destination' => $target_directory, ) ); // Check for filesystem errors @@ -217,7 +187,7 @@ function show_message( $message ) { } // Installation successful, get the theme folder name (stylesheet) from the result array -$theme_folder_name = ! empty( $theme_slug ) ? $theme_slug : ( $upgrader->result['destination_name'] ?? null ); +$theme_folder_name = $upgrader->result['destination_name'] ?? null; if ( ! $theme_folder_name ) { error_log( "Blueprint Error: Could not determine theme folder name after installation." ); exit( 1 ); diff --git a/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php b/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php index 742350df1..4404b45ea 100644 --- a/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php @@ -159,4 +159,38 @@ public function testInstallThemeFromZip() { $this->assertEquals( 'test-theme', trim( $active_theme ) ); } + + public function testInstallThemeFromZipWithDotRootPreservesExistingThemes() { + $target_filesystem = $this->runtime->get_target_filesystem(); + $target_filesystem->mkdir( + 'wp-content/themes/existing-theme', [ 'recursive' => true ] + ); + $target_filesystem->put_contents( + 'wp-content/themes/existing-theme/style.css', + self::THEME_STYLE_CSS_CONTENT + ); + + $zip_file = wp_join_unix_paths( $this->execution_context_path, 'dot-root-theme.zip' ); + $zip = new ZipArchive(); + if ( $zip->open( $zip_file, ZipArchive::CREATE ) === true ) { + $zip->addEmptyDir( './' ); + $zip->addFromString( './style.css', self::THEME_STYLE_CSS_CONTENT ); + $zip->addFromString( './index.php', self::THEME_INDEX_PHP_CONTENT ); + $zip->close(); + } + + $step = new InstallThemeStep( + DataReference::create( './dot-root-theme.zip', [ + ExecutionContextPath::class + ] ), + false + ); + + $tracker = new Tracker(); + $step->run( $this->runtime, $tracker ); + + $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/existing-theme/style.css' ) ); + $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/dot-root-theme/style.css' ) ); + $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/dot-root-theme/index.php' ) ); + } } From b6e812e53c1b057412cffdf263b622e48313cabc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 1 Sep 2026 18:15:24 +0200 Subject: [PATCH 3/4] Keep the theme regression within the ZIP install test --- .../Tests/Unit/Steps/InstallThemeStepTest.php | 50 +++++-------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php b/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php index 4404b45ea..fc9c7469f 100644 --- a/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/InstallThemeStepTest.php @@ -124,42 +124,6 @@ public function testInstallThemeWithoutActivation() { $this->assertNotEquals( 'test-theme', trim( $active_theme ) ); } - public function testInstallThemeFromZip() { - $zip_file = wp_join_unix_paths( $this->execution_context_path, 'zipped-test-theme.zip' ); - $zip = new ZipArchive(); - if ( $zip->open( $zip_file, ZipArchive::CREATE ) === true ) { - $zip->addFromString( 'test-theme/style.css', self::THEME_STYLE_CSS_CONTENT ); - $zip->addFromString( 'test-theme/index.php', self::THEME_INDEX_PHP_CONTENT ); - $zip->close(); - } - - $step = new InstallThemeStep( - DataReference::create( './zipped-test-theme.zip', [ - ExecutionContextPath::class - ] ), - true - ); - - $tracker = new Tracker(); - $step->run( $this->runtime, $tracker ); - - $fs = $this->runtime->get_target_filesystem(); - $this->assertTrue( $fs->exists( 'wp-content/themes/test-theme' ) ); - $this->assertTrue( $fs->exists( 'wp-content/themes/test-theme/style.css' ) ); - $this->assertTrue( $fs->exists( 'wp-content/themes/test-theme/index.php' ) ); - - $active_theme = $this->runtime->eval_php_code_in_subprocess( - <<<'PHP' -output_file_content; - - $this->assertEquals( 'test-theme', trim( $active_theme ) ); - } - public function testInstallThemeFromZipWithDotRootPreservesExistingThemes() { $target_filesystem = $this->runtime->get_target_filesystem(); $target_filesystem->mkdir( @@ -183,14 +147,26 @@ public function testInstallThemeFromZipWithDotRootPreservesExistingThemes() { DataReference::create( './dot-root-theme.zip', [ ExecutionContextPath::class ] ), - false + true ); $tracker = new Tracker(); $step->run( $this->runtime, $tracker ); $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/existing-theme/style.css' ) ); + $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/dot-root-theme' ) ); $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/dot-root-theme/style.css' ) ); $this->assertTrue( $target_filesystem->exists( 'wp-content/themes/dot-root-theme/index.php' ) ); + + $active_theme = $this->runtime->eval_php_code_in_subprocess( + <<<'PHP' +output_file_content; + + $this->assertEquals( 'dot-root-theme', trim( $active_theme ) ); } } From cef5a9f21f8e1598d563fda55b58f9fd39900349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 1 Sep 2026 18:35:44 +0200 Subject: [PATCH 4/4] Clean copied WordPress sites during Windows tests --- components/Blueprints/Tests/Unit/Steps/StepTestCase.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/Blueprints/Tests/Unit/Steps/StepTestCase.php b/components/Blueprints/Tests/Unit/Steps/StepTestCase.php index a9d7ed97b..75822b0a3 100644 --- a/components/Blueprints/Tests/Unit/Steps/StepTestCase.php +++ b/components/Blueprints/Tests/Unit/Steps/StepTestCase.php @@ -101,10 +101,8 @@ public function setUp(): void { * @after */ public function tearDown(): void { - // Don't clean up on Windows – it adds ~20s to each test in GitHub CI! - if (PHP_OS_FAMILY === 'Windows') { - return; - } + // Cleanup is slow on Windows, but retaining every copied WordPress site + // exhausts the temporary drive during the full test suite. // Clean up temp directory if ( is_dir( $this->document_root ) ) { $this->removeDirectory( $this->document_root );