diff --git a/.github/workflows/php-test-plugins.yml b/.github/workflows/php-test-plugins.yml index 7c07127a9c..9298226967 100644 --- a/.github/workflows/php-test-plugins.yml +++ b/.github/workflows/php-test-plugins.yml @@ -106,6 +106,7 @@ jobs: run: | if [ "${{ matrix.coverage }}" == "true" ]; then npm run test-php:performance-lab -- -- -- --coverage-clover=./single-site-reports/coverage-performance-lab.xml + npm run test-php:ai-performance-advisor -- -- -- --coverage-clover=./single-site-reports/coverage-ai-performance-advisor.xml npm run test-php:auto-sizes -- -- -- --coverage-clover=./single-site-reports/coverage-auto-sizes.xml npm run test-php:dominant-color-images -- -- -- --coverage-clover=./single-site-reports/coverage-dominant-color-images.xml npm run test-php:embed-optimizer -- -- -- --coverage-clover=./single-site-reports/coverage-embed-optimizer.xml @@ -122,6 +123,7 @@ jobs: run: | if [ "${{ matrix.coverage }}" == "true" ]; then npm run test-php-multisite:performance-lab -- -- -- --coverage-clover=./multisite-reports/coverage-multisite-performance-lab.xml + npm run test-php-multisite:ai-performance-advisor -- -- -- --coverage-clover=./multisite-reports/coverage-multisite-ai-performance-advisor.xml npm run test-php-multisite:auto-sizes -- -- -- --coverage-clover=./multisite-reports/coverage-multisite-auto-sizes.xml npm run test-php-multisite:dominant-color-images -- -- -- --coverage-clover=./multisite-reports/coverage-multisite-dominant-color-images.xml npm run test-php-multisite:embed-optimizer -- -- -- --coverage-clover=./multisite-reports/coverage-multisite-embed-optimizer.xml diff --git a/.wp-env.json b/.wp-env.json index 2dbd5c790a..4baa2c3cd0 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -3,6 +3,7 @@ "testsEnvironment": false, "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 08f92ac747..da90a4d0c2 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 83c029f1ae..e95f6cad6f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,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", @@ -73,6 +74,7 @@ "test-php-watch": "./bin/test-php-watch.sh", "test-php-multisite": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:plugins", "test-php:performance-lab": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:performance-lab", + "test-php:ai-performance-advisor": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:ai-performance-advisor", "test-php:auto-sizes": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:auto-sizes", "test-php:dominant-color-images": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:dominant-color-images", "test-php:embed-optimizer": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:embed-optimizer", @@ -83,6 +85,7 @@ "test-php:web-worker-offloading": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:web-worker-offloading", "test-php:webp-uploads": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:webp-uploads", "test-php-multisite:performance-lab": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:performance-lab", + "test-php-multisite:ai-performance-advisor": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:ai-performance-advisor", "test-php-multisite:auto-sizes": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:auto-sizes", "test-php-multisite:dominant-color-images": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:dominant-color-images", "test-php-multisite:embed-optimizer": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:embed-optimizer", 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..6f71964ad1 --- /dev/null +++ b/plugins/ai-performance-advisor/helper.php @@ -0,0 +1,294 @@ +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 ) + ); + + /** + * 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 ) ) { + 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/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..c8ddfe077f --- /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_unparsable_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 new file mode 100644 index 0000000000..24bd1fc9f4 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-context-providers.php @@ -0,0 +1,438 @@ +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(); + $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'] ); + $this->assertSame( PHP_VERSION, $data['php_version'] ); + } + + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_provider_collects_and_excludes_private_fields(): void { + $provider = new AIPA_Provider_Site_Health(); + $this->assertSame( 'site_health', $provider->get_key() ); + $data = $provider->collect(); + + // 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() ); + + update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); + $this->assertFalse( $provider->is_available() ); + } + + /** + * @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_unparsable_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_Fake_Context_Provider() ); + $registry->register( new AIPA_Empty_Context_Provider() ); + $registry->register( new AIPA_Unavailable_Context_Provider() ); + + $context = $registry->collect(); + $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 ); + + add_filter( + 'aipa_context', + static function ( array $ctx ): array { + $ctx['injected'] = array( 'hello' => 'world' ); + return $ctx; + } + ); + $filtered = $registry->collect(); + $this->assertArrayHasKey( 'injected', $filtered ); + } + + /** + * 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 = ''; + + /** + * 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 new file mode 100644 index 0000000000..055ca43cb7 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-helper.php @@ -0,0 +1,315 @@ +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(); + $this->assertFalse( $settings['include_pagespeed'] ); + $this->assertSame( '', $settings['pagespeed_api_key'] ); + } + + /** + * @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.', + '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', '', 42 ), + 'details' => 'Use a **CDN** and remove blocking JS.', + ), + 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'] ); + // 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( '