diff --git a/includes/Abilities/Slug_Generation/Slug_Generation.php b/includes/Abilities/Slug_Generation/Slug_Generation.php new file mode 100644 index 000000000..fcbe378c4 --- /dev/null +++ b/includes/Abilities/Slug_Generation/Slug_Generation.php @@ -0,0 +1,326 @@ + 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Title to generate slug suggestions for.', 'ai' ), + ), + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Content to generate slug suggestions for.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Additional context or post ID.', 'ai' ), + ), + 'number_of_suggestions' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'sanitize_callback' => 'absint', + 'default' => 3, + 'description' => esc_html__( 'Number of slug suggestions to return.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'slugs' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'description' => esc_html__( 'Generated slug suggestions.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'title' => null, + 'content' => null, + 'context' => null, + 'number_of_suggestions' => 3, + ) + ); + + $post_id = null; + $post = null; + if ( is_numeric( $args['context'] ) ) { + $post_id = (int) $args['context']; + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + // Fetch the post context when a numeric post ID is provided. + $context = get_post_context( $post->ID ); + $post_content = $context['content'] ?? ''; + $post_title = $post->post_title; + unset( $context['content'] ); + + // Override with explicitly passed title or content if available. + if ( $args['title'] ) { + $post_title = sanitize_text_field( $args['title'] ); + } + if ( $args['content'] ) { + $post_content = normalize_content( $args['content'] ); + } + } else { + $post_content = normalize_content( $args['content'] ?? '' ); + $post_title = sanitize_text_field( $args['title'] ?? '' ); + $context = $args['context'] ?? ''; + } + + if ( empty( $post_title ) && empty( $post_content ) ) { + return new WP_Error( + 'insufficient_data', + esc_html__( 'Post title or content is required to generate slug suggestions.', 'ai' ) + ); + } + + // Build the prompt input with structured XML tags for title, content, and context. + $prompt_input = ''; + if ( ! empty( $post_title ) ) { + $prompt_input .= "{$post_title}\n\n"; + } + if ( ! empty( $post_content ) ) { + $prompt_input .= "{$post_content}"; + } + if ( ! empty( $context ) ) { + if ( is_array( $context ) ) { + $context_lines = array(); + foreach ( $context as $key => $value ) { + if ( is_array( $value ) ) { + $value = implode( ', ', $value ); + } + if ( is_string( $key ) && ! is_numeric( $key ) ) { + $context_lines[] = "{$key}: {$value}"; + } else { + $context_lines[] = (string) $value; + } + } + $context = implode( "\n", $context_lines ); + } + $prompt_input .= "\n\n{$context}"; + } + + $number_of_suggestions = (int) $args['number_of_suggestions']; + $number_of_suggestions = min( max( $number_of_suggestions, 1 ), 10 ); + + // Generate the raw slug suggestion text from the AI model. + $result = $this->generate_slugs( $prompt_input, $context, $number_of_suggestions ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No slug suggestion was generated.', 'ai' ) + ); + } + + // Parse the output lines into clean, sanitized, and unique WordPress slugs. + $lines = explode( "\n", $result ); + $slugs = array(); + foreach ( $lines as $line ) { + $line = trim( $line, " \t\n\r\0\x0B\"'" ); + if ( empty( $line ) ) { + continue; + } + + $clean_slug = sanitize_title( str_replace( '_', '-', $line ) ); + if ( empty( $clean_slug ) ) { + continue; + } + + if ( $post instanceof \WP_Post ) { + $slug = wp_unique_post_slug( + $clean_slug, + $post->ID, + $post->post_status, + $post->post_type, + $post->post_parent + ); + } else { + $slug = wp_unique_post_slug( $clean_slug, 0, 'publish', 'post', 0 ); + } + + if ( empty( $slug ) ) { + continue; + } + + $slugs[] = $slug; + } + + $slugs = array_slice( array_unique( $slugs ), 0, $number_of_suggestions ); + + if ( empty( $slugs ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No slug suggestion was generated.', 'ai' ) + ); + } + + return array( + 'slugs' => $slugs, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null; + + if ( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $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 slugs for this post.', 'ai' ) + ); + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + if ( ! $post_type_obj || 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 slugs.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates slug suggestions from the prompt. + * + * @since x.x.x + * + * @param string $prompt The prompt. + * @param mixed $context The context. + * @param int $number_of_suggestions The number of suggestions. + * @return string|\WP_Error The generated suggestions, or WP_Error. + */ + protected function generate_slugs( string $prompt, $context, int $number_of_suggestions ) { + $prompt = $this->filter_prompt( $prompt, $context ); + $prompt_builder = $this->get_prompt_builder( $prompt, $number_of_suggestions ); + + if ( is_wp_error( $prompt_builder ) ) { + return $prompt_builder; + } + + return $prompt_builder->generate_text(); + } + + /** + * Gets a prompt builder for generating slugs. + * + * @since x.x.x + * + * @param string $prompt The prompt. + * @param int $number_of_suggestions The number of suggestions. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or WP_Error. + */ + private function get_prompt_builder( string $prompt, int $number_of_suggestions ) { + $prompt_builder = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $this->get_system_instruction( null, array( 'number_of_suggestions' => $number_of_suggestions ) ) ) + ->using_temperature( 0.5 ); + + $prompt_builder = $this->filter_prompt_builder( $prompt_builder, Slug_Generation_Experiment::class, array(), $prompt ); + + return $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Slug generation failed. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + } +} diff --git a/includes/Abilities/Slug_Generation/system-instruction.php b/includes/Abilities/Slug_Generation/system-instruction.php new file mode 100644 index 000000000..860e72729 --- /dev/null +++ b/includes/Abilities/Slug_Generation/system-instruction.php @@ -0,0 +1,30 @@ + __( 'Slug Generation', 'ai' ), + 'description' => __( 'Suggests SEO-friendly permalink slugs from post title or content. Requires an AI connector that includes support for text generation models.', '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' ) ); + } + + /** + * Registers any needed abilities. + * + * @since x.x.x + */ + public function register_abilities(): void { + // Register the AI ability to generate slugs using the Abilities API. + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Slug_Generation_Ability::class, + ), + ); + } + + /** + * 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 { + // Only enqueue on post creation and edit screens. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + // Ensure the post type supports titles and isn't an attachment screen. + if ( + ! $screen || + ! post_type_supports( $screen->post_type, 'title' ) || + in_array( $screen->post_type, array( 'attachment' ), true ) + ) { + return; + } + + /** + * Filters the default number of slug suggestions to generate for the editor UI. + * + * @since x.x.x + * + * @param int $number_of_suggestions Number of suggestions. Default 3. + */ + $number_of_suggestions = (int) apply_filters( 'wpai_slug_generation_number_of_suggestions', 3 ); + $number_of_suggestions = min( max( $number_of_suggestions, 1 ), 10 ); + + // Enqueue backend scripts, styles, and pass localized configuration settings to window. + Asset_Loader::enqueue_script( 'slug_generation', 'experiments/slug-generation', array( 'include_core_abilities' => true ) ); + Asset_Loader::enqueue_style( 'slug_generation', 'experiments/slug-generation' ); + Asset_Loader::localize_script( + 'slug_generation', + 'SlugGenerationData', + array( + 'enabled' => $this->is_enabled(), + 'minContentLength' => get_min_content_length( 'slug-generation', 250 ), + 'numberOfSuggestions' => $number_of_suggestions, + ) + ); + } +} diff --git a/src/experiments/slug-generation/components/SlugGenerationButton.tsx b/src/experiments/slug-generation/components/SlugGenerationButton.tsx new file mode 100644 index 000000000..9b1b69912 --- /dev/null +++ b/src/experiments/slug-generation/components/SlugGenerationButton.tsx @@ -0,0 +1,134 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useRef } from '@wordpress/element'; +import { update } from '@wordpress/icons'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { hasMinimumContent } from '../../../utils/character-count'; +import type { SlugGenerationData } from '../types'; + +const MINIMUM_CONTENT_COUNT_DEFAULT = 250; +const NUMBER_OF_SUGGESTIONS_DEFAULT = 3; + +/** + * Helper to fetch localized settings passed from PHP to the global window object. + */ +const getSettings = (): SlugGenerationData => { + const settings = window.aiSlugGenerationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? MINIMUM_CONTENT_COUNT_DEFAULT, + numberOfSuggestions: + settings.numberOfSuggestions ?? NUMBER_OF_SUGGESTIONS_DEFAULT, + }; +}; + +/** + * Renders the "Generate Slug" button inside the Block Editor permalink popover. + * + * @return The button component. + */ +export default function SlugGenerationButton(): React.JSX.Element { + const buttonRef = useRef< HTMLButtonElement >( null ); + + // Retrieve post ID, title, content, and current slug from the block editor store. + const { postId, title, content, currentSlug } = useSelect( ( select ) => { + const editor = select( editorStore ); + const rawSlug = + ( editor.getEditedPostAttribute( 'slug' ) as string ) ?? ''; + const generatedSlug = + ( editor.getEditedPostAttribute( 'generated_slug' ) as string ) ?? + ''; + + return { + postId: editor.getCurrentPostId(), + title: ( editor.getEditedPostAttribute( 'title' ) as string ) ?? '', + content: ( editor.getEditedPostContent() as string ) ?? '', + currentSlug: rawSlug || generatedSlug, + }; + }, [] ); + + const settings = getSettings(); + const minContentLength = settings.minContentLength; + const isContentTooShort = ! hasMinimumContent( content, minContentLength ); + const hasSlug = Boolean( currentSlug && currentSlug.trim().length > 0 ); + + const handleButtonClick = () => { + // Dispatch the trigger event to open the modal and start generation + window.dispatchEvent( + new CustomEvent( 'ai-trigger-slug-generation', { + detail: { postId, title, content }, + } ) + ); + + // Close the slug popover immediately in a language-agnostic way + const popover = document + .querySelector( '.editor-post-url' ) + ?.closest( '.components-popover, .components-dropdown__content' ); + + const closeButton = popover?.querySelector< HTMLElement >( + '.components-popover__header button, button.components-popover__close-button' + ); + + if ( closeButton ) { + closeButton.click(); + } else { + const toggleButton = document.querySelector< HTMLElement >( + '.editor-post-url__toggle[aria-expanded="true"], button.editor-post-url__hostname[aria-expanded="true"], .editor-post-url__toggle, .editor-post-url__toggle-button, button.editor-post-url__hostname' + ); + + if ( toggleButton ) { + toggleButton.click(); + } else { + const activeElement = + buttonRef.current?.ownerDocument?.activeElement; + activeElement?.dispatchEvent( + new KeyboardEvent( 'keydown', { + key: 'Escape', + keyCode: 27, + bubbles: true, + } ) + ); + } + } + }; + + const tooShortLabel = sprintf( + /* translators: %d: minimum number of characters required. */ + __( + 'Slug suggestions will be available when the post content has at least %d characters.', + 'ai' + ), + minContentLength + ); + + const buttonLabel = hasSlug + ? __( 'Regenerate Slug', 'ai' ) + : __( 'Generate Slug', 'ai' ); + const buttonTooltip = isContentTooShort ? tooShortLabel : buttonLabel; + + return ( + + ); +} diff --git a/src/experiments/slug-generation/components/SlugGenerationModal.tsx b/src/experiments/slug-generation/components/SlugGenerationModal.tsx new file mode 100644 index 000000000..2f4b35d7e --- /dev/null +++ b/src/experiments/slug-generation/components/SlugGenerationModal.tsx @@ -0,0 +1,137 @@ +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + Modal, + RadioControl, + TextControl, + Spinner, +} from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +interface SlugGenerationModalProps { + suggestions: string[]; + currentSlug?: string; + onClose: () => void; + onSelect: ( slug: string ) => void; + onRegenerate: () => void; + isRegenerating: boolean; +} + +/** + * Renders the modal dialog for inspecting, editing, and selecting generated slug suggestions. + * + * @param props Component props. + * @param props.suggestions List of suggested slugs. + * @param props.onClose Callback when modal is closed. + * @param props.onSelect Callback when a slug is selected. + * @param props.onRegenerate Callback when regeneration is triggered. + * @param props.isRegenerating Whether suggestions are currently being generated. + * @return The modal component. + */ +export default function SlugGenerationModal( { + suggestions, + onClose, + onSelect, + onRegenerate, + isRegenerating, +}: SlugGenerationModalProps ): React.JSX.Element { + const [ selectedSlug, setSelectedSlug ] = useState( '' ); + + // Select the first suggestion whenever a new list of suggestions is received + useEffect( () => { + if ( suggestions.length > 0 ) { + setSelectedSlug( suggestions[ 0 ] ?? '' ); + } + }, [ suggestions ] ); + + const handleInsert = () => { + onSelect( selectedSlug ); + }; + + return ( + +

+ { __( + 'Review, edit, and insert a suggested slug or regenerate new options.', + 'ai' + ) } +

+ +
+ { isRegenerating && suggestions.length === 0 ? ( + + + + { __( 'Generating suggestions…', 'ai' ) } + + + ) : ( + ( { + label: slug, + value: slug, + } ) ) } + onChange={ setSelectedSlug } + /> + ) } +
+ + + + + + + + + + + +
+ ); +} diff --git a/src/experiments/slug-generation/components/SlugPrePublishPanel.tsx b/src/experiments/slug-generation/components/SlugPrePublishPanel.tsx new file mode 100644 index 000000000..0c3279fbb --- /dev/null +++ b/src/experiments/slug-generation/components/SlugPrePublishPanel.tsx @@ -0,0 +1,231 @@ +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + RadioControl, + TextControl, + Spinner, +} from '@wordpress/components'; +import { dispatch, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; +import { update, check } from '@wordpress/icons'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import { ensureProvider } from '../../../utils/provider-status'; +import { hasMinimumContent } from '../../../utils/character-count'; +import type { + SlugGenerationAbilityInput, + GeneratedSlugData, + SlugGenerationData, +} from '../types'; + +const NOTICE_ID = 'ai_slug_prepublish_error'; +const MINIMUM_CONTENT_COUNT_DEFAULT = 250; +const NUMBER_OF_SUGGESTIONS_DEFAULT = 3; + +const getSettings = (): SlugGenerationData => { + const settings = window.aiSlugGenerationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? MINIMUM_CONTENT_COUNT_DEFAULT, + numberOfSuggestions: + settings.numberOfSuggestions ?? NUMBER_OF_SUGGESTIONS_DEFAULT, + }; +}; + +/** + * Renders the pre-publish sidebar panel for generating and applying slug suggestions. + * + * @return The panel component. + */ +export default function SlugPrePublishPanel(): React.JSX.Element | null { + const { postId, title, content, currentSlug } = useSelect( ( select ) => { + const editor = select( editorStore ); + const rawSlug = + ( editor.getEditedPostAttribute( 'slug' ) as string ) ?? ''; + const generatedSlug = + ( editor.getEditedPostAttribute( 'generated_slug' ) as string ) ?? + ''; + + return { + postId: editor.getCurrentPostId(), + title: ( editor.getEditedPostAttribute( 'title' ) as string ) ?? '', + content: ( editor.getEditedPostContent() as string ) ?? '', + currentSlug: rawSlug || generatedSlug, + }; + }, [] ); + + const [ isGenerating, setIsGenerating ] = useState( false ); + const [ suggestions, setSuggestions ] = useState< string[] >( [] ); + const [ selectedSlug, setSelectedSlug ] = useState( '' ); + + const settings = getSettings(); + const minContentLength = settings.minContentLength; + const isContentTooShort = ! hasMinimumContent( content, minContentLength ); + + const handleGenerate = async () => { + if ( isGenerating ) { + return; + } + + if ( ! ensureProvider( NOTICE_ID ) ) { + return; + } + + setIsGenerating( true ); + dispatch( noticesStore ).removeNotice( NOTICE_ID ); + + try { + const params: SlugGenerationAbilityInput = { + title, + content, + context: postId ? postId.toString() : '', + number_of_suggestions: settings.numberOfSuggestions, + }; + + const response = await runAbility< GeneratedSlugData >( + 'ai/slug-generation', + params + ); + + if ( + response && + typeof response === 'object' && + 'slugs' in response && + Array.isArray( response.slugs ) && + response.slugs.length > 0 + ) { + setSuggestions( response.slugs ); + setSelectedSlug( response.slugs[ 0 ] ?? '' ); + } else { + throw new Error( + __( 'No slug suggestion was generated.', 'ai' ) + ); + } + } catch ( error: any ) { + const message = + typeof error === 'string' + ? error + : error?.message ?? __( 'Failed to generate slug.', 'ai' ); + dispatch( noticesStore ).createErrorNotice( message, { + id: NOTICE_ID, + isDismissible: true, + } ); + } finally { + setIsGenerating( false ); + } + }; + + const handleApply = () => { + if ( selectedSlug ) { + dispatch( editorStore ).editPost( { + slug: selectedSlug, + } ); + } + }; + + const tooShortLabel = sprintf( + /* translators: %d: minimum number of characters required. */ + __( + 'Slug suggestions will be available when the post content has at least %d characters.', + 'ai' + ), + minContentLength + ); + + if ( isContentTooShort ) { + return ( +

+ { tooShortLabel } +

+ ); + } + + return ( +
+
+ { __( 'Current Slug:', 'ai' ) }{ ' ' } + { currentSlug || __( '(no slug set)', 'ai' ) } +
+ + { isGenerating ? ( +
+ + { __( 'Generating suggestions…', 'ai' ) } +
+ ) : ( + <> + { suggestions.length > 0 && ( +
+ ( { + label: slug, + value: slug, + } ) ) } + onChange={ setSelectedSlug } + /> + + +
+ ) } + + + + + + { suggestions.length > 0 && ( + + + + ) } + + + ) } +
+ ); +} diff --git a/src/experiments/slug-generation/index.scss b/src/experiments/slug-generation/index.scss new file mode 100644 index 000000000..9f39830c9 --- /dev/null +++ b/src/experiments/slug-generation/index.scss @@ -0,0 +1,77 @@ +/** + * Styles for the Slug Generation experiment. + */ +.ai-slug-generation-container { + margin-top: 8px; + justify-content: end; + display: flex; + align-items: center; +} + +// The slug panel renders inside a Dropdown popover (z-index: 1000000). +// The Modal overlay defaults to z-index: 100000, so the popover sits on top. +// Bump the modal above the popover so suggestions are fully visible. +.ai-slug-generation-modal { + z-index: 1000001; +} + +.ai-slug-prepublish-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; + .components-button.has-icon.has-text { + min-width: 110px; + justify-content: center; + } +} + +.ai-slug-prepublish-current { + font-size: 13px; + margin-bottom: 4px; + + strong { + color: #1e1e1e; + } + + code { + font-family: monospace; + word-break: break-all; + background-color: #f0f0f0; + padding: 2px 6px; + border-radius: 4px; + color: #2e2e2e; + font-size: 12px; + } +} + +.ai-slug-prepublish-too-short-notice { + font-size: 12px; + color: #757575; + margin: 0; + line-height: 1.4; +} + +.ai-slug-prepublish-suggestions { + margin-top: 4px; + + > * + *, + .components-text-control { + margin-top: 20px; + } +} + +.ai-slug-prepublish-actions { + margin-top: 16px; + justify-content: center; +} + +.ai-slug-prepublish-spinner-container { + display: flex; + align-items: center; + justify-content: center; + padding: 16px 0; + color: #757575; + font-size: 13px; + gap: 8px; +} diff --git a/src/experiments/slug-generation/index.tsx b/src/experiments/slug-generation/index.tsx new file mode 100644 index 000000000..578a5bed4 --- /dev/null +++ b/src/experiments/slug-generation/index.tsx @@ -0,0 +1,360 @@ +/** + * WordPress dependencies + */ +import { PluginPrePublishPanel, store as editorStore } from '@wordpress/editor'; +import { + createRoot, + useEffect, + useState, + useCallback, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { registerPlugin } from '@wordpress/plugins'; +import { dispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import SlugGenerationButton from './components/SlugGenerationButton'; +import SlugPrePublishPanel from './components/SlugPrePublishPanel'; +import SlugGenerationModal from './components/SlugGenerationModal'; +import { runAbility } from '../../utils/run-ability'; +import { ensureProvider } from '../../utils/provider-status'; +import './index.scss'; +import type { + SlugGenerationAbilityInput, + GeneratedSlugData, + SlugGenerationData, +} from './types'; + +const NOTICE_ID = 'ai_slug_generation_error'; +const MINIMUM_CONTENT_COUNT_DEFAULT = 250; +const NUMBER_OF_SUGGESTIONS_DEFAULT = 3; + +const getSettings = (): SlugGenerationData => { + const settings = window.aiSlugGenerationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? MINIMUM_CONTENT_COUNT_DEFAULT, + numberOfSuggestions: + settings.numberOfSuggestions ?? NUMBER_OF_SUGGESTIONS_DEFAULT, + }; +}; + +/** + * Main plugin wrapper component for slug generation. + * + * Attaches the "Generate Slug" button to the permalink inspector popover, + * handles custom events, renders the pre-publish panel, and manages modal state. + * + * @return The plugin components. + */ +function SlugGenerationWrapper(): React.JSX.Element { + const [ modalState, setModalState ] = useState< { + isOpen: boolean; + suggestions: string[]; + isRegenerating: boolean; + title: string; + content: string; + postId: number | null; + } >( { + isOpen: false, + suggestions: [], + isRegenerating: false, + title: '', + content: '', + postId: null, + } ); + + const generateSlugs = useCallback( + async ( title: string, content: string, postId: number | null ) => { + if ( ! ensureProvider( NOTICE_ID ) ) { + setModalState( ( prev ) => ( { + ...prev, + isOpen: false, + isRegenerating: false, + } ) ); + return; + } + + setModalState( ( prev ) => ( { ...prev, isRegenerating: true } ) ); + dispatch( noticesStore ).removeNotice( NOTICE_ID ); + + try { + const params: SlugGenerationAbilityInput = { + title, + content, + context: postId ? postId.toString() : '', + number_of_suggestions: getSettings().numberOfSuggestions, + }; + + const response = await runAbility< GeneratedSlugData >( + 'ai/slug-generation', + params + ); + + if ( + response && + typeof response === 'object' && + 'slugs' in response && + Array.isArray( response.slugs ) && + response.slugs.length > 0 + ) { + setModalState( ( prev ) => ( { + ...prev, + suggestions: response.slugs, + isRegenerating: false, + } ) ); + } else { + throw new Error( + __( 'No slug suggestion was generated.', 'ai' ) + ); + } + } catch ( error: any ) { + const message = + typeof error === 'string' + ? error + : error?.message ?? + __( 'Failed to generate slug.', 'ai' ); + dispatch( noticesStore ).createErrorNotice( message, { + id: NOTICE_ID, + isDismissible: true, + } ); + setModalState( ( prev ) => ( { + ...prev, + isOpen: false, + isRegenerating: false, + } ) ); + } + }, + [] + ); + + useEffect( () => { + if ( ! getSettings().enabled ) { + return; + } + + // Listen for the trigger event from the button + const handleTrigger = ( e: Event ) => { + const customEvent = e as CustomEvent; + const { title, content, postId } = customEvent.detail; + setModalState( { + isOpen: true, + suggestions: [], + isRegenerating: true, + title, + content, + postId, + } ); + generateSlugs( title, content, postId ); + }; + + window.addEventListener( 'ai-trigger-slug-generation', handleTrigger ); + + let isAttached = false; + let root: ReturnType< typeof createRoot > | null = null; + let observer: MutationObserver | null = null; + let container: HTMLElement | null = null; + let timeoutId: NodeJS.Timeout | null = null; + + const findAndAttach = () => { + if ( isAttached ) { + return; + } + + // The slug panel in WordPress 7.0+ renders inside a Dropdown + // popover. The popover content uses the PostURL component which + // wraps everything in a div with class "editor-post-url". + const slugPanel = document.querySelector< HTMLElement >( + '.components-popover .editor-post-url, .components-dropdown__content .editor-post-url, .editor-post-url' + ); + + if ( ! slugPanel ) { + return; + } + + // Ensure we don't double attach + if ( slugPanel.querySelector( '.ai-slug-generation-container' ) ) { + isAttached = true; + return; + } + + // Create wrapper container for the Generate button + container = document.createElement( 'div' ); + container.className = 'ai-slug-generation-container'; + + // Insert the button container at the end of the slug panel, + // after the permalink section. + slugPanel.appendChild( container ); + + root = createRoot( container ); + root.render( ); + + isAttached = true; + }; + + const checkAndAttach = () => { + const containerExists = !! document.querySelector( + '.ai-slug-generation-container' + ); + if ( isAttached && ! containerExists ) { + if ( root ) { + root.unmount(); + root = null; + } + if ( container ) { + container.remove(); + container = null; + } + isAttached = false; + } + + if ( ! isAttached ) { + findAndAttach(); + } + }; + + const debouncedCheck = () => { + if ( timeoutId ) { + clearTimeout( timeoutId ); + } + timeoutId = setTimeout( checkAndAttach, 100 ); + }; + + // Run initial check + findAndAttach(); + + // Observe document.body so popovers appended via portals are never missed. + observer = new MutationObserver( ( mutations ) => { + // Fast relevance filter: skip execution for standard block editing mutations + const isRelevantMutation = mutations.some( ( mutation ) => { + const target = mutation.target as HTMLElement | null; + + if ( + target?.closest?.( + '.editor-post-url, .components-popover, .components-dropdown__content, .edit-post-sidebar, .ai-slug-generation-container' + ) + ) { + return true; + } + + for ( let i = 0; i < mutation.addedNodes.length; i++ ) { + const node = mutation.addedNodes[ i ]; + if ( node instanceof HTMLElement ) { + if ( + node.classList?.contains( 'components-popover' ) || + node.classList?.contains( + 'components-dropdown__content' + ) || + node.classList?.contains( 'editor-post-url' ) || + node.querySelector?.( + '.editor-post-url, .components-popover' + ) + ) { + return true; + } + } + } + + for ( let i = 0; i < mutation.removedNodes.length; i++ ) { + const node = mutation.removedNodes[ i ]; + if ( node instanceof HTMLElement ) { + if ( + node.classList?.contains( 'components-popover' ) || + node.classList?.contains( + 'ai-slug-generation-container' + ) || + node.querySelector?.( + '.ai-slug-generation-container' + ) + ) { + return true; + } + } + } + + return false; + } ); + + if ( isRelevantMutation ) { + debouncedCheck(); + } + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + window.removeEventListener( + 'ai-trigger-slug-generation', + handleTrigger + ); + if ( timeoutId ) { + clearTimeout( timeoutId ); + } + if ( observer ) { + observer.disconnect(); + } + if ( root ) { + root.unmount(); + } + if ( container ) { + container.remove(); + } + }; + }, [ generateSlugs ] ); + + if ( ! getSettings().enabled ) { + return <>; + } + + return ( + <> + + + + + { modalState.isOpen && ( + + setModalState( ( prev ) => ( { + ...prev, + isOpen: false, + } ) ) + } + onSelect={ ( selectedSlug ) => { + dispatch( editorStore ).editPost( { + slug: selectedSlug, + } ); + setModalState( ( prev ) => ( { + ...prev, + isOpen: false, + } ) ); + } } + onRegenerate={ () => + generateSlugs( + modalState.title, + modalState.content, + modalState.postId + ) + } + isRegenerating={ modalState.isRegenerating } + /> + ) } + + ); +} + +registerPlugin( 'ai-slug-generation', { + render: SlugGenerationWrapper, +} ); diff --git a/src/experiments/slug-generation/types.ts b/src/experiments/slug-generation/types.ts new file mode 100644 index 000000000..52dbf9eb7 --- /dev/null +++ b/src/experiments/slug-generation/types.ts @@ -0,0 +1,36 @@ +/** + * Type definitions for slug generation. + */ + +/** + * Input parameters for the ai/slug-generation ability. + */ +export interface SlugGenerationAbilityInput { + title: string; + content: string; + context: string; + number_of_suggestions?: number; + [ key: string ]: string | number | undefined; +} + +/** + * Response from the ai/slug-generation ability. + */ +export interface GeneratedSlugData { + slugs: string[]; +} + +/** + * Localized data from the PHP side. + */ +export interface SlugGenerationData { + enabled: boolean; + minContentLength: number; + numberOfSuggestions: number; +} + +declare global { + interface Window { + aiSlugGenerationData?: Partial< SlugGenerationData >; + } +} diff --git a/tests/Integration/Includes/Abilities/Slug_GenerationTest.php b/tests/Integration/Includes/Abilities/Slug_GenerationTest.php new file mode 100644 index 000000000..ea89afe87 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Slug_GenerationTest.php @@ -0,0 +1,716 @@ + 'Slug Generation', + 'description' => 'Suggests slug suggestions from content', + ); + } + + /** + * Registers the experiment. + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Testable subclass of Slug_Generation to mock AI prompt generation. + */ +class Testable_Slug_Generation extends Slug_Generation { + /** + * Mock response to return from generate_slugs(). + * + * @var string|\WP_Error|null + */ + public $mock_response = null; + + /** + * Last prompt passed to generate_slugs(). + * + * @var string|null + */ + public $last_prompt = null; + + /** + * {@inheritDoc} + */ + protected function generate_slugs( string $prompt, $context, int $number_of_suggestions ) { + $this->last_prompt = $prompt; + if ( null !== $this->mock_response ) { + return $this->mock_response; + } + + return parent::generate_slugs( $prompt, $context, $number_of_suggestions ); + } +} + +/** + * Slug_Generation Ability test case. + */ +class Slug_GenerationTest extends WP_UnitTestCase { + + /** + * Slug_Generation ability instance. + * + * @var \WordPress\AI\Abilities\Slug_Generation\Slug_Generation + */ + private $ability; + + /** + * Testable Slug_Generation ability instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Testable_Slug_Generation + */ + private $testable_ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Slug_Generation_Experiment + */ + private $experiment; + + /** + * Set up test case. + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Slug_Generation_Experiment(); + $this->ability = new Slug_Generation( + 'ai/slug-generation', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + $this->testable_ability = new Testable_Slug_Generation( + 'ai/slug-generation', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that input_schema() returns the expected schema structure. + */ + 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( 'title', $schema['properties'], 'Schema should have title property' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' ); + $this->assertArrayHasKey( 'number_of_suggestions', $schema['properties'], 'Schema should have number_of_suggestions property' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + */ + 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( 'slugs', $schema['properties'], 'Schema should have slugs property' ); + $this->assertEquals( 'array', $schema['properties']['slugs']['type'], 'slugs should be array type' ); + } + + /** + * Test that meta() returns show_in_rest set to true. + */ + 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->assertTrue( $meta['show_in_rest'] ?? false, 'show_in_rest should be true' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + */ + 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 ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + */ + 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 ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + */ + 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 ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Test that permission_callback() returns true for valid post ID and edit_post capability. + */ + 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( 'context' => (string) $post_id ) ); + + $this->assertTrue( $result ); + } + + /** + * Test that permission_callback() returns error for invalid post ID. + */ + 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( 'context' => '99999' ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'post_not_found', $result->get_error_code() ); + } + + /** + * Test that execute_callback() returns error when neither title nor content is provided. + */ + public function test_execute_callback_without_title_or_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'insufficient_data', $result->get_error_code() ); + } + + /** + * Test that execute_callback() returns error when post ID context points to non-existent post. + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'context' => '99999' ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'post_not_found', $result->get_error_code() ); + } + + /** + * Test that execute_callback() correctly parses, cleans, and sanitizes multiline output lines into slugs. + */ + public function test_execute_callback_parses_multiline_output_into_sanitized_slugs() { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $this->testable_ability->mock_response = " my-first-slug \n \"My Second Slug!\" \n\n third_slug \n fourth-slug "; + + $result = $method->invoke( + $this->testable_ability, + array( + 'title' => 'How to create WordPress plugins', + 'content' => 'Detailed guide about plugin creation.', + 'number_of_suggestions' => 3, + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'slugs', $result ); + $this->assertCount( 3, $result['slugs'] ); + $this->assertSame( + array( 'my-first-slug', 'my-second-slug', 'third-slug' ), + $result['slugs'] + ); + } + + /** + * Test that execute_callback() returns no_results WP_Error when prompt output is empty or invalid. + */ + public function test_execute_callback_handles_empty_or_error_results() { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Case 1: generate_slugs returns empty string. + $this->testable_ability->mock_response = ''; + $result = $method->invoke( + $this->testable_ability, + array( 'title' => 'Test title' ) + ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'no_results', $result->get_error_code() ); + + // Case 2: generate_slugs returns a WP_Error instance directly. + $expected_error = new WP_Error( 'test_error', 'Test error message' ); + $this->testable_ability->mock_response = $expected_error; + $result = $method->invoke( + $this->testable_ability, + array( 'title' => 'Test title' ) + ); + $this->assertSame( $expected_error, $result ); + } + + /** + * Test that execute_callback() uses post content and title when a valid post ID is passed. + */ + public function test_execute_callback_with_valid_post_id() { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Sample Article', + 'post_content' => 'Sample content body text.', + ) + ); + + $this->testable_ability->mock_response = 'sample-article'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'context' => (string) $post_id, + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'sample-article' ), $result['slugs'] ); + $this->assertStringContainsString( 'Sample Article', $this->testable_ability->last_prompt ); + } + + /** + * Test that get_system_instruction() returns the system instruction with default 3 suggestions count. + */ + public function test_get_system_instruction_defaults_to_3_suggestions(): void { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction ); + $this->assertStringContainsString( 'Output exactly 3 suggestions, one per line.', $system_instruction ); + } + + /** + * Test that get_system_instruction() formats custom number_of_suggestions correctly. + */ + public function test_get_system_instruction_with_custom_number_of_suggestions(): void { + $system_instruction = $this->ability->get_system_instruction( null, array( 'number_of_suggestions' => 5 ) ); + + $this->assertIsString( $system_instruction ); + $this->assertStringContainsString( 'Output exactly 5 suggestions, one per line.', $system_instruction ); + } + + /** + * Test that system-instruction.php exits when accessed directly without ABSPATH defined. + */ + public function test_system_instruction_direct_access_exits(): void { + $file = TESTS_REPO_ROOT_DIR . '/includes/Abilities/Slug_Generation/system-instruction.php'; + $output = shell_exec( sprintf( '%s %s 2>&1', escapeshellarg( PHP_BINARY ), escapeshellarg( $file ) ) ); + + $this->assertEmpty( trim( (string) $output ) ); + } + + /** + * Test that execute_callback() overrides post title when explicit title is passed with numeric context post ID. + */ + public function test_execute_callback_overrides_title_with_valid_post_id(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Original Article Title', + 'post_content' => 'Original article content body.', + ) + ); + + $this->testable_ability->mock_response = 'overridden-title-slug'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'context' => (string) $post_id, + 'title' => 'Custom Overridden Title', + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'overridden-title-slug' ), $result['slugs'] ); + $this->assertStringContainsString( 'Custom Overridden Title', $this->testable_ability->last_prompt ); + $this->assertStringNotContainsString( 'Original Article Title', $this->testable_ability->last_prompt ); + } + + /** + * Test that execute_callback() overrides post content when explicit content is passed with numeric context post ID. + */ + public function test_execute_callback_overrides_content_with_valid_post_id(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Original Article Title', + 'post_content' => 'Original article content body.', + ) + ); + + $this->testable_ability->mock_response = 'overridden-content-slug'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'context' => (string) $post_id, + 'content' => 'Custom Overridden Content Body', + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'overridden-content-slug' ), $result['slugs'] ); + $this->assertStringContainsString( 'Custom Overridden Content Body', $this->testable_ability->last_prompt ); + $this->assertStringNotContainsString( 'Original article content body.', $this->testable_ability->last_prompt ); + } + + /** + * Test that execute_callback() formats array context with newlines into additional-context tag. + */ + public function test_execute_callback_with_array_context(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $this->testable_ability->mock_response = 'array-context-slug'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'title' => 'Array Context Article', + 'context' => array( 'Context line 1', 'Context line 2' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'array-context-slug' ), $result['slugs'] ); + $this->assertStringContainsString( "Context line 1\nContext line 2", $this->testable_ability->last_prompt ); + } + + /** + * Test that execute_callback() formats associative array context as Key: Value lines. + */ + public function test_execute_callback_with_associative_array_context(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $this->testable_ability->mock_response = 'assoc-context-slug'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'title' => 'Assoc Context Article', + 'context' => array( + 'post_type' => 'post', + 'categories' => array( 'Tech', 'AI' ), + ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'assoc-context-slug' ), $result['slugs'] ); + $this->assertStringContainsString( "post_type: post\ncategories: Tech, AI", $this->testable_ability->last_prompt ); + } + + /** + * Test that execute_callback() uses wp_unique_post_slug to avoid collisions with existing posts. + */ + public function test_execute_callback_generates_unique_slug_avoiding_collisions(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create an existing post with the slug 'existing-slug'. + $this->factory->post->create( + array( + 'post_name' => 'existing-slug', + 'post_status' => 'publish', + ) + ); + + // Create a second post to generate a slug for. + $target_post_id = $this->factory->post->create( + array( + 'post_name' => 'new-post', + 'post_status' => 'publish', + ) + ); + + $this->testable_ability->mock_response = 'existing-slug'; + + $result = $method->invoke( + $this->testable_ability, + array( + 'context' => (string) $target_post_id, + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'existing-slug-2' ), $result['slugs'] ); + } + + /** + * Test that execute_callback() returns no_results WP_Error when generated response produces no valid slugs after sanitization. + */ + public function test_execute_callback_returns_no_results_when_sanitization_yields_empty_slugs(): void { + $reflection = new \ReflectionClass( $this->testable_ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $this->testable_ability->mock_response = " \n \"\" \n !@#$%^&*() "; + + $result = $method->invoke( + $this->testable_ability, + array( + 'title' => 'Unsanitizable Output Test', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'no_results', $result->get_error_code() ); + $this->assertEquals( 'No slug suggestion was generated.', $result->get_error_message() ); + } + + /** + * Test that permission_callback() returns error when post ID is provided but current user cannot edit the post. + */ + public function test_permission_callback_with_post_id_without_edit_capability(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Protected Post', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'context' => (string) $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + $this->assertEquals( 'You do not have permission to generate slugs for this post.', $result->get_error_message() ); + } + + /** + * Test that permission_callback() returns false when get_post_type() returns false or empty. + */ + public function test_permission_callback_returns_false_when_post_type_is_false(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create(); + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $this->setExpectedIncorrectUsage( 'map_meta_cap' ); + + global $wpdb; + $wpdb->update( $wpdb->posts, array( 'post_type' => '' ), array( 'ID' => $post_id ) ); + clean_post_cache( $post_id ); + + $result = $method->invoke( $this->ability, array( 'context' => (string) $post_id ) ); + + $this->assertFalse( $result ); + } + + /** + * Test that permission_callback() returns false when post type has show_in_rest set to false. + */ + public function test_permission_callback_with_post_type_without_show_in_rest(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + register_post_type( + 'test_no_rest_slug', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $post_id = $this->factory->post->create( + array( + 'post_type' => 'test_no_rest_slug', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'context' => (string) $post_id ) ); + + unregister_post_type( 'test_no_rest_slug' ); + + $this->assertFalse( $result ); + } + + /** + * Test that generate_slugs() returns WP_Error when prompt builder or provider validation fails. + */ + public function test_generate_slugs_returns_error_when_prompt_builder_fails(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_slugs' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, 'Test Prompt', 'context', 3 ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( + 'Slug generation failed. Please ensure you have a connected provider that supports text generation.', + $result->get_error_message() + ); + } + + /** + * Test that generate_slugs() and get_prompt_builder() invoke generate_text() on configured prompt builder. + */ + public function test_generate_slugs_and_get_prompt_builder_success(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_slugs' ); + $method->setAccessible( true ); + + $mock_builder = $this->getMockBuilder( \WP_AI_Client_Prompt_Builder::class ) + ->disableOriginalConstructor() + ->addMethods( array( 'is_supported_for_text_generation', 'generate_text' ) ) + ->getMock(); + + $mock_builder->method( 'is_supported_for_text_generation' ) + ->willReturn( true ); + + $mock_builder->expects( $this->once() ) + ->method( 'generate_text' ) + ->willReturn( "generated-slug-1\ngenerated-slug-2" ); + + add_filter( + 'wpai_slug_generation_prompt_builder', + static function () use ( $mock_builder ) { + return $mock_builder; + } + ); + + $result = $method->invoke( $this->ability, 'Test Prompt', '', 2 ); + + remove_all_filters( 'wpai_slug_generation_prompt_builder' ); + + $this->assertSame( "generated-slug-1\ngenerated-slug-2", $result ); + } +} + + diff --git a/tests/Integration/Includes/Experiments/Slug_Generation/Slug_GenerationTest.php b/tests/Integration/Includes/Experiments/Slug_Generation/Slug_GenerationTest.php new file mode 100644 index 000000000..72fad6dad --- /dev/null +++ b/tests/Integration/Includes/Experiments/Slug_Generation/Slug_GenerationTest.php @@ -0,0 +1,205 @@ + 'test-api-key' ) ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_slug-generation_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->init(); + + $experiment = $registry->get_feature( 'slug-generation' ); + $this->assertInstanceOf( Slug_Generation::class, $experiment, 'Slug generation experiment should be registered in the registry.' ); + } + + /** + * Tear down test case. + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + set_current_screen( 'front' ); + unset( $GLOBALS['current_screen'] ); + wp_dequeue_style( 'ai_slug_generation' ); + wp_deregister_style( 'ai_slug_generation' ); + wp_dequeue_script( 'ai_slug_generation' ); + wp_deregister_script( 'ai_slug_generation' ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_slug-generation_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + */ + public function test_experiment_registration() { + $experiment = new Slug_Generation(); + + $this->assertEquals( 'slug-generation', $experiment->get_id() ); + $this->assertEquals( 'Slug Generation', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that register() adds the expected hooks. + */ + public function test_register_adds_hooks() { + $experiment = new Slug_Generation(); + $experiment->register(); + $this->assertIsInt( has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ), 'Should register abilities hook' ); + $this->assertIsInt( has_action( 'admin_enqueue_scripts', array( $experiment, 'enqueue_assets' ) ), 'Should register assets hook' ); + } + + /** + * Test that enqueue_assets() returns early for non-post screens. + */ + public function test_enqueue_assets_returns_early_for_non_post_screens() { + $experiment = new Slug_Generation(); + + // Should not enqueue for a non-post screen. + $experiment->enqueue_assets( 'options-general.php' ); + + $this->assertFalse( wp_script_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue script on options page' ); + $this->assertFalse( wp_style_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue style on options page' ); + } + + /** + * Test that the experiment is not enabled when globally disabled. + */ + public function test_experiment_not_enabled_when_globally_disabled() { + update_option( 'wpai_features_enabled', false ); + + $experiment = new Slug_Generation(); + + $this->assertFalse( $experiment->is_enabled(), 'Should not be enabled when global toggle is off' ); + } + + /** + * Test that the experiment is not enabled when individually disabled. + */ + public function test_experiment_not_enabled_when_individually_disabled() { + update_option( 'wpai_feature_slug-generation_enabled', false ); + + $experiment = new Slug_Generation(); + + $this->assertFalse( $experiment->is_enabled(), 'Should not be enabled when feature toggle is off' ); + } + + /** + * Tests that enqueue_assets() enqueues scripts, enqueues stylesheet and localizes settings. + */ + public function test_enqueue_assets_enqueues_and_localizes() { + set_current_screen( 'post' ); + + $experiment = new Slug_Generation(); + $experiment->enqueue_assets( 'post.php' ); + + $this->assertTrue( wp_script_is( 'ai_slug_generation', 'enqueued' ), 'Script should be enqueued' ); + $this->assertTrue( wp_style_is( 'ai_slug_generation', 'enqueued' ), 'Style should be enqueued' ); + + $localized_data = wp_scripts()->get_data( 'ai_slug_generation', 'data' ); + $this->assertStringContainsString( '"enabled":"1"', $localized_data ); + $this->assertStringContainsString( '"minContentLength":"250"', $localized_data ); + $this->assertStringContainsString( '"numberOfSuggestions":"3"', $localized_data ); + } + + /** + * Test that enqueue_assets() returns early for attachment post type screens. + */ + public function test_enqueue_assets_returns_early_for_attachment_post_type() { + set_current_screen( 'attachment' ); + + $experiment = new Slug_Generation(); + $experiment->enqueue_assets( 'post.php' ); + + $this->assertFalse( wp_script_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue script for attachment screen' ); + $this->assertFalse( wp_style_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue style for attachment screen' ); + } + + /** + * Test that enqueue_assets() returns early for post types that do not support title. + */ + public function test_enqueue_assets_returns_early_for_post_type_without_title_support() { + register_post_type( + 'no_title_cpt', + array( + 'supports' => array( 'editor' ), + ) + ); + set_current_screen( 'no_title_cpt' ); + + $experiment = new Slug_Generation(); + $experiment->enqueue_assets( 'post.php' ); + + $this->assertFalse( wp_script_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue script for post type without title support' ); + $this->assertFalse( wp_style_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue style for post type without title support' ); + + unregister_post_type( 'no_title_cpt' ); + } + + /** + * Test that enqueue_assets() returns early when current screen is null. + */ + public function test_enqueue_assets_returns_early_when_no_screen() { + unset( $GLOBALS['current_screen'] ); + + $experiment = new Slug_Generation(); + $experiment->enqueue_assets( 'post.php' ); + + $this->assertFalse( wp_script_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue script when current screen is null' ); + $this->assertFalse( wp_style_is( 'ai_slug_generation', 'enqueued' ), 'Should not enqueue style when current screen is null' ); + } + + /** + * Test that enqueue_assets() validates and clamps values returned by wpai_slug_generation_number_of_suggestions filter. + */ + public function test_enqueue_assets_clamps_filtered_number_of_suggestions() { + set_current_screen( 'post' ); + + add_filter( + 'wpai_slug_generation_number_of_suggestions', + static function () { + return 999; + } + ); + + $experiment = new Slug_Generation(); + $experiment->enqueue_assets( 'post.php' ); + + $localized_data = wp_scripts()->get_data( 'ai_slug_generation', 'data' ); + $this->assertStringContainsString( '"numberOfSuggestions":"10"', $localized_data ); + + remove_all_filters( 'wpai_slug_generation_number_of_suggestions' ); + } +} diff --git a/tests/e2e-testing/e2e-testing.php b/tests/e2e-testing/e2e-testing.php index 4c9b403b7..e403f727b 100644 --- a/tests/e2e-testing/e2e-testing.php +++ b/tests/e2e-testing/e2e-testing.php @@ -202,6 +202,9 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { } elseif ( is_string( $body ) && str_contains( $body, 'inline ghost text suggestions' ) ) { // Route type-ahead text requests to their own fixture. $response = file_get_contents( __DIR__ . '/responses/OpenAI/type-ahead-responses.json' ); + } elseif ( is_string( $body ) && str_contains( $body, 'permalink slug suggestions' ) ) { + // Route slug-generation requests to their own fixture. + $response = file_get_contents( __DIR__ . '/responses/OpenAI/slug-generation-responses.json' ); } elseif ( is_string( $body ) && str_contains( $body, 'comment moderation assistant' ) ) { $response = file_get_contents( __DIR__ . '/responses/OpenAI/comment-moderation-responses.json' ); @@ -231,6 +234,9 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { } elseif ( is_string( $body ) && str_contains( $body, 'content taxonomy assistant' ) ) { // Route content-classification requests to their own fixture. $response = file_get_contents( __DIR__ . '/responses/OpenAI/content-classification-completions.json' ); + } elseif ( is_string( $body ) && str_contains( $body, 'permalink slug suggestions' ) ) { + // Route slug-generation requests to their own fixture. + $response = file_get_contents( __DIR__ . '/responses/OpenAI/slug-generation-completions.json' ); } else { $response = file_get_contents( __DIR__ . '/responses/OpenAI/completions.json' ); } diff --git a/tests/e2e-testing/responses/OpenAI/slug-generation-completions.json b/tests/e2e-testing/responses/OpenAI/slug-generation-completions.json new file mode 100644 index 000000000..5927d2085 --- /dev/null +++ b/tests/e2e-testing/responses/OpenAI/slug-generation-completions.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-slug-gen-e2e-mock-001", + "object": "chat.completion", + "created": 1766520705, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ai-content-creation-web\nseo-slug-blogging-tools\nautomated-writing-ai", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 259, + "completion_tokens": 15, + "total_tokens": 274, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_31670n7411" +} diff --git a/tests/e2e-testing/responses/OpenAI/slug-generation-responses.json b/tests/e2e-testing/responses/OpenAI/slug-generation-responses.json new file mode 100644 index 000000000..85a46eecf --- /dev/null +++ b/tests/e2e-testing/responses/OpenAI/slug-generation-responses.json @@ -0,0 +1,71 @@ +{ + "id": "resp_slug_gen_e2e_mock_001", + "object": "response", + "created_at": 1771602524, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1771602526, + "error": null, + "frequency_penalty": 0.0, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "msg_slug_gen_e2e_mock_001", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "ai-content-creation-web\nseo-slug-blogging-tools\nautomated-writing-ai" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 490, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 15, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 505 + }, + "user": null, + "metadata": {} +} diff --git a/tests/e2e/specs/admin/settings.spec.js b/tests/e2e/specs/admin/settings.spec.js index 9f2272c6f..00a33ce63 100644 --- a/tests/e2e/specs/admin/settings.spec.js +++ b/tests/e2e/specs/admin/settings.spec.js @@ -341,6 +341,18 @@ test.describe( 'Plugin settings', () => { // Ensure AI is enabled. await enableExperiments( admin, page ); + // Disable all experiments in both groups to start from a clean state. + await disableAllExperimentsInGroup( + admin, + page, + EXPERIMENT_GROUPS.editor + ); + await disableAllExperimentsInGroup( + admin, + page, + EXPERIMENT_GROUPS.admin + ); + // Verify all groups have enable/disable all buttons. const editorEnableAll = getEnableAllButton( page, @@ -363,6 +375,13 @@ test.describe( 'Plugin settings', () => { // Enable all Editor Experiments. await editorEnableAll.click(); + const count = editorToggles.length; + await expect( + page.getByTestId( 'snackbar' ).filter( { + hasText: `${ count } experiments enabled`, + } ) + ).toBeVisible(); + // Verify Editor Experiments are enabled. for ( const toggle of editorToggles ) { await expect( toggle ).toBeChecked(); diff --git a/tests/e2e/specs/experiments/slug-generation.spec.js b/tests/e2e/specs/experiments/slug-generation.spec.js new file mode 100644 index 000000000..5c7c74c94 --- /dev/null +++ b/tests/e2e/specs/experiments/slug-generation.spec.js @@ -0,0 +1,232 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + disableExperiment, + disableExperiments, + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +const LONG_CONTENT = + 'Artificial intelligence is rapidly changing how content is created, edited, and published across the web today. Writers increasingly rely on automated tools to draft outlines, summarize research, and suggest improvements to their work. These systems analyze large amounts of text and surface patterns that would take a human many hours to find on their own. As the technology matures, editors are learning to combine their own judgment with machine generated suggestions to produce stronger results. This paragraph exists only to provide enough characters for the slug generation experiment to run, because the feature now requires a reasonable amount of content before it will offer to generate slug suggestions for the post.'; + +/** + * Opens the permalink popover in the post settings sidebar. + * + * Ensures the document sidebar is visible, then clicks the URL / permalink + * section to reveal the popover where the "Generate Slug" button is injected. + * + * @param {Object} editor The editor fixture from the test context. + * @param {Object} page The Playwright page object. + */ +const openPermalinkPopover = async ( editor, page ) => { + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + const popoverLocator = page.locator( + '.components-popover .editor-post-url, .components-dropdown__content .editor-post-url' + ); + + // Only click toggle if popover is not already visible. + if ( ! ( await popoverLocator.first().isVisible() ) ) { + // The permalink section is accessed via the "Link" or "URL" panel in the + // post settings sidebar. In the block editor it renders as a button that + // toggles the popover. + const linkButton = page.locator( + '.editor-post-url__toggle, .editor-post-url__toggle-button, button.editor-post-url__hostname, button.editor-post-url__panel-toggle, .editor-post-url button, [aria-label*="URL"], [aria-label*="Permalink"], [aria-label*="Link"]' + ); + + // Wait for the slug panel toggle to render (it may take a moment after save). + await expect( linkButton.first() ).toBeVisible( { timeout: 10000 } ); + await linkButton.first().click(); + } + + // Wait for the popover content to appear. + await expect( + page + .locator( + '.components-popover .editor-post-url, .components-dropdown__content .editor-post-url, .editor-post-url' + ) + .first() + ).toBeVisible( { + timeout: 10000, + } ); +}; + +test.describe( 'Slug Generation Experiment', () => { + test( 'Can enable the slug generation experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Slug Generation Experiment. + await enableExperiment( admin, page, 'Slug Generation' ); + } ); + + test( 'Can use slug generation from the permalink popover', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Slug Generation Experiment. + await enableExperiment( admin, page, 'Slug Generation' ); + + // Create a new post with sufficient content. + await admin.createNewPost( { + postType: 'post', + title: 'Test Slug Generation', + content: LONG_CONTENT, + } ); + + // Save the post so a permalink / slug section is generated. + await editor.saveDraft(); + + // Open the permalink popover. + await openPermalinkPopover( editor, page ); + + // Ensure the "Generate Slug" or "Regenerate Slug" button is visible. + const generateButton = page.getByRole( 'button', { + name: /Generate Slug|Regenerate Slug/i, + } ); + await expect( generateButton.first() ).toBeVisible( { + timeout: 10000, + } ); + await expect( generateButton.first() ).toBeEnabled(); + + // Click the Generate Slug button. + await generateButton.first().click(); + + // The slug generation modal should appear. + const modal = page.getByRole( 'dialog', { + name: 'Slug suggestions', + } ); + await expect( modal ).toBeVisible( { timeout: 10000 } ); + + // Wait for suggestions to load (the spinner should disappear). + await expect( + modal.getByText( 'Generating suggestions…' ) + ).not.toBeVisible( { timeout: 15000 } ); + + // Verify suggestion buttons are rendered. + await expect( modal.getByText( 'Suggested Slugs' ) ).toBeVisible(); + + // Verify the "Selected slug" text control is pre-filled with the first suggestion. + const selectedSlugInput = modal.getByLabel( 'Selected slug' ); + await expect( selectedSlugInput ).toBeVisible(); + await expect( selectedSlugInput ).not.toHaveValue( '' ); + + // Click Insert to apply the generated slug. + await modal.getByRole( 'button', { name: 'Insert' } ).click(); + + // Ensure the modal closes. + await expect( modal ).not.toBeVisible(); + + // Save the post. + await editor.saveDraft(); + } ); + + test( 'Generate Slug button is disabled when there is not enough content', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Slug Generation Experiment. + await enableExperiment( admin, page, 'Slug Generation' ); + + // Create a new post with content well below the minimum length. + await admin.createNewPost( { + postType: 'post', + title: 'Test Slug Too Short', + content: 'Too short.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Open the permalink popover. + await openPermalinkPopover( editor, page ); + + // The Generate/Regenerate Slug button should be visible but disabled. + const generateButton = page.getByRole( 'button', { + name: /Generate Slug|Regenerate Slug|Slug suggestions will be available/i, + } ); + await expect( generateButton.first() ).toBeVisible( { + timeout: 10000, + } ); + await expect( generateButton.first() ).toBeDisabled(); + } ); + + test( 'Ensure the Slug Generation Experiment UI is not visible when Experiments are globally disabled', async ( { + admin, + editor, + page, + } ) => { + // Enable the Slug Generation Experiment first. + await enableExperiment( admin, page, 'Slug Generation' ); + + // Globally turn off Experiments. + await disableExperiments( admin, page ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Slug Generation Globally Disabled', + content: LONG_CONTENT, + } ); + + // Save the post. + await editor.saveDraft(); + + // Open the permalink popover. + await openPermalinkPopover( editor, page ); + + // The slug generation container should not be present. + await expect( + page.locator( '.ai-slug-generation-container' ) + ).not.toBeVisible(); + } ); + + test( 'Ensure the Slug Generation Experiment UI is not visible when the experiment is disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Disable the Slug Generation Experiment. + await disableExperiment( admin, page, 'Slug Generation' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Slug Generation Experiment Disabled', + content: LONG_CONTENT, + } ); + + // Save the post. + await editor.saveDraft(); + + // Open the permalink popover. + await openPermalinkPopover( editor, page ); + + // The slug generation container should not be present. + await expect( + page.locator( '.ai-slug-generation-container' ) + ).not.toBeVisible(); + } ); +} ); diff --git a/tests/e2e/utils/helpers.ts b/tests/e2e/utils/helpers.ts index bea215481..43899193f 100644 --- a/tests/e2e/utils/helpers.ts +++ b/tests/e2e/utils/helpers.ts @@ -335,6 +335,11 @@ export const enableAllExperimentsInGroup = async ( await enableAllButton.click(); await expect( enableAllButton ).toBeDisabled(); await expect( disableAllButton ).toBeEnabled(); + await expect( + page.getByTestId( 'snackbar' ).filter( { + hasText: /enabled/i, + } ) + ).toBeVisible(); }; /** @@ -364,6 +369,11 @@ export const disableAllExperimentsInGroup = async ( await disableAllButton.click(); await expect( disableAllButton ).toBeDisabled(); await expect( enableAllButton ).toBeEnabled(); + await expect( + page.getByTestId( 'snackbar' ).filter( { + hasText: /disabled/i, + } ) + ).toBeVisible(); }; /** diff --git a/webpack.config.js b/webpack.config.js index c86e49052..35db526e5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -95,6 +95,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'experiments/slug-generation': path.resolve( + process.cwd(), + 'src/experiments/slug-generation', + 'index.tsx' + ), 'experiments/type-ahead': path.resolve( process.cwd(), 'src/experiments/type-ahead',