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
19 changes: 16 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,16 @@ jobs:
working-directory: .wp-performance-action/env

- name: Install Playwright browsers
run: npx playwright install --with-deps
timeout-minutes: 15
run: |
if ! timeout 12m npx playwright install --with-deps chromium; then
echo "Playwright browser install timed out or failed; skipping advisory performance metrics."
echo "PERF_SKIP=true" >> "$GITHUB_ENV"
fi
working-directory: .wp-performance-action/env

- name: Prepare merged blueprint
if: env.PERF_SKIP != 'true'
run: |
BLUEPRINT=$(realpath "$GITHUB_WORKSPACE/current/.github/performance-blueprint.json")
jq -s 'map(to_entries)|flatten|group_by(.key)|map({(.[0].key):map(.value)|add})|add' \
Expand All @@ -197,6 +203,7 @@ jobs:

# ── Baseline run (main branch) ─────────────────────────────────
- name: Start server with baseline plugin
if: env.PERF_SKIP != 'true'
run: |
BASELINE_PATH=$(realpath "$GITHUB_WORKSPACE/baseline")
./node_modules/@wp-playground/cli/wp-playground.js server \
Expand All @@ -209,6 +216,7 @@ jobs:
working-directory: .wp-performance-action/env

- name: Wait for baseline server
if: env.PERF_SKIP != 'true'
run: |
echo "Waiting for baseline server on port 9400..."
for i in $(seq 1 60); do
Expand All @@ -223,6 +231,7 @@ jobs:
exit 1

- name: Run baseline performance tests
if: env.PERF_SKIP != 'true'
run: |
mkdir -p "$WP_PERF_ARTIFACTS"
for attempt in 1 2 3; do
Expand Down Expand Up @@ -257,6 +266,7 @@ jobs:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "true"

- name: Stop baseline server
if: env.PERF_SKIP != 'true'
run: |
npm run stop-server 2>/dev/null || true
# Clean artifacts for the PR run
Expand All @@ -265,6 +275,7 @@ jobs:

# ── PR branch run ───────────────────────────────────────────────
- name: Start server with PR branch plugin
if: env.PERF_SKIP != 'true'
run: |
CURRENT_PATH=$(realpath "$GITHUB_WORKSPACE/current")
./node_modules/@wp-playground/cli/wp-playground.js server \
Expand All @@ -276,6 +287,7 @@ jobs:
working-directory: .wp-performance-action/env

- name: Wait for PR server
if: env.PERF_SKIP != 'true'
run: |
echo "Waiting for PR server on port 9400..."
for i in $(seq 1 60); do
Expand All @@ -290,6 +302,7 @@ jobs:
exit 1

- name: Run PR branch performance tests
if: env.PERF_SKIP != 'true'
run: |
mkdir -p "$WP_PERF_ARTIFACTS"
for attempt in 1 2 3; do
Expand Down Expand Up @@ -320,14 +333,14 @@ jobs:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "true"

- name: Stop server
if: always()
if: always() && env.PERF_SKIP != 'true'
run: npm run stop-server 2>/dev/null || true
working-directory: .wp-performance-action/env

# ── Generate comparison report ──────────────────────────────────
- name: Generate performance comparison report
id: prepare-results
if: always()
if: always() && env.PERF_SKIP != 'true'
run: |
RESULTS_JSON="$WP_PERF_ARTIFACTS/performance-results.json"
if [ ! -f "$RESULTS_JSON" ]; then
Expand Down
151 changes: 151 additions & 0 deletions inc/compat/class-general-compat.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public function init(): void {
*/
add_filter('wu_should_redirect_to_primary_domain', [$this, 'fix_divi_editor_screen']);

add_action('wu_duplicate_site', [$this, 'clear_divi_static_css_cache'], 20);

/**
* WP Hide Pro
*
Expand Down Expand Up @@ -332,6 +334,155 @@ public function fix_divi_editor_screen(bool $should_redirect): bool {
return $should_redirect;
}

/**
* Clear Divi's generated static CSS files after site duplication.
*
* Divi stores generated builder CSS outside the uploads tree under
* wp-content/et-cache/{network_id}/{blog_id}/. A cloned site can keep
* stale CSS generated before the copied Divi layout data is available,
* leaving page modules with default spacing/positioning until the cache is
* manually cleared. Removing only the cloned blog's cache directory forces
* Divi to rebuild the files on the next frontend request.
*
* @since 2.5.1
*
* @param array|int|object $site Duplicated site payload.
* @return void
*/
public function clear_divi_static_css_cache($site): void {

$blog_id = $this->get_duplicated_site_id($site);

if (2 > $blog_id || ! defined('WP_CONTENT_DIR')) {
return;
}

switch_to_blog($blog_id);

try {
foreach ($this->get_divi_static_css_cache_directories($blog_id) as $cache_dir) {
$this->delete_divi_static_css_cache_directory($cache_dir);
}
} finally {
restore_current_blog();
}
}

/**
* Extract the duplicated site ID from the wu_duplicate_site payload.
*
* @since 2.5.1
*
* @param array|int|object $site Duplicated site payload.
* @return int
*/
private function get_duplicated_site_id($site): int {

if (is_array($site) && isset($site['site_id'])) {
return (int) $site['site_id'];
}

if (is_array($site) && isset($site['blog_id'])) {
return (int) $site['blog_id'];
}

if (is_object($site) && isset($site->blog_id)) {
return (int) $site->blog_id;
}

if (is_object($site) && isset($site->site_id)) {
return (int) $site->site_id;
}

return (int) $site;
}

/**
* Get possible Divi static CSS cache directories for a cloned blog.
*
* @since 2.5.1
*
* @param int $blog_id Blog ID.
* @return array
*/
private function get_divi_static_css_cache_directories(int $blog_id): array {

$cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache';
$paths = [];
$site = function_exists('get_site') ? get_site($blog_id) : null;
$network_id = $site && isset($site->site_id) ? (int) $site->site_id : 0;

if (0 === $network_id && function_exists('get_current_network_id')) {
$network_id = (int) get_current_network_id();
}

if (0 < $network_id) {
$paths[] = trailingslashit($cache_root) . $network_id . '/' . $blog_id;
}

/*
* Older Divi/static-resource layouts did not include the network ID in
* the path. Keep this as a no-op fallback when that directory is absent.
*/
$paths[] = trailingslashit($cache_root) . $blog_id;

return array_values(array_unique($paths));
}

/**
* Safely delete a Divi static CSS cache directory.
*
* @since 2.5.1
*
* @param string $cache_dir Cache directory path.
* @return void
*/
private function delete_divi_static_css_cache_directory(string $cache_dir): void {

$cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache';

if ('' === $cache_dir || ! is_dir($cache_dir)) {
return;
}

$real_cache_dir = realpath($cache_dir);
$real_cache_root = realpath($cache_root);

if (false === $real_cache_dir || false === $real_cache_root) {
return;
}

$real_cache_dir = untrailingslashit(wp_normalize_path($real_cache_dir));
$real_cache_root = untrailingslashit(wp_normalize_path($real_cache_root));

if ($real_cache_root === $real_cache_dir || 0 !== strpos(trailingslashit($real_cache_dir), trailingslashit($real_cache_root))) {
return;
}

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($real_cache_dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($iterator as $file) {
$path = $file->getRealPath();

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

if ($file->isDir()) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Removes an empty generated cache directory after deleting its contents.
@rmdir($path);
} else {
wp_delete_file($path);
}
Comment on lines +448 to +479

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does PHP SplFileInfo::getRealPath()resolve symlink targets, and doesRecursiveDirectoryIteratorstill yield symlink entries whenFilesystemIterator::FOLLOW_SYMLINKS is not set?

💡 Result:

Yes, PHP's SplFileInfo::getRealPath resolves all symbolic links [1][2]. It expands symlinks, resolves relative references (like /./ or /../), and returns the canonicalized absolute path to the file [3][2]. If the file does not exist, the method returns false [1][2]. Regarding RecursiveDirectoryIterator, it does still yield symlink entries even when the FilesystemIterator::FOLLOW_SYMLINKS flag is not set [4][5]. The iterator will return the symlink itself as an entry in the iteration [6]. The FilesystemIterator::FOLLOW_SYMLINKS flag controls whether the iterator should follow these symlinks to traverse into the target directories, but it does not prevent the iterator from encountering and yielding the symlink files themselves [7][4].

Citations:


Skip symlinks when purging cache contents to prevent path escape

SplFileInfo::getRealPath() resolves symlink targets, and RecursiveDirectoryIterator still yields symlink entries even without FOLLOW_SYMLINKS. A symlink inside $real_cache_dir can therefore make wp_delete_file() act on a path outside the cache tree. Use getPathname(), skip links, and re-check each entry against a $real_cache_dir prefix before deleting.

Suggested hardening
 		$real_cache_dir  = untrailingslashit(wp_normalize_path($real_cache_dir));
 		$real_cache_root = untrailingslashit(wp_normalize_path($real_cache_root));
 
 		if ($real_cache_root === $real_cache_dir || 0 !== strpos(trailingslashit($real_cache_dir), trailingslashit($real_cache_root))) {
 			return;
 		}
+
+		$real_cache_dir_prefix = trailingslashit($real_cache_dir);
 
 		$iterator = new \RecursiveIteratorIterator(
 			new \RecursiveDirectoryIterator($real_cache_dir, \FilesystemIterator::SKIP_DOTS),
 			\RecursiveIteratorIterator::CHILD_FIRST
 		);
 
 		foreach ($iterator as $file) {
-			$path = $file->getRealPath();
+			if ($file->isLink()) {
+				continue;
+			}
 
-			if (false === $path) {
+			$path = untrailingslashit(wp_normalize_path($file->getPathname()));
+
+			if ('' === $path || 0 !== strpos(trailingslashit($path), $real_cache_dir_prefix)) {
 				continue;
 			}
 
 			if ($file->isDir()) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/compat/class-general-compat.php` around lines 448 - 479, The purge
currently uses $file->getRealPath() which resolves symlinks and can cause
deletions outside $real_cache_dir; change the loop to use $file->getPathname(),
skip any symlink entries (use SplFileInfo::isLink()/isLink), normalize the
pathname (wp_normalize_path + untrailingslashit/trailingslashit) and then verify
the normalized path has the $real_cache_dir prefix before deleting; keep the
existing behavior for directories (rmdir) and files (wp_delete_file()) but only
after the symlink check and prefix re-check to prevent path escape.

}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Removes the generated cloned-site Divi cache directory.
@rmdir($real_cache_dir);
}

/**
* Fixes WP Hide Pro URLs while on Ultimo's template preview
*
Expand Down
116 changes: 116 additions & 0 deletions tests/WP_Ultimo/General_Compat_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php
/**
* Tests for the General_Compat class.
*
* @package WP_Ultimo
* @subpackage Tests
*/

namespace WP_Ultimo\Tests;

use WP_Ultimo\Compat\General_Compat;
use WP_UnitTestCase;

/**
* Test general compatibility fixes.
*/
class General_Compat_Test extends WP_UnitTestCase {
Comment on lines +1 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Match the repo's PHP test-file conventions.

This file is missing the standard defined('ABSPATH') || exit; guard, and namespace WP_Ultimo\Tests; does not mirror the source namespace for WP_Ultimo\Compat\General_Compat.

Suggested cleanup
 <?php
+defined('ABSPATH') || exit;
+
 /**
  * Tests for the General_Compat class.
  *
  * `@package` WP_Ultimo
  * `@subpackage` Tests
  */
 
-namespace WP_Ultimo\Tests;
+namespace WP_Ultimo\Compat;
 
-use WP_Ultimo\Compat\General_Compat;
 use WP_UnitTestCase;

As per coding guidelines, Every PHP file must start with defined('ABSPATH') || exit; and tests/WP_Ultimo/**/*_Test.php: Test namespace must mirror source structure.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<?php
/**
* Tests for the General_Compat class.
*
* @package WP_Ultimo
* @subpackage Tests
*/
namespace WP_Ultimo\Tests;
use WP_Ultimo\Compat\General_Compat;
use WP_UnitTestCase;
/**
* Test general compatibility fixes.
*/
class General_Compat_Test extends WP_UnitTestCase {
<?php
defined('ABSPATH') || exit;
/**
* Tests for the General_Compat class.
*
* `@package` WP_Ultimo
* `@subpackage` Tests
*/
namespace WP_Ultimo\Compat;
use WP_UnitTestCase;
/**
* Test general compatibility fixes.
*/
class General_Compat_Test extends WP_UnitTestCase {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/WP_Ultimo/General_Compat_Test.php` around lines 1 - 17, Add the missing
file guard and update the test namespace to mirror the source: insert
defined('ABSPATH') || exit; at the top of the file immediately after <?php, and
change the namespace from WP_Ultimo\Tests to WP_Ultimo\Compat\Tests so the test
namespace mirrors the source class WP_Ultimo\Compat\General_Compat; keep the
existing use/imports and class name General_Compat_Test unchanged.

Source: Coding guidelines


/**
* Cache directories created by a test.
*
* @var array
*/
private $cache_dirs = [];

/**
* Clean up generated cache fixtures.
*/
public function tearDown(): void {

foreach ($this->cache_dirs as $cache_dir) {
$this->remove_directory($cache_dir);
}

parent::tearDown();
}

/**
* Test init registers the Divi cache purge duplication hook.
*/
public function test_init_registers_divi_cache_purge_hook(): void {

$instance = General_Compat::get_instance();
$instance->init();

$this->assertNotFalse(has_action('wu_duplicate_site', [$instance, 'clear_divi_static_css_cache']));
}

/**
* Test Divi et-cache files are deleted only for the cloned site.
*/
public function test_clear_divi_static_css_cache_deletes_cloned_site_cache_only(): void {

if ( ! is_multisite()) {
$this->markTestSkipped('Divi cache purge tests require multisite');
}

$blog_id = self::factory()->blog->create();
$other_blog_id = self::factory()->blog->create();
$network_id = (int) get_current_network_id();
$cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache';
$cache_dir = trailingslashit($cache_root) . $network_id . '/' . $blog_id;
$other_dir = trailingslashit($cache_root) . $network_id . '/' . $other_blog_id;

$this->cache_dirs = [$cache_dir, $other_dir];

wp_mkdir_p($cache_dir . '/9');
wp_mkdir_p($other_dir . '/9');

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.
file_put_contents($cache_dir . '/9/et-core-unified-deferred-9.min.css', 'stale divi css');
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.
file_put_contents($other_dir . '/9/et-core-unified-deferred-9.min.css', 'other divi css');

General_Compat::get_instance()->clear_divi_static_css_cache(['site_id' => $blog_id]);

$this->assertDirectoryDoesNotExist($cache_dir);
$this->assertDirectoryExists($other_dir);
}

/**
* Recursively remove a test directory.
*
* @param string $dir Directory path.
* @return void
*/
private function remove_directory(string $dir): void {

if ('' === $dir || ! is_dir($dir)) {
return;
}

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($iterator as $file) {
$path = $file->getRealPath();

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

if ($file->isDir()) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Test fixture cleanup.
@rmdir($path);
} else {
wp_delete_file($path);
}
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Test fixture cleanup.
@rmdir($dir);
}
}
Loading