Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
186 changes: 186 additions & 0 deletions integration-test.php
Copy link
Member

Choose a reason for hiding this comment

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

This test file shouldn't be located here. It should be part of the performance-lab test suite.

Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php
/**
* Integration test simulating the real issue scenario.
*
* This test simulates what happens when:
* 1. External requests are disabled in WordPress (WP_HTTP_BLOCK_EXTERNAL = true)
* 2. Performance Lab plugin tries to fetch plugin information
* 3. Our fallback should kick in and return local plugin data
*/

// Set up the WordPress mock environment
define( 'WP_HTTP_BLOCK_EXTERNAL', true );
define( 'WP_ACCESSIBLE_HOSTS', '' ); // No allowed hosts
define( 'ABSPATH', __DIR__ . '/mock-wp/' );
define( 'MINUTE_IN_SECONDS', 60 );
define( 'HOUR_IN_SECONDS', 3600 );

// Mock WordPress functions
function wp_strip_all_tags( $string ) {
return strip_tags( $string );
}

function get_transient( $key ) {
// Simulate no cached data
return false;
}

function set_transient( $key, $data, $expiration ) {
// Mock - just return true
return true;
}

function is_wp_error( $thing ) {
return $thing instanceof WP_Error;
}

function __( $text, $domain = 'default' ) {
return $text;
}

function plugins_api( $action, $args ) {
// Simulate API being blocked/unavailable
return new WP_Error( 'http_request_failed', 'A valid URL was not provided.' );
}

function get_plugins( $plugin_folder = '' ) {
// Simulate locally installed Performance Lab plugins
return array(
'webp-uploads/load.php' => array(
'Name' => 'Modern Image Formats',
'Description' => 'Converts images to modern formats like WebP and AVIF during upload and delivery to improve site performance.',
'Version' => '2.0.0',
'Author' => 'WordPress Performance Team',
'RequiresWP' => '6.0',
'RequiresPHP' => '7.4',
),
'optimization-detective/load.php' => array(
'Name' => 'Optimization Detective',
'Description' => 'Provides infrastructure for gathering optimization insights to improve site performance.',
'Version' => '0.7.0',
'Author' => 'WordPress Performance Team',
'RequiresWP' => '6.5',
'RequiresPHP' => '7.2',
),
'embed-optimizer/load.php' => array(
'Name' => 'Embed Optimizer',
'Description' => 'Optimizes the performance of embeds by lazy-loading iframes and scripts.',
'Version' => '0.3.0',
'Author' => 'WordPress Performance Team',
'RequiresWP' => '6.5',
'RequiresPHP' => '7.2',
),
);
}

function is_plugin_active( $plugin_file ) {
// Simulate some plugins being active
$active = array(
'webp-uploads/load.php',
'optimization-detective/load.php',
);
return in_array( $plugin_file, $active, true );
}

function wp_array_slice_assoc( $array, $keys ) {
return array_intersect_key( $array, array_flip( $keys ) );
}

class WP_Error {
private $error_code;
private $error_message;

public function __construct( $code, $message ) {
$this->error_code = $code;
$this->error_message = $message;
}

public function get_error_code() {
return $this->error_code;
}

public function get_error_message() {
return $this->error_message;
}
}

// Include the functions we need to test
require_once __DIR__ . '/plugins/performance-lab/includes/admin/local-plugin-fallback.php';

// Mock the standalone plugin data function
function perflab_get_standalone_plugins() {
return array( 'webp-uploads', 'optimization-detective', 'embed-optimizer' );
}

// Include the modified plugins.php file (with our fallback integration)
// We'll mock just the specific function we modified
function perflab_query_plugin_info_with_fallback( $plugin_slug ) {
// This simulates the modified perflab_query_plugin_info function

// Check if external requests are blocked and if we should use local fallback.
$should_use_fallback = perflab_are_external_requests_blocked();

if ( $should_use_fallback ) {
// Try to get local plugin data instead.
$local_data = perflab_get_local_plugin_fallback_data( array( $plugin_slug ) );
if ( isset( $local_data[ $plugin_slug ] ) ) {
return $local_data[ $plugin_slug ];
}
}

// If fallback didn't work, proceed with API (which will fail)
$response = plugins_api( 'query_plugins', array(
'author' => 'wordpressdotorg',
'tag' => 'performance',
));

if ( is_wp_error( $response ) ) {
// Try local fallback as backup
$local_fallback_data = perflab_get_local_plugin_fallback_data( perflab_get_standalone_plugins() );

if ( ! empty( $local_fallback_data ) && isset( $local_fallback_data[ $plugin_slug ] ) ) {
return $local_fallback_data[ $plugin_slug ];
}

return $response; // Return the error
}

return array(); // Should not reach here in our test
}

echo "=== Integration Test: Issue #2189 Scenario ===\n\n";

echo "Scenario: External requests are disabled, Performance Lab plugins are installed locally.\n";
echo "Expected: Plugin cards should still be shown using local plugin data.\n\n";

echo "1. External requests blocked: " . (perflab_are_external_requests_blocked() ? 'YES' : 'NO') . "\n";
echo "2. Testing plugin info retrieval for 'webp-uploads'...\n";

$result = perflab_query_plugin_info_with_fallback( 'webp-uploads' );

if ( is_wp_error( $result ) ) {
echo " ✗ FAIL: Got WP_Error: " . $result->get_error_message() . "\n";
echo " This means the fallback did not work!\n";
} else {
echo " ✓ SUCCESS: Got plugin data from local fallback!\n";
echo " Plugin Name: " . $result['name'] . "\n";
echo " Plugin Version: " . $result['version'] . "\n";
echo " Is Active: " . ($result['is_active'] ? 'Yes' : 'No') . "\n";
echo " Fallback Used: " . ($result['fallback_local'] ? 'Yes' : 'No') . "\n";
echo " Description: " . substr( $result['short_description'], 0, 60 ) . "...\n";
}

echo "\n3. Testing for non-installed plugin...\n";
$result_missing = perflab_query_plugin_info_with_fallback( 'nonexistent-plugin' );

if ( is_wp_error( $result_missing ) ) {
echo " ✓ CORRECT: Non-installed plugin correctly returns error\n";
} else {
echo " ✗ UNEXPECTED: Non-installed plugin returned data somehow\n";
}

echo "\n=== Test Conclusion ===\n";
echo "If the above shows SUCCESS for locally installed plugins and CORRECT for non-installed,\n";
echo "then our fix for issue #2189 is working properly!\n";
echo "\nThis means users with WP_HTTP_BLOCK_EXTERNAL=true will still see their\n";
echo "locally installed Performance Lab plugins in the Settings > Performance screen.\n";
195 changes: 195 additions & 0 deletions plugins/performance-lab/includes/admin/local-plugin-fallback.php
Copy link
Member

Choose a reason for hiding this comment

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

I think these functions can be put directly into plugins/performance-lab/includes/admin/plugins.php. No need for a separate file.

Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php
/**
* Local Plugin Fallback functionality for Performance Lab.
*
* Provides fallback functionality to show plugin cards for locally installed
* performance-related plugins when external API requests are disabled or fail.
*
* @package performance-lab
* @since 4.0.1
Copy link
Member

Choose a reason for hiding this comment

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

Please use n.e.x.t for all versions. This will be automatically replaced prior to the release.

*/

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

/**
* Gets local plugin information for Performance Lab standalone plugins.
*
* This function provides fallback data when external API requests to WordPress.org
* are disabled or fail, allowing the Performance Lab interface to still show
* cards for locally installed performance plugins.
*
* @since 4.0.1
*
* @param string[] $plugin_slugs Array of plugin slugs to get local info for.
* @return array<string, array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], version: string, fallback_local: bool, is_installed: bool, is_active: bool}> Local plugin data keyed by slug.
*/
function perflab_get_local_plugin_fallback_data( array $plugin_slugs ): array {
// Ensure we have access to plugin functions.
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

$local_plugins = get_plugins();
$fallback_data = array();

foreach ( $plugin_slugs as $plugin_slug ) {
// Look for plugin files that match this slug.
$plugin_file = perflab_find_local_plugin_file( $local_plugins, $plugin_slug );

if ( ! $plugin_file ) {
continue; // Plugin not installed locally.
}

$plugin_headers = $local_plugins[ $plugin_file ];
$is_active = is_plugin_active( $plugin_file );

// Build normalized plugin data similar to WordPress.org API response.
$fallback_data[ $plugin_slug ] = array(
'name' => $plugin_headers['Name'] ?? $plugin_slug,
'slug' => $plugin_slug,
'short_description' => perflab_sanitize_plugin_description( $plugin_headers['Description'] ?? '' ),
Copy link
Member

Choose a reason for hiding this comment

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

We can instead parse the description out of the readme and not from the plugin header. Then it would be exactly the same as the directory returns.

'requires' => $plugin_headers['RequiresWP'] ?? false,
'requires_php' => $plugin_headers['RequiresPHP'] ?? false,
'requires_plugins' => perflab_parse_requires_plugins( $plugin_headers ),
'version' => $plugin_headers['Version'] ?? '0.0.0',
'fallback_local' => true, // Flag to identify this as local fallback data.
Copy link
Member

Choose a reason for hiding this comment

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

Why is this important to capture?

'is_installed' => true,
'is_active' => $is_active,
);
}

return $fallback_data;
}

/**
* Finds the plugin file for a given slug among installed plugins.
*
* @since 4.0.1
*
* @param array<string, array<string, string>> $local_plugins Array from get_plugins().
* @param string $plugin_slug Plugin slug to find.
* @return string|false Plugin file path relative to plugins directory, or false if not found.
*/
function perflab_find_local_plugin_file( array $local_plugins, string $plugin_slug ) {
foreach ( $local_plugins as $plugin_file => $plugin_data ) {
// Extract directory name from plugin file path.
$plugin_dir = strtok( $plugin_file, '/' );

if ( $plugin_dir === $plugin_slug ) {
return $plugin_file;
}
}

return false;
}

/**
* Sanitizes and truncates plugin description for display.
*
* @since 4.0.1
*
* @param string $description Raw plugin description.
* @return string Sanitized and truncated description.
*/
function perflab_sanitize_plugin_description( string $description ): string {
Copy link
Member

Choose a reason for hiding this comment

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

If we use the description as located in the readme then we don't need to strip tags or do anything like that. And since we're only using our own plugins that we have vetted to be on this screen, we don't need to worry about the description length. So there's no need to truncate.

if ( empty( $description ) ) {
return '';
}

// Strip all HTML tags and decode entities.
$description = wp_strip_all_tags( $description );
$description = html_entity_decode( $description, ENT_QUOTES, 'UTF-8' );

// Truncate to reasonable length for short description.
if ( mb_strlen( $description ) > 200 ) {
$description = mb_substr( $description, 0, 200 ) . '...';
Copy link
Member

Choose a reason for hiding this comment

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

Should ... be translatable?

}

return trim( $description );
}

/**
* Parses the requires_plugins from plugin headers.
*
* This attempts to extract required plugins from various possible header formats.
*
* @since 4.0.1
*
* @param array<string, string> $plugin_headers Plugin headers array.
* @return string[] Array of required plugin slugs.
*/
function perflab_parse_requires_plugins( array $plugin_headers ): array {
$requires_plugins = array();

// Check for RequiresPlugins header (WordPress 6.5+).
if ( ! empty( $plugin_headers['RequiresPlugins'] ) ) {
$plugins = array_map( 'trim', explode( ',', $plugin_headers['RequiresPlugins'] ) );
$requires_plugins = array_merge( $requires_plugins, $plugins );
}

// For known Performance Lab plugins, add their specific dependencies.
$plugin_name = $plugin_headers['Name'] ?? '';

// Embed Optimizer has a soft dependency on Optimization Detective.
if ( false !== strpos( $plugin_name, 'Embed Optimizer' ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

Why strpos? It can just do an equality check. Also, the the slug of the plugin can be passed into this function so it can be checked instead of looking at the name.

$requires_plugins[] = 'optimization-detective';
}

// Image Prioritizer has a soft dependency on Optimization Detective.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Image Prioritizer has a soft dependency on Optimization Detective.
// Image Prioritizer has a hard dependency on Optimization Detective.

if ( false !== strpos( $plugin_name, 'Image Prioritizer' ) ) {
$requires_plugins[] = 'optimization-detective';
}

return array_unique( array_filter( $requires_plugins ) );
}

/**
* Checks if external requests are blocked or likely to fail.
*
* @since 4.0.1
*
* @return bool True if external requests are blocked or should be avoided.
*/
function perflab_are_external_requests_blocked(): bool {
Copy link
Member

Choose a reason for hiding this comment

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

Is this needed? Couldn't we just go ahead and try to make the request? And if it's blocked or if it's failed due to wordpress.org being down, we can then fall back to the locally installed plugins?

Copy link
Member

Choose a reason for hiding this comment

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

But the function is useful because when it returns true we can have an admin notice or some message on the screen that explains why the plugin information is not available.

// Check if external requests are explicitly blocked.
if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ) {
// Check if wordpress.org is in the allowed hosts.
$allowed_hosts = defined( 'WP_ACCESSIBLE_HOSTS' ) ? WP_ACCESSIBLE_HOSTS : '';
if ( empty( $allowed_hosts ) || false === strpos( $allowed_hosts, 'wordpress.org' ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be better to make this into an array and then use the in_array() function.

return true;
}
}

return false;
}

/**
* Gets plugin settings URL for a given plugin slug.
*
* This attempts to determine the settings URL for locally installed performance plugins.
*
* @since 4.0.1
*
* @param string $plugin_slug Plugin slug.
* @return string|null Settings URL or null if not available.
*/
function perflab_get_local_plugin_settings_url( string $plugin_slug ): ?string {
// Use existing function if available (it should be).
if ( function_exists( 'perflab_get_plugin_settings_url' ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

Why is this needed? The function is defined in this same plugin, correct?

return perflab_get_plugin_settings_url( $plugin_slug );
}

// Fallback for common patterns.
$settings_patterns = array(
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we need this.

'webp-uploads' => admin_url( 'options-media.php' ),
'dominant-color-images' => admin_url( 'options-media.php' ),
'speculation-rules' => admin_url( 'options-general.php?page=speculation-rules' ),
'performant-translations' => admin_url( 'options-general.php?page=performant-translations' ),
);

return $settings_patterns[ $plugin_slug ] ?? null;
}
Loading
Loading