diff --git a/docs/experiments/meta-description.md b/docs/experiments/meta-description.md new file mode 100644 index 00000000..5b246eee --- /dev/null +++ b/docs/experiments/meta-description.md @@ -0,0 +1,473 @@ +# Meta Description + +## Summary + +The Meta Description experiment adds AI-powered meta description generation to the WordPress post editor. It provides a "Meta Description" sidebar panel with a modal workflow for generating, selecting, editing, and applying meta descriptions. The experiment automatically detects active SEO plugins (Yoast SEO, Rank Math, All in One SEO, SEOPress) and writes to the correct meta field. It registers a WordPress Ability (`ai/meta-description`) that can be used both through the admin UI and directly via REST API requests. + +## Overview + +### For End Users + +When enabled, the Meta Description experiment adds a "Meta Description" panel to the post editor sidebar. Users can generate AI-powered meta description suggestions optimized for search engines, select or edit a suggestion, and apply it to their post. + +**Key Features:** + +- Generates multiple meta description suggestions (default: 3) from post content and title +- Suggestions target the optimal 140–160 character range for search engine display +- Editable textarea allows fine-tuning suggestions before applying +- Live character count with color-coded indicator (green for 140–160, yellow outside range) +- Automatic SEO plugin detection — writes to the correct meta field for Yoast SEO, Rank Math, All in One SEO, and SEOPress +- Falls back to a standard post meta field (`_meta_description`) when no SEO plugin is active +- Copy to clipboard functionality for use with unsupported SEO plugins or external tools +- Works with any post type that has `show_in_rest` enabled + +**Workflow:** + +1. Open or create a post in the editor +2. Find the "Meta Description" panel in the sidebar +3. Click "Generate meta description" to open the modal +4. Review the AI-generated suggestions and select one (or edit the textarea directly) +5. Click "Apply" to save the description to the appropriate meta field +6. Save/update the post as usual + +### For Developers + +The experiment consists of three main components: + +1. **Experiment Class** (`WordPress\AI\Experiments\Meta_Description\Meta_Description`): Handles registration, asset enqueuing, post meta registration, and SEO plugin detection +2. **Ability Class** (`WordPress\AI\Abilities\Meta_Description\Meta_Description`): Implements the core meta description generation logic via the WordPress Abilities API +3. **SEO Integration** (`WordPress\AI\Abilities\Meta_Description\SEO_Integration`): Utility class for detecting active SEO plugins and resolving the correct meta key + +The ability can be called directly via REST API, making it useful for automation, bulk processing, or custom integrations. + +## Architecture & Implementation + +### Key Hooks & Entry Points + +- `WordPress\AI\Experiments\Meta_Description\Meta_Description::register()` wires everything once the experiment is enabled: + - `wp_abilities_api_init` → registers the `ai/meta-description` ability (`includes/Abilities/Meta_Description/Meta_Description.php`) + - `admin_enqueue_scripts` → enqueues the React bundle and styles on `post.php` and `post-new.php` screens for REST-enabled post types + - `init` → registers the fallback post meta key for REST API access (only when no SEO plugin is active) + +### Assets & Data Flow + +1. **PHP Side:** + - `enqueue_assets()` loads `experiments/meta-description` (`src/experiments/meta-description/index.tsx`) and localizes `window.aiMetaDescriptionData` with: + - `enabled`: Whether the experiment is enabled + - `metaKey`: The resolved meta key for the active SEO plugin (or fallback) + - `seoPlugin`: The slug of the detected SEO plugin, or `null` + +2. **React Side:** + - The React entry point (`index.tsx`) registers a `PluginDocumentSettingPanel` in the editor sidebar + - `MetaDescriptionPanel` component shows the current description with edit/regenerate actions, or a generate button if none exists + - Clicking generate or edit opens `MetaDescriptionModal` which displays suggestion cards and an editable textarea + - `useMetaDescription` hook: + - Gets current post ID, content, title, and existing meta description from the editor store + - Calls the ability via `runAbility()` when generation is triggered + - Updates the post meta via `editPost()` when a description is applied + - Copy to clipboard uses WordPress's `useCopyToClipboard` from `@wordpress/compose` + +3. **Ability Execution:** + - Accepts `content` (string), `title` (string), and `post_id` (integer) as input + - If `post_id` is provided, fetches post content and context using `get_post_context()` + - Normalizes content using `normalize_content()` helper + - Sends content to AI client with system instruction targeting 140–160 character descriptions + - Returns an object with an array of description suggestions, each including the text and character count + +### Input Schema + +The ability accepts the following input parameters: + +```php +array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Post content to generate a meta description for.', + ), + 'title' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The post title, used to avoid duplication in the generated description.', + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => 'The post ID to generate a meta description for. If provided without content, the post content will be used.', + ), +) +``` + +### Output Schema + +The ability returns an object containing an array of description suggestions: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'descriptions' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'text' => array( 'type' => 'string' ), + 'character_count' => array( 'type' => 'integer' ), + ), + ), + ), + ), +) +``` + +### Permissions + +The ability checks permissions based on the input: + +- **If `post_id` is provided:** + - Verifies the post exists + - Checks `current_user_can( 'edit_post', $post_id )` + - Ensures the post type has `show_in_rest` enabled + +- **If `post_id` is not provided:** + - Checks `current_user_can( 'edit_posts' )` + +### SEO Plugin Detection + +The `SEO_Integration` utility class detects active SEO plugins and resolves the correct meta key: + +| Plugin | Slug | Meta Key | +|--------|------|----------| +| Yoast SEO | `yoast-seo` | `_yoast_wpseo_metadesc` | +| Rank Math | `rank-math` | `rank_math_description` | +| All in One SEO | `all-in-one-seo` | `_aioseo_description` | +| SEOPress | `seopress` | `_seopress_titles_desc` | +| None (fallback) | — | `_meta_description` | + +When no SEO plugin is active, the experiment registers the fallback `_meta_description` meta key for REST-enabled post types so it can be read and written through the WordPress data layer. + +## Using the Ability via REST API + +The meta description ability can be called directly via REST API, making it useful for automation, bulk processing, or custom integrations. + +### Endpoint + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/meta-description/run +``` + +### Authentication + +You can authenticate using either: + +1. **Application Password** (Recommended) +2. **Cookie Authentication with Nonce** + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions. + +### Request Examples + +#### Example 1: Generate from Content and Title + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/meta-description/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "content": "This is a comprehensive article about artificial intelligence and machine learning. AI has revolutionized many industries including healthcare, finance, and transportation.", + "title": "How AI is Transforming Industries" + } + }' +``` + +**Response:** + +```json +{ + "descriptions": [ + { + "text": "Discover how artificial intelligence and machine learning are revolutionizing healthcare, finance, and transportation with data-driven insights and automation.", + "character_count": 156 + }, + { + "text": "Learn how AI transforms industries from healthcare to transportation through advanced machine learning algorithms and predictive analytics capabilities.", + "character_count": 153 + }, + { + "text": "Explore the impact of AI and machine learning across healthcare, finance, and transportation sectors, reshaping how industries process data and make decisions.", + "character_count": 160 + } + ] +} +``` + +#### Example 2: Generate from Post ID + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/meta-description/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "post_id": 123 + } + }' +``` + +This will automatically fetch the content and title from post ID 123 and generate meta description suggestions. + +#### Example 3: Using WordPress API Fetch (in Gutenberg/Admin) + +```javascript +import apiFetch from '@wordpress/api-fetch'; + +async function generateMetaDescriptions(content, title, postId = null) { + const input = { content, title }; + if (postId) { + input.post_id = postId; + } + + try { + const result = await apiFetch({ + path: '/wp-abilities/v1/abilities/ai/meta-description/run', + method: 'POST', + data: { input }, + }); + return result.descriptions; // Array of { text, character_count } + } catch (error) { + console.error('Error generating meta descriptions:', error); + throw error; + } +} +``` + +### Error Responses + +The ability may return the following error codes: + +- `post_not_found`: The provided post ID does not exist +- `content_not_provided`: No content was provided and no valid post ID was found +- `no_results`: The AI client did not return any results +- `insufficient_capabilities`: The current user does not have permission to generate meta descriptions + +Example error response: + +```json +{ + "code": "content_not_provided", + "message": "Content is required to generate a meta description.", + "data": { + "status": 400 + } +} +``` + +## Extending the Experiment + +### Customizing the System Instruction + +The system instruction that guides the AI can be customized by modifying: + +```php +includes/Abilities/Meta_Description/system-instruction.php +``` + +This file returns a string that instructs the AI on how to generate meta descriptions. You can modify the character length requirements, tone, style, or other parameters. + +### Filtering the Prompt Content + +You can filter the assembled prompt before it is sent to the AI model: + +```php +add_filter( 'ai_meta_description_prompt', function( $prompt, $content, $title ) { + // Append custom instructions to the prompt + $prompt .= "\n\nFocus on the environmental impact angle."; + return $prompt; +}, 10, 3 ); +``` + +### Filtering the Number of Suggestions + +You can change how many description candidates are generated: + +```php +add_filter( 'ai_meta_description_candidate_count', function( $count ) { + return 5; // Generate 5 suggestions instead of the default 3 +} ); +``` + +### Filtering the Result Temperature + +You can adjust the AI temperature for more creative or more consistent results: + +```php +add_filter( 'ai_meta_description_result_temperature', function( $temperature ) { + return 0.3; // Lower temperature for more consistent output +} ); +``` + +### Registering Additional SEO Plugins + +You can add support for additional SEO plugins: + +```php +add_filter( 'ai_meta_description_seo_plugins', function( $plugins ) { + $plugins['my-seo-plugin'] = array( + 'file' => 'my-seo-plugin/my-seo-plugin.php', + 'meta_key' => '_my_seo_meta_description', + ); + return $plugins; +} ); +``` + +### Overriding the Meta Key + +You can override the resolved meta key regardless of which SEO plugin is detected: + +```php +add_filter( 'ai_meta_description_meta_key', function( $key, $plugin_slug ) { + return '_custom_meta_description_key'; +}, 10, 2 ); +``` + +### Filtering Preferred Models + +You can filter which AI models are used for meta description generation using the `ai_experiments_preferred_models_for_text_generation` filter: + +```php +add_filter( 'ai_experiments_preferred_models_for_text_generation', function( $models ) { + return array( + array( 'openai', 'gpt-4' ), + array( 'anthropic', 'claude-haiku-4-5' ), + ); +} ); +``` + +### Customizing Content Normalization + +The `normalize_content()` helper function processes content before sending it to the AI. You can filter the normalized content: + +```php +// Filter content before normalization +add_filter( 'ai_experiments_pre_normalize_content', function( $content ) { + // Custom preprocessing + return $content; +} ); + +// Filter content after normalization +add_filter( 'ai_experiments_normalize_content', function( $content ) { + // Custom post-processing + return $content; +} ); +``` + +## Testing + +### Manual Testing + +1. **Enable the experiment:** + - Go to `Settings → AI Experiments` + - Toggle **Meta Description** to enabled + - Ensure you have valid AI credentials configured + +2. **Test in the editor:** + - Create or edit a post with content + - Find the "Meta Description" panel in the editor sidebar + - Click "Generate meta description" to open the modal + - Verify that 3 suggestions are generated with character counts + - Select a suggestion and verify it populates the textarea + - Edit the text and verify the character count updates live + - Click "Apply" and verify the description appears in the sidebar panel + - Click "Edit description" and verify the modal opens with the current text + - Click the regenerate icon and verify new suggestions are generated + - Test "Copy to clipboard" and verify the text is copied + +3. **Test SEO plugin integration:** + - With Yoast SEO active, verify the description is saved to `_yoast_wpseo_metadesc` + - Without any SEO plugin, verify the description is saved to `_meta_description` + - Verify the correct meta key is displayed in the localized data + +4. **Test with different post types:** + - The experiment loads for any REST-enabled post type except attachments + - Test with posts, pages, and custom post types + +5. **Test REST API:** + - Use curl or Postman to test the REST endpoint + - Verify authentication works + - Test with different input combinations + - Verify error handling for invalid inputs + +### Automated Testing + +Tests are located in: + +- `tests/Integration/Includes/Abilities/Meta_DescriptionTest.php` +- `tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php` +- `tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php` + +Run tests with: + +```bash +npm run test:php +``` + +## Notes & Considerations + +### Requirements + +- The experiment requires valid AI credentials to be configured +- The experiment loads for any post type with `show_in_rest` enabled, except attachments +- Users must have `edit_posts` capability (or `edit_post` for specific posts when using post ID) + +### Performance + +- Meta description generation is an AI operation and may take several seconds +- The UI shows a loading state (busy button) while generation is in progress +- Multiple candidates are generated in a single API call using `candidate_count` + +### Content Processing + +- Content is normalized before being sent to the AI (HTML stripped, shortcodes removed, etc.) +- The `normalize_content()` function handles this processing +- Additional context from post metadata (title, categories, tags) can be included when using post ID +- The post title is passed separately to prevent duplication in the generated description + +### AI Model Selection + +- The ability uses `get_preferred_models_for_text_generation()` to determine which AI models to use +- Models are tried in order until one succeeds +- Temperature defaults to 0.7 for balanced creativity and consistency (filterable via `ai_meta_description_result_temperature`) + +### System Instruction + +The system instruction guides the AI to: + +- Generate descriptions between 140 and 160 characters +- Use plain text only (no markdown, HTML, or special formatting) +- Avoid duplicating or closely mirroring the post title +- Avoid keyword stuffing or repetitive terms +- Use active, action-oriented language that encourages click-through +- Accurately reflect the actual content + +### Limitations + +- Descriptions are generated in real-time and not cached +- The ability does not support batch processing (one request generates multiple candidates for a single post) +- Generated descriptions are suggestions and should be reviewed before publishing +- SEO plugin integration is read/write only for supported plugins — unsupported plugins require the `ai_meta_description_seo_plugins` filter or copy-to-clipboard +- The experiment requires JavaScript to be enabled in the admin + +## Related Files + +- **Experiment:** `includes/Experiments/Meta_Description/Meta_Description.php` +- **Ability:** `includes/Abilities/Meta_Description/Meta_Description.php` +- **SEO Integration:** `includes/Abilities/Meta_Description/SEO_Integration.php` +- **System Instruction:** `includes/Abilities/Meta_Description/system-instruction.php` +- **React Entry:** `src/experiments/meta-description/index.tsx` +- **React Components:** `src/experiments/meta-description/components/` +- **Styles:** `src/experiments/meta-description/index.scss` +- **Types:** `src/experiments/meta-description/types.ts` +- **Tests:** `tests/Integration/Includes/Abilities/Meta_DescriptionTest.php` +- **Tests:** `tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php` +- **Tests:** `tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php` diff --git a/includes/Abilities/Meta_Description/Meta_Description.php b/includes/Abilities/Meta_Description/Meta_Description.php new file mode 100644 index 00000000..76c759f4 --- /dev/null +++ b/includes/Abilities/Meta_Description/Meta_Description.php @@ -0,0 +1,319 @@ + 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Post content to generate a meta description for.', 'ai' ), + ), + 'title' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The post title, used to avoid duplication in the generated description.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'The post ID to generate a meta description for. If provided without content, the post content will be used.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'description' => esc_html__( 'Generated meta description suggestions.', 'ai' ), + 'properties' => array( + 'descriptions' => array( + 'type' => 'array', + 'description' => esc_html__( 'Array of meta description suggestions.', 'ai' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'text' => array( + 'type' => 'string', + 'description' => esc_html__( 'The meta description text.', 'ai' ), + ), + 'character_count' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The character count of the description.', 'ai' ), + ), + ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'content' => null, + 'title' => null, + 'post_id' => null, + ), + ); + + $content = ''; + $title = $args['title'] ?? ''; + $context = ''; + + // If a post ID is provided, fetch content and context from the post. + if ( $args['post_id'] ) { + $post = get_post( (int) $args['post_id'] ); + + if ( ! $post instanceof WP_Post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + $post_context = get_post_context( $post->ID ); + $content = $post_context['content'] ?? ''; + unset( $post_context['content'] ); + $context = $post_context; + + // Use the post title if none was provided. + if ( empty( $title ) && ! empty( $post->post_title ) ) { + $title = $post->post_title; + } + } + + // Prefer explicitly provided content over post content. + if ( $args['content'] ) { + $content = normalize_content( $args['content'] ); + } + + if ( empty( $content ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate a meta description.', 'ai' ) + ); + } + + $descriptions = $this->generate_descriptions( $content, $title, $context ); + if ( is_wp_error( $descriptions ) ) { + return $descriptions; + } + + if ( empty( $descriptions ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No meta description suggestions were generated.', 'ai' ) + ); + } + + return array( 'descriptions' => $descriptions ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : 0; + + if ( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate meta descriptions for this post.', 'ai' ) + ); + } + + $post_type_obj = get_post_type_object( $post->post_type ); + if ( ! $post_type_obj instanceof WP_Post_Type || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate meta descriptions.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generate meta description suggestions from the given content. + * + * @since x.x.x + * + * @param string $content The content to generate descriptions from. + * @param string $title The post title. + * @param string|array $context Additional context to use. + * @return array|\WP_Error The generated descriptions, or a WP_Error. + */ + protected function generate_descriptions( string $content, string $title, $context ) { + // Convert the context to a string if it's an array. + if ( is_array( $context ) ) { + $context = implode( + "\n", + array_map( + static function ( $key, $value ) { + return sprintf( + '%s: %s', + ucwords( str_replace( '_', ' ', $key ) ), + $value + ); + }, + array_keys( $context ), + $context + ) + ); + } + + $prompt = '' . $content . ''; + + if ( ! empty( $title ) ) { + $prompt .= "\n\n" . $title . ''; + } + + if ( ! empty( $context ) ) { + $prompt .= "\n\n" . $context . ''; + } + + /** + * Filters the prompt content sent to the AI model for meta description generation. + * + * Allows developers to modify or augment the content before it is sent to the model. + * + * @since x.x.x + * + * @param string $prompt The assembled prompt including content, title, and context tags. + * @param string $content The normalized post content. + * @param string $title The post title. + */ + $prompt = (string) apply_filters( 'wpai_meta_description_prompt', $prompt, $content, $title ); + + /** + * Filters the number of meta description candidates to generate. + * + * @since x.x.x + * + * @param int $candidate_count The number of candidates to request from the AI model. + */ + $candidate_count = (int) apply_filters( 'wpai_meta_description_candidate_count', self::DEFAULT_CANDIDATE_COUNT ); + + /** + * Filters the temperature for the result of the meta description generation. + * + * @since x.x.x + * + * @param float $result_temperature The temperature for the result of the meta description generation. + */ + $result_temperature = (float) apply_filters( 'wpai_meta_description_result_temperature', 0.7 ); + + $results = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( $result_temperature ) + ->using_candidate_count( $candidate_count ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_texts(); + + if ( is_wp_error( $results ) ) { + return $results; + } + + if ( ! is_array( $results ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No meta description suggestions were generated.', 'ai' ) + ); + } + + $descriptions = array(); + + foreach ( $results as $result ) { + if ( ! is_string( $result ) || empty( trim( $result ) ) ) { + continue; + } + + $text = sanitize_text_field( trim( $result, ' "\'' ) ); + + $descriptions[] = array( + 'text' => $text, + 'character_count' => mb_strlen( $text ), + ); + } + + return $descriptions; + } +} diff --git a/includes/Abilities/Meta_Description/SEO_Integration.php b/includes/Abilities/Meta_Description/SEO_Integration.php new file mode 100644 index 00000000..1865770f --- /dev/null +++ b/includes/Abilities/Meta_Description/SEO_Integration.php @@ -0,0 +1,121 @@ + Map of plugin slug to detection info. + */ + public static function get_supported_plugins(): array { + $plugins = array( + 'yoast-seo' => array( + 'file' => 'wordpress-seo/wp-seo.php', + 'meta_key' => '_yoast_wpseo_metadesc', + ), + 'rank-math' => array( + 'file' => 'seo-by-rank-math/rank-math.php', + 'meta_key' => 'rank_math_description', + ), + 'all-in-one-seo' => array( + 'file' => 'all-in-one-seo-pack/all_in_one_seo_pack.php', + 'meta_key' => '_aioseo_description', + ), + 'seopress' => array( + 'file' => 'wp-seopress/seopress.php', + 'meta_key' => '_seopress_titles_desc', + ), + ); + + /** + * Filters the list of supported SEO plugins for meta description integration. + * + * Allows developers to register additional SEO plugins or modify existing entries. + * + * @since x.x.x + * + * @param array $plugins Map of plugin slug to detection info. + */ + return (array) apply_filters( 'wpai_meta_description_seo_plugins', $plugins ); + } + + /** + * Detects the currently active SEO plugin. + * + * @since x.x.x + * + * @return string|null The slug of the active SEO plugin, or null if none detected. + */ + public static function detect_active_plugin(): ?string { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + foreach ( self::get_supported_plugins() as $slug => $info ) { + if ( is_plugin_active( $info['file'] ) ) { + return $slug; + } + } + + return null; + } + + /** + * Returns the meta key to use for storing the meta description. + * + * @since x.x.x + * + * @param string|null $plugin_slug Optional. The SEO plugin slug. If null, auto-detects. + * @return string The meta key. + */ + public static function get_meta_key( ?string $plugin_slug = null ): string { + if ( null === $plugin_slug ) { + $plugin_slug = self::detect_active_plugin(); + } + + $plugins = self::get_supported_plugins(); + $key = self::FALLBACK_META_KEY; + + if ( $plugin_slug && isset( $plugins[ $plugin_slug ] ) ) { + $key = $plugins[ $plugin_slug ]['meta_key']; + } + + /** + * Filters the meta key used to store the meta description. + * + * @since x.x.x + * + * @param string $key The meta key. + * @param string|null $plugin_slug The detected SEO plugin slug, or null. + */ + return (string) apply_filters( 'wpai_meta_description_meta_key', $key, $plugin_slug ); + } +} diff --git a/includes/Abilities/Meta_Description/system-instruction.php b/includes/Abilities/Meta_Description/system-instruction.php new file mode 100644 index 00000000..165624b9 --- /dev/null +++ b/includes/Abilities/Meta_Description/system-instruction.php @@ -0,0 +1,31 @@ + __( 'Meta Description Generation', 'ai' ), + 'description' => __( 'Generates meta description suggestions with SEO plugin integration', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + $this->register_post_meta(); + } + + /** + * Registers the meta description ability. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Meta_Description_Ability::class, + ), + ); + } + + /** + * Registers the fallback post meta key for REST API access. + * + * This ensures the meta key is accessible through the WordPress data layer + * when no SEO plugin is active to manage it. + * + * @since x.x.x + */ + public function register_post_meta(): void { + $meta_key = SEO_Integration::get_meta_key(); + $seo_plugin = SEO_Integration::detect_active_plugin(); + + // Only register the fallback meta key. SEO plugins register their own. + if ( null !== $seo_plugin ) { + return; + } + + $post_types = get_post_types( array( 'show_in_rest' => true ), 'names' ); + + foreach ( $post_types as $post_type ) { + if ( 'attachment' === $post_type ) { + continue; + } + + register_post_meta( + $post_type, + $meta_key, + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'revisions_enabled' => true, + 'auth_callback' => static function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); + } + } + + /** + * Enqueues and localizes the admin script. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + if ( + ! $screen || + ! in_array( $screen->post_type, get_post_types( array( 'show_in_rest' => true ), 'names' ), true ) || + 'attachment' === $screen->post_type + ) { + return; + } + + $seo_plugin = SEO_Integration::detect_active_plugin(); + + Asset_Loader::enqueue_script( 'meta_description', 'experiments/meta-description' ); + Asset_Loader::enqueue_style( 'meta_description', 'experiments/meta-description' ); + Asset_Loader::localize_script( + 'meta_description', + 'MetaDescriptionData', + array( + 'enabled' => $this->is_enabled(), + 'metaKey' => SEO_Integration::get_meta_key( $seo_plugin ), + 'seoPlugin' => $seo_plugin, + ) + ); + } +} diff --git a/src/experiments/meta-description/components/CharacterCount.tsx b/src/experiments/meta-description/components/CharacterCount.tsx new file mode 100644 index 00000000..056114c7 --- /dev/null +++ b/src/experiments/meta-description/components/CharacterCount.tsx @@ -0,0 +1,51 @@ +/** + * Character count indicator component for meta descriptions. + */ + +/** + * WordPress dependencies + */ +import { sprintf, __ } from '@wordpress/i18n'; + +const MIN_LENGTH = 140; +const MAX_LENGTH = 160; + +interface CharacterCountProps { + count: number; +} + +/** + * Renders a color-coded character count indicator. + * + * Green when within 140–160 range, yellow outside. + * + * @param props Component props. + * @param props.count The current character count. + */ +export default function CharacterCount( { + count, +}: CharacterCountProps ): JSX.Element { + const isInRange = count >= MIN_LENGTH && count <= MAX_LENGTH; + + let rangeClass = 'ai-meta-description__char-count'; + rangeClass += isInRange + ? ` ${ rangeClass }--in-range` + : ` ${ rangeClass }--out-of-range`; + + return ( + + { sprintf( + /* translators: %d: character count */ + __( '%d characters', 'ai' ), + count + ) } + + ); +} diff --git a/src/experiments/meta-description/components/MetaDescriptionModal.tsx b/src/experiments/meta-description/components/MetaDescriptionModal.tsx new file mode 100644 index 00000000..1fae1f42 --- /dev/null +++ b/src/experiments/meta-description/components/MetaDescriptionModal.tsx @@ -0,0 +1,175 @@ +/** + * Modal component for generating and editing meta description suggestions. + */ + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Modal, Button, TextareaControl } from '@wordpress/components'; +import { useCopyToClipboard } from '@wordpress/compose'; +import { dispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import SuggestionCard from './SuggestionCard'; +import CharacterCount from './CharacterCount'; +import type { MetaDescriptionSuggestion } from '../types'; + +interface MetaDescriptionModalProps { + isGenerating: boolean; + suggestions: MetaDescriptionSuggestion[]; + initialDescription: string; + onGenerate: () => Promise< void >; + onApply: ( text: string ) => void; + onClose: () => void; +} + +/** + * Modal for generating, selecting, and editing meta descriptions. + * + * @param props Component props. + * @param props.isGenerating Whether generation is in progress. + * @param props.suggestions Array of generated suggestions. + * @param props.initialDescription Pre-existing description to edit. + * @param props.onGenerate Callback to trigger generation. + * @param props.onApply Callback to apply the description. + * @param props.onClose Callback to close the modal. + */ +export default function MetaDescriptionModal( { + isGenerating, + suggestions, + initialDescription, + onGenerate, + onApply, + onClose, +}: MetaDescriptionModalProps ): JSX.Element { + const [ selectedIndex, setSelectedIndex ] = useState< number | null >( + null + ); + const [ editableText, setEditableText ] = useState( initialDescription ); + + const { createSuccessNotice } = dispatch( noticesStore ); + + const copyButtonRef = useCopyToClipboard< HTMLButtonElement >( + () => editableText, + () => { + createSuccessNotice( + __( 'Meta description copied to clipboard.', 'ai' ), + { + type: 'snackbar', + isDismissible: true, + } + ); + } + ); + + const handleSelectSuggestion = ( index: number, text: string ) => { + setSelectedIndex( index ); + setEditableText( text ); + }; + + const handleApply = () => { + onApply( editableText ); + onClose(); + }; + + const hasSuggestions = suggestions.length > 0; + + let generateButtonLabel: string = hasSuggestions + ? __( 'Regenerate Suggestions', 'ai' ) + : __( 'Generate Suggestions', 'ai' ); + + if ( isGenerating ) { + generateButtonLabel = __( 'Generating', 'ai' ); + } + + return ( + +
+ { /* Generation controls */ } +
+ +
+ + { /* Suggestion cards */ } + { hasSuggestions && ( +
+

+ { __( + 'Select a suggestion to use as a starting point:', + 'ai' + ) } +

+ { suggestions.map( ( suggestion, index ) => ( + + handleSelectSuggestion( + index, + suggestion.text + ) + } + /> + ) ) } +
+ ) } + + { /* Editable textarea */ } +
+ + +
+ + { /* Actions */ } +
+ + + +
+
+
+ ); +} diff --git a/src/experiments/meta-description/components/MetaDescriptionPanel.tsx b/src/experiments/meta-description/components/MetaDescriptionPanel.tsx new file mode 100644 index 00000000..84677bc6 --- /dev/null +++ b/src/experiments/meta-description/components/MetaDescriptionPanel.tsx @@ -0,0 +1,106 @@ +/** + * Sidebar panel component for the meta description experiment. + */ + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { update } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useMetaDescription } from './useMetaDescription'; +import MetaDescriptionModal from './MetaDescriptionModal'; +import CharacterCount from './CharacterCount'; + +/** + * Panel rendering the current meta description state and controls. + * + * Shows a generate button when no description exists, or the current + * description with edit/regenerate actions when one does. + */ +export default function MetaDescriptionPanel(): JSX.Element { + const { + isGenerating, + suggestions, + currentDescription, + generateDescriptions, + applyDescription, + } = useMetaDescription(); + + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const hasDescription = + currentDescription && currentDescription.trim().length > 0; + + const handleOpenModal = async () => { + setIsModalOpen( true ); + + // Auto-generate on first open if no suggestions yet and no existing description. + if ( suggestions.length === 0 && ! hasDescription ) { + await generateDescriptions(); + } + }; + + const handleOpenEditModal = () => { + setIsModalOpen( true ); + }; + + const handleRegenerate = async () => { + setIsModalOpen( true ); + await generateDescriptions(); + }; + + return ( +
+ { hasDescription ? ( +
+

+ { currentDescription } +

+ +
+ +
+
+ ) : ( + + ) } + + { isModalOpen && ( + setIsModalOpen( false ) } + /> + ) } +
+ ); +} diff --git a/src/experiments/meta-description/components/SuggestionCard.tsx b/src/experiments/meta-description/components/SuggestionCard.tsx new file mode 100644 index 00000000..8ffabe2e --- /dev/null +++ b/src/experiments/meta-description/components/SuggestionCard.tsx @@ -0,0 +1,56 @@ +/** + * Individual suggestion card component for meta description suggestions. + */ + +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import CharacterCount from './CharacterCount'; + +interface SuggestionCardProps { + text: string; + characterCount: number; + isSelected: boolean; + onSelect: () => void; +} + +/** + * Renders a selectable meta description suggestion card. + * + * @param props Component props. + * @param props.text The suggestion text. + * @param props.characterCount The character count. + * @param props.isSelected Whether this card is currently selected. + * @param props.onSelect Callback when the card is selected. + */ +export default function SuggestionCard( { + text, + characterCount, + isSelected, + onSelect, +}: SuggestionCardProps ): JSX.Element { + let suggestionCardClass = 'ai-meta-description__suggestion-card'; + if ( isSelected ) { + suggestionCardClass += ` ${ suggestionCardClass }--selected`; + } + + return ( + + ); +} diff --git a/src/experiments/meta-description/components/useMetaDescription.ts b/src/experiments/meta-description/components/useMetaDescription.ts new file mode 100644 index 00000000..b371c252 --- /dev/null +++ b/src/experiments/meta-description/components/useMetaDescription.ts @@ -0,0 +1,135 @@ +/** + * Hook for meta description generation logic. + */ + +/** + * WordPress dependencies + */ +import { dispatch, useDispatch, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState, useCallback } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { + MetaDescriptionAbilityInput, + MetaDescriptionAbilityResponse, + MetaDescriptionSuggestion, + MetaDescriptionData, +} from '../types'; + +const NOTICE_ID = 'ai_meta_description_error'; + +const getLocalized = (): MetaDescriptionData | undefined => + ( window as any ).aiMetaDescriptionData as MetaDescriptionData | undefined; + +interface UseMetaDescriptionReturn { + isGenerating: boolean; + suggestions: MetaDescriptionSuggestion[]; + currentDescription: string; + metaKey: string; + hasSeoPlugin: boolean; + generateDescriptions: () => Promise< void >; + applyDescription: ( text: string ) => void; +} + +/** + * Hook providing meta description generation state and actions. + * + * @return Object with generation state, suggestions, and handlers. + */ +export function useMetaDescription(): UseMetaDescriptionReturn { + const localized = getLocalized(); + const metaKey = localized?.metaKey ?? '_meta_description'; + const hasSeoPlugin = Boolean( localized?.seoPlugin ); + + const { editPost } = useDispatch( editorStore ); + const { removeNotice, createErrorNotice } = dispatch( noticesStore ); + + const [ isGenerating, setIsGenerating ] = useState( false ); + const [ suggestions, setSuggestions ] = useState< + MetaDescriptionSuggestion[] + >( [] ); + + const { postId, content, title, meta } = useSelect( ( select ) => { + const editor = select( editorStore ); + const currentMeta = editor.getEditedPostAttribute( 'meta' ) as + | Record< string, string > + | undefined; + + return { + postId: editor.getCurrentPostId() as number, + content: editor.getEditedPostContent(), + title: editor.getEditedPostAttribute( 'title' ) as string, + meta: currentMeta, + }; + }, [] ); + + const generateDescriptions = useCallback( async () => { + setIsGenerating( true ); + setSuggestions( [] ); + + // Clear any existing notices. + removeNotice( NOTICE_ID ); + + try { + // Generate the meta descriptions. + const params: MetaDescriptionAbilityInput = { + content, + title, + post_id: postId, + }; + + const response = await runAbility< MetaDescriptionAbilityResponse >( + 'ai/meta-description', + params + ); + + if ( response?.descriptions && response.descriptions.length > 0 ) { + setSuggestions( response.descriptions ); + } else { + createErrorNotice( + 'No meta description suggestions were generated.', + { id: NOTICE_ID, isDismissible: true } + ); + } + } catch ( error: any ) { + const message = + typeof error === 'string' + ? error + : error?.message ?? 'Failed to generate meta descriptions.'; + + createErrorNotice( message, { + id: NOTICE_ID, + isDismissible: true, + } ); + } finally { + setIsGenerating( false ); + } + }, [ content, title, postId, removeNotice, createErrorNotice ] ); + + const applyDescription = useCallback( + ( text: string ) => { + editPost( { + meta: { + ...meta, + [ metaKey ]: text, + }, + } ); + }, + [ editPost, metaKey, meta ] + ); + + return { + isGenerating, + suggestions, + currentDescription: meta?.[ metaKey ] ?? '', + metaKey, + hasSeoPlugin, + generateDescriptions, + applyDescription, + }; +} diff --git a/src/experiments/meta-description/index.scss b/src/experiments/meta-description/index.scss new file mode 100644 index 00000000..ee21fb61 --- /dev/null +++ b/src/experiments/meta-description/index.scss @@ -0,0 +1,108 @@ +/** + * Styles for the Meta Description experiment. + */ + +// Panel display. +.ai-meta-description-panel { + &__text { + margin: 0 0 8px; + font-size: 13px; + line-height: 1.5; + color: var(--wp--preset--color--foreground, #1e1e1e); + } + + &__actions { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + margin-top: 8px; + } +} + +// Character count. +.ai-meta-description__char-count { + display: inline-block; + font-size: 12px; + line-height: 1; + + &--in-range { + color: var(--wp-components-color-success, #00a32a); + } + + &--out-of-range { + color: var(--wp-components-color-warning, #dba617); + } +} + +// Suggestion card. +.ai-meta-description__suggestion-card { + display: block; + width: 100%; + padding: 12px; + margin-bottom: 8px; + text-align: left; + background: var(--wp--preset--color--background, #f0f0f0); + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + height: auto; + + &:hover { + background: var(--wp-admin-border-color-lighter, #dcdcde); + } + + &--selected { + border-color: var(--wp-admin-theme-color, #3858e9); + background: color-mix(in srgb, var(--wp-admin-theme-color, #3858e9) 8%, white) !important; + + &:hover { + background: color-mix(in srgb, var(--wp-admin-theme-color, #3858e9) 12%, white) !important; + } + } + + .ai-meta-description__suggestion-text { + display: block; + margin-bottom: 4px; + font-size: 13px; + line-height: 1.5; + white-space: normal; + word-wrap: break-word; + color: var(--wp--preset--color--foreground, #1e1e1e); + } +} + +// Modal layout. +.ai-meta-description-modal { + &__content { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__generate { + display: flex; + align-items: center; + gap: 8px; + } + + &__suggestions-label { + margin: 0 0 8px; + font-size: 13px; + color: var(--wp--preset--color--foreground, #757575); + } + + &__editor { + .ai-meta-description__char-count { + margin-top: 4px; + } + } + + &__actions { + display: flex; + align-items: center; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--wp-admin-border-color-lighter, #dcdcde); + } +} diff --git a/src/experiments/meta-description/index.tsx b/src/experiments/meta-description/index.tsx new file mode 100644 index 00000000..24d9dcbc --- /dev/null +++ b/src/experiments/meta-description/index.tsx @@ -0,0 +1,45 @@ +/** + * Meta description experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import { PluginDocumentSettingPanel } from '@wordpress/editor'; +import { registerPlugin } from '@wordpress/plugins'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import MetaDescriptionPanel from './components/MetaDescriptionPanel'; +import './index.scss'; + +import type { MetaDescriptionData } from './types'; + +const localized = ( window as any ).aiMetaDescriptionData as + | MetaDescriptionData + | undefined; + +/** + * Plugin component that renders the Meta Description panel in the editor sidebar. + */ +const MetaDescriptionPlugin = (): JSX.Element | null => { + if ( ! localized?.enabled ) { + return null; + } + + return ( + + + + ); +}; + +registerPlugin( 'ai-meta-description', { + render: MetaDescriptionPlugin, +} ); diff --git a/src/experiments/meta-description/types.ts b/src/experiments/meta-description/types.ts new file mode 100644 index 00000000..b6a4ccdb --- /dev/null +++ b/src/experiments/meta-description/types.ts @@ -0,0 +1,37 @@ +/** + * Type definitions for the meta description experiment. + */ + +/** + * Input parameters for the ai/meta-description ability. + */ +export interface MetaDescriptionAbilityInput { + content: string; + title: string; + post_id: number; + [ key: string ]: string | number | undefined; +} + +/** + * A single meta description suggestion returned by the ability. + */ +export interface MetaDescriptionSuggestion { + text: string; + character_count: number; +} + +/** + * Response from the ai/meta-description ability. + */ +export interface MetaDescriptionAbilityResponse { + descriptions: MetaDescriptionSuggestion[]; +} + +/** + * Localized data available on `window.aiMetaDescriptionData`. + */ +export interface MetaDescriptionData { + enabled: boolean; + metaKey: string; + seoPlugin: string | null; +} diff --git a/tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php b/tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php new file mode 100644 index 00000000..ad97d093 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php @@ -0,0 +1,172 @@ +assertIsArray( $plugins, 'Supported plugins should be an array' ); + $this->assertArrayHasKey( 'yoast-seo', $plugins, 'Should include Yoast SEO' ); + $this->assertArrayHasKey( 'rank-math', $plugins, 'Should include Rank Math' ); + $this->assertArrayHasKey( 'all-in-one-seo', $plugins, 'Should include All in One SEO' ); + $this->assertArrayHasKey( 'seopress', $plugins, 'Should include SEOPress' ); + + // Verify each plugin has required keys. + foreach ( $plugins as $slug => $info ) { + $this->assertArrayHasKey( 'file', $info, "Plugin '{$slug}' should have a file key" ); + $this->assertArrayHasKey( 'meta_key', $info, "Plugin '{$slug}' should have a meta_key key" ); + } + } + + /** + * Test that get_supported_plugins() can be filtered. + * + * @since x.x.x + */ + public function test_get_supported_plugins_is_filterable() { + add_filter( + 'wpai_meta_description_seo_plugins', + static function ( $plugins ) { + $plugins['custom-seo'] = array( + 'file' => 'custom-seo/custom-seo.php', + 'meta_key' => '_custom_seo_description', + ); + return $plugins; + } + ); + + $plugins = SEO_Integration::get_supported_plugins(); + + $this->assertArrayHasKey( 'custom-seo', $plugins, 'Custom SEO plugin should be registered' ); + $this->assertEquals( '_custom_seo_description', $plugins['custom-seo']['meta_key'], 'Custom meta key should match' ); + } + + /** + * Test that detect_active_plugin() returns null when no SEO plugin is active. + * + * @since x.x.x + */ + public function test_detect_active_plugin_returns_null_when_none_active() { + $result = SEO_Integration::detect_active_plugin(); + + $this->assertNull( $result, 'Should return null when no SEO plugin is active' ); + } + + /** + * Test that get_meta_key() returns fallback when no SEO plugin is active. + * + * @since x.x.x + */ + public function test_get_meta_key_returns_fallback_when_no_plugin_active() { + $meta_key = SEO_Integration::get_meta_key(); + + $this->assertEquals( SEO_Integration::FALLBACK_META_KEY, $meta_key, 'Should return fallback meta key' ); + } + + /** + * Test that get_meta_key() returns correct key for known plugin slug. + * + * @since x.x.x + */ + public function test_get_meta_key_returns_correct_key_for_known_plugin() { + $this->assertEquals( '_yoast_wpseo_metadesc', SEO_Integration::get_meta_key( 'yoast-seo' ), 'Should return Yoast meta key' ); + $this->assertEquals( 'rank_math_description', SEO_Integration::get_meta_key( 'rank-math' ), 'Should return Rank Math meta key' ); + $this->assertEquals( '_aioseo_description', SEO_Integration::get_meta_key( 'all-in-one-seo' ), 'Should return AIOSEO meta key' ); + $this->assertEquals( '_seopress_titles_desc', SEO_Integration::get_meta_key( 'seopress' ), 'Should return SEOPress meta key' ); + } + + /** + * Test that get_meta_key() returns fallback for unknown plugin slug. + * + * @since x.x.x + */ + public function test_get_meta_key_returns_fallback_for_unknown_plugin() { + $meta_key = SEO_Integration::get_meta_key( 'unknown-plugin' ); + + $this->assertEquals( SEO_Integration::FALLBACK_META_KEY, $meta_key, 'Should return fallback for unknown plugin' ); + } + + /** + * Test that get_meta_key() can be filtered. + * + * @since x.x.x + */ + public function test_get_meta_key_is_filterable() { + add_filter( + 'wpai_meta_description_meta_key', + static function () { + return '_custom_override_key'; + } + ); + + $meta_key = SEO_Integration::get_meta_key(); + + $this->assertEquals( '_custom_override_key', $meta_key, 'Meta key should be overridable via filter' ); + } + + /** + * Test that detect_active_plugin() returns the slug when a supported plugin is active. + * + * @since x.x.x + */ + public function test_detect_active_plugin_returns_slug_when_plugin_active() { + // Force a known plugin into the active plugins list. + $active = get_option( 'active_plugins', array() ); + update_option( 'active_plugins', array_merge( $active, array( 'wordpress-seo/wp-seo.php' ) ) ); + + $result = SEO_Integration::detect_active_plugin(); + + $this->assertEquals( 'yoast-seo', $result, 'Should return the slug of the active plugin' ); + + // Restore. + update_option( 'active_plugins', $active ); + } + + /** + * Test that get_meta_key() auto-detects active plugin when no slug provided. + * + * @since x.x.x + */ + public function test_get_meta_key_auto_detects_active_plugin() { + $active = get_option( 'active_plugins', array() ); + update_option( 'active_plugins', array_merge( $active, array( 'wordpress-seo/wp-seo.php' ) ) ); + + $meta_key = SEO_Integration::get_meta_key(); + + $this->assertEquals( '_yoast_wpseo_metadesc', $meta_key, 'Should return the meta key of the detected active plugin' ); + + // Restore. + update_option( 'active_plugins', $active ); + } +} diff --git a/tests/Integration/Includes/Abilities/Meta_DescriptionTest.php b/tests/Integration/Includes/Abilities/Meta_DescriptionTest.php new file mode 100644 index 00000000..bcc0ff35 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Meta_DescriptionTest.php @@ -0,0 +1,685 @@ + 'Meta Description', + 'description' => 'Generates meta description suggestions with SEO plugin integration', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Meta_Description Ability test case. + * + * @since x.x.x + */ +class Meta_DescriptionTest extends WP_UnitTestCase { + + /** + * Meta_Description ability instance. + * + * @var \WordPress\AI\Abilities\Meta_Description\Meta_Description + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Meta_Description_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Meta_Description_Experiment(); + $this->ability = new Meta_Description( + 'ai/meta-description', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'title', $schema['properties'], 'Schema should have title property' ); + $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); + + // Verify content property. + $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' ); + + // Verify title property. + $this->assertEquals( 'string', $schema['properties']['title']['type'], 'Title should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['title']['sanitize_callback'], 'Title should use sanitize_text_field' ); + + // Verify post_id property. + $this->assertEquals( 'integer', $schema['properties']['post_id']['type'], 'Post ID should be integer type' ); + $this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'descriptions', $schema['properties'], 'Schema should have descriptions property' ); + $this->assertEquals( 'array', $schema['properties']['descriptions']['type'], 'Descriptions should be array type' ); + } + + /** + * Test that get_system_instruction() returns a non-empty system instruction. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + $this->assertStringContainsString( 'meta description', $system_instruction, 'System instruction should mention meta descriptions' ); + } + + /** + * Test that execute_callback() handles content parameter correctly. + * + * @since x.x.x + */ + public function test_execute_callback_with_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This is some test content about artificial intelligence and machine learning in modern healthcare systems.', + 'title' => 'AI in Healthcare', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'descriptions', $result, 'Result should have descriptions key' ); + $this->assertIsArray( $result['descriptions'], 'Descriptions should be an array' ); + $this->assertNotEmpty( $result['descriptions'], 'Descriptions should not be empty' ); + + // Verify each description has the expected structure. + foreach ( $result['descriptions'] as $description ) { + $this->assertArrayHasKey( 'text', $description, 'Description should have text key' ); + $this->assertArrayHasKey( 'character_count', $description, 'Description should have character_count key' ); + $this->assertIsString( $description['text'], 'Description text should be a string' ); + $this->assertIsInt( $description['character_count'], 'Character count should be an integer' ); + $this->assertEquals( mb_strlen( $description['text'] ), $description['character_count'], 'Character count should match text length' ); + } + } + + /** + * Test that execute_callback() handles post_id parameter correctly. + * + * @since x.x.x + */ + public function test_execute_callback_with_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'This is post content about renewable energy.', + 'post_title' => 'Renewable Energy', + ) + ); + + $input = array( + 'post_id' => $post_id, + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'descriptions', $result, 'Result should have descriptions key' ); + } + + /** + * Test that execute_callback() returns error when post_id points to non-existent post. + * + * @since x.x.x + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'post_id' => 99999, + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() prefers explicit content over post content. + * + * @since x.x.x + */ + public function test_execute_callback_explicit_content_overrides_post_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Original post content.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'content' => 'This explicit content should be used instead.', + 'post_id' => $post_id, + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'descriptions', $result, 'Result should have descriptions key' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since x.x.x + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns true for user with edit_post capability on specific post. + * + * @since x.x.x + */ + public function test_permission_callback_with_post_id_and_edit_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_post capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_post capability on specific post. + * + * @since x.x.x + */ + public function test_permission_callback_with_post_id_without_edit_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for non-existent post. + * + * @since x.x.x + */ + public function test_permission_callback_with_nonexistent_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => 99999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that permission_callback() returns false for post type without show_in_rest. + * + * @since x.x.x + */ + public function test_permission_callback_with_post_type_without_show_in_rest() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + register_post_type( + 'test_no_rest', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_type' => 'test_no_rest', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertFalse( $result, 'Permission should be denied for post type without show_in_rest' ); + + unregister_post_type( 'test_no_rest' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } + + /** + * Test that execute_callback() uses post title when no title is provided. + * + * @since x.x.x + */ + public function test_execute_callback_uses_post_title_as_fallback() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Content about testing meta descriptions in WordPress plugins.', + 'post_title' => 'My Test Title', + ) + ); + + $input = array( + 'post_id' => $post_id, + // No explicit title provided — should fall back to post title. + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'descriptions', $result, 'Result should have descriptions key' ); + } + + /** + * Test that execute_callback() uses explicit title over post title. + * + * @since x.x.x + */ + public function test_execute_callback_explicit_title_overrides_post_title() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Content about testing meta descriptions in WordPress plugins.', + 'post_title' => 'Post Title', + ) + ); + + $input = array( + 'post_id' => $post_id, + 'title' => 'Custom Override Title', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'descriptions', $result, 'Result should have descriptions key' ); + } + + /** + * Test that generate_descriptions() builds a prompt with content tags. + * + * @since x.x.x + */ + public function test_generate_descriptions_builds_prompt_with_content() { + $captured_prompt = ''; + + add_filter( + 'wpai_meta_description_prompt', + static function ( $prompt ) use ( &$captured_prompt ) { + $captured_prompt = $prompt; + return $prompt; + } + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_descriptions' ); + $method->setAccessible( true ); + + try { + $method->invoke( $this->ability, 'Test content here.', 'Test Title', '' ); + } catch ( \Throwable $e ) { + // We only care about prompt construction, not AI availability. + } + + $this->assertNotNull( $captured_prompt, 'Filter should have been called' ); + $this->assertStringContainsString( 'Test content here.', $captured_prompt, 'Prompt should contain content tags' ); + $this->assertStringContainsString( 'Test Title', $captured_prompt, 'Prompt should contain title tags' ); + + remove_all_filters( 'wpai_meta_description_prompt' ); + } + + /** + * Test that generate_descriptions() includes context when provided as string. + * + * @since x.x.x + */ + public function test_generate_descriptions_includes_string_context() { + $captured_prompt = ''; + + add_filter( + 'wpai_meta_description_prompt', + static function ( $prompt ) use ( &$captured_prompt ) { + $captured_prompt = $prompt; + return $prompt; + } + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_descriptions' ); + $method->setAccessible( true ); + + try { + $method->invoke( $this->ability, 'Test content.', '', 'Extra context here' ); + } catch ( \Throwable $e ) { + // We only care about prompt construction. + } + + $this->assertNotNull( $captured_prompt, 'Filter should have been called' ); + $this->assertStringContainsString( 'Extra context here', $captured_prompt, 'Prompt should contain additional context' ); + + remove_all_filters( 'wpai_meta_description_prompt' ); + } + + /** + * Test that generate_descriptions() converts array context to string. + * + * @since x.x.x + */ + public function test_generate_descriptions_converts_array_context() { + $captured_prompt = ''; + + add_filter( + 'wpai_meta_description_prompt', + static function ( $prompt ) use ( &$captured_prompt ) { + $captured_prompt = $prompt; + return $prompt; + } + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_descriptions' ); + $method->setAccessible( true ); + + $context = array( + 'post_type' => 'post', + 'category' => 'News', + ); + + try { + $method->invoke( $this->ability, 'Test content.', '', $context ); + } catch ( \Throwable $e ) { + // We only care about prompt construction. + } + + $this->assertNotNull( $captured_prompt, 'Filter should have been called' ); + $this->assertStringContainsString( 'Post Type: post', $captured_prompt, 'Context should be converted to key-value pairs' ); + $this->assertStringContainsString( 'Category: News', $captured_prompt, 'Context should include all array entries' ); + + remove_all_filters( 'wpai_meta_description_prompt' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php b/tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php new file mode 100644 index 00000000..3d2da97a --- /dev/null +++ b/tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php @@ -0,0 +1,204 @@ + 'test-api-key' ) ); + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_meta-description_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->register_features(); + + $experiment = $registry->get_feature( 'meta-description' ); + $this->assertInstanceOf( + Meta_Description::class, + $experiment, + 'Meta description experiment should be registered in the registry.' + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_meta-description_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Tests that the experiment reports correct metadata. + * + * @since x.x.x + */ + public function test_experiment_registration(): void { + $experiment = new Meta_Description(); + + $this->assertEquals( 'meta-description', $experiment->get_id() ); + $this->assertEquals( 'Meta Description Generation', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Tests that the experiment can be disabled via the filter. + * + * @since x.x.x + */ + public function test_experiment_can_be_disabled_via_filter(): void { + add_filter( 'wpai_feature_meta-description_enabled', '__return_false' ); + + $experiment = new Meta_Description(); + $this->assertFalse( $experiment->is_enabled() ); + + remove_all_filters( 'wpai_feature_meta-description_enabled' ); + } + + /** + * Tests that register() hooks all required actions. + * + * @since x.x.x + */ + public function test_register_hooks_actions(): void { + // WordPress may warn about revisions_enabled on non-revisioned subtypes. + $this->setExpectedIncorrectUsage( 'register_meta' ); + + $experiment = new Meta_Description(); + $experiment->register(); + + $this->assertNotFalse( + has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ), + 'register_abilities should be hooked to wp_abilities_api_init' + ); + $this->assertNotFalse( + has_action( 'admin_enqueue_scripts', array( $experiment, 'enqueue_assets' ) ), + 'enqueue_assets should be hooked to admin_enqueue_scripts' + ); + + $meta = get_registered_meta_keys( 'post', 'post' ); + $this->assertArrayHasKey( '_meta_description', $meta, 'register_post_meta should be called during register()' ); + } + + /** + * Tests that register_abilities() hooks into the abilities API. + * + * @since x.x.x + */ + public function test_register_abilities(): void { + // WordPress may warn about revisions_enabled on non-revisioned subtypes. + $this->setExpectedIncorrectUsage( 'register_meta' ); + + $experiment = new Meta_Description(); + $experiment->register(); + + $this->assertNotFalse( + has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ), + 'register_abilities should be hooked to wp_abilities_api_init' + ); + } + + /** + * Tests that register_post_meta() registers fallback meta when no SEO plugin is active. + * + * @since x.x.x + */ + public function test_register_post_meta_registers_fallback(): void { + // WordPress may warn about revisions_enabled on non-revisioned subtypes. + $this->setExpectedIncorrectUsage( 'register_meta' ); + + $experiment = new Meta_Description(); + $experiment->register_post_meta(); + + $meta = get_registered_meta_keys( 'post', 'post' ); + $this->assertArrayHasKey( '_meta_description', $meta, 'Fallback meta key should be registered for post type' ); + $this->assertTrue( $meta['_meta_description']['show_in_rest'], 'Meta key should be available in REST API' ); + $this->assertEquals( 'string', $meta['_meta_description']['type'], 'Meta key type should be string' ); + } + + /** + * Tests that register_post_meta() skips attachment post type. + * + * @since x.x.x + */ + public function test_register_post_meta_skips_attachment(): void { + // WordPress may warn about revisions_enabled on non-revisioned subtypes. + $this->setExpectedIncorrectUsage( 'register_meta' ); + + $experiment = new Meta_Description(); + $experiment->register_post_meta(); + + $meta = get_registered_meta_keys( 'post', 'attachment' ); + $this->assertArrayNotHasKey( '_meta_description', $meta, 'Meta key should not be registered for attachment post type' ); + } + + /** + * Tests that register_post_meta() does not register when SEO plugin is active. + * + * @since x.x.x + */ + public function test_register_post_meta_skips_when_seo_plugin_active(): void { + // Simulate an active SEO plugin via the active_plugins option. + $active = get_option( 'active_plugins', array() ); + update_option( 'active_plugins', array_merge( $active, array( 'wordpress-seo/wp-seo.php' ) ) ); + + $experiment = new Meta_Description(); + $experiment->register_post_meta(); + + $meta = get_registered_meta_keys( 'post', 'post' ); + $this->assertArrayNotHasKey( '_yoast_wpseo_metadesc', $meta, 'Should not register SEO plugin meta key' ); + $this->assertArrayNotHasKey( '_meta_description', $meta, 'Should not register fallback meta when SEO plugin is active' ); + + // Restore. + update_option( 'active_plugins', $active ); + } + + /** + * Tests that enqueue_assets() does not load on irrelevant screens. + * + * @since x.x.x + */ + public function test_enqueue_assets_skips_irrelevant_screens(): void { + // WordPress may warn about revisions_enabled on non-revisioned subtypes. + $this->setExpectedIncorrectUsage( 'register_meta' ); + + $experiment = new Meta_Description(); + $experiment->register(); + + $experiment->enqueue_assets( 'options-general.php' ); + $this->assertFalse( wp_script_is( 'ai_meta_description', 'enqueued' ), 'Script should not be enqueued on options-general.php' ); + } +} diff --git a/webpack.config.js b/webpack.config.js index bb929257..738e49a7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -39,6 +39,11 @@ module.exports = { 'src/experiments/image-generation', 'index.ts' ), + 'experiments/meta-description': path.resolve( + process.cwd(), + 'src/experiments/meta-description', + 'index.tsx' + ), 'experiments/review-notes': path.resolve( process.cwd(), 'src/experiments/review-notes',