From d2815c06603d39a432e041c7a91d565272a84cb7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 29 May 2026 12:54:37 -0400 Subject: [PATCH 1/5] Add AI Performance Advisor plugin (#2485) Introduce a new standalone feature plugin that surfaces actionable, AI-generated performance recommendations in a dedicated Site Health tab. The plugin gathers bounded site context through a pluggable provider registry (environment, Site Health debug data with private fields removed, plugin Site Health test results, a compact PageSpeed Insights snapshot, and an optional Optimization Detective summary), sends it to the WordPress 7.0 AI Client API, and renders the returned recommendations as prioritized cards. Analysis is on demand via an authenticated REST endpoint and the result is cached in a transient to control token cost. This initial version is suggest-only: it never changes site configuration. The recommendation data model includes an optional, currently-inert action.ability field so a future version can apply changes through the Abilities API as that surface matures. Includes unit tests (single site and multisite), registration in the monorepo tooling and the Performance Lab feature list, and a PHPStan stub for the WordPress 7.0 AI Client and Abilities APIs that are not yet present in wordpress-stubs. Fixes #2485 --- .wp-env.json | 1 + composer.json | 4 + package.json | 1 + phpstan.neon.dist | 1 + phpunit.xml.dist | 3 + plugins.json | 1 + .../ai-performance-advisor/css/analyzer.css | 90 ++++++ plugins/ai-performance-advisor/helper.php | 278 ++++++++++++++++++ plugins/ai-performance-advisor/hooks.php | 53 ++++ .../includes/class-aipa-analyzer.php | 163 ++++++++++ .../class-aipa-context-provider-registry.php | 116 ++++++++ .../includes/class-aipa-context-provider.php | 68 +++++ .../class-aipa-provider-environment.php | 98 ++++++ ...s-aipa-provider-optimization-detective.php | 80 +++++ .../class-aipa-provider-pagespeed.php | 168 +++++++++++ .../class-aipa-provider-site-health-tests.php | 97 ++++++ .../class-aipa-provider-site-health.php | 136 +++++++++ .../includes/rest-api.php | 81 +++++ .../includes/site-health.php | 83 ++++++ plugins/ai-performance-advisor/js/analyzer.js | 232 +++++++++++++++ plugins/ai-performance-advisor/load.php | 48 +++ plugins/ai-performance-advisor/phpcs.xml.dist | 12 + plugins/ai-performance-advisor/readme.txt | 61 ++++ plugins/ai-performance-advisor/settings.php | 167 +++++++++++ .../tests/test-context-providers.php | 84 ++++++ .../tests/test-helper.php | 137 +++++++++ .../tests/test-rest-and-site-health.php | 55 ++++ plugins/ai-performance-advisor/uninstall.php | 21 ++ plugins/performance-lab/load.php | 4 + tools/phpstan/stubs/ai-client.stub | 61 ++++ 30 files changed, 2404 insertions(+) create mode 100644 plugins/ai-performance-advisor/css/analyzer.css create mode 100644 plugins/ai-performance-advisor/helper.php create mode 100644 plugins/ai-performance-advisor/hooks.php create mode 100644 plugins/ai-performance-advisor/includes/class-aipa-analyzer.php create mode 100644 plugins/ai-performance-advisor/includes/class-aipa-context-provider-registry.php create mode 100644 plugins/ai-performance-advisor/includes/class-aipa-context-provider.php create mode 100644 plugins/ai-performance-advisor/includes/providers/class-aipa-provider-environment.php create mode 100644 plugins/ai-performance-advisor/includes/providers/class-aipa-provider-optimization-detective.php create mode 100644 plugins/ai-performance-advisor/includes/providers/class-aipa-provider-pagespeed.php create mode 100644 plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health-tests.php create mode 100644 plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health.php create mode 100644 plugins/ai-performance-advisor/includes/rest-api.php create mode 100644 plugins/ai-performance-advisor/includes/site-health.php create mode 100644 plugins/ai-performance-advisor/js/analyzer.js create mode 100644 plugins/ai-performance-advisor/load.php create mode 100644 plugins/ai-performance-advisor/phpcs.xml.dist create mode 100644 plugins/ai-performance-advisor/readme.txt create mode 100644 plugins/ai-performance-advisor/settings.php create mode 100644 plugins/ai-performance-advisor/tests/test-context-providers.php create mode 100644 plugins/ai-performance-advisor/tests/test-helper.php create mode 100644 plugins/ai-performance-advisor/tests/test-rest-and-site-health.php create mode 100644 plugins/ai-performance-advisor/uninstall.php create mode 100644 tools/phpstan/stubs/ai-client.stub diff --git a/.wp-env.json b/.wp-env.json index 7e0511af4c..2e56b0e6be 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -2,6 +2,7 @@ "$schema": "https://schemas.wp.org/trunk/wp-env.json", "core": null, "plugins": [ + "./plugins/ai-performance-advisor", "./plugins/optimization-detective", "./plugins/auto-sizes", "./plugins/dominant-color-images", diff --git a/composer.json b/composer.json index 5536f72f4a..6199a73868 100644 --- a/composer.json +++ b/composer.json @@ -101,6 +101,7 @@ "WP_MULTISITE=1 php vendor/bin/phpunit --exclude-group=ms-excluded" ], "test-multisite:plugins": [ + "@test-multisite:ai-performance-advisor", "@test-multisite:auto-sizes", "@test-multisite:dominant-color-images", "@test-multisite:embed-optimizer", @@ -112,6 +113,7 @@ "@test-multisite:web-worker-offloading", "@test-multisite:webp-uploads" ], + "test-multisite:ai-performance-advisor": "@test-multisite --verbose --testsuite ai-performance-advisor", "test-multisite:auto-sizes": "@test-multisite --verbose --testsuite auto-sizes", "test-multisite:dominant-color-images": "@test-multisite --verbose --testsuite dominant-color-images", "test-multisite:embed-optimizer": "@test-multisite --verbose --testsuite embed-optimizer", @@ -123,6 +125,7 @@ "test-multisite:web-worker-offloading": "@test-multisite --verbose --testsuite web-worker-offloading", "test-multisite:webp-uploads": "@test-multisite --verbose --testsuite webp-uploads", "test:plugins": [ + "@test:ai-performance-advisor", "@test:auto-sizes", "@test:dominant-color-images", "@test:embed-optimizer", @@ -134,6 +137,7 @@ "@test:web-worker-offloading", "@test:webp-uploads" ], + "test:ai-performance-advisor": "@test --verbose --testsuite ai-performance-advisor", "test:auto-sizes": "@test --verbose --testsuite auto-sizes", "test:dominant-color-images": "@test --verbose --testsuite dominant-color-images", "test:embed-optimizer": "@test --verbose --testsuite embed-optimizer", diff --git a/package.json b/package.json index cfd5dcd534..748588cdfe 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "build": "wp-scripts build", "build-plugins": "npm-run-all 'build:plugin:*'", "build-plugins:zip": "npm-run-all 'build:plugin:* --env zip=true'", + "build:plugin:ai-performance-advisor": "webpack --mode production --env plugin=ai-performance-advisor", "build:plugin:performance-lab": "webpack --mode production --env plugin=performance-lab", "build:plugin:auto-sizes": "webpack --mode production --env plugin=auto-sizes", "build:plugin:dominant-color-images": "webpack --mode production --env plugin=dominant-color-images", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8b4bf2d4e2..8f631eac3c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -18,6 +18,7 @@ parameters: - vendor/phpstan/php-8-stubs/stubs/ext/standard/str_contains.php - vendor/phpstan/php-8-stubs/stubs/ext/standard/str_starts_with.php - vendor/phpstan/php-8-stubs/stubs/ext/standard/str_ends_with.php + - tools/phpstan/stubs/ai-client.stub stubFiles: - tools/phpstan/filtered-functions.stub dynamicConstantNames: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 58530fba96..247fd8717a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,9 @@ plugins/performance-lab/tests + + plugins/ai-performance-advisor/tests + plugins/auto-sizes/tests diff --git a/plugins.json b/plugins.json index b7b1c94ccb..0db7dd9ecc 100644 --- a/plugins.json +++ b/plugins.json @@ -1,5 +1,6 @@ { "plugins": [ + "ai-performance-advisor", "auto-sizes", "dominant-color-images", "embed-optimizer", diff --git a/plugins/ai-performance-advisor/css/analyzer.css b/plugins/ai-performance-advisor/css/analyzer.css new file mode 100644 index 0000000000..efeb1d296c --- /dev/null +++ b/plugins/ai-performance-advisor/css/analyzer.css @@ -0,0 +1,90 @@ +/** + * AI Performance Advisor - Site Health tab styles. + * + * @since 1.0.0 + */ + +#aipa-results { + margin-top: 1em; +} + +.aipa-card { + background: #fff; + border: 1px solid #c3c4c7; + border-left-width: 4px; + border-radius: 2px; + padding: 12px 16px; + margin-bottom: 12px; +} + +.aipa-card.aipa-severity-critical { + border-left-color: #d63638; +} + +.aipa-card.aipa-severity-recommended { + border-left-color: #dba617; +} + +.aipa-card.aipa-severity-good { + border-left-color: #00a32a; +} + +.aipa-card.aipa-severity-info { + border-left-color: #72aee6; +} + +.aipa-card-title { + margin: 0 0 0.5em; + font-size: 1.05em; +} + +.aipa-badge { + display: inline-block; + font-size: 11px; + line-height: 1.6; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0 8px; + border-radius: 9px; + color: #fff; + vertical-align: middle; +} + +.aipa-badge-critical { + background: #d63638; +} + +.aipa-badge-recommended { + background: #dba617; +} + +.aipa-badge-good { + background: #00a32a; +} + +.aipa-badge-info { + background: #72aee6; +} + +.aipa-card-summary { + margin: 0 0 0.5em; + font-weight: 600; +} + +.aipa-card-details { + white-space: pre-wrap; + margin: 0 0 0.5em; +} + +.aipa-card-evidence summary { + cursor: pointer; + color: #50575e; +} + +.aipa-card-actions { + margin-top: 0.75em; +} + +.aipa-card-actions .button { + margin-right: 8px; +} diff --git a/plugins/ai-performance-advisor/helper.php b/plugins/ai-performance-advisor/helper.php new file mode 100644 index 0000000000..7506a89886 --- /dev/null +++ b/plugins/ai-performance-advisor/helper.php @@ -0,0 +1,278 @@ +is_supported_for_text_generation(); + } catch ( \Throwable $e ) { + $available = false; + } + + return $available; +} + +/** + * Returns the default plugin settings. + * + * @since 1.0.0 + * + * @return array{ include_pagespeed: bool, pagespeed_api_key: string } Default settings. + */ +function aipa_get_default_settings(): array { + return array( + 'include_pagespeed' => true, + 'pagespeed_api_key' => '', + ); +} + +/** + * Returns the plugin settings, merged with defaults. + * + * @since 1.0.0 + * + * @return array{ include_pagespeed: bool, pagespeed_api_key: string } Settings. + */ +function aipa_get_settings(): array { + $settings = get_option( 'aipa_settings', array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + $merged = array_merge( aipa_get_default_settings(), $settings ); + + return array( + 'include_pagespeed' => (bool) ( $merged['include_pagespeed'] ?? true ), + 'pagespeed_api_key' => (string) ( $merged['pagespeed_api_key'] ?? '' ), + ); +} + +/** + * Returns the list of valid recommendation severities, ordered most to least urgent. + * + * @since 1.0.0 + * + * @return string[] Severity slugs. + */ +function aipa_get_severities(): array { + return array( 'critical', 'recommended', 'good', 'info' ); +} + +/** + * Returns the list of valid recommendation categories. + * + * @since 1.0.0 + * + * @return string[] Category slugs. + */ +function aipa_get_categories(): array { + return array( 'images', 'caching', 'scripts', 'navigation', 'server', 'database', 'other' ); +} + +/** + * Sanitizes and validates raw recommendation data returned by the AI model. + * + * Untrusted model output is normalized into a predictable, escaped-on-render shape. + * Invalid entries are dropped; results are sorted by severity. + * + * @since 1.0.0 + * + * @param mixed $raw Decoded model output. Expected to be a list of recommendation arrays. + * @return array}|null}|null}> Sanitized recommendations. + */ +function aipa_sanitize_recommendations( $raw ): array { + // Allow either a bare list or an object with a "recommendations" key. + if ( is_array( $raw ) && isset( $raw['recommendations'] ) && is_array( $raw['recommendations'] ) ) { + $raw = $raw['recommendations']; + } + + if ( ! is_array( $raw ) ) { + return array(); + } + + $severities = aipa_get_severities(); + $categories = aipa_get_categories(); + $clean = array(); + + foreach ( $raw as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + + $title = isset( $item['title'] ) && is_string( $item['title'] ) ? sanitize_text_field( $item['title'] ) : ''; + $summary = isset( $item['summary'] ) && is_string( $item['summary'] ) ? sanitize_text_field( $item['summary'] ) : ''; + if ( '' === $title || '' === $summary ) { + continue; + } + + $severity = isset( $item['severity'] ) && in_array( $item['severity'], $severities, true ) ? $item['severity'] : 'recommended'; + $category = isset( $item['category'] ) && in_array( $item['category'], $categories, true ) ? $item['category'] : 'other'; + + $id = isset( $item['id'] ) && is_string( $item['id'] ) ? sanitize_key( $item['id'] ) : sanitize_key( $title ); + if ( '' === $id ) { + $id = 'recommendation'; + } + + // Details may contain limited Markdown; keep as plain text and let the renderer format it safely. + $details = isset( $item['details'] ) && is_string( $item['details'] ) ? wp_kses_post( $item['details'] ) : ''; + + $evidence = array(); + if ( isset( $item['evidence'] ) && is_array( $item['evidence'] ) ) { + foreach ( $item['evidence'] as $line ) { + if ( is_string( $line ) && '' !== $line ) { + $evidence[] = sanitize_text_field( $line ); + } + } + } + + $clean[] = array( + 'id' => $id, + 'title' => $title, + 'severity' => $severity, + 'category' => $category, + 'summary' => $summary, + 'details' => $details, + 'evidence' => $evidence, + 'action' => aipa_sanitize_recommendation_action( $item['action'] ?? null ), + ); + } + + // Sort by severity order (critical first). + $order = array_flip( $severities ); + usort( + $clean, + static function ( array $a, array $b ) use ( $order ): int { + return ( $order[ $a['severity'] ] ?? 99 ) <=> ( $order[ $b['severity'] ] ?? 99 ); + } + ); + + return $clean; +} + +/** + * Sanitizes the optional, forward-looking "action" payload on a recommendation. + * + * In v1 only `settings_url` is honored for display. The `ability` field is parsed + * and preserved for forward compatibility, but is only retained when the named + * ability is actually registered (which never happens in v1). + * + * @since 1.0.0 + * + * @param mixed $action Raw action payload. + * @return array{settings_url: string, ability: array{name: string, args: array}|null}|null Sanitized action or null. + */ +function aipa_sanitize_recommendation_action( $action ): ?array { + if ( ! is_array( $action ) ) { + return null; + } + + $settings_url = ''; + if ( isset( $action['settings_url'] ) && is_string( $action['settings_url'] ) ) { + // Only allow same-site admin URLs. + $candidate = esc_url_raw( $action['settings_url'] ); + if ( '' !== $candidate && 0 === strpos( $candidate, admin_url() ) ) { + $settings_url = $candidate; + } + } + + $ability = null; + if ( + isset( $action['ability']['name'] ) && + is_string( $action['ability']['name'] ) && + function_exists( 'wp_has_ability' ) && + wp_has_ability( $action['ability']['name'] ) + ) { + $args = isset( $action['ability']['args'] ) && is_array( $action['ability']['args'] ) ? $action['ability']['args'] : array(); + $ability = array( + 'name' => $action['ability']['name'], + 'args' => $args, + ); + } + + if ( '' === $settings_url && null === $ability ) { + return null; + } + + return array( + 'settings_url' => $settings_url, + 'ability' => $ability, + ); +} + +/** + * Computes a stable cache key fragment for a given context payload. + * + * @since 1.0.0 + * + * @param array $context The assembled context payload. + * @return string An md5 hash of the context. + */ +function aipa_get_context_hash( array $context ): string { + return md5( (string) wp_json_encode( $context ) ); +} + +/** + * Builds the context provider registry with the default providers registered. + * + * @since 1.0.0 + * + * @return AIPA_Context_Provider_Registry The populated registry. + */ +function aipa_get_context_registry(): AIPA_Context_Provider_Registry { + $registry = new AIPA_Context_Provider_Registry(); + + $registry->register( new AIPA_Provider_Environment() ); + $registry->register( new AIPA_Provider_Site_Health() ); + $registry->register( new AIPA_Provider_Site_Health_Tests() ); + $registry->register( new AIPA_Provider_PageSpeed() ); + $registry->register( new AIPA_Provider_Optimization_Detective() ); + + /** + * Fires after the default context providers are registered. + * + * Use this to register additional context providers (subclasses of + * AIPA_Context_Provider) or to unregister the defaults. + * + * @since 1.0.0 + * + * @param AIPA_Context_Provider_Registry $registry The context provider registry. + */ + do_action( 'aipa_register_context_providers', $registry ); + + return $registry; +} diff --git a/plugins/ai-performance-advisor/hooks.php b/plugins/ai-performance-advisor/hooks.php new file mode 100644 index 0000000000..186678e941 --- /dev/null +++ b/plugins/ai-performance-advisor/hooks.php @@ -0,0 +1,53 @@ +>|WP_Error Recommendations, or an error. + */ + public function analyze( bool $use_cache = true ) { + if ( ! aipa_is_ai_available() ) { + return new WP_Error( + 'aipa_ai_unavailable', + __( 'AI is not available. Connect an AI provider that supports text generation to use the AI Performance Advisor.', 'ai-performance-advisor' ) + ); + } + + $context = aipa_get_context_registry()->collect(); + $hash = aipa_get_context_hash( $context ); + + if ( $use_cache ) { + $cached = get_transient( self::CACHE_KEY ); + if ( is_array( $cached ) && isset( $cached['hash'] ) && $cached['hash'] === $hash && isset( $cached['recommendations'] ) ) { + return $cached['recommendations']; + } + } + + $text = $this->request( $context ); + if ( is_wp_error( $text ) ) { + return $text; + } + + $recommendations = aipa_sanitize_recommendations( $this->decode_json( $text ) ); + + set_transient( + self::CACHE_KEY, + array( + 'hash' => $hash, + 'recommendations' => $recommendations, + ), + 12 * HOUR_IN_SECONDS + ); + + return $recommendations; + } + + /** + * Sends the prompt to the AI model. + * + * @since 1.0.0 + * + * @param array $context The assembled context payload. + * @return string|WP_Error The raw model response, or an error. + */ + private function request( array $context ) { + $user_prompt = sprintf( + "Analyze the following WordPress site data and return performance recommendations as JSON.\n\nSITE DATA:\n%s", + (string) wp_json_encode( $context ) + ); + + try { + $result = wp_ai_client_prompt( $user_prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.2 ) + ->generate_text(); + } catch ( \Throwable $e ) { + return new WP_Error( 'aipa_ai_request_failed', $e->getMessage() ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! is_string( $result ) || '' === trim( $result ) ) { + return new WP_Error( 'aipa_ai_empty_response', __( 'The AI provider returned an empty response.', 'ai-performance-advisor' ) ); + } + + return $result; + } + + /** + * Decodes JSON from a model response, tolerating Markdown code fences. + * + * @since 1.0.0 + * + * @param string $text The raw model response. + * @return mixed Decoded data, or null on failure. + */ + private function decode_json( string $text ) { + $text = trim( $text ); + + // Strip a leading/trailing Markdown code fence if present. + if ( 0 === strpos( $text, '```' ) ) { + $text = (string) preg_replace( '/^```[a-zA-Z]*\s*/', '', $text ); + $text = (string) preg_replace( '/\s*```$/', '', $text ); + } + + $decoded = json_decode( $text, true ); + if ( null !== $decoded ) { + return $decoded; + } + + // As a fallback, extract the first JSON array or object in the text. + if ( 1 === preg_match( '/(\[.*\]|\{.*\})/s', $text, $matches ) ) { + return json_decode( $matches[1], true ); + } + + return null; + } + + /** + * Returns the system instruction that defines the advisor persona and output contract. + * + * @since 1.0.0 + * + * @return string The system instruction. + */ + private function get_system_instruction(): string { + $severities = implode( ', ', aipa_get_severities() ); + $categories = implode( ', ', aipa_get_categories() ); + + return sprintf( + 'You are a WordPress performance expert. You are given structured data about a WordPress site (server and database configuration, active plugins and theme, Site Health test results, and possibly a PageSpeed Insights snapshot). ' . + 'Analyze it and produce specific, actionable performance recommendations tailored to this site. Prefer concrete, high-impact advice over generic tips, and cite the data that supports each recommendation. ' . + 'Assets served from a plugin or theme include the slug in their path (/wp-content/plugins/{slug}/ or /wp-content/themes/{slug}/); use this to attribute issues. Do not invent data that is not present. ' . + "\n\n" . + 'Respond with ONLY a JSON array (no prose, no Markdown fences). Each array item is an object with these fields: ' . + '"id" (a short stable slug), ' . + '"title" (a concise headline), ' . + '"severity" (one of: %1$s), ' . + '"category" (one of: %2$s), ' . + '"summary" (one or two sentences on what to do and why), ' . + '"details" (a longer explanation including manual fix steps; plain text or simple Markdown), ' . + '"evidence" (an array of short strings naming the data points that triggered this recommendation). ' . + 'Order the array from most to least urgent. Return at most 12 recommendations.', + $severities, + $categories + ); + } +} diff --git a/plugins/ai-performance-advisor/includes/class-aipa-context-provider-registry.php b/plugins/ai-performance-advisor/includes/class-aipa-context-provider-registry.php new file mode 100644 index 0000000000..b0099621b3 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/class-aipa-context-provider-registry.php @@ -0,0 +1,116 @@ +providers[ $provider->get_key() ] = $provider; + } + + /** + * Unregisters a context provider by key. + * + * @since 1.0.0 + * + * @param string $key Provider key. + */ + public function unregister( string $key ): void { + unset( $this->providers[ $key ] ); + } + + /** + * Returns the registered, currently-available providers. + * + * @since 1.0.0 + * + * @return AIPA_Context_Provider[] Available providers keyed by provider key. + */ + public function get_available_providers(): array { + return array_filter( + $this->providers, + static function ( AIPA_Context_Provider $provider ): bool { + return $provider->is_available(); + } + ); + } + + /** + * Returns labels for the available providers, for display to the user. + * + * @since 1.0.0 + * + * @return string[] Provider labels. + */ + public function get_available_labels(): array { + return array_values( + array_map( + static function ( AIPA_Context_Provider $provider ): string { + return $provider->get_label(); + }, + $this->get_available_providers() + ) + ); + } + + /** + * Collects context from every available provider. + * + * @since 1.0.0 + * + * @return array The assembled context payload. + */ + public function collect(): array { + $context = array(); + + foreach ( $this->get_available_providers() as $key => $provider ) { + $data = $provider->collect(); + if ( count( $data ) > 0 ) { + $context[ $key ] = $data; + } + } + + /** + * Filters the full context payload sent to the AI model. + * + * Use this to redact, add, or otherwise adjust the data transmitted for + * analysis. The array is keyed by provider key. + * + * @since 1.0.0 + * + * @param array $context The assembled context payload. + */ + return apply_filters( 'aipa_context', $context ); + } +} diff --git a/plugins/ai-performance-advisor/includes/class-aipa-context-provider.php b/plugins/ai-performance-advisor/includes/class-aipa-context-provider.php new file mode 100644 index 0000000000..1b1f76b091 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/class-aipa-context-provider.php @@ -0,0 +1,68 @@ + Structured, AI-consumable data. + */ + abstract public function collect(): array; +} diff --git a/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-environment.php b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-environment.php new file mode 100644 index 0000000000..95db4345c9 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-environment.php @@ -0,0 +1,98 @@ + Environment data. + */ + public function collect(): array { + global $wp_version, $wpdb; + + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $active_plugin_files = (array) get_option( 'active_plugins', array() ); + if ( is_multisite() ) { + $active_plugin_files = array_merge( $active_plugin_files, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); + } + + $all_plugins = get_plugins(); + $plugins = array(); + foreach ( array_unique( $active_plugin_files ) as $plugin_file ) { + if ( isset( $all_plugins[ $plugin_file ] ) ) { + $plugins[] = array( + 'name' => $all_plugins[ $plugin_file ]['Name'], + 'version' => $all_plugins[ $plugin_file ]['Version'], + ); + } + } + + $theme = wp_get_theme(); + $theme_data = array( + 'name' => $theme->get( 'Name' ), + 'version' => $theme->get( 'Version' ), + ); + $parent_theme = $theme->parent(); + if ( $parent_theme instanceof WP_Theme ) { + $theme_data['parent'] = array( + 'name' => $parent_theme->get( 'Name' ), + 'version' => $parent_theme->get( 'Version' ), + ); + } + + return array( + 'wp_version' => (string) $wp_version, + 'php_version' => PHP_VERSION, + 'database' => $wpdb instanceof wpdb ? $wpdb->db_server_info() : '', + 'is_multisite' => is_multisite(), + 'locale' => get_locale(), + 'active_theme' => $theme_data, + 'active_plugins' => $plugins, + ); + } +} diff --git a/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-optimization-detective.php b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-optimization-detective.php new file mode 100644 index 0000000000..e60ba3c1b4 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-optimization-detective.php @@ -0,0 +1,80 @@ + Optimization Detective summary. + */ + public function collect(): array { + $measured = 0; + $counts = wp_count_posts( OD_URL_Metrics_Post_Type::SLUG ); + if ( is_object( $counts ) ) { + $measured = (int) array_sum( array_map( 'intval', (array) $counts ) ); + } + + return array( + 'active' => true, + 'version' => OPTIMIZATION_DETECTIVE_VERSION, + 'measured_url_count' => $measured, + ); + } +} diff --git a/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-pagespeed.php b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-pagespeed.php new file mode 100644 index 0000000000..1c5e11b25a --- /dev/null +++ b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-pagespeed.php @@ -0,0 +1,168 @@ + Compact PageSpeed Insights data. + */ + public function collect(): array { + $cached = get_transient( self::CACHE_KEY ); + if ( is_array( $cached ) ) { + return $cached; + } + + $settings = aipa_get_settings(); + $query_params = array( + 'url' => home_url( '/' ), + 'category' => 'performance', + 'strategy' => 'mobile', + ); + if ( '' !== $settings['pagespeed_api_key'] ) { + $query_params['key'] = $settings['pagespeed_api_key']; + } + + $response = wp_remote_get( + add_query_arg( $query_params, 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed' ), + array( 'timeout' => 60 ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'error' => $response->get_error_message() ); + } + + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return array( 'error' => sprintf( 'PageSpeed Insights returned HTTP %d.', (int) wp_remote_retrieve_response_code( $response ) ) ); + } + + $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $decoded ) || ! isset( $decoded['lighthouseResult'] ) || ! is_array( $decoded['lighthouseResult'] ) ) { + return array( 'error' => 'Could not decode the PageSpeed Insights response.' ); + } + + $compact = $this->compact_result( $decoded['lighthouseResult'] ); + $compact['url'] = home_url( '/' ); + $compact['strategy'] = 'mobile'; + + set_transient( self::CACHE_KEY, $compact, 12 * HOUR_IN_SECONDS ); + + return $compact; + } + + /** + * Reduces a Lighthouse result to a compact, token-friendly summary. + * + * @since 1.0.0 + * + * @param array $lhr The lighthouseResult object. + * @return array Compact summary. + */ + private function compact_result( array $lhr ): array { + $audits = isset( $lhr['audits'] ) && is_array( $lhr['audits'] ) ? $lhr['audits'] : array(); + + $score = null; + if ( isset( $lhr['categories']['performance']['score'] ) && is_numeric( $lhr['categories']['performance']['score'] ) ) { + $score = (int) round( ( (float) $lhr['categories']['performance']['score'] ) * 100 ); + } + + $metric_keys = array( + 'first-contentful-paint', + 'largest-contentful-paint', + 'total-blocking-time', + 'cumulative-layout-shift', + 'speed-index', + 'interactive', + ); + $metrics = array(); + foreach ( $metric_keys as $metric_key ) { + if ( isset( $audits[ $metric_key ]['displayValue'] ) && is_string( $audits[ $metric_key ]['displayValue'] ) ) { + $metrics[ $metric_key ] = $audits[ $metric_key ]['displayValue']; + } + } + + $opportunities = array(); + foreach ( $audits as $audit ) { + if ( ! is_array( $audit ) ) { + continue; + } + $is_opportunity = isset( $audit['details']['type'] ) && 'opportunity' === $audit['details']['type']; + $audit_score = isset( $audit['score'] ) && is_numeric( $audit['score'] ) ? (float) $audit['score'] : 1.0; + if ( $is_opportunity && $audit_score < 0.9 && isset( $audit['title'] ) ) { + $opportunities[] = array( + 'title' => sanitize_text_field( (string) $audit['title'] ), + 'displayValue' => isset( $audit['displayValue'] ) ? sanitize_text_field( (string) $audit['displayValue'] ) : '', + ); + } + if ( count( $opportunities ) >= 10 ) { + break; + } + } + + return array( + 'performance_score' => $score, + 'metrics' => $metrics, + 'opportunities' => $opportunities, + ); + } +} diff --git a/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health-tests.php b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health-tests.php new file mode 100644 index 0000000000..2cececf072 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health-tests.php @@ -0,0 +1,97 @@ + Test results keyed by test slug. + */ + public function collect(): array { + /** This filter is documented in wp-admin/includes/class-wp-site-health.php. */ + $tests = apply_filters( + 'site_status_tests', + array( + 'direct' => array(), + 'async' => array(), + ) + ); + + if ( ! is_array( $tests ) || ! isset( $tests['direct'] ) || ! is_array( $tests['direct'] ) ) { + return array(); + } + + $results = array(); + foreach ( $tests['direct'] as $slug => $test ) { + if ( ! is_array( $test ) || ! isset( $test['test'] ) || ! is_callable( $test['test'] ) ) { + continue; + } + + try { + $result = call_user_func( $test['test'] ); + } catch ( \Throwable $e ) { + continue; + } + + if ( ! is_array( $result ) ) { + continue; + } + + $results[ sanitize_key( (string) $slug ) ] = array( + 'label' => isset( $result['label'] ) ? wp_strip_all_tags( (string) $result['label'] ) : '', + 'status' => isset( $result['status'] ) ? (string) $result['status'] : '', + 'description' => isset( $result['description'] ) ? wp_strip_all_tags( (string) $result['description'] ) : '', + ); + } + + return $results; + } +} diff --git a/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health.php b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health.php new file mode 100644 index 0000000000..8bd0200ecd --- /dev/null +++ b/plugins/ai-performance-advisor/includes/providers/class-aipa-provider-site-health.php @@ -0,0 +1,136 @@ + + */ + private const SECTIONS = array( + 'wp-core' => 'wp_core', + 'wp-server' => 'server', + 'wp-database' => 'database', + 'wp-media' => 'media', + 'wp-constants' => 'constants', + ); + + /** + * {@inheritDoc} + * + * @since 1.0.0 + * + * @return string Provider key. + */ + public function get_key(): string { + return 'site_health'; + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + * + * @return string Provider label. + */ + public function get_label(): string { + return __( 'Server, PHP, database, media, and configuration details from Site Health (private fields excluded)', 'ai-performance-advisor' ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + * + * @return array Site Health data. + */ + public function collect(): array { + if ( ! class_exists( 'WP_Debug_Data' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-debug-data.php'; + } + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( ! function_exists( 'get_core_updates' ) ) { + require_once ABSPATH . 'wp-admin/includes/update.php'; + } + + $all_sections = WP_Debug_Data::debug_data(); + + $data = array(); + foreach ( self::SECTIONS as $section_key => $output_key ) { + if ( ! isset( $all_sections[ $section_key ] ) || ! is_array( $all_sections[ $section_key ] ) ) { + continue; + } + $flattened = $this->flatten_section( $all_sections[ $section_key ] ); + if ( count( $flattened ) > 0 ) { + $data[ $output_key ] = $flattened; + } + } + + return $data; + } + + /** + * Flattens a WP_Debug_Data section into a label => value map, dropping private fields. + * + * @since 1.0.0 + * + * @param array $section A debug-data section with a 'fields' array. + * @return array Label => value pairs. + */ + private function flatten_section( array $section ): array { + $out = array(); + + if ( ! isset( $section['fields'] ) || ! is_array( $section['fields'] ) ) { + return $out; + } + + foreach ( $section['fields'] as $field_key => $field ) { + if ( ! is_array( $field ) ) { + continue; + } + if ( (bool) ( $field['private'] ?? false ) ) { + continue; + } + + $label = isset( $field['label'] ) && is_string( $field['label'] ) ? $field['label'] : (string) $field_key; + $value = $field['value'] ?? ''; + + if ( is_bool( $value ) ) { + $value = $value ? 'true' : 'false'; + } elseif ( is_array( $value ) ) { + $value = (string) wp_json_encode( $value ); + } elseif ( ! is_scalar( $value ) ) { + $value = ''; + } + + $out[ $label ] = (string) $value; + } + + return $out; + } +} diff --git a/plugins/ai-performance-advisor/includes/rest-api.php b/plugins/ai-performance-advisor/includes/rest-api.php new file mode 100644 index 0000000000..44a43175aa --- /dev/null +++ b/plugins/ai-performance-advisor/includes/rest-api.php @@ -0,0 +1,81 @@ + WP_REST_Server::CREATABLE, + 'callback' => 'aipa_rest_analyze', + 'permission_callback' => 'aipa_rest_permission_check', + 'args' => array( + 'refresh' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass the cache and run a fresh analysis.', 'ai-performance-advisor' ), + ), + ), + ) + ); +} + +/** + * Permission callback for the analysis route. + * + * @since 1.0.0 + * + * @return bool|WP_Error True if allowed, error otherwise. + */ +function aipa_rest_permission_check() { + if ( ! current_user_can( 'view_site_health_checks' ) ) { + return new WP_Error( + 'aipa_forbidden', + __( 'Sorry, you are not allowed to run a performance analysis.', 'ai-performance-advisor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + return true; +} + +/** + * Handles the analysis request. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request The REST request. + * @phpstan-param WP_REST_Request> $request The REST request. + * @return WP_REST_Response|WP_Error Response with recommendations, or an error. + */ +function aipa_rest_analyze( WP_REST_Request $request ) { + $analyzer = new AIPA_Analyzer(); + $recommendations = $analyzer->analyze( ! (bool) $request->get_param( 'refresh' ) ); + + if ( is_wp_error( $recommendations ) ) { + $recommendations->add_data( array( 'status' => 500 ), $recommendations->get_error_code() ); + return $recommendations; + } + + return rest_ensure_response( + array( + 'recommendations' => $recommendations, + ) + ); +} diff --git a/plugins/ai-performance-advisor/includes/site-health.php b/plugins/ai-performance-advisor/includes/site-health.php new file mode 100644 index 0000000000..9402dad854 --- /dev/null +++ b/plugins/ai-performance-advisor/includes/site-health.php @@ -0,0 +1,83 @@ + +
+

+ + +
+

+ AI connectors settings.', 'ai-performance-advisor' ), + esc_url( admin_url( 'options-connectors.php' ) ) + ); + echo wp_kses( $aipa_connect_message, array( 'a' => array( 'href' => array() ) ) ); + ?> +

+
+ +

+ +

+
    + get_available_labels() as $aipa_label ) : ?> +
  • + +
+

+ + +

+

+ +

+
+ +
+ , action?: AipaAction|null}} AipaRecommendation + */ + +/** + * Identity fallback used when wp.i18n is unavailable. + * + * @param {string} text Text to return unchanged. + * @return {string} The same text. + */ +const aipaIdentity = ( text ) => text; + +( function () { + 'use strict'; + + const apiFetch = window.wp && window.wp.apiFetch; + + // Reference the translation function directly so all calls below pass string + // literals (required by the @wordpress/i18n lint rules). Fall back to an + // identity function when wp.i18n is not available. + const __ = + window.wp && window.wp.i18n && window.wp.i18n.__ + ? window.wp.i18n.__ + : aipaIdentity; + + const buttonElement = document.getElementById( 'aipa-analyze' ); + const resultsElement = document.getElementById( 'aipa-results' ); + + if ( + ! ( buttonElement instanceof HTMLButtonElement ) || + ! ( resultsElement instanceof HTMLElement ) || + ! apiFetch + ) { + return; + } + + const button = buttonElement; + const results = resultsElement; + const spinner = document.getElementById( 'aipa-spinner' ); + + /** + * Sets the busy state of the analyze control. + * + * @param {boolean} busy Whether a request is in flight. + */ + function setBusy( busy ) { + button.disabled = busy; + if ( spinner ) { + spinner.classList.toggle( 'is-active', busy ); + } + } + + /** + * Renders a notice in the results area. + * + * @param {string} message The message text. + * @param {string} type Notice type (error, info). + */ + function renderNotice( message, type ) { + const notice = document.createElement( 'div' ); + notice.className = 'notice notice-' + ( type || 'info' ) + ' inline'; + const p = document.createElement( 'p' ); + p.textContent = message; + notice.appendChild( p ); + results.replaceChildren( notice ); + } + + /** + * Builds a single recommendation card element. + * + * @param {AipaRecommendation} rec A sanitized recommendation object. + * @return {HTMLElement} The card element. + */ + function buildCard( rec ) { + const card = document.createElement( 'div' ); + card.className = + 'aipa-card aipa-severity-' + ( rec.severity || 'info' ); + + const heading = document.createElement( 'h3' ); + heading.className = 'aipa-card-title'; + + const badge = document.createElement( 'span' ); + badge.className = 'aipa-badge aipa-badge-' + ( rec.severity || 'info' ); + badge.textContent = rec.severity || 'info'; + heading.appendChild( badge ); + + heading.appendChild( + document.createTextNode( ' ' + ( rec.title || '' ) ) + ); + card.appendChild( heading ); + + const summary = document.createElement( 'p' ); + summary.className = 'aipa-card-summary'; + summary.textContent = rec.summary || ''; + card.appendChild( summary ); + + if ( rec.details ) { + const details = document.createElement( 'div' ); + details.className = 'aipa-card-details'; + details.textContent = rec.details; + card.appendChild( details ); + } + + if ( Array.isArray( rec.evidence ) && rec.evidence.length ) { + const evidenceWrap = document.createElement( 'details' ); + evidenceWrap.className = 'aipa-card-evidence'; + const sum = document.createElement( 'summary' ); + sum.textContent = __( 'Evidence', 'ai-performance-advisor' ); + evidenceWrap.appendChild( sum ); + const list = document.createElement( 'ul' ); + rec.evidence.forEach( ( /** @type {string} */ line ) => { + const li = document.createElement( 'li' ); + li.textContent = line; + list.appendChild( li ); + } ); + evidenceWrap.appendChild( list ); + card.appendChild( evidenceWrap ); + } + + // Forward-looking: render a "Configure" link now, and an "Apply" affordance + // once the AI can map a recommendation to a registered ability. + if ( rec.action ) { + const actions = document.createElement( 'p' ); + actions.className = 'aipa-card-actions'; + if ( rec.action.settings_url ) { + const link = document.createElement( 'a' ); + link.className = 'button'; + link.href = rec.action.settings_url; + link.textContent = __( 'Configure', 'ai-performance-advisor' ); + actions.appendChild( link ); + } + if ( rec.action.ability && rec.action.ability.name ) { + const apply = document.createElement( 'button' ); + apply.type = 'button'; + apply.className = 'button button-secondary aipa-apply'; + apply.disabled = true; + apply.textContent = __( + 'Apply (coming soon)', + 'ai-performance-advisor' + ); + actions.appendChild( apply ); + } + if ( actions.childNodes.length ) { + card.appendChild( actions ); + } + } + + return card; + } + + /** + * Renders the list of recommendations. + * + * @param {Array|undefined} recommendations The recommendations array. + */ + function renderRecommendations( recommendations ) { + if ( ! Array.isArray( recommendations ) || ! recommendations.length ) { + renderNotice( + __( + 'No recommendations were returned. Your site may already be well optimized.', + 'ai-performance-advisor' + ), + 'info' + ); + return; + } + + const fragment = document.createDocumentFragment(); + recommendations.forEach( ( /** @type {AipaRecommendation} */ rec ) => { + fragment.appendChild( buildCard( rec ) ); + } ); + results.replaceChildren( fragment ); + } + + button.addEventListener( 'click', function () { + setBusy( true ); + renderNotice( + __( + 'Analyzing your site, this may take a moment…', + 'ai-performance-advisor' + ), + 'info' + ); + + apiFetch( { + path: '/ai-performance-advisor/v1/analyze', + method: 'POST', + data: { refresh: true }, + } ) + .then( + ( + /** @type {{recommendations?: Array}} */ response + ) => { + renderRecommendations( + response && response.recommendations + ); + } + ) + .catch( ( /** @type {{message?: string}} */ error ) => { + renderNotice( + ( error && error.message ) || + __( + 'The analysis could not be completed.', + 'ai-performance-advisor' + ), + 'error' + ); + } ) + .finally( () => { + setBusy( false ); + } ); + } ); +} )(); diff --git a/plugins/ai-performance-advisor/load.php b/plugins/ai-performance-advisor/load.php new file mode 100644 index 0000000000..e44267c85b --- /dev/null +++ b/plugins/ai-performance-advisor/load.php @@ -0,0 +1,48 @@ + + + WordPress Coding Standards for AI Performance Advisor Plugin + + + + + + + + . + diff --git a/plugins/ai-performance-advisor/readme.txt b/plugins/ai-performance-advisor/readme.txt new file mode 100644 index 0000000000..13af0348ea --- /dev/null +++ b/plugins/ai-performance-advisor/readme.txt @@ -0,0 +1,61 @@ +=== AI Performance Advisor === + +Contributors: wordpressdotorg +Requires at least: 7.0 +Tested up to: 7.1 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +Tags: performance, ai, site health, recommendations, optimization + +Actionable, AI-generated performance tuning recommendations for your site, surfaced in a Site Health tab. + +== Description == + +AI Performance Advisor analyzes your site using real configuration and performance data and asks a connected AI provider for specific, actionable performance recommendations. Results appear in a dedicated **AI Performance Advisor** tab on the Site Health screen. + +The analysis runs only when you click **Analyze my site**, so you remain in control of when (and how often) your configured AI provider is called. + += What gets analyzed = + +* WordPress, server, PHP, and database details from Site Health (private fields are excluded). +* Active plugins, the active theme, and version information. +* Performance-related Site Health test results. +* A PageSpeed Insights (Lighthouse) snapshot of your home page (optional; can be disabled in settings). +* Optimization Detective URL Metrics, when that plugin is active. + += Privacy = + +No analysis is sent anywhere until you explicitly start it. Sensitive Site Health fields marked private (keys, salts, secrets) are never included. Site owners and developers can adjust exactly what is sent using the `aipa_context` filter. + += Requirements = + +* WordPress 7.0 or higher (for the AI Client and Connectors APIs). +* At least one connected AI provider that supports text generation. + += Suggest-only = + +This version only *suggests* changes and links you to where to make them. It does not modify any settings. The recommendation format is designed so that a future version can offer to apply changes for you as the WordPress Abilities API matures. + +== Installation == + +1. Install and activate the plugin. +2. Connect an AI provider under Settings (Connectors). +3. Visit Tools → Site Health → AI Performance Advisor and click "Analyze my site". + +== Frequently Asked Questions == + += Does this cost anything? = + +The analysis calls whatever AI provider you have connected, so any usage costs are determined by that provider. PageSpeed Insights is free at the low volumes this plugin uses. + += Does it change my site automatically? = + +No. This version only provides recommendations. It never changes settings on your behalf. + +== Changelog == + += 1.0.0 = + +* Initial release. diff --git a/plugins/ai-performance-advisor/settings.php b/plugins/ai-performance-advisor/settings.php new file mode 100644 index 0000000000..7aff64792b --- /dev/null +++ b/plugins/ai-performance-advisor/settings.php @@ -0,0 +1,167 @@ + isset( $input['include_pagespeed'] ) && (bool) $input['include_pagespeed'], + 'pagespeed_api_key' => isset( $input['pagespeed_api_key'] ) ? sanitize_text_field( (string) $input['pagespeed_api_key'] ) : '', + ); +} + +/** + * Registers the plugin setting. + * + * @since 1.0.0 + */ +function aipa_register_setting(): void { + register_setting( + 'general', + 'aipa_settings', + array( + 'type' => 'object', + 'description' => __( 'AI Performance Advisor configuration.', 'ai-performance-advisor' ), + 'sanitize_callback' => 'aipa_sanitize_settings', + 'default' => aipa_get_default_settings(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'include_pagespeed' => array( + 'type' => 'boolean', + ), + 'pagespeed_api_key' => array( + 'type' => 'string', + ), + ), + 'additionalProperties' => false, + ), + ), + ) + ); +} +add_action( 'admin_init', 'aipa_register_setting' ); + +/** + * Adds the settings section and fields to the General settings screen. + * + * @since 1.0.0 + */ +function aipa_add_settings_ui(): void { + add_settings_section( + 'aipa_settings', + __( 'AI Performance Advisor', 'ai-performance-advisor' ), + static function (): void { + ?> +

+ +

+ + + + +

+ +

+ sprintf( + '%2$s', + esc_url( admin_url( 'options-general.php#ai-performance-advisor' ) ), + esc_html__( 'Settings', 'ai-performance-advisor' ) + ), + ), + $links + ); +} +add_filter( 'plugin_action_links_' . plugin_basename( AI_PERFORMANCE_ADVISOR_MAIN_FILE ), 'aipa_add_settings_action_link' ); diff --git a/plugins/ai-performance-advisor/tests/test-context-providers.php b/plugins/ai-performance-advisor/tests/test-context-providers.php new file mode 100644 index 0000000000..10938c3036 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-context-providers.php @@ -0,0 +1,84 @@ +assertSame( 'environment', $provider->get_key() ); + $this->assertTrue( $provider->is_available() ); + + $data = $provider->collect(); + $this->assertArrayHasKey( 'wp_version', $data ); + $this->assertArrayHasKey( 'php_version', $data ); + $this->assertArrayHasKey( 'active_theme', $data ); + $this->assertArrayHasKey( 'active_plugins', $data ); + $this->assertIsArray( $data['active_plugins'] ); + } + + public function test_site_health_provider_excludes_private_fields(): void { + $provider = new AIPA_Provider_Site_Health(); + $data = $provider->collect(); + + // Flatten all collected values and ensure no obvious secret leaked. + $flat = wp_json_encode( $data ); + $this->assertIsString( $flat ); + if ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ) { + $this->assertStringNotContainsString( (string) AUTH_KEY, $flat ); + } + } + + public function test_pagespeed_provider_availability_follows_setting(): void { + $provider = new AIPA_Provider_PageSpeed(); + + update_option( 'aipa_settings', array( 'include_pagespeed' => true ) ); + $this->assertTrue( $provider->is_available() ); + + update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); + $this->assertFalse( $provider->is_available() ); + } + + public function test_registry_collect_and_filter(): void { + $registry = new AIPA_Context_Provider_Registry(); + $registry->register( new AIPA_Provider_Environment() ); + + $context = $registry->collect(); + $this->assertArrayHasKey( 'environment', $context ); + + // The aipa_context filter can adjust the payload. + add_filter( + 'aipa_context', + static function ( array $ctx ): array { + $ctx['injected'] = array( 'hello' => 'world' ); + return $ctx; + } + ); + $filtered = $registry->collect(); + $this->assertArrayHasKey( 'injected', $filtered ); + remove_all_filters( 'aipa_context' ); + } + + public function test_registry_unregister(): void { + $registry = new AIPA_Context_Provider_Registry(); + $registry->register( new AIPA_Provider_Environment() ); + $registry->unregister( 'environment' ); + $this->assertArrayNotHasKey( 'environment', $registry->get_available_providers() ); + } + + public function test_default_registry_includes_expected_providers(): void { + $labels = aipa_get_context_registry()->get_available_labels(); + $this->assertNotEmpty( $labels ); + } +} diff --git a/plugins/ai-performance-advisor/tests/test-helper.php b/plugins/ai-performance-advisor/tests/test-helper.php new file mode 100644 index 0000000000..933e12f24f --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-helper.php @@ -0,0 +1,137 @@ +assertArrayHasKey( 'include_pagespeed', $defaults ); + $this->assertArrayHasKey( 'pagespeed_api_key', $defaults ); + $this->assertTrue( $defaults['include_pagespeed'] ); + } + + public function test_get_settings_merges_defaults(): void { + update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); + $settings = aipa_get_settings(); + $this->assertFalse( $settings['include_pagespeed'] ); + $this->assertSame( '', $settings['pagespeed_api_key'] ); + } + + public function test_is_ai_available_false_when_unsupported(): void { + add_filter( 'wp_supports_ai', '__return_false' ); + $this->assertFalse( aipa_is_ai_available() ); + remove_filter( 'wp_supports_ai', '__return_false' ); + } + + public function test_sanitize_recommendations_drops_invalid_and_sorts(): void { + $raw = array( + array( + 'title' => 'Low priority tip', + 'summary' => 'Do this eventually.', + 'severity' => 'info', + 'category' => 'other', + ), + array( + // Missing summary, should be dropped. + 'title' => 'Incomplete', + ), + array( + 'title' => 'Urgent fix', + 'summary' => 'Fix this now.', + 'severity' => 'critical', + 'category' => 'images', + 'evidence' => array( 'LCP is 5s', '' ), + ), + array( + 'title' => 'Bad severity', + 'summary' => 'Defaults applied.', + 'severity' => 'not-a-severity', + 'category' => 'not-a-category', + ), + ); + + $clean = aipa_sanitize_recommendations( $raw ); + + $this->assertCount( 3, $clean ); + // Critical sorts first. + $this->assertSame( 'Urgent fix', $clean[0]['title'] ); + $this->assertSame( 'critical', $clean[0]['severity'] ); + // Empty evidence line is dropped. + $this->assertSame( array( 'LCP is 5s' ), $clean[0]['evidence'] ); + // Invalid severity/category fall back to defaults. + $bad = array_values( + array_filter( + $clean, + static function ( array $r ): bool { + return 'Bad severity' === $r['title']; + } + ) + ); + $this->assertSame( 'recommended', $bad[0]['severity'] ); + $this->assertSame( 'other', $bad[0]['category'] ); + } + + public function test_sanitize_recommendations_unwraps_object(): void { + $raw = array( + 'recommendations' => array( + array( + 'title' => 'Wrapped', + 'summary' => 'Inside a recommendations key.', + ), + ), + ); + + $clean = aipa_sanitize_recommendations( $raw ); + $this->assertCount( 1, $clean ); + $this->assertSame( 'Wrapped', $clean[0]['title'] ); + } + + public function test_sanitize_action_keeps_admin_url_drops_unregistered_ability(): void { + $raw = array( + array( + 'title' => 'With action', + 'summary' => 'Has an action payload.', + 'action' => array( + 'settings_url' => admin_url( 'options-general.php' ), + 'ability' => array( + 'name' => 'nonexistent/ability', + 'args' => array( 'foo' => 'bar' ), + ), + ), + ), + ); + + $clean = aipa_sanitize_recommendations( $raw ); + $this->assertNotNull( $clean[0]['action'] ); + $this->assertSame( admin_url( 'options-general.php' ), $clean[0]['action']['settings_url'] ); + // The ability is not registered, so it must not be retained. + $this->assertNull( $clean[0]['action']['ability'] ); + } + + public function test_sanitize_action_rejects_offsite_url(): void { + $raw = array( + array( + 'title' => 'Offsite', + 'summary' => 'Has an off-site settings URL.', + 'action' => array( + 'settings_url' => 'https://evil.example.com/', + ), + ), + ); + + $clean = aipa_sanitize_recommendations( $raw ); + // Off-site URL is rejected and there is no ability, so action becomes null. + $this->assertNull( $clean[0]['action'] ); + } +} diff --git a/plugins/ai-performance-advisor/tests/test-rest-and-site-health.php b/plugins/ai-performance-advisor/tests/test-rest-and-site-health.php new file mode 100644 index 0000000000..519227b8eb --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-rest-and-site-health.php @@ -0,0 +1,55 @@ + 'Status' ) ); + $this->assertArrayHasKey( 'ai-performance-advisor', $tabs ); + } + + public function test_site_health_tab_filter_ignores_non_array(): void { + $this->assertSame( 'unexpected', aipa_add_site_health_tab( 'unexpected' ) ); + } + + public function test_rest_route_registered(): void { + // rest_get_server() fires the rest_api_init action, which is where the + // plugin registers its route (see hooks.php). Calling the registration + // function directly would trip register_rest_route()'s doing-it-wrong notice. + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/ai-performance-advisor/v1/analyze', $routes ); + } + + public function test_permission_denied_for_subscriber(): void { + $user = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user ); + $result = aipa_rest_permission_check(); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function test_permission_granted_for_admin(): void { + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + // On multisite the view_site_health_checks capability is reserved for super + // admins, so elevate the user to ensure they genuinely hold the capability. + if ( is_multisite() ) { + grant_super_admin( $user ); + } + wp_set_current_user( $user ); + $this->assertTrue( aipa_rest_permission_check() ); + } + + public function test_analyzer_errors_when_ai_unavailable(): void { + add_filter( 'wp_supports_ai', '__return_false' ); + $analyzer = new AIPA_Analyzer(); + $result = $analyzer->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_unavailable', $result->get_error_code() ); + remove_filter( 'wp_supports_ai', '__return_false' ); + } +} diff --git a/plugins/ai-performance-advisor/uninstall.php b/plugins/ai-performance-advisor/uninstall.php new file mode 100644 index 0000000000..9b32391b6f --- /dev/null +++ b/plugins/ai-performance-advisor/uninstall.php @@ -0,0 +1,21 @@ + array( + 'constant' => 'AI_PERFORMANCE_ADVISOR_VERSION', + 'experimental' => true, + ), 'auto-sizes' => array( 'constant' => 'IMAGE_AUTO_SIZES_VERSION', 'experimental' => false, diff --git a/tools/phpstan/stubs/ai-client.stub b/tools/phpstan/stubs/ai-client.stub new file mode 100644 index 0000000000..8ee68f1794 --- /dev/null +++ b/tools/phpstan/stubs/ai-client.stub @@ -0,0 +1,61 @@ + Date: Fri, 29 May 2026 14:58:34 -0400 Subject: [PATCH 2/5] Add comprehensive test coverage for AI Performance Advisor The initial test files carried no @covers annotations, so under the project's strict coverage-metadata setting their coverage was discarded and Codecov reported 0.67% patch coverage. Annotate every test and greatly expand the suite to exercise the helper functions, the analyzer (cache, parsing, and error paths via mocked seams), all five context providers (including PageSpeed Insights HTTP mocking), the registry, the settings, the REST endpoint, and the Site Health tab renderer. Add two filters as testability seams: aipa_pre_is_ai_available (force the availability result) and aipa_pre_generate_text (short-circuit the model call). These let the AI-dependent paths be tested deterministically and also let integrators force availability or route the request through their own client. --- plugins/ai-performance-advisor/helper.php | 16 + .../includes/class-aipa-analyzer.php | 31 +- .../class-aipa-empty-context-provider.php | 43 ++ .../class-aipa-fake-context-provider.php | 43 ++ ...lass-aipa-unavailable-context-provider.php | 52 +++ .../tests/test-analyzer.php | 247 +++++++++++ .../tests/test-context-providers.php | 392 +++++++++++++++++- .../tests/test-helper.php | 208 +++++++++- .../tests/test-rest-and-site-health.php | 139 ++++++- .../tests/test-settings.php | 117 ++++++ 10 files changed, 1241 insertions(+), 47 deletions(-) create mode 100644 plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php create mode 100644 plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php create mode 100644 plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php create mode 100644 plugins/ai-performance-advisor/tests/test-analyzer.php create mode 100644 plugins/ai-performance-advisor/tests/test-settings.php diff --git a/plugins/ai-performance-advisor/helper.php b/plugins/ai-performance-advisor/helper.php index 7506a89886..6f71964ad1 100644 --- a/plugins/ai-performance-advisor/helper.php +++ b/plugins/ai-performance-advisor/helper.php @@ -24,6 +24,22 @@ * @return bool Whether AI recommendations can be generated. */ function aipa_is_ai_available(): bool { + /** + * Short-circuits the AI availability check. + * + * Returning a boolean from this filter bypasses the built-in detection, which is + * useful for forcing the advisor on or off (for example when a host manages + * provider availability itself, or in tests). + * + * @since 1.0.0 + * + * @param bool|null $pre Null to run the built-in detection, or a boolean to force the result. + */ + $pre = apply_filters( 'aipa_pre_is_ai_available', null ); + if ( is_bool( $pre ) ) { + return $pre; + } + static $available = null; if ( null !== $available ) { return $available; diff --git a/plugins/ai-performance-advisor/includes/class-aipa-analyzer.php b/plugins/ai-performance-advisor/includes/class-aipa-analyzer.php index 7f3936d560..360f72ebd9 100644 --- a/plugins/ai-performance-advisor/includes/class-aipa-analyzer.php +++ b/plugins/ai-performance-advisor/includes/class-aipa-analyzer.php @@ -81,13 +81,30 @@ private function request( array $context ) { (string) wp_json_encode( $context ) ); - try { - $result = wp_ai_client_prompt( $user_prompt ) - ->using_system_instruction( $this->get_system_instruction() ) - ->using_temperature( 0.2 ) - ->generate_text(); - } catch ( \Throwable $e ) { - return new WP_Error( 'aipa_ai_request_failed', $e->getMessage() ); + /** + * Short-circuits the call to the AI model. + * + * Returning a non-null value (a string of model output, or a WP_Error) from + * this filter bypasses the built-in AI Client request. This is the seam used + * by tests, and lets integrators route the request through their own client. + * + * @since 1.0.0 + * + * @param string|WP_Error|null $pre Null to call the AI Client, or pre-computed output. + * @param string $user_prompt The user prompt that would be sent. + * @param array $context The assembled context payload. + */ + $result = apply_filters( 'aipa_pre_generate_text', null, $user_prompt, $context ); + + if ( null === $result ) { + try { + $result = wp_ai_client_prompt( $user_prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.2 ) + ->generate_text(); + } catch ( \Throwable $e ) { + return new WP_Error( 'aipa_ai_request_failed', $e->getMessage() ); + } } if ( is_wp_error( $result ) ) { diff --git a/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php new file mode 100644 index 0000000000..964ab61420 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php @@ -0,0 +1,43 @@ + An empty payload. + */ + public function collect(): array { + return array(); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php new file mode 100644 index 0000000000..f24eb3b18b --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php @@ -0,0 +1,43 @@ + Fixed payload. + */ + public function collect(): array { + return array( 'note' => 'hermetic' ); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php new file mode 100644 index 0000000000..33df20b807 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php @@ -0,0 +1,52 @@ + A payload that should never be collected. + */ + public function collect(): array { + return array( 'should' => 'never appear' ); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/test-analyzer.php b/plugins/ai-performance-advisor/tests/test-analyzer.php new file mode 100644 index 0000000000..eed30dc98d --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-analyzer.php @@ -0,0 +1,247 @@ +ai_calls = 0; + delete_transient( AIPA_Analyzer::CACHE_KEY ); + add_action( 'aipa_register_context_providers', array( $this, 'use_hermetic_provider' ) ); + } + + public function tear_down(): void { + delete_transient( AIPA_Analyzer::CACHE_KEY ); + remove_all_filters( 'aipa_pre_is_ai_available' ); + remove_all_filters( 'aipa_pre_generate_text' ); + remove_all_actions( 'aipa_register_context_providers' ); + parent::tear_down(); + } + + /** + * Replaces the default providers with a single hermetic one (no network/Site Health). + * + * @param AIPA_Context_Provider_Registry $registry The registry being assembled. + */ + public function use_hermetic_provider( AIPA_Context_Provider_Registry $registry ): void { + foreach ( array( 'environment', 'site_health', 'site_health_tests', 'pagespeed', 'optimization_detective' ) as $key ) { + $registry->unregister( $key ); + } + $registry->register( new AIPA_Fake_Context_Provider() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_error_when_ai_unavailable(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_false' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_unavailable', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_sanitized_recommendations(): void { + $this->force_available(); + $this->set_ai_response( + (string) wp_json_encode( + array( + array( + 'title' => 'Compress images', + 'summary' => 'Serve images as WebP and size them correctly.', + 'severity' => 'critical', + 'category' => 'images', + ), + ) + ) + ); + + $result = ( new AIPA_Analyzer() )->analyze(); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Compress images', $result[0]['title'] ); + $this->assertSame( 'critical', $result[0]['severity'] ); + + // The result is cached in a transient. + $cached = get_transient( AIPA_Analyzer::CACHE_KEY ); + $this->assertIsArray( $cached ); + $this->assertArrayHasKey( 'hash', $cached ); + $this->assertArrayHasKey( 'recommendations', $cached ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_uses_cache_then_bypasses_with_refresh(): void { + $this->force_available(); + $this->set_ai_response( + (string) wp_json_encode( + array( + array( + 'title' => 'First result', + 'summary' => 'From the first model call.', + ), + ) + ) + ); + + $analyzer = new AIPA_Analyzer(); + $first = $analyzer->analyze(); + $this->assertSame( 1, $this->ai_calls ); + $this->assertIsArray( $first ); + $this->assertSame( 'First result', $first[0]['title'] ); + + // A cached call with an unchanged context does not call the model again. + $second = $analyzer->analyze( true ); + $this->assertSame( 1, $this->ai_calls ); + $this->assertIsArray( $second ); + $this->assertSame( 'First result', $second[0]['title'] ); + + // Refreshing bypasses the cache and calls the model again. + $third = $analyzer->analyze( false ); + $this->assertSame( 2, $this->ai_calls ); + $this->assertIsArray( $third ); + $this->assertSame( 'First result', $third[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_parses_code_fenced_json(): void { + $this->force_available(); + $this->set_ai_response( "```json\n[{\"title\":\"Enable caching\",\"summary\":\"Add a page cache.\"}]\n```" ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Enable caching', $result[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_parses_json_wrapped_in_prose(): void { + $this->force_available(); + $this->set_ai_response( 'Here are the recommendations: [{"title":"Defer scripts","summary":"Defer non-critical JS."}] Hope this helps.' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Defer scripts', $result[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_empty_for_unparseable_response(): void { + $this->force_available(); + $this->set_ai_response( 'totally not json' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertSame( array(), $result ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_propagates_empty_response_error(): void { + $this->force_available(); + $this->set_ai_response( '' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_empty_response', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_propagates_wp_error_from_model(): void { + $this->force_available(); + add_filter( + 'aipa_pre_generate_text', + static function () { + return new WP_Error( 'provider_down', 'The provider is unavailable.' ); + } + ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'provider_down', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_get_system_instruction_describes_output_contract(): void { + $method = new ReflectionMethod( AIPA_Analyzer::class, 'get_system_instruction' ); + $method->setAccessible( true ); + $instruction = (string) $method->invoke( new AIPA_Analyzer() ); + + $this->assertStringContainsString( 'JSON', $instruction ); + $this->assertStringContainsString( 'critical', $instruction ); + $this->assertStringContainsString( 'images', $instruction ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_request_returns_error_when_ai_client_throws(): void { + if ( function_exists( 'wp_ai_client_prompt' ) ) { + // The real AI Client API is present (WordPress >= 7.0), so the + // missing-client failure path (an undefined-function Throwable) cannot be + // exercised here. It is covered on environments without the AI Client. + $this->assertTrue( function_exists( 'wp_ai_client_prompt' ) ); + return; + } + + $this->force_available(); + // With no aipa_pre_generate_text filter, the analyzer calls the absent AI + // client, which throws and is converted into a WP_Error. + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_request_failed', $result->get_error_code() ); + } + + /** + * Forces the AI availability check to report "available". + */ + private function force_available(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_true' ); + } + + /** + * Routes the model call through a canned response, counting invocations. + * + * @param string $response The canned model output. + */ + private function set_ai_response( string $response ): void { + add_filter( + 'aipa_pre_generate_text', + function () use ( $response ) { + ++$this->ai_calls; + return $response; + } + ); + } +} diff --git a/plugins/ai-performance-advisor/tests/test-context-providers.php b/plugins/ai-performance-advisor/tests/test-context-providers.php index 10938c3036..deca214fec 100644 --- a/plugins/ai-performance-advisor/tests/test-context-providers.php +++ b/plugins/ai-performance-advisor/tests/test-context-providers.php @@ -7,17 +7,38 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/class-aipa-fake-context-provider.php'; +require_once __DIR__ . '/class-aipa-empty-context-provider.php'; +require_once __DIR__ . '/class-aipa-unavailable-context-provider.php'; + class AIPA_Test_Context_Providers extends WP_UnitTestCase { public function tear_down(): void { delete_option( 'aipa_settings' ); delete_transient( AIPA_Provider_PageSpeed::CACHE_KEY ); + remove_all_filters( 'pre_http_request' ); + remove_all_filters( 'aipa_context' ); + remove_all_filters( 'site_status_tests' ); parent::tear_down(); } + /** + * @covers AIPA_Context_Provider + */ + public function test_base_provider_is_available_defaults_to_true(): void { + $provider = new AIPA_Fake_Context_Provider(); + $this->assertTrue( $provider->is_available() ); + $this->assertSame( 'fake', $provider->get_key() ); + $this->assertSame( 'Fake provider', $provider->get_label() ); + } + + /** + * @covers AIPA_Provider_Environment + */ public function test_environment_provider_shape(): void { $provider = new AIPA_Provider_Environment(); $this->assertSame( 'environment', $provider->get_key() ); + $this->assertNotEmpty( $provider->get_label() ); $this->assertTrue( $provider->is_available() ); $data = $provider->collect(); @@ -26,22 +47,164 @@ public function test_environment_provider_shape(): void { $this->assertArrayHasKey( 'active_theme', $data ); $this->assertArrayHasKey( 'active_plugins', $data ); $this->assertIsArray( $data['active_plugins'] ); + $this->assertSame( PHP_VERSION, $data['php_version'] ); } - public function test_site_health_provider_excludes_private_fields(): void { + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_provider_collects_and_excludes_private_fields(): void { $provider = new AIPA_Provider_Site_Health(); - $data = $provider->collect(); + $this->assertSame( 'site_health', $provider->get_key() ); + $data = $provider->collect(); - // Flatten all collected values and ensure no obvious secret leaked. - $flat = wp_json_encode( $data ); - $this->assertIsString( $flat ); + // No private secret should leak into the payload. + $flat = (string) wp_json_encode( $data ); if ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ) { $this->assertStringNotContainsString( (string) AUTH_KEY, $flat ); } } + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_flatten_section_handles_all_value_types(): void { + $provider = new AIPA_Provider_Site_Health(); + $method = new ReflectionMethod( AIPA_Provider_Site_Health::class, 'flatten_section' ); + $method->setAccessible( true ); + + $section = array( + 'fields' => array( + 'secret' => array( + 'label' => 'Secret', + 'value' => 'sensitive', + 'private' => true, + ), + 'flag' => array( + 'label' => 'Flag', + 'value' => true, + ), + 'list' => array( + 'label' => 'List', + 'value' => array( 'a', 'b' ), + ), + 'obj' => array( + 'label' => 'Obj', + 'value' => new stdClass(), + ), + 'no_label' => array( + 'value' => 'no-label-value', + ), + 'not_array' => 'scalar-field', + ), + ); + + $flattened = $method->invoke( $provider, $section ); + + $this->assertArrayNotHasKey( 'Secret', $flattened ); + $this->assertSame( 'true', $flattened['Flag'] ); + $this->assertSame( wp_json_encode( array( 'a', 'b' ) ), $flattened['List'] ); + $this->assertSame( '', $flattened['Obj'] ); + $this->assertSame( 'no-label-value', $flattened['no_label'] ); + $this->assertArrayNotHasKey( 'scalar-field', $flattened ); + } + + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_flatten_section_without_fields_returns_empty(): void { + $provider = new AIPA_Provider_Site_Health(); + $method = new ReflectionMethod( AIPA_Provider_Site_Health::class, 'flatten_section' ); + $method->setAccessible( true ); + $this->assertSame( array(), $method->invoke( $provider, array( 'no_fields_key' => true ) ) ); + } + + /** + * @covers AIPA_Provider_Site_Health_Tests + */ + public function test_site_health_tests_provider_runs_only_valid_direct_tests(): void { + // Return only the fake tests (ignoring core tests, some of which make network + // requests) so the provider's behavior can be asserted deterministically. + add_filter( + 'site_status_tests', + static function (): array { + return array( + 'direct' => array( + 'aipa_good' => array( + 'label' => 'Good', + 'test' => static function (): array { + return array( + 'label' => 'All good here', + 'status' => 'good', + 'description' => '

Nothing to do.

', + ); + }, + ), + 'aipa_noncallable' => array( + 'label' => 'Non callable', + 'test' => 'aipa_definitely_not_a_callable_function', + ), + 'aipa_throws' => array( + 'label' => 'Throws', + 'test' => static function (): array { + throw new RuntimeException( 'boom' ); + }, + ), + 'aipa_nonarray' => array( + 'label' => 'Non array', + 'test' => static function () { + return 'not-an-array'; + }, + ), + 'aipa_notest' => array( + 'label' => 'Missing test callback', + ), + ), + 'async' => array(), + ); + }, + 99 + ); + + $provider = new AIPA_Provider_Site_Health_Tests(); + $this->assertSame( 'site_health_tests', $provider->get_key() ); + $results = $provider->collect(); + + $this->assertArrayHasKey( 'aipa_good', $results ); + $this->assertSame( 'good', $results['aipa_good']['status'] ); + $this->assertSame( 'All good here', $results['aipa_good']['label'] ); + $this->assertSame( 'Nothing to do.', $results['aipa_good']['description'] ); + + $this->assertArrayNotHasKey( 'aipa_noncallable', $results ); + $this->assertArrayNotHasKey( 'aipa_throws', $results ); + $this->assertArrayNotHasKey( 'aipa_nonarray', $results ); + $this->assertArrayNotHasKey( 'aipa_notest', $results ); + } + + /** + * @covers AIPA_Provider_Optimization_Detective + */ + public function test_optimization_detective_provider(): void { + $provider = new AIPA_Provider_Optimization_Detective(); + $this->assertSame( 'optimization_detective', $provider->get_key() ); + $this->assertNotEmpty( $provider->get_label() ); + + if ( $provider->is_available() ) { + $data = $provider->collect(); + $this->assertTrue( $data['active'] ); + $this->assertArrayHasKey( 'version', $data ); + $this->assertIsInt( $data['measured_url_count'] ); + } else { + $this->assertFalse( $provider->is_available() ); + } + } + + /** + * @covers AIPA_Provider_PageSpeed + */ public function test_pagespeed_provider_availability_follows_setting(): void { $provider = new AIPA_Provider_PageSpeed(); + $this->assertSame( 'pagespeed', $provider->get_key() ); update_option( 'aipa_settings', array( 'include_pagespeed' => true ) ); $this->assertTrue( $provider->is_available() ); @@ -50,14 +213,174 @@ public function test_pagespeed_provider_availability_follows_setting(): void { $this->assertFalse( $provider->is_available() ); } - public function test_registry_collect_and_filter(): void { + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_compacts_successful_response(): void { + $body = (string) wp_json_encode( + array( + 'lighthouseResult' => array( + 'categories' => array( + 'performance' => array( 'score' => 0.42 ), + ), + 'audits' => array( + 'largest-contentful-paint' => array( 'displayValue' => '4.2 s' ), + 'cumulative-layout-shift' => array( 'displayValue' => '0.05' ), + 'render-blocking-resources' => array( + 'title' => 'Eliminate render-blocking resources', + 'displayValue' => 'Potential savings of 1,200 ms', + 'score' => 0.1, + 'details' => array( 'type' => 'opportunity' ), + ), + 'already-good-opportunity' => array( + 'title' => 'Already good', + 'score' => 1.0, + 'details' => array( 'type' => 'opportunity' ), + ), + 'not-an-opportunity' => array( + 'title' => 'Diagnostic', + 'score' => 0.1, + 'details' => array( 'type' => 'table' ), + ), + ), + ), + ) + ); + + $this->mock_http( 200, $body ); + + $provider = new AIPA_Provider_PageSpeed(); + $data = $provider->collect(); + + $this->assertSame( 42, $data['performance_score'] ); + $this->assertSame( '4.2 s', $data['metrics']['largest-contentful-paint'] ); + $this->assertSame( '0.05', $data['metrics']['cumulative-layout-shift'] ); + $this->assertSame( home_url( '/' ), $data['url'] ); + $this->assertSame( 'mobile', $data['strategy'] ); + + $titles = wp_list_pluck( $data['opportunities'], 'title' ); + $this->assertContains( 'Eliminate render-blocking resources', $titles ); + $this->assertNotContains( 'Already good', $titles ); + $this->assertNotContains( 'Diagnostic', $titles ); + + // The result is cached, so a second call does not perform another request. + $this->http_calls = 0; + $cached = $provider->collect(); + $this->assertSame( $data, $cached ); + $this->assertSame( 0, $this->http_calls ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_includes_api_key_in_request(): void { + update_option( + 'aipa_settings', + array( + 'include_pagespeed' => true, + 'pagespeed_api_key' => 'secret-key', + ) + ); + $this->mock_http( 200, (string) wp_json_encode( array( 'lighthouseResult' => array( 'audits' => array() ) ) ) ); + + ( new AIPA_Provider_PageSpeed() )->collect(); + + $this->assertStringContainsString( 'key=secret-key', $this->last_http_url ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_http_error(): void { + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'http_request_failed', 'Network down' ); + } + ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Network down', $data['error'] ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_non_200(): void { + $this->mock_http( 500, 'Server error' ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( '500', $data['error'] ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_unparseable_body(): void { + $this->mock_http( 200, 'this is not json' ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_returns_cached_value(): void { + $cached = array( + 'performance_score' => 99, + 'metrics' => array(), + 'opportunities' => array(), + ); + set_transient( AIPA_Provider_PageSpeed::CACHE_KEY, $cached, HOUR_IN_SECONDS ); + + $this->http_calls = 0; + add_filter( + 'pre_http_request', + function () { + ++$this->http_calls; + return new WP_Error( 'should_not_run', 'unexpected' ); + } + ); + + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertSame( $cached, $data ); + $this->assertSame( 0, $this->http_calls ); + } + + /** + * @covers AIPA_Context_Provider_Registry + */ + public function test_registry_register_unregister_and_available(): void { + $registry = new AIPA_Context_Provider_Registry(); + $registry->register( new AIPA_Fake_Context_Provider() ); + $registry->register( new AIPA_Unavailable_Context_Provider() ); + + $available = $registry->get_available_providers(); + $this->assertArrayHasKey( 'fake', $available ); + $this->assertArrayNotHasKey( 'unavailable', $available ); + + $this->assertSame( array( 'Fake provider' ), $registry->get_available_labels() ); + + $registry->unregister( 'fake' ); + $this->assertArrayNotHasKey( 'fake', $registry->get_available_providers() ); + } + + /** + * @covers AIPA_Context_Provider_Registry + */ + public function test_registry_collect_skips_empty_and_unavailable_and_applies_filter(): void { $registry = new AIPA_Context_Provider_Registry(); - $registry->register( new AIPA_Provider_Environment() ); + $registry->register( new AIPA_Fake_Context_Provider() ); + $registry->register( new AIPA_Empty_Context_Provider() ); + $registry->register( new AIPA_Unavailable_Context_Provider() ); $context = $registry->collect(); - $this->assertArrayHasKey( 'environment', $context ); + $this->assertArrayHasKey( 'fake', $context ); + // A provider that returns no data contributes no key. + $this->assertArrayNotHasKey( 'empty', $context ); + // An unavailable provider is never collected. + $this->assertArrayNotHasKey( 'unavailable', $context ); - // The aipa_context filter can adjust the payload. add_filter( 'aipa_context', static function ( array $ctx ): array { @@ -67,18 +390,49 @@ static function ( array $ctx ): array { ); $filtered = $registry->collect(); $this->assertArrayHasKey( 'injected', $filtered ); - remove_all_filters( 'aipa_context' ); } - public function test_registry_unregister(): void { - $registry = new AIPA_Context_Provider_Registry(); - $registry->register( new AIPA_Provider_Environment() ); - $registry->unregister( 'environment' ); - $this->assertArrayNotHasKey( 'environment', $registry->get_available_providers() ); - } + /** + * Number of mocked HTTP requests performed. + * + * @var int + */ + private int $http_calls = 0; + + /** + * The URL of the most recent mocked HTTP request. + * + * @var string + */ + private string $last_http_url = ''; - public function test_default_registry_includes_expected_providers(): void { - $labels = aipa_get_context_registry()->get_available_labels(); - $this->assertNotEmpty( $labels ); + /** + * Intercepts outbound HTTP requests and returns a canned response. + * + * @param int $code Response status code. + * @param string $body Response body. + */ + private function mock_http( int $code, string $body ): void { + $this->http_calls = 0; + $this->last_http_url = ''; + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) use ( $code, $body ) { + ++$this->http_calls; + $this->last_http_url = (string) $url; + return array( + 'headers' => array(), + 'body' => $body, + 'response' => array( + 'code' => $code, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => '', + ); + }, + 10, + 3 + ); } } diff --git a/plugins/ai-performance-advisor/tests/test-helper.php b/plugins/ai-performance-advisor/tests/test-helper.php index 933e12f24f..055ca43cb7 100644 --- a/plugins/ai-performance-advisor/tests/test-helper.php +++ b/plugins/ai-performance-advisor/tests/test-helper.php @@ -11,16 +11,49 @@ class AIPA_Test_Helper extends WP_UnitTestCase { public function tear_down(): void { delete_option( 'aipa_settings' ); + remove_all_filters( 'aipa_pre_is_ai_available' ); + remove_all_actions( 'aipa_register_context_providers' ); parent::tear_down(); } + /** + * @covers ::aipa_is_ai_available + */ + public function test_is_ai_available_is_false_in_test_environment(): void { + // The AI Client API is not configured in the test environment. + $this->assertFalse( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_is_ai_available + */ + public function test_is_ai_available_can_be_forced_on(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_true' ); + $this->assertTrue( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_is_ai_available + */ + public function test_is_ai_available_can_be_forced_off(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_false' ); + $this->assertFalse( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_get_default_settings + */ public function test_default_settings(): void { $defaults = aipa_get_default_settings(); $this->assertArrayHasKey( 'include_pagespeed', $defaults ); $this->assertArrayHasKey( 'pagespeed_api_key', $defaults ); $this->assertTrue( $defaults['include_pagespeed'] ); + $this->assertSame( '', $defaults['pagespeed_api_key'] ); } + /** + * @covers ::aipa_get_settings + */ public function test_get_settings_merges_defaults(): void { update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); $settings = aipa_get_settings(); @@ -28,14 +61,42 @@ public function test_get_settings_merges_defaults(): void { $this->assertSame( '', $settings['pagespeed_api_key'] ); } - public function test_is_ai_available_false_when_unsupported(): void { - add_filter( 'wp_supports_ai', '__return_false' ); - $this->assertFalse( aipa_is_ai_available() ); - remove_filter( 'wp_supports_ai', '__return_false' ); + /** + * @covers ::aipa_get_settings + */ + public function test_get_settings_tolerates_non_array_option(): void { + update_option( 'aipa_settings', 'not-an-array' ); + $settings = aipa_get_settings(); + $this->assertTrue( $settings['include_pagespeed'] ); + $this->assertSame( '', $settings['pagespeed_api_key'] ); + } + + /** + * @covers ::aipa_get_severities + * @covers ::aipa_get_categories + */ + public function test_severities_and_categories(): void { + $this->assertSame( array( 'critical', 'recommended', 'good', 'info' ), aipa_get_severities() ); + $this->assertContains( 'images', aipa_get_categories() ); + $this->assertContains( 'other', aipa_get_categories() ); + } + + /** + * @covers ::aipa_sanitize_recommendations + * @covers ::aipa_sanitize_recommendation_action + */ + public function test_sanitize_recommendations_returns_empty_for_non_array(): void { + $this->assertSame( array(), aipa_sanitize_recommendations( 'nope' ) ); + $this->assertSame( array(), aipa_sanitize_recommendations( null ) ); } + /** + * @covers ::aipa_sanitize_recommendations + * @covers ::aipa_sanitize_recommendation_action + */ public function test_sanitize_recommendations_drops_invalid_and_sorts(): void { $raw = array( + 'not-an-array-item', array( 'title' => 'Low priority tip', 'summary' => 'Do this eventually.', @@ -51,7 +112,8 @@ public function test_sanitize_recommendations_drops_invalid_and_sorts(): void { 'summary' => 'Fix this now.', 'severity' => 'critical', 'category' => 'images', - 'evidence' => array( 'LCP is 5s', '' ), + 'evidence' => array( 'LCP is 5s', '', 42 ), + 'details' => 'Use a **CDN** and remove blocking JS.', ), array( 'title' => 'Bad severity', @@ -67,21 +129,53 @@ public function test_sanitize_recommendations_drops_invalid_and_sorts(): void { // Critical sorts first. $this->assertSame( 'Urgent fix', $clean[0]['title'] ); $this->assertSame( 'critical', $clean[0]['severity'] ); - // Empty evidence line is dropped. + // Only valid, non-empty string evidence lines are kept. $this->assertSame( array( 'LCP is 5s' ), $clean[0]['evidence'] ); + // Details are run through wp_kses_post, stripping the script tag. + $this->assertStringNotContainsString( '