\n";
// Call get_links() with all the appropriate params.
get_links($cat->term_id, '
', "
", "\n", true, 'name', false);
@@ -2702,6 +2705,7 @@ function get_boundary_post_rel_link($title = '%title', $in_same_cat = false, $ex
$title = str_replace('%title', $post->post_title, $title);
$title = str_replace('%date', $date, $title);
+ /** This filter is documented in wp-includes/post-template.php */
$title = apply_filters('the_title', $title, $post->ID);
$link = $start ? "post_title, $title);
$title = str_replace('%date', $date, $title);
+ /** This filter is documented in wp-includes/post-template.php */
$title = apply_filters('the_title', $title, $post->ID);
$link = ".
* }
* @return bool Whether a tag was matched.
+ *
+ * @phpstan-impure
*/
public function next_tag( $query = null ): bool {
$this->parse_query( $query );
@@ -5061,4 +5063,13 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info {
* @since 6.7.0
*/
const TEXT_IS_WHITESPACE = 'TEXT_IS_WHITESPACE';
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 6.9.2
+ */
+ public function __wakeup() {
+ throw new \LogicException( __CLASS__ . ' should never be unserialized' );
+ }
}
diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
index b4cc86566d948..9b0e11b086ccf 100644
--- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
+++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
@@ -1029,6 +1029,20 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
return;
}
+ // Skip if the suffix is an event handler.
+ if ( str_starts_with( $entry['suffix'], 'on' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The directive, e.g. data-wp-on--click. */
+ __( 'Binding event handler attributes is not supported. Please use "%s" instead.' ),
+ esc_attr( 'data-wp-on--' . substr( $entry['suffix'], 2 ) )
+ ),
+ '6.9.2'
+ );
+ continue;
+ }
+
$result = $this->evaluate( $entry );
if (
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index ed2f96503ac27..062f85308512f 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -2118,8 +2118,8 @@ function wp_kses_normalize_entities( $content, $context = 'html' ) {
*
* Here, each input is normalized to an appropriate output.
*/
- $content = preg_replace_callback( '/&#(0*[0-9]{1,7});/', 'wp_kses_normalize_entities2', $content );
- $content = preg_replace_callback( '/&#[Xx](0*[0-9A-Fa-f]{1,6});/', 'wp_kses_normalize_entities3', $content );
+ $content = preg_replace_callback( '/&#(0*[1-9][0-9]{0,6});/', 'wp_kses_normalize_entities2', $content );
+ $content = preg_replace_callback( '/&#[Xx](0*[1-9A-Fa-f][0-9A-Fa-f]{0,5});/', 'wp_kses_normalize_entities3', $content );
if ( 'xml' === $context ) {
$content = preg_replace_callback( '/&([A-Za-z]{2,8}[0-9]{0,2});/', 'wp_kses_xml_named_entities', $content );
} else {
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index bfd2e58487429..fd215c800f9dc 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -4568,7 +4568,7 @@ function wp_prepare_attachment_for_js( $attachment ) {
if ( $attachment->post_parent ) {
$post_parent = get_post( $attachment->post_parent );
- if ( $post_parent ) {
+ if ( $post_parent && current_user_can( 'read_post', $attachment->post_parent ) ) {
$response['uploadedToTitle'] = $post_parent->post_title ? $post_parent->post_title : __( '(no title)' );
$response['uploadedToLink'] = get_edit_post_link( $attachment->post_parent, 'raw' );
}
@@ -5967,6 +5967,7 @@ function wp_get_webp_info( $filename ) {
* both attributes are present with those values.
*
* @since 6.3.0
+ * @since 7.0.0 Support `fetchpriority=low` and `fetchpriority=auto` so that `loading=lazy` is not added and the media count is not increased.
*
* @global WP_Query $wp_query WordPress Query object.
*
@@ -6067,7 +6068,9 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
}
// Logic to handle a `fetchpriority` attribute that is already provided.
- if ( isset( $attr['fetchpriority'] ) && 'high' === $attr['fetchpriority'] ) {
+ $existing_fetchpriority = ( $attr['fetchpriority'] ?? null );
+ $is_low_fetchpriority = ( 'low' === $existing_fetchpriority );
+ if ( 'high' === $existing_fetchpriority ) {
/*
* If the image was already determined to not be in the viewport (e.g.
* from an already provided `loading` attribute), trigger a warning.
@@ -6090,6 +6093,31 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
} else {
$maybe_in_viewport = true;
}
+ } elseif ( $is_low_fetchpriority ) {
+ /*
+ * An IMG with fetchpriority=low is not initially displayed; it may be hidden in the Navigation Overlay,
+ * or it may be occluded in a non-initial carousel slide. Such images must not be lazy-loaded because the browser
+ * has no heuristic to know when to start loading them before the user needs to see them.
+ */
+ $maybe_in_viewport = false;
+
+ // Preserve fetchpriority=low.
+ $loading_attrs['fetchpriority'] = 'low';
+ } elseif ( 'auto' === $existing_fetchpriority ) {
+ /*
+ * When a block's visibility support identifies that the block is conditionally displayed based on the viewport
+ * size, then it adds `fetchpriority=auto` to the block's IMG tags. These images must not be fetched with high
+ * priority because they could be erroneously loaded in viewports which do not even display them. Contrarily,
+ * they must not get `fetchpriority=low` because they may in fact be displayed in the current viewport. So as
+ * a signal to indicate that an IMG may be in the viewport, `fetchpriority=auto` is added. This has the effect
+ * here of preventing the media count from being increased, so that images hidden with block visibility do not
+ * affect whether a following IMG gets `loading=lazy`. In particular, `loading=lazy` should still be omitted
+ * on an IMG following any number of initial IMGs with `fetchpriority=auto` since those initial images may not
+ * be displayed.
+ */
+
+ // Preserve fetchpriority=auto.
+ $loading_attrs['fetchpriority'] = 'auto';
}
if ( null === $maybe_in_viewport ) {
@@ -6140,7 +6168,7 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
* does not include any loop.
*/
&& did_action( 'get_header' ) && ! did_action( 'get_footer' )
- ) {
+ ) {
$maybe_in_viewport = true;
$maybe_increase_count = true;
}
@@ -6149,12 +6177,14 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
/*
* If the element is in the viewport (`true`), potentially add
* `fetchpriority` with a value of "high". Otherwise, i.e. if the element
- * is not not in the viewport (`false`) or it is unknown (`null`), add
- * `loading` with a value of "lazy".
+ * is not in the viewport (`false`) or it is unknown (`null`), add
+ * `loading` with a value of "lazy" if the element is not already being
+ * de-prioritized with `fetchpriority=low` due to occlusion in
+ * Navigation Overlay, non-initial carousel slides, or a collapsed Details block.
*/
if ( $maybe_in_viewport ) {
$loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr );
- } else {
+ } elseif ( ! $is_low_fetchpriority ) {
// Only add `loading="lazy"` if the feature is enabled.
if ( wp_lazy_loading_enabled( $tag_name, $context ) ) {
$loading_attrs['loading'] = 'lazy';
@@ -6164,16 +6194,20 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
/*
* If flag was set based on contextual logic above, increase the content
* media count, either unconditionally, or based on whether the image size
- * is larger than the threshold.
+ * is larger than the threshold. This does not apply when the IMG has
+ * fetchpriority=auto because it may be conditionally displayed by viewport
+ * size.
*/
- if ( $increase_count ) {
- wp_increase_content_media_count();
- } elseif ( $maybe_increase_count ) {
- /** This filter is documented in wp-includes/media.php */
- $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 );
-
- if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) {
+ if ( 'auto' !== $existing_fetchpriority ) {
+ if ( $increase_count ) {
wp_increase_content_media_count();
+ } elseif ( $maybe_increase_count ) {
+ /** This filter is documented in wp-includes/media.php */
+ $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 );
+
+ if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) {
+ wp_increase_content_media_count();
+ }
}
}
@@ -6245,12 +6279,13 @@ function wp_increase_content_media_count( $amount = 1 ) {
* Determines whether to add `fetchpriority='high'` to loading attributes.
*
* @since 6.3.0
+ * @since 7.0.0 Support is added for IMG tags with `fetchpriority='low'` and `fetchpriority='auto'`.
* @access private
*
- * @param array $loading_attrs Array of the loading optimization attributes for the element.
- * @param string $tag_name The tag name.
- * @param array $attr Array of the attributes for the element.
- * @return array Updated loading optimization attributes for the element.
+ * @param array $loading_attrs Array of the loading optimization attributes for the element.
+ * @param string $tag_name The tag name.
+ * @param array $attr Array of the attributes for the element.
+ * @return array Updated loading optimization attributes for the element.
*/
function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ) {
// For now, adding `fetchpriority="high"` is only supported for images.
@@ -6258,14 +6293,17 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr
return $loading_attrs;
}
- if ( isset( $attr['fetchpriority'] ) ) {
+ $existing_fetchpriority = $attr['fetchpriority'] ?? null;
+ if ( null !== $existing_fetchpriority && 'auto' !== $existing_fetchpriority ) {
/*
- * While any `fetchpriority` value could be set in `$loading_attrs`,
- * for consistency we only do it for `fetchpriority="high"` since that
- * is the only possible value that WordPress core would apply on its
- * own.
+ * When an IMG has been explicitly marked with `fetchpriority=high`, then honor that this is the element that
+ * should have the priority. In contrast, the Navigation block may add `fetchpriority=low` to an IMG which
+ * appears in the Navigation Overlay; such images should never be considered candidates for
+ * `fetchpriority=high`. Lastly, block visibility may add `fetchpriority=auto` to an IMG when the block is
+ * conditionally displayed based on viewport size. Such an image is considered an LCP element candidate if it
+ * exceeds the threshold for the minimum number of square pixels.
*/
- if ( 'high' === $attr['fetchpriority'] ) {
+ if ( 'high' === $existing_fetchpriority ) {
$loading_attrs['fetchpriority'] = 'high';
wp_high_priority_element_flag( false );
}
@@ -6292,7 +6330,9 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr
$wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 );
if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) {
- $loading_attrs['fetchpriority'] = 'high';
+ if ( 'auto' !== $existing_fetchpriority ) {
+ $loading_attrs['fetchpriority'] = 'high';
+ }
wp_high_priority_element_flag( false );
}
@@ -6306,9 +6346,9 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr
* @access private
*
* @param bool $value Optional. Used to change the static variable. Default null.
- * @return bool Returns true if high-priority element was marked already, otherwise false.
+ * @return bool Returns true if the high-priority element was not already marked.
*/
-function wp_high_priority_element_flag( $value = null ) {
+function wp_high_priority_element_flag( $value = null ): bool {
static $high_priority_element = true;
if ( is_bool( $value ) ) {
@@ -6371,14 +6411,18 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) {
* @return bool Whether client-side media processing is enabled.
*/
function wp_is_client_side_media_processing_enabled(): bool {
+ // This is due to SharedArrayBuffer requiring a secure context.
+ $host = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) );
+ $enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) );
+
/**
* Filters whether client-side media processing is enabled.
*
* @since 7.0.0
*
- * @param bool $enabled Whether client-side media processing is enabled. Default true.
+ * @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context.
*/
- return (bool) apply_filters( 'wp_client_side_media_processing_enabled', true );
+ return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled );
}
/**
@@ -6391,7 +6435,7 @@ function wp_set_client_side_media_processing_flag(): void {
return;
}
- wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
+ wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' );
$chromium_version = wp_get_chromium_major_version();
@@ -6437,6 +6481,10 @@ function wp_get_chromium_major_version(): ?int {
* media processing in the editor. Uses Document-Isolation-Policy
* on supported browsers (Chromium 137+).
*
+ * Skips setup when a third-party page builder overrides the block
+ * editor via a custom `action` query parameter, as DIP would block
+ * same-origin iframe access that these editors rely on.
+ *
* @since 7.0.0
*/
function wp_set_up_cross_origin_isolation(): void {
@@ -6454,6 +6502,15 @@ function wp_set_up_cross_origin_isolation(): void {
return;
}
+ /*
+ * Skip when a third-party page builder overrides the block editor.
+ * DIP isolates the document into its own agent cluster,
+ * which blocks same-origin iframe access that these editors rely on.
+ */
+ if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
+ return;
+ }
+
// Cross-origin isolation is not needed if users can't upload files anyway.
if ( ! current_user_can( 'upload_files' ) ) {
return;
diff --git a/src/wp-includes/ms-blogs.php b/src/wp-includes/ms-blogs.php
index f8323dd3b7211..19fc6c1a7613e 100644
--- a/src/wp-includes/ms-blogs.php
+++ b/src/wp-includes/ms-blogs.php
@@ -755,7 +755,7 @@ function update_archived( $id, $archived ) {
* @param int $blog_id Blog ID.
* @param string $pref Field name.
* @param string $value Field value.
- * @param null $deprecated Not used.
+ * @param mixed $deprecated Not used.
* @return string|false $value
*/
function update_blog_status( $blog_id, $pref, $value, $deprecated = null ) {
diff --git a/src/wp-includes/ms-deprecated.php b/src/wp-includes/ms-deprecated.php
index 187ebb1565542..fe7e8da494c4a 100644
--- a/src/wp-includes/ms-deprecated.php
+++ b/src/wp-includes/ms-deprecated.php
@@ -694,6 +694,7 @@ function install_blog_defaults( $blog_id, $user_id ) {
* Previously used in core to mark a user as spam or "ham" (not spam) in Multisite.
*
* @since 3.0.0
+ * @since 3.0.2 Deprecated fourth argument.
* @deprecated 5.3.0 Use wp_update_user()
* @see wp_update_user()
*
@@ -703,7 +704,7 @@ function install_blog_defaults( $blog_id, $user_id ) {
* @param string $pref The column in the wp_users table to update the user's status
* in (presumably user_status, spam, or deleted).
* @param int $value The new status for the user.
- * @param null $deprecated Deprecated as of 3.0.2 and should not be used.
+ * @param mixed $deprecated Not used.
* @return int The initially passed $value.
*/
function update_user_status( $id, $pref, $value, $deprecated = null ) {
diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php
index b8d5228d09097..bbc464bdad269 100644
--- a/src/wp-includes/ms-load.php
+++ b/src/wp-includes/ms-load.php
@@ -383,7 +383,7 @@ function ms_load_current_site_and_network( $domain, $path, $subdomain = false )
// No network has been found, bail.
if ( empty( $current_site ) ) {
- /** This action is documented in wp-includes/ms-settings.php */
+ /** This action is documented in wp-includes/ms-load.php */
do_action( 'ms_network_not_found', $domain, $path );
return false;
diff --git a/src/wp-includes/nav-menu.php b/src/wp-includes/nav-menu.php
index 0b9d4038ed0d2..ed49892ac0eb6 100644
--- a/src/wp-includes/nav-menu.php
+++ b/src/wp-includes/nav-menu.php
@@ -511,7 +511,7 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item
}
}
- if ( wp_unslash( $args['menu-item-title'] ) === wp_specialchars_decode( $original_title ) ) {
+ if ( wp_unslash( $args['menu-item-title'] ) === $original_title ) {
$args['menu-item-title'] = '';
}
diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php
index d554bd5264fae..39e226b4df75a 100644
--- a/src/wp-includes/php-ai-client/src/AiClient.php
+++ b/src/wp-includes/php-ai-client/src/AiClient.php
@@ -84,7 +84,7 @@ class AiClient
/**
* @var string The version of the AI Client.
*/
- public const VERSION = '1.2.1';
+ public const VERSION = '1.3.0';
/**
* @var ProviderRegistry|null The default provider registry instance.
*/
@@ -314,6 +314,26 @@ public static function generateSpeechResult($prompt, $modelOrConfig = null, ?Pro
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult();
}
+ /**
+ * Generates a video using the traditional API approach.
+ *
+ * @since 1.3.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
+ * or model configuration for auto-discovery,
+ * or null for defaults.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the prompt format is invalid.
+ * @throws \RuntimeException If no suitable model is found.
+ */
+ public static function generateVideoResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult();
+ }
/**
* Creates a new message builder for fluent API usage.
*
diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
index 8a9fb1dd99502..87b0fedd4f696 100644
--- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
+++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
@@ -10,6 +10,7 @@
use WordPress\AiClient\Events\BeforeGenerateResultEvent;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
+use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\UserMessage;
@@ -26,6 +27,7 @@
use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface;
+use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface;
use WordPress\AiClient\Providers\ProviderRegistry;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
@@ -398,7 +400,7 @@ public function usingTopK(int $topK): self
*/
public function usingStopSequences(string ...$stopSequences): self
{
- $this->modelConfig->setCustomOption('stopSequences', $stopSequences);
+ $this->modelConfig->setStopSequences($stopSequences);
return $this;
}
/**
@@ -552,6 +554,48 @@ public function asOutputFileType(FileTypeEnum $fileType): self
$this->modelConfig->setOutputFileType($fileType);
return $this;
}
+ /**
+ * Sets the output media orientation.
+ *
+ * @since 1.3.0
+ *
+ * @param MediaOrientationEnum $orientation The output media orientation.
+ * @return self
+ */
+ public function asOutputMediaOrientation(MediaOrientationEnum $orientation): self
+ {
+ $this->modelConfig->setOutputMediaOrientation($orientation);
+ return $this;
+ }
+ /**
+ * Sets the output media aspect ratio.
+ *
+ * If set, this supersedes the output media orientation, as it is a more
+ * specific configuration.
+ *
+ * @since 1.3.0
+ *
+ * @param string $aspectRatio The aspect ratio (e.g. "16:9", "3:2").
+ * @return self
+ */
+ public function asOutputMediaAspectRatio(string $aspectRatio): self
+ {
+ $this->modelConfig->setOutputMediaAspectRatio($aspectRatio);
+ return $this;
+ }
+ /**
+ * Sets the output speech voice.
+ *
+ * @since 1.3.0
+ *
+ * @param string $voice The output speech voice.
+ * @return self
+ */
+ public function asOutputSpeechVoice(string $voice): self
+ {
+ $this->modelConfig->setOutputSpeechVoice($voice);
+ return $this;
+ }
/**
* Configures the prompt for JSON response output.
*
@@ -627,6 +671,9 @@ private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?Cap
if ($model instanceof SpeechGenerationModelInterface) {
return CapabilityEnum::speechGeneration();
}
+ if ($model instanceof VideoGenerationModelInterface) {
+ return CapabilityEnum::videoGeneration();
+ }
// No supported interface found
return null;
}
@@ -825,9 +872,11 @@ private function executeModelGeneration(ModelInterface $model, CapabilityEnum $c
}
return $model->generateSpeechResult($messages);
}
- // Video generation is not yet implemented
if ($capability->isVideoGeneration()) {
- throw new RuntimeException('Output modality "video" is not yet supported.');
+ if (!$model instanceof VideoGenerationModelInterface) {
+ throw new RuntimeException(sprintf('Model "%s" does not support video generation.', $model->metadata()->getId()));
+ }
+ return $model->generateVideoResult($messages);
}
// TODO: Add support for other capabilities when interfaces are available
throw new RuntimeException(sprintf('Capability "%s" is not yet supported for generation.', $capability->value));
@@ -896,6 +945,22 @@ public function convertTextToSpeechResult(): GenerativeAiResult
// Generate and return the result with text-to-speech conversion capability
return $this->generateResult(CapabilityEnum::textToSpeechConversion());
}
+ /**
+ * Generates a video result from the prompt.
+ *
+ * @since 1.3.0
+ *
+ * @return GenerativeAiResult The generated result containing video candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support video generation.
+ */
+ public function generateVideoResult(): GenerativeAiResult
+ {
+ // Include video in output modalities
+ $this->includeOutputModalities(ModalityEnum::video());
+ // Generate and return the result with video generation capability
+ return $this->generateResult(CapabilityEnum::videoGeneration());
+ }
/**
* Generates text from the prompt.
*
@@ -1015,6 +1080,36 @@ public function generateSpeeches(?int $candidateCount = null): array
}
return $this->generateSpeechResult()->toFiles();
}
+ /**
+ * Generates a video from the prompt.
+ *
+ * @since 1.3.0
+ *
+ * @return File The generated video file.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no video is generated.
+ */
+ public function generateVideo(): File
+ {
+ return $this->generateVideoResult()->toFile();
+ }
+ /**
+ * Generates multiple videos from the prompt.
+ *
+ * @since 1.3.0
+ *
+ * @param int|null $candidateCount The number of videos to generate.
+ * @return list The generated video files.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no videos are generated.
+ */
+ public function generateVideos(?int $candidateCount = null): array
+ {
+ if ($candidateCount !== null) {
+ $this->usingCandidateCount($candidateCount);
+ }
+ return $this->generateVideoResult()->toFiles();
+ }
/**
* Appends a MessagePart to the messages array.
*
diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
index bbc858d76c4b4..264b2c5b37942 100644
--- a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
+++ b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
@@ -26,6 +26,7 @@
* @phpstan-type MessagePartArrayShape array{
* channel: string,
* type: string,
+ * thoughtSignature?: string,
* text?: string,
* file?: FileArrayShape,
* functionCall?: FunctionCallArrayShape,
@@ -38,6 +39,7 @@ class MessagePart extends AbstractDataTransferObject
{
public const KEY_CHANNEL = 'channel';
public const KEY_TYPE = 'type';
+ public const KEY_THOUGHT_SIGNATURE = 'thoughtSignature';
public const KEY_TEXT = 'text';
public const KEY_FILE = 'file';
public const KEY_FUNCTION_CALL = 'functionCall';
@@ -50,6 +52,10 @@ class MessagePart extends AbstractDataTransferObject
* @var MessagePartTypeEnum The type of this message part.
*/
private MessagePartTypeEnum $type;
+ /**
+ * @var string|null Thought signature for extended thinking.
+ */
+ private ?string $thoughtSignature = null;
/**
* @var string|null Text content (when type is TEXT).
*/
@@ -73,11 +79,13 @@ class MessagePart extends AbstractDataTransferObject
*
* @param mixed $content The content of this message part.
* @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT.
+ * @param string|null $thoughtSignature Optional thought signature for extended thinking.
* @throws InvalidArgumentException If an unsupported content type is provided.
*/
- public function __construct($content, ?MessagePartChannelEnum $channel = null)
+ public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null)
{
$this->channel = $channel ?? MessagePartChannelEnum::content();
+ $this->thoughtSignature = $thoughtSignature;
if (is_string($content)) {
$this->type = MessagePartTypeEnum::text();
$this->text = $content;
@@ -117,6 +125,17 @@ public function getType(): MessagePartTypeEnum
{
return $this->type;
}
+ /**
+ * Gets the thought signature.
+ *
+ * @since 1.3.0
+ *
+ * @return string|null The thought signature or null if not set.
+ */
+ public function getThoughtSignature(): ?string
+ {
+ return $this->thoughtSignature;
+ }
/**
* Gets the text content.
*
@@ -169,7 +188,8 @@ public function getFunctionResponse(): ?FunctionResponse
public static function getJsonSchema(): array
{
$channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.'];
- return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.']], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]];
+ $thoughtSignatureSchema = ['type' => 'string', 'description' => 'Thought signature for extended thinking.'];
+ return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.'], self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]];
}
/**
* {@inheritDoc}
@@ -192,6 +212,9 @@ public function toArray(): array
} else {
throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.');
}
+ if ($this->thoughtSignature !== null) {
+ $data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature;
+ }
return $data;
}
/**
@@ -206,15 +229,16 @@ public static function fromArray(array $array): self
} else {
$channel = null;
}
+ $thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null;
// Check which properties are set to determine how to construct the MessagePart
if (isset($array[self::KEY_TEXT])) {
- return new self($array[self::KEY_TEXT], $channel);
+ return new self($array[self::KEY_TEXT], $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FILE])) {
- return new self(File::fromArray($array[self::KEY_FILE]), $channel);
+ return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_CALL])) {
- return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel);
+ return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) {
- return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel);
+ return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, $thoughtSignature);
} else {
throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.');
}
diff --git a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php
index 592a9d3ab6b31..08ca58d22c1ec 100644
--- a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php
+++ b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php
@@ -14,6 +14,7 @@
*
* @since 0.1.0
* @since 1.2.0 Added optional description property.
+ * @since 1.3.0 Added optional logoPath property.
*
* @phpstan-type ProviderMetadataArrayShape array{
* id: string,
@@ -21,7 +22,8 @@
* description?: ?string,
* type: string,
* credentialsUrl?: ?string,
- * authenticationMethod?: ?string
+ * authenticationMethod?: ?string,
+ * logoPath?: ?string
* }
*
* @extends AbstractDataTransferObject
@@ -34,6 +36,7 @@ class ProviderMetadata extends AbstractDataTransferObject
public const KEY_TYPE = 'type';
public const KEY_CREDENTIALS_URL = 'credentialsUrl';
public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod';
+ public const KEY_LOGO_PATH = 'logoPath';
/**
* @var string The provider's unique identifier.
*/
@@ -58,11 +61,16 @@ class ProviderMetadata extends AbstractDataTransferObject
* @var RequestAuthenticationMethod|null The authentication method.
*/
protected ?RequestAuthenticationMethod $authenticationMethod;
+ /**
+ * @var string|null The full path to the provider's logo image file.
+ */
+ protected ?string $logoPath;
/**
* Constructor.
*
* @since 0.1.0
* @since 1.2.0 Added optional $description parameter.
+ * @since 1.3.0 Added optional $logoPath parameter.
*
* @param string $id The provider's unique identifier.
* @param string $name The provider's display name.
@@ -70,8 +78,9 @@ class ProviderMetadata extends AbstractDataTransferObject
* @param string|null $credentialsUrl The URL where users can get credentials.
* @param RequestAuthenticationMethod|null $authenticationMethod The authentication method.
* @param string|null $description The provider's description.
+ * @param string|null $logoPath The full path to the provider's logo image file.
*/
- public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null)
+ public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null, ?string $logoPath = null)
{
$this->id = $id;
$this->name = $name;
@@ -79,6 +88,7 @@ public function __construct(string $id, string $name, ProviderTypeEnum $type, ?s
$this->type = $type;
$this->credentialsUrl = $credentialsUrl;
$this->authenticationMethod = $authenticationMethod;
+ $this->logoPath = $logoPath;
}
/**
* Gets the provider's unique identifier.
@@ -146,37 +156,51 @@ public function getAuthenticationMethod(): ?RequestAuthenticationMethod
{
return $this->authenticationMethod;
}
+ /**
+ * Gets the full path to the provider's logo image file.
+ *
+ * @since 1.3.0
+ *
+ * @return string|null The full path to the logo image file.
+ */
+ public function getLogoPath(): ?string
+ {
+ return $this->logoPath;
+ }
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to schema.
+ * @since 1.3.0 Added logoPath to schema.
*/
public static function getJsonSchema(): array
{
- return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]];
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.'], self::KEY_LOGO_PATH => ['type' => 'string', 'description' => 'The full path to the provider\'s logo image file.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to output.
+ * @since 1.3.0 Added logoPath to output.
*
* @return ProviderMetadataArrayShape
*/
public function toArray(): array
{
- return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null];
+ return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null, self::KEY_LOGO_PATH => $this->logoPath];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description support.
+ * @since 1.3.0 Added logoPath support.
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]);
- return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null);
+ return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null, $array[self::KEY_LOGO_PATH] ?? null);
}
}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php
index 8963674137ccc..248c0a775c98a 100644
--- a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php
@@ -30,7 +30,7 @@ class ServerException extends RuntimeException
public static function fromServerErrorResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
- $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage'];
+ $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage', 529 => 'Overloaded'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php
new file mode 100644
index 0000000000000..f83b4742e2500
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the video generation prompt.
+ * @return GenerativeAiResult Result containing generated videos.
+ */
+ public function generateVideoResult(array $prompt): GenerativeAiResult;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php
new file mode 100644
index 0000000000000..45177bc602fef
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the video generation prompt.
+ * @return GenerativeAiOperation The initiated video generation operation.
+ */
+ public function generateVideoOperation(array $prompt): GenerativeAiOperation;
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php
index df3201c92f77d..39265a77a1d2c 100644
--- a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php
+++ b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php
@@ -10,12 +10,16 @@
* This DTO tracks the number of tokens used in prompts and completions,
* which is important for monitoring usage and costs.
*
+ * Note that thought tokens are a subset of completion tokens, not additive.
+ * In other words: completionTokens - thoughtTokens = tokens of actual output content.
+ *
* @since 0.1.0
*
* @phpstan-type TokenUsageArrayShape array{
* promptTokens: int,
* completionTokens: int,
- * totalTokens: int
+ * totalTokens: int,
+ * thoughtTokens?: int
* }
*
* @extends AbstractDataTransferObject
@@ -25,32 +29,39 @@ class TokenUsage extends AbstractDataTransferObject
public const KEY_PROMPT_TOKENS = 'promptTokens';
public const KEY_COMPLETION_TOKENS = 'completionTokens';
public const KEY_TOTAL_TOKENS = 'totalTokens';
+ public const KEY_THOUGHT_TOKENS = 'thoughtTokens';
/**
* @var int Number of tokens in the prompt.
*/
private int $promptTokens;
/**
- * @var int Number of tokens in the completion.
+ * @var int Number of tokens in the completion, including any thought tokens.
*/
private int $completionTokens;
/**
* @var int Total number of tokens used.
*/
private int $totalTokens;
+ /**
+ * @var int|null Number of tokens used for thinking, as a subset of completion tokens.
+ */
+ private ?int $thoughtTokens;
/**
* Constructor.
*
* @since 0.1.0
*
* @param int $promptTokens Number of tokens in the prompt.
- * @param int $completionTokens Number of tokens in the completion.
+ * @param int $completionTokens Number of tokens in the completion, including any thought tokens.
* @param int $totalTokens Total number of tokens used.
+ * @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens.
*/
- public function __construct(int $promptTokens, int $completionTokens, int $totalTokens)
+ public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null)
{
$this->promptTokens = $promptTokens;
$this->completionTokens = $completionTokens;
$this->totalTokens = $totalTokens;
+ $this->thoughtTokens = $thoughtTokens;
}
/**
* Gets the number of prompt tokens.
@@ -64,7 +75,7 @@ public function getPromptTokens(): int
return $this->promptTokens;
}
/**
- * Gets the number of completion tokens.
+ * Gets the number of completion tokens, including any thought tokens.
*
* @since 0.1.0
*
@@ -85,6 +96,17 @@ public function getTotalTokens(): int
{
return $this->totalTokens;
}
+ /**
+ * Gets the number of thought tokens, which is a subset of the completion token count.
+ *
+ * @since 1.3.0
+ *
+ * @return int|null The thought token count or null if not available.
+ */
+ public function getThoughtTokens(): ?int
+ {
+ return $this->thoughtTokens;
+ }
/**
* {@inheritDoc}
*
@@ -92,7 +114,7 @@ public function getTotalTokens(): int
*/
public static function getJsonSchema(): array
{
- return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]];
+ return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion, including any thought tokens.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.'], self::KEY_THOUGHT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]];
}
/**
* {@inheritDoc}
@@ -103,7 +125,11 @@ public static function getJsonSchema(): array
*/
public function toArray(): array
{
- return [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens];
+ $data = [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens];
+ if ($this->thoughtTokens !== null) {
+ $data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens;
+ }
+ return $data;
}
/**
* {@inheritDoc}
@@ -113,6 +139,6 @@ public function toArray(): array
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]);
- return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS]);
+ return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS], $array[self::KEY_THOUGHT_TOKENS] ?? null);
}
}
diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php
index 03593ea3e64e6..9574ba123b0b7 100644
--- a/src/wp-includes/pluggable.php
+++ b/src/wp-includes/pluggable.php
@@ -2263,7 +2263,7 @@ function wp_password_change_notification( $user ) {
* @since 4.6.0 The `$notify` parameter accepts 'user' for sending notification only to the user created.
*
* @param int $user_id User ID.
- * @param null $deprecated Not used (argument deprecated).
+ * @param mixed $deprecated Not used.
* @param string $notify Optional. Type of notification that should happen. Accepts 'admin' or an empty
* string (admin only), 'user', or 'both' (admin and user). Default empty.
*/
diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php
index 521b0f4a81e04..704a990298826 100644
--- a/src/wp-includes/rest-api/class-wp-rest-server.php
+++ b/src/wp-includes/rest-api/class-wp-rest-server.php
@@ -1383,16 +1383,16 @@ public function get_index( $request ) {
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
$output_formats = array();
foreach ( $input_formats as $mime_type ) {
- /** This filter is documented in wp-includes/class-wp-image-editor.php */
+ /** This filter is documented in wp-includes/media.php */
$output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type );
}
$available['image_output_formats'] = (object) $output_formats;
- /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
$available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' );
- /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
$available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' );
- /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
$available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' );
}
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
index 574d0e92533a9..cb714d5a5de71 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
@@ -103,6 +103,26 @@ public function register_routes() {
'schema' => array( $this, 'get_public_item_schema' ),
)
);
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P[\d]+)/finalize',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'finalize_item' ),
+ 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the attachment.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
}
}
@@ -232,6 +252,12 @@ public function create_item_permissions_check( $request ) {
*/
$prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null );
+ // When the client handles image processing (generate_sub_sizes is false),
+ // skip the server-side image editor support check.
+ if ( false === $request['generate_sub_sizes'] ) {
+ $prevent_unsupported_uploads = false;
+ }
+
// If the upload is an image, check if the server can handle the mime type.
if (
$prevent_unsupported_uploads &&
@@ -278,7 +304,7 @@ public function create_item( $request ) {
}
// Handle generate_sub_sizes parameter.
- if ( isset( $request['generate_sub_sizes'] ) && ! $request['generate_sub_sizes'] ) {
+ if ( false === $request['generate_sub_sizes'] ) {
add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
// Disable server-side EXIF rotation so the client can handle it.
@@ -2185,4 +2211,48 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at
return $filename;
}
+
+ /**
+ * Finalizes an attachment after client-side media processing.
+ *
+ * Triggers the 'wp_generate_attachment_metadata' filter so that
+ * server-side plugins can process the attachment after all client-side
+ * operations (upload, thumbnail generation, sideloads) are complete.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+ */
+ public function finalize_item( WP_REST_Request $request ) {
+ $attachment_id = $request['id'];
+
+ $post = $this->get_post( $attachment_id );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+ if ( ! is_array( $metadata ) ) {
+ $metadata = array();
+ }
+
+ /** This filter is documented in wp-admin/includes/image.php */
+ $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
+
+ wp_update_attachment_metadata( $attachment_id, $metadata );
+
+ $response_request = new WP_REST_Request(
+ WP_REST_Server::READABLE,
+ rest_get_route_for_post( $attachment_id )
+ );
+
+ $response_request['context'] = 'edit';
+
+ if ( isset( $request['_fields'] ) ) {
+ $response_request['_fields'] = $request['_fields'];
+ }
+
+ return $this->prepare_item_for_response( $post, $response_request );
+ }
}
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
index 36839d9c72bbf..9db45cadd0dbd 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
@@ -436,7 +436,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) {
$new_autosave['ID'] = $old_autosave->ID;
$new_autosave['post_author'] = $user_id;
- /** This filter is documented in wp-admin/post.php */
+ /** This action is documented in wp-admin/includes/post.php */
do_action( 'wp_creating_autosave', $new_autosave );
// wp_update_post() expects escaped array.
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
index 3f83504f8a3e5..f462928847c77 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
@@ -560,6 +560,14 @@ public function create_item_permissions_check( $request ) {
}
}
+ if ( $is_note && ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_create_note',
+ __( 'Sorry, you are not allowed to create notes for this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
$edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' );
if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) {
return new WP_Error(
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
index 8e343d2447141..0ab54a3a0d384 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
@@ -2294,7 +2294,7 @@ protected function prepare_links( $post ) {
// If we have a featured media, add that.
$featured_media = get_post_thumbnail_id( $post->ID );
- if ( $featured_media ) {
+ if ( $featured_media && ( 'publish' === get_post_status( $featured_media ) || current_user_can( 'read_post', $featured_media ) ) ) {
$image_url = rest_url( rest_get_route_for_post( $featured_media ) );
$links['https://api.w.org/featuredmedia'] = array(
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
index 99282e6d3e986..73a888d6eac48 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
@@ -226,6 +226,8 @@ protected function get_revision( $id ) {
*
* @since 4.7.0
*
+ * @see WP_REST_Posts_Controller::get_items()
+ *
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
@@ -297,7 +299,17 @@ public function get_items( $request ) {
$args['update_post_meta_cache'] = false;
}
- /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ /**
+ * Filters WP_Query arguments when querying revisions via the REST API.
+ *
+ * Serves the same purpose as the {@see 'rest_{$this->post_type}_query'} filter in
+ * WP_REST_Posts_Controller, but for the standalone WP_REST_Revisions_Controller.
+ *
+ * @since 5.0.0
+ *
+ * @param array $args Array of arguments for WP_Query.
+ * @param WP_REST_Request $request The REST API request.
+ */
$args = apply_filters( 'rest_revision_query', $args, $request );
if ( ! is_array( $args ) ) {
$args = array();
@@ -639,35 +651,46 @@ public function prepare_item_for_response( $item, $request ) {
$data['slug'] = $post->post_name;
}
- if ( in_array( 'guid', $fields, true ) ) {
- $data['guid'] = array(
- /** This filter is documented in wp-includes/post-template.php */
- 'rendered' => apply_filters( 'get_the_guid', $post->guid, $post->ID ),
- 'raw' => $post->guid,
- );
+ if ( rest_is_field_included( 'guid', $fields ) ) {
+ $data['guid'] = array();
}
-
- if ( in_array( 'title', $fields, true ) ) {
- $data['title'] = array(
- 'raw' => $post->post_title,
- 'rendered' => get_the_title( $post->ID ),
- );
+ if ( rest_is_field_included( 'guid.rendered', $fields ) ) {
+ /** This filter is documented in wp-includes/post-template.php */
+ $data['guid']['rendered'] = apply_filters( 'get_the_guid', $post->guid, $post->ID );
+ }
+ if ( rest_is_field_included( 'guid.raw', $fields ) ) {
+ $data['guid']['raw'] = $post->guid;
}
- if ( in_array( 'content', $fields, true ) ) {
+ if ( rest_is_field_included( 'title', $fields ) ) {
+ $data['title'] = array();
+ }
+ if ( rest_is_field_included( 'title.raw', $fields ) ) {
+ $data['title']['raw'] = $post->post_title;
+ }
+ if ( rest_is_field_included( 'title.rendered', $fields ) ) {
+ $data['title']['rendered'] = get_the_title( $post->ID );
+ }
- $data['content'] = array(
- 'raw' => $post->post_content,
- /** This filter is documented in wp-includes/post-template.php */
- 'rendered' => apply_filters( 'the_content', $post->post_content ),
- );
+ if ( rest_is_field_included( 'content', $fields ) ) {
+ $data['content'] = array();
+ }
+ if ( rest_is_field_included( 'content.raw', $fields ) ) {
+ $data['content']['raw'] = $post->post_content;
+ }
+ if ( rest_is_field_included( 'content.rendered', $fields ) ) {
+ /** This filter is documented in wp-includes/post-template.php */
+ $data['content']['rendered'] = apply_filters( 'the_content', $post->post_content );
}
- if ( in_array( 'excerpt', $fields, true ) ) {
- $data['excerpt'] = array(
- 'raw' => $post->post_excerpt,
- 'rendered' => $this->prepare_excerpt_response( $post->post_excerpt, $post ),
- );
+ if ( rest_is_field_included( 'excerpt', $fields ) ) {
+ $data['excerpt'] = array();
+ }
+ if ( rest_is_field_included( 'excerpt.raw', $fields ) ) {
+ $data['excerpt']['raw'] = $post->post_excerpt;
+ }
+ if ( rest_is_field_included( 'excerpt.rendered', $fields ) ) {
+ $data['excerpt']['rendered'] = $this->prepare_excerpt_response( $post->post_excerpt, $post );
}
if ( rest_is_field_included( 'meta', $fields ) ) {
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index f9ea36720baea..733914d1d3656 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -2551,21 +2551,42 @@ function wp_enqueue_global_styles() {
$is_block_theme = wp_is_block_theme();
$is_classic_theme = ! $is_block_theme;
- /*
- * Global styles should be printed in the head for block themes, or for classic themes when loading assets on
- * demand is disabled, which is the default.
- * The footer should only be used for classic themes when loading assets on demand is enabled.
+ /**
+ * Global styles should be printed in the HEAD for block themes, or for classic themes when loading assets on
+ * demand is disabled (which is no longer the default since WordPress 6.9).
*
- * See https://core.trac.wordpress.org/ticket/53494 and https://core.trac.wordpress.org/ticket/61965.
+ * @link https://core.trac.wordpress.org/ticket/53494
+ * @link https://core.trac.wordpress.org/ticket/61965
*/
if (
- ( $is_block_theme && doing_action( 'wp_footer' ) ) ||
- ( $is_classic_theme && doing_action( 'wp_footer' ) && ! $assets_on_demand ) ||
- ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) && $assets_on_demand )
+ doing_action( 'wp_footer' ) &&
+ (
+ $is_block_theme ||
+ ( $is_classic_theme && ! $assets_on_demand )
+ )
) {
return;
}
+ /**
+ * The footer should only be used for classic themes when loading assets on demand is enabled. In WP 6.9 this is the
+ * default with the introduction of hoisting late-printed styles (via {@see wp_load_classic_theme_block_styles_on_demand()}).
+ * So even though the main global styles are not printed here in the HEAD for classic themes with on-demand asset
+ * loading, a placeholder for the global styles is still enqueued. Then when {@see wp_hoist_late_printed_styles()}
+ * processes the output buffer, it can locate the placeholder and inject the global styles from the footer into the
+ * HEAD, replacing the placeholder.
+ *
+ * @link https://core.trac.wordpress.org/ticket/64099
+ */
+ if ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) && $assets_on_demand ) {
+ if ( has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) {
+ wp_register_style( 'wp-global-styles-placeholder', false );
+ wp_add_inline_style( 'wp-global-styles-placeholder', ':root { --wp-internal-comment: "Placeholder for wp_hoist_late_printed_styles() to replace with the global-styles printed at wp_footer." }' );
+ wp_enqueue_style( 'wp-global-styles-placeholder' );
+ }
+ return;
+ }
+
/*
* If loading the CSS for each block separately, then load the theme.json CSS conditionally.
* This removes the CSS from the global-styles stylesheet and adds it to the inline CSS for each block.
@@ -2741,6 +2762,16 @@ function wp_should_load_block_assets_on_demand() {
*/
function wp_enqueue_registered_block_scripts_and_styles() {
if ( wp_should_load_block_assets_on_demand() ) {
+ /**
+ * Add placeholder for where block styles would historically get enqueued in a classic theme when block assets
+ * are not loaded on demand. This happens right after {@see wp_common_block_scripts_and_styles()} is called
+ * at which time wp-block-library is enqueued.
+ */
+ if ( ! wp_is_block_theme() && has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) {
+ wp_register_style( 'wp-block-styles-placeholder', false );
+ wp_add_inline_style( 'wp-block-styles-placeholder', ':root { --wp-internal-comment: "Placeholder for wp_hoist_late_printed_styles() to replace with the block styles printed at wp_footer." }' );
+ wp_enqueue_style( 'wp-block-styles-placeholder' );
+ }
return;
}
@@ -3643,11 +3674,15 @@ function wp_remove_surrounding_empty_script_tags( $contents ) {
/**
* Adds hooks to load block styles on demand in classic themes.
*
+ * This function must be called before {@see wp_default_styles()} and {@see register_core_block_style_handles()} so that
+ * the filters are added to cause {@see wp_should_load_separate_core_block_assets()} to return true.
+ *
* @since 6.9.0
+ * @since 7.0.0 This is now invoked at the `wp_default_styles` action with priority 0 instead of at `init` with priority 8.
*
* @see _add_default_theme_supports()
*/
-function wp_load_classic_theme_block_styles_on_demand() {
+function wp_load_classic_theme_block_styles_on_demand(): void {
// This is not relevant to block themes, as they are opted in to loading separate styles on demand via _add_default_theme_supports().
if ( wp_is_block_theme() ) {
return;
@@ -3700,42 +3735,30 @@ function wp_load_classic_theme_block_styles_on_demand() {
* @see wp_load_classic_theme_block_styles_on_demand()
* @see _wp_footer_scripts()
*/
-function wp_hoist_late_printed_styles() {
+function wp_hoist_late_printed_styles(): void {
// Skip the embed template on-demand styles aren't relevant, and there is no wp_head action.
if ( is_embed() ) {
return;
}
- // Capture the styles enqueued at the enqueue_block_assets action, so that non-core block styles and global styles can be inserted afterwards during hoisting.
- $style_handles_at_enqueue_block_assets = array();
- add_action(
- 'enqueue_block_assets',
- static function () use ( &$style_handles_at_enqueue_block_assets ) {
- $style_handles_at_enqueue_block_assets = wp_styles()->queue;
- },
- PHP_INT_MIN
- );
- add_action(
- 'enqueue_block_assets',
- static function () use ( &$style_handles_at_enqueue_block_assets ) {
- $style_handles_at_enqueue_block_assets = array_values( array_diff( wp_styles()->queue, $style_handles_at_enqueue_block_assets ) );
- },
- PHP_INT_MAX
- );
-
/*
* Add a placeholder comment into the inline styles for wp-block-library, after which the late block styles
* can be hoisted from the footer to be printed in the header by means of a filter below on the template enhancement
- * output buffer. The `wp_print_styles` action is used to ensure that if the inline style gets replaced at
- * `enqueue_block_assets` or `wp_enqueue_scripts` that the placeholder will be sure to be present.
+ * output buffer.
+ *
+ * Note that wp_maybe_inline_styles() prepends the inlined style to the extra 'after' array, which happens after
+ * this code runs. This ensures that the placeholder appears right after any inlined wp-block-library styles,
+ * which would be common.css.
*/
$placeholder = sprintf( '/*%s*/', uniqid( 'wp_block_styles_on_demand_placeholder:' ) );
- add_action(
- 'wp_print_styles',
- static function () use ( $placeholder ) {
+ $dependency = wp_styles()->query( 'wp-block-library', 'registered' );
+ if ( $dependency ) {
+ if ( ! isset( $dependency->extra['after'] ) ) {
wp_add_inline_style( 'wp-block-library', $placeholder );
+ } else {
+ array_unshift( $dependency->extra['after'], $placeholder );
}
- );
+ }
/*
* Create a substitute for `print_late_styles()` which is aware of block styles. This substitute does not print
@@ -3765,29 +3788,29 @@ static function () use ( $placeholder ) {
}
/*
- * First print all styles related to blocks which should be inserted right after the wp-block-library stylesheet
+ * First print all styles related to core blocks which should be inserted right after the wp-block-library stylesheet
* to preserve the CSS cascade. The logic in this `if` statement is derived from `wp_print_styles()`.
*/
$enqueued_core_block_styles = array_values( array_intersect( $all_core_block_style_handles, wp_styles()->queue ) );
if ( count( $enqueued_core_block_styles ) > 0 ) {
ob_start();
wp_styles()->do_items( $enqueued_core_block_styles );
- $printed_core_block_styles = ob_get_clean();
+ $printed_core_block_styles = (string) ob_get_clean();
}
- // Non-core block styles get printed after the classic-theme-styles stylesheet.
+ // Capture non-core block styles so they can get printed at the point where wp_enqueue_registered_block_scripts_and_styles() runs.
$enqueued_other_block_styles = array_values( array_intersect( $all_other_block_style_handles, wp_styles()->queue ) );
if ( count( $enqueued_other_block_styles ) > 0 ) {
ob_start();
wp_styles()->do_items( $enqueued_other_block_styles );
- $printed_other_block_styles = ob_get_clean();
+ $printed_other_block_styles = (string) ob_get_clean();
}
- // Capture the global-styles so that it can be printed separately after classic-theme-styles and other styles enqueued at enqueue_block_assets.
+ // Capture the global-styles so that it can be printed at the point where wp_enqueue_global_styles() runs.
if ( wp_style_is( 'global-styles' ) ) {
ob_start();
wp_styles()->do_items( array( 'global-styles' ) );
- $printed_global_styles = ob_get_clean();
+ $printed_global_styles = (string) ob_get_clean();
}
/*
@@ -3797,7 +3820,7 @@ static function () use ( $placeholder ) {
*/
ob_start();
wp_styles()->do_footer_items();
- $printed_late_styles = ob_get_clean();
+ $printed_late_styles = (string) ob_get_clean();
};
/*
@@ -3828,7 +3851,7 @@ static function () use ( $capture_late_styles ) {
// Replace placeholder with the captured late styles.
add_filter(
'wp_template_enhancement_output_buffer',
- static function ( $buffer ) use ( $placeholder, &$style_handles_at_enqueue_block_assets, &$printed_core_block_styles, &$printed_other_block_styles, &$printed_global_styles, &$printed_late_styles ) {
+ static function ( $buffer ) use ( $placeholder, &$printed_core_block_styles, &$printed_other_block_styles, &$printed_global_styles, &$printed_late_styles ) {
// Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans.
$processor = new class( $buffer ) extends WP_HTML_Tag_Processor {
@@ -3848,7 +3871,7 @@ private function get_span(): WP_HTML_Span {
*
* @param string $text Text to insert.
*/
- public function insert_before( string $text ) {
+ public function insert_before( string $text ): void {
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->get_span()->start, 0, $text );
}
@@ -3857,7 +3880,7 @@ public function insert_before( string $text ) {
*
* @param string $text Text to insert.
*/
- public function insert_after( string $text ) {
+ public function insert_after( string $text ): void {
$span = $this->get_span();
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start + $span->length, 0, $text );
@@ -3866,47 +3889,60 @@ public function insert_after( string $text ) {
/**
* Removes the current token.
*/
- public function remove() {
+ public function remove(): void {
$span = $this->get_span();
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' );
}
+
+ /**
+ * Replaces the current token.
+ *
+ * @param string $text Text to replace with.
+ */
+ public function replace( string $text ): void {
+ $span = $this->get_span();
+
+ $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, $text );
+ }
};
// Locate the insertion points in the HEAD.
while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
if (
+ 'STYLE' === $processor->get_tag() &&
+ 'wp-global-styles-placeholder-inline-css' === $processor->get_attribute( 'id' )
+ ) {
+ /** This is added in {@see wp_enqueue_global_styles()} */
+ $processor->set_bookmark( 'wp_global_styles_placeholder' );
+ } elseif (
+ 'STYLE' === $processor->get_tag() &&
+ 'wp-block-styles-placeholder-inline-css' === $processor->get_attribute( 'id' )
+ ) {
+ /** This is added in {@see wp_enqueue_registered_block_scripts_and_styles()} */
+ $processor->set_bookmark( 'wp_block_styles_placeholder' );
+ } elseif (
'STYLE' === $processor->get_tag() &&
'wp-block-library-inline-css' === $processor->get_attribute( 'id' )
) {
+ /** This is added here in {@see wp_hoist_late_printed_styles()} */
$processor->set_bookmark( 'wp_block_library' );
} elseif ( 'HEAD' === $processor->get_tag() && $processor->is_tag_closer() ) {
$processor->set_bookmark( 'head_end' );
break;
- } elseif ( ( 'STYLE' === $processor->get_tag() || 'LINK' === $processor->get_tag() ) && $processor->get_attribute( 'id' ) ) {
- $id = $processor->get_attribute( 'id' );
- $handle = null;
- if ( 'STYLE' === $processor->get_tag() ) {
- if ( preg_match( '/^(.+)-inline-css$/', $id, $matches ) ) {
- $handle = $matches[1];
- }
- } elseif ( preg_match( '/^(.+)-css$/', $id, $matches ) ) {
- $handle = $matches[1];
- }
-
- if ( 'classic-theme-styles' === $handle ) {
- $processor->set_bookmark( 'classic_theme_styles' );
- }
-
- if ( $handle && in_array( $handle, $style_handles_at_enqueue_block_assets, true ) ) {
- if ( ! $processor->has_bookmark( 'first_style_at_enqueue_block_assets' ) ) {
- $processor->set_bookmark( 'first_style_at_enqueue_block_assets' );
- }
- $processor->set_bookmark( 'last_style_at_enqueue_block_assets' );
- }
}
}
+ /**
+ * Replace the placeholder for global styles enqueued during {@see wp_enqueue_global_styles()}. This is done
+ * even if $printed_global_styles is empty.
+ */
+ if ( $processor->has_bookmark( 'wp_global_styles_placeholder' ) ) {
+ $processor->seek( 'wp_global_styles_placeholder' );
+ $processor->replace( $printed_global_styles );
+ $printed_global_styles = '';
+ }
+
/*
* Insert block styles right after wp-block-library (if it is present). The placeholder CSS comment will
* always be added to the wp-block-library inline style since it gets printed at `wp_head` before the blocks
@@ -3921,13 +3957,34 @@ public function remove() {
$css_text = $processor->get_modifiable_text();
/*
- * A placeholder CSS comment is added to the inline style in order to force an inline STYLE tag to
- * be printed. Now that we've located the inline style, the placeholder comment can be removed. If
- * there is no CSS left in the STYLE tag after removing the placeholder (aside from the sourceURL
- * comment), then remove the STYLE entirely.
+ * Split the block library inline style by the placeholder to identify the original inlined CSS, which
+ * likely would be common.css, followed by any inline styles which had been added by the theme or
+ * plugins via `wp_add_inline_style( 'wp-block-library', '...' )`. The separate block styles loaded on
+ * demand will get inserted after the inlined common.css and before the extra inline styles added by the
+ * user.
+ */
+ $css_text_around_placeholder = explode( $placeholder, $css_text, 2 );
+ $extra_inline_styles = '';
+ if ( count( $css_text_around_placeholder ) === 2 ) {
+ $css_text = $css_text_around_placeholder[0];
+ if ( '' !== trim( $css_text ) ) {
+ $inlined_src = wp_styles()->get_data( 'wp-block-library', 'inlined_src' );
+ if ( $inlined_src ) {
+ $css_text .= sprintf(
+ "\n/*# sourceURL=%s */\n",
+ esc_url_raw( $inlined_src )
+ );
+ }
+ }
+ $extra_inline_styles = $css_text_around_placeholder[1];
+ }
+
+ /*
+ * The placeholder CSS comment was added to the inline style in order to force an inline STYLE tag to
+ * be printed. Now that the inline style has been located and the placeholder comment has been removed, if
+ * there is no CSS left in the STYLE tag after removal, then remove the STYLE tag entirely.
*/
- $css_text = str_replace( $placeholder, '', $css_text );
- if ( preg_match( ':^/\*# sourceURL=\S+? \*/$:', trim( $css_text ) ) ) {
+ if ( '' === trim( $css_text ) ) {
$processor->remove();
} else {
$processor->set_modifiable_text( $css_text );
@@ -3936,20 +3993,18 @@ public function remove() {
$inserted_after = $printed_core_block_styles;
$printed_core_block_styles = '';
- // If the classic-theme-styles is absent, then the third-party block styles cannot be inserted after it, so they get inserted here.
- if ( ! $processor->has_bookmark( 'classic_theme_styles' ) ) {
- if ( '' !== $printed_other_block_styles ) {
- $inserted_after .= $printed_other_block_styles;
- }
- $printed_other_block_styles = '';
-
- // If there aren't any other styles printed at enqueue_block_assets either, then the global styles need to also be printed here.
- if ( ! $processor->has_bookmark( 'last_style_at_enqueue_block_assets' ) ) {
- if ( '' !== $printed_global_styles ) {
- $inserted_after .= $printed_global_styles;
- }
- $printed_global_styles = '';
- }
+ /*
+ * Add a new inline style for any user styles added via wp_add_inline_style( 'wp-block-library', '...' ).
+ * This must be added here after $printed_core_block_styles to preserve the original CSS cascade when
+ * the combined block library stylesheet was used. The pattern here is checking to see if it is not just
+ * a sourceURL comment after the placeholder above is removed.
+ */
+ if ( ! preg_match( ':^\s*(/\*# sourceURL=\S+? \*/\s*)?$:s', $extra_inline_styles ) ) {
+ $style_processor = new WP_HTML_Tag_Processor( '' );
+ $style_processor->next_tag();
+ $style_processor->set_attribute( 'id', 'wp-block-library-inline-css-extra' );
+ $style_processor->set_modifiable_text( $extra_inline_styles );
+ $inserted_after .= "{$style_processor->get_updated_html()}\n";
}
if ( '' !== $inserted_after ) {
@@ -3957,23 +4012,14 @@ public function remove() {
}
}
- // Insert global-styles after the styles enqueued at enqueue_block_assets.
- if ( '' !== $printed_global_styles && $processor->has_bookmark( 'last_style_at_enqueue_block_assets' ) ) {
- $processor->seek( 'last_style_at_enqueue_block_assets' );
-
- $processor->insert_after( "\n" . $printed_global_styles );
- $printed_global_styles = '';
-
- if ( ! $processor->has_bookmark( 'classic_theme_styles' ) && '' !== $printed_other_block_styles ) {
- $processor->insert_after( "\n" . $printed_other_block_styles );
- $printed_other_block_styles = '';
+ // Insert block styles at the point where wp_enqueue_registered_block_scripts_and_styles() normally enqueues styles.
+ if ( $processor->has_bookmark( 'wp_block_styles_placeholder' ) ) {
+ $processor->seek( 'wp_block_styles_placeholder' );
+ if ( '' !== $printed_other_block_styles ) {
+ $processor->replace( "\n" . $printed_other_block_styles );
+ } else {
+ $processor->remove();
}
- }
-
- // Insert third-party block styles right after the classic-theme-styles.
- if ( '' !== $printed_other_block_styles && $processor->has_bookmark( 'classic_theme_styles' ) ) {
- $processor->seek( 'classic_theme_styles' );
- $processor->insert_after( "\n" . $printed_other_block_styles );
$printed_other_block_styles = '';
}
diff --git a/src/wp-includes/template-loader.php b/src/wp-includes/template-loader.php
index ffd2567524622..b3183590398b7 100644
--- a/src/wp-includes/template-loader.php
+++ b/src/wp-includes/template-loader.php
@@ -111,8 +111,15 @@
*
* @param string $template The path of the template to include.
*/
- $template = apply_filters( 'template_include', $template );
- if ( $template ) {
+ $template = apply_filters( 'template_include', $template );
+ $is_stringy = is_string( $template ) || ( is_object( $template ) && method_exists( $template, '__toString' ) );
+ $template = $is_stringy ? realpath( (string) $template ) : null;
+ if (
+ is_string( $template ) &&
+ ( str_ends_with( $template, '.php' ) || str_ends_with( $template, '.html' ) ) &&
+ is_file( $template ) &&
+ is_readable( $template )
+ ) {
/**
* Fires immediately before including the template.
*
diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php
index 095edd7dda7f5..733850aa6eb21 100644
--- a/src/wp-includes/version.php
+++ b/src/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.0-beta3-61849-src';
+$wp_version = '7.0-beta5-61991-src';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php
index 6d2af50844313..fb872386639e0 100644
--- a/src/wp-includes/widgets.php
+++ b/src/wp-includes/widgets.php
@@ -711,11 +711,11 @@ function dynamic_sidebar( $index = 1 ) {
$sidebars_widgets = wp_get_sidebars_widgets();
if ( empty( $wp_registered_sidebars[ $index ] ) || empty( $sidebars_widgets[ $index ] ) || ! is_array( $sidebars_widgets[ $index ] ) ) {
- /** This action is documented in wp-includes/widget.php */
+ /** This action is documented in wp-includes/widgets.php */
do_action( 'dynamic_sidebar_before', $index, false );
- /** This action is documented in wp-includes/widget.php */
+ /** This action is documented in wp-includes/widgets.php */
do_action( 'dynamic_sidebar_after', $index, false );
- /** This filter is documented in wp-includes/widget.php */
+ /** This filter is documented in wp-includes/widgets.php */
return apply_filters( 'dynamic_sidebar_has_widgets', false, $index );
}
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 023cdccd5ecc9..dab1d8fd4c0de 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -294,6 +294,7 @@
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php';
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
require ABSPATH . WPINC . '/ai-client.php';
+require ABSPATH . WPINC . '/class-wp-connector-registry.php';
require ABSPATH . WPINC . '/connectors.php';
require ABSPATH . WPINC . '/class-wp-icons-registry.php';
require ABSPATH . WPINC . '/widgets.php';
diff --git a/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php b/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php
index aa3baf4b9ce0c..7513df3ff0fd5 100644
--- a/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php
+++ b/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php
@@ -18,6 +18,7 @@
use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface;
+use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
@@ -140,6 +141,25 @@ protected function create_test_text_to_speech_model_metadata(
);
}
+ /**
+ * Creates a test model metadata instance for video generation.
+ *
+ * @param string $id Optional model ID.
+ * @param string $name Optional model name.
+ * @return ModelMetadata
+ */
+ protected function create_test_video_model_metadata(
+ string $id = 'test-video-model',
+ string $name = 'Test Video Model'
+ ): ModelMetadata {
+ return new ModelMetadata(
+ $id,
+ $name,
+ array( CapabilityEnum::videoGeneration() ),
+ array()
+ );
+ }
+
/**
* Creates a mock text generation model using anonymous class.
*
@@ -380,6 +400,65 @@ public function convertTextToSpeechResult( array $prompt ): GenerativeAiResult {
};
}
+ /**
+ * Creates a mock video generation model using anonymous class.
+ *
+ * @param GenerativeAiResult $result The result to return from generation.
+ * @param ModelMetadata|null $metadata Optional metadata.
+ * @return ModelInterface&VideoGenerationModelInterface The mock model.
+ */
+ protected function create_mock_video_generation_model(
+ GenerativeAiResult $result,
+ ?ModelMetadata $metadata = null
+ ): ModelInterface {
+ $metadata = $metadata ?? $this->create_test_video_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock-provider',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, VideoGenerationModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private GenerativeAiResult $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ GenerativeAiResult $result
+ ) {
+ $this->metadata = $metadata;
+ $this->provider_metadata = $provider_metadata;
+ $this->result = $result;
+ $this->config = new ModelConfig();
+ }
+
+ public function metadata(): ModelMetadata {
+ return $this->metadata;
+ }
+
+ public function providerMetadata(): ProviderMetadata {
+ return $this->provider_metadata;
+ }
+
+ public function setConfig( ModelConfig $config ): void {
+ $this->config = $config;
+ }
+
+ public function getConfig(): ModelConfig {
+ return $this->config;
+ }
+
+ public function generateVideoResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return $this->result;
+ }
+ };
+ }
+
/**
* Creates a mock text generation model that throws an exception.
*
diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
index 9797017451e0d..e7637bf239119 100644
--- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
+++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
@@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait {
* Must be called from set_up_before_class() after parent::set_up_before_class().
*/
private static function register_mock_connectors_provider(): void {
- $registry = AiClient::defaultRegistry();
- if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) {
- $registry->registerProvider( Mock_Connectors_Test_Provider::class );
+ $ai_registry = AiClient::defaultRegistry();
+ if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) {
+ $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class );
+ }
+
+ // Also register in the WP connector registry if not already present.
+ $connector_registry = WP_Connector_Registry::get_instance();
+ if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) {
+ $connector_registry->register(
+ 'mock_connectors_test',
+ array(
+ 'name' => 'Mock Connectors Test',
+ 'description' => '',
+ 'type' => 'ai_provider',
+ 'authentication' => array(
+ 'method' => 'api_key',
+ 'credentials_url' => null,
+ ),
+ )
+ );
}
}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
index a8c8ec58e7037..ea4814212d335 100644
--- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
+++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
@@ -8,6 +8,7 @@
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Files\DTO\File;
+use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\ModelMessage;
@@ -1029,12 +1030,13 @@ public function test_using_model_config_with_custom_options() {
$builder->using_stop_sequences( 'STOP' );
/** @var ModelConfig $merged_config */
- $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
- $custom_options = $merged_config->getCustomOptions();
+ $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+ $this->assertEquals( array( 'STOP' ), $merged_config->getStopSequences() );
+
+ $custom_options = $merged_config->getCustomOptions();
$this->assertArrayHasKey( 'stopSequences', $custom_options );
- $this->assertIsArray( $custom_options['stopSequences'] );
- $this->assertEquals( array( 'STOP' ), $custom_options['stopSequences'] );
+ $this->assertEquals( array( 'CONFIG_STOP' ), $custom_options['stopSequences'] );
$this->assertArrayHasKey( 'otherOption', $custom_options );
$this->assertEquals( 'value', $custom_options['otherOption'] );
}
@@ -1153,9 +1155,7 @@ public function test_using_stop_sequences() {
/** @var ModelConfig $config */
$config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
- $custom_options = $config->getCustomOptions();
- $this->assertArrayHasKey( 'stopSequences', $custom_options );
- $this->assertEquals( array( 'STOP', 'END', '###' ), $custom_options['stopSequences'] );
+ $this->assertEquals( array( 'STOP', 'END', '###' ), $config->getStopSequences() );
}
/**
@@ -1558,7 +1558,7 @@ public function test_generate_result_returns_wp_error_for_unsupported_output_mod
$this->assertWPError( $result );
$this->assertSame( 'prompt_builder_error', $result->get_error_code() );
- $this->assertStringContainsString( 'Output modality "video" is not yet supported', $result->get_error_message() );
+ $this->assertStringContainsString( 'does not support video generation', $result->get_error_message() );
}
/**
@@ -2051,6 +2051,168 @@ public function test_generate_speeches() {
$this->assertSame( $files[2], $speech_files[2] );
}
+ /**
+ * Tests generateVideo method.
+ *
+ * @ticket 64591
+ */
+ public function test_generate_video() {
+ $file = new File( 'https://example.com/video.mp4', 'video/mp4' );
+ $message_part = new MessagePart( $file );
+ $message = new Message( MessageRoleEnum::model(), array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_video_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_video_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate video' );
+ $builder->using_model( $model );
+
+ $video_file = $builder->generate_video();
+ $this->assertSame( $file, $video_file );
+ }
+
+ /**
+ * Tests generateVideos method.
+ *
+ * @ticket 64591
+ */
+ public function test_generate_videos() {
+ $files = array(
+ new File( 'https://example.com/video1.mp4', 'video/mp4' ),
+ new File( 'https://example.com/video2.mp4', 'video/mp4' ),
+ );
+
+ $candidates = array();
+ foreach ( $files as $file ) {
+ $candidates[] = new Candidate(
+ new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ),
+ FinishReasonEnum::stop()
+ );
+ }
+
+ $result = new GenerativeAiResult(
+ 'test-result-id',
+ $candidates,
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_video_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_video_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate videos' );
+ $builder->using_model( $model );
+
+ $video_files = $builder->generate_videos( 2 );
+
+ $this->assertCount( 2, $video_files );
+ $this->assertSame( $files[0], $video_files[0] );
+ $this->assertSame( $files[1], $video_files[1] );
+ }
+
+ /**
+ * Tests generateVideoResult method.
+ *
+ * @ticket 64591
+ */
+ public function test_generate_video_result() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array(
+ new Candidate(
+ new ModelMessage( array( new MessagePart( new File( 'data:video/mp4;base64,AAAAAA==', 'video/mp4' ) ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_video_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_video_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate video' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_video_result();
+ $this->assertSame( $result, $actual_result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $modalities = $config->getOutputModalities();
+ $this->assertNotNull( $modalities );
+ $this->assertTrue( $modalities[0]->isVideo() );
+ }
+
+ /**
+ * Tests asOutputMediaOrientation method.
+ *
+ * @ticket 64591
+ */
+ public function test_as_output_media_orientation() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_media_orientation( MediaOrientationEnum::landscape() );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertTrue( $config->getOutputMediaOrientation()->isLandscape() );
+ }
+
+ /**
+ * Tests asOutputMediaAspectRatio method.
+ *
+ * @ticket 64591
+ */
+ public function test_as_output_media_aspect_ratio() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_media_aspect_ratio( '16:9' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( '16:9', $config->getOutputMediaAspectRatio() );
+ }
+
+ /**
+ * Tests asOutputSpeechVoice method.
+ *
+ * @ticket 64591
+ */
+ public function test_as_output_speech_voice() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_speech_voice( 'alloy' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'alloy', $config->getOutputSpeechVoice() );
+ }
+
/**
* Tests using_abilities with ability name string.
*
diff --git a/tests/phpunit/tests/block-supports/block-visibility.php b/tests/phpunit/tests/block-supports/block-visibility.php
index dd116472ba1f4..4f24b931744f8 100644
--- a/tests/phpunit/tests/block-supports/block-visibility.php
+++ b/tests/phpunit/tests/block-supports/block-visibility.php
@@ -11,18 +11,18 @@
* @covers ::wp_render_block_visibility_support
*/
class Tests_Block_Supports_Block_Visibility extends WP_UnitTestCase {
- /**
- * @var string|null
- */
- private $test_block_name;
- public function set_up() {
+ private ?string $test_block_name;
+
+ public function set_up(): void {
parent::set_up();
$this->test_block_name = null;
}
- public function tear_down() {
- unregister_block_type( $this->test_block_name );
+ public function tear_down(): void {
+ if ( $this->test_block_name ) {
+ unregister_block_type( $this->test_block_name );
+ }
$this->test_block_name = null;
parent::tear_down();
}
@@ -30,12 +30,10 @@ public function tear_down() {
/**
* Registers a new block for testing block visibility support.
*
- * @param string $block_name Name for the test block.
- * @param array $supports Array defining block support configuration.
- *
- * @return WP_Block_Type The block type for the newly registered test block.
+ * @param string $block_name Name for the test block.
+ * @param array $supports Array defining block support configuration.
*/
- private function register_visibility_block_with_support( $block_name, $supports = array() ) {
+ private function register_visibility_block_with_support( string $block_name, array $supports = array() ): void {
$this->test_block_name = $block_name;
register_block_type(
$this->test_block_name,
@@ -51,7 +49,7 @@ private function register_visibility_block_with_support( $block_name, $supports
);
$registry = WP_Block_Type_Registry::get_instance();
- return $registry->get_registered( $this->test_block_name );
+ $registry->get_registered( $this->test_block_name );
}
/**
@@ -60,7 +58,7 @@ private function register_visibility_block_with_support( $block_name, $supports
*
* @ticket 64061
*/
- public function test_block_visibility_support_hides_block_when_visibility_false() {
+ public function test_block_visibility_support_hides_block_when_visibility_false(): void {
$this->register_visibility_block_with_support(
'test/visibility-block',
array( 'visibility' => true )
@@ -87,13 +85,13 @@ public function test_block_visibility_support_hides_block_when_visibility_false(
*
* @ticket 64061
*/
- public function test_block_visibility_support_shows_block_when_support_not_opted_in() {
+ public function test_block_visibility_support_shows_block_when_support_not_opted_in(): void {
$this->register_visibility_block_with_support(
'test/visibility-block',
array( 'visibility' => false )
);
- $block_content = '
This is a test block.
';
+ $block_content = '
Test content
';
$block = array(
'blockName' => 'test/visibility-block',
'attrs' => array(
@@ -108,10 +106,10 @@ public function test_block_visibility_support_shows_block_when_support_not_opted
$this->assertSame( $block_content, $result, 'Block content should remain unchanged when blockVisibility support is not opted in.' );
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_no_visibility_attribute() {
+ public function test_block_visibility_support_no_visibility_attribute(): void {
$this->register_visibility_block_with_support(
'test/block-visibility-none',
array( 'visibility' => true )
@@ -122,16 +120,16 @@ public function test_block_visibility_support_no_visibility_attribute() {
'attrs' => array(),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
$this->assertSame( $block_content, $result, 'Block content should remain unchanged when no visibility attribute is present.' );
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_mobile_viewport_size() {
+ public function test_block_visibility_support_generated_css_with_mobile_viewport_size(): void {
$this->register_visibility_block_with_support(
'test/viewport-mobile',
array( 'visibility' => true )
@@ -150,7 +148,7 @@ public function test_block_visibility_support_generated_css_with_mobile_viewport
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
$this->assertStringContainsString( 'wp-block-hidden-mobile', $result, 'Block should have the visibility class for the mobile breakpoint.' );
@@ -164,10 +162,10 @@ public function test_block_visibility_support_generated_css_with_mobile_viewport
);
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_tablet_viewport_size() {
+ public function test_block_visibility_support_generated_css_with_tablet_viewport_size(): void {
$this->register_visibility_block_with_support(
'test/viewport-tablet',
array( 'visibility' => true )
@@ -186,10 +184,15 @@ public function test_block_visibility_support_generated_css_with_tablet_viewport
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
- $this->assertStringContainsString( 'class="existing-class wp-block-hidden-tablet"', $result, 'Block should have the existing class and the visibility class for the tablet breakpoint in the class attribute.' );
+ $this->assertEqualHTML(
+ '
Test content
',
+ $result,
+ '',
+ 'Block should have the existing class and the visibility class for the tablet breakpoint in the class attribute.'
+ );
$actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) );
@@ -200,10 +203,10 @@ public function test_block_visibility_support_generated_css_with_tablet_viewport
);
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_desktop_breakpoint() {
+ public function test_block_visibility_support_generated_css_with_desktop_breakpoint(): void {
$this->register_visibility_block_with_support(
'test/viewport-desktop',
array( 'visibility' => true )
@@ -222,10 +225,15 @@ public function test_block_visibility_support_generated_css_with_desktop_breakpo
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
- $this->assertStringContainsString( 'class="wp-block-hidden-desktop"', $result, 'Block should have the visibility class for the desktop breakpoint in the class attribute.' );
+ $this->assertEqualHTML(
+ '
Test content
',
+ $result,
+ '',
+ 'Block should have the visibility class for the desktop breakpoint in the class attribute.'
+ );
$actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) );
@@ -236,10 +244,11 @@ public function test_block_visibility_support_generated_css_with_desktop_breakpo
);
}
- /*
+ /**
* @ticket 64414
+ * @ticket 64823
*/
- public function test_block_visibility_support_generated_css_with_two_viewport_sizes() {
+ public function test_block_visibility_support_generated_css_with_two_viewport_sizes(): void {
$this->register_visibility_block_with_support(
'test/viewport-two',
array( 'visibility' => true )
@@ -259,13 +268,14 @@ public function test_block_visibility_support_generated_css_with_two_viewport_si
),
);
- $block_content = '
',
$result,
- 'Block should have both visibility classes in the class attribute'
+ '',
+ 'Block should have both visibility classes in the class attribute, and the IMG should have fetchpriority=auto.'
);
$actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) );
@@ -277,10 +287,11 @@ public function test_block_visibility_support_generated_css_with_two_viewport_si
);
}
- /*
+ /**
* @ticket 64414
+ * @ticket 64823
*/
- public function test_block_visibility_support_generated_css_with_all_viewport_sizes_visible() {
+ public function test_block_visibility_support_generated_css_with_all_viewport_sizes_visible(): void {
$this->register_visibility_block_with_support(
'test/viewport-all-visible',
array( 'visibility' => true )
@@ -301,16 +312,17 @@ public function test_block_visibility_support_generated_css_with_all_viewport_si
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
$this->assertSame( $block_content, $result, 'Block content should remain unchanged when all breakpoints are visible.' );
}
- /*
+ /**
* @ticket 64414
+ * @ticket 64823
*/
- public function test_block_visibility_support_generated_css_with_all_viewport_sizes_hidden() {
+ public function test_block_visibility_support_generated_css_with_all_viewport_sizes_hidden(): void {
$this->register_visibility_block_with_support(
'test/viewport-all-hidden',
array( 'visibility' => true )
@@ -331,16 +343,21 @@ public function test_block_visibility_support_generated_css_with_all_viewport_si
),
);
- $block_content = '
', $result, 'Block content should have the visibility classes for all viewport sizes in the class attribute.' );
+ $this->assertEqualHTML(
+ '
Test content
',
+ $result,
+ '',
+ 'Block content should have the visibility classes for all viewport sizes in the class attribute, and an IMG should get fetchpriority=auto.'
+ );
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_empty_object() {
+ public function test_block_visibility_support_generated_css_with_empty_object(): void {
$this->register_visibility_block_with_support(
'test/viewport-empty',
array( 'visibility' => true )
@@ -355,16 +372,16 @@ public function test_block_visibility_support_generated_css_with_empty_object()
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
$this->assertSame( $block_content, $result, 'Block content should remain unchanged when blockVisibility is an empty array.' );
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_unknown_viewport_sizes_ignored() {
+ public function test_block_visibility_support_generated_css_with_unknown_viewport_sizes_ignored(): void {
$this->register_visibility_block_with_support(
'test/viewport-unknown-viewport-sizes',
array( 'visibility' => true )
@@ -385,7 +402,7 @@ public function test_block_visibility_support_generated_css_with_unknown_viewpor
),
);
- $block_content = '
Test content
';
+ $block_content = '
Test content
';
$result = wp_render_block_visibility_support( $block_content, $block );
$this->assertStringContainsString(
@@ -395,10 +412,10 @@ public function test_block_visibility_support_generated_css_with_unknown_viewpor
);
}
- /*
+ /**
* @ticket 64414
*/
- public function test_block_visibility_support_generated_css_with_empty_content() {
+ public function test_block_visibility_support_generated_css_with_empty_content(): void {
$this->register_visibility_block_with_support(
'test/viewport-empty-content',
array( 'visibility' => true )
diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php
new file mode 100644
index 0000000000000..161739b7a8ab8
--- /dev/null
+++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php
@@ -0,0 +1,384 @@
+
+ * @phpstan-var Connector
+ */
+ private static array $default_args;
+
+ /**
+ * Set up each test method.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $this->registry = new WP_Connector_Registry();
+
+ self::$default_args = array(
+ 'name' => 'Test Provider',
+ 'description' => 'A test AI provider.',
+ 'type' => 'ai_provider',
+ 'authentication' => array(
+ 'method' => 'api_key',
+ 'credentials_url' => 'https://example.com/keys',
+ ),
+ );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_returns_connector_data() {
+ $result = $this->registry->register( 'test_provider', self::$default_args );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ $this->assertSame( 'A test AI provider.', $result['description'] );
+ $this->assertSame( 'ai_provider', $result['type'] );
+ $this->assertSame( 'api_key', $result['authentication']['method'] );
+ $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] );
+ $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_generates_setting_name_for_api_key() {
+ $result = $this->registry->register( 'my_ai', self::$default_args );
+
+ $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_no_setting_name_for_none_auth() {
+ $args = array(
+ 'name' => 'No Auth Provider',
+ 'type' => 'ai_provider',
+ 'authentication' => array( 'method' => 'none' ),
+ );
+ $result = $this->registry->register( 'no_auth', $args );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_defaults_description_to_empty_string() {
+ $args = array(
+ 'name' => 'Minimal',
+ 'type' => 'ai_provider',
+ 'authentication' => array( 'method' => 'none' ),
+ );
+
+ $result = $this->registry->register( 'minimal', $args );
+
+ $this->assertSame( '', $result['description'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_includes_logo_url() {
+ $args = self::$default_args;
+ $args['logo_url'] = 'https://example.com/logo.png';
+
+ $result = $this->registry->register( 'with_logo', $args );
+
+ $this->assertArrayHasKey( 'logo_url', $result );
+ $this->assertSame( 'https://example.com/logo.png', $result['logo_url'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_omits_logo_url_when_not_provided() {
+ $result = $this->registry->register( 'no_logo', self::$default_args );
+
+ $this->assertArrayNotHasKey( 'logo_url', $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_omits_logo_url_when_empty() {
+ $args = self::$default_args;
+ $args['logo_url'] = '';
+
+ $result = $this->registry->register( 'empty_logo', $args );
+
+ $this->assertArrayNotHasKey( 'logo_url', $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_includes_plugin_data() {
+ $args = self::$default_args;
+ $args['plugin'] = array( 'slug' => 'my-plugin' );
+
+ $result = $this->registry->register( 'with_plugin', $args );
+
+ $this->assertArrayHasKey( 'plugin', $result );
+ $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_omits_plugin_when_not_provided() {
+ $result = $this->registry->register( 'no_plugin', self::$default_args );
+
+ $this->assertArrayNotHasKey( 'plugin', $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_id_with_uppercase() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( 'InvalidId', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_id_with_dashes() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( 'my-provider', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_empty_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( '', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_duplicate_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $this->registry->register( 'duplicate', self::$default_args );
+ $result = $this->registry->register( 'duplicate', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_name() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['name'] );
+
+ $result = $this->registry->register( 'no_name', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_empty_name() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ $args['name'] = '';
+
+ $result = $this->registry->register( 'empty_name', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_type() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['type'] );
+
+ $result = $this->registry->register( 'no_type', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_authentication() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['authentication'] );
+
+ $result = $this->registry->register( 'no_auth', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_auth_method() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ $args['authentication']['method'] = 'oauth';
+
+ $result = $this->registry->register( 'bad_auth', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_is_registered_returns_true_for_registered() {
+ $this->registry->register( 'exists', self::$default_args );
+
+ $this->assertTrue( $this->registry->is_registered( 'exists' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_is_registered_returns_false_for_unregistered() {
+ $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_registered_returns_connector_data() {
+ $this->registry->register( 'my_connector', self::$default_args );
+
+ $result = $this->registry->get_registered( 'my_connector' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_registered_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' );
+
+ $result = $this->registry->get_registered( 'nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_all_registered_returns_all_connectors() {
+ $this->registry->register( 'first', self::$default_args );
+
+ $args = self::$default_args;
+ $args['name'] = 'Second Provider';
+ $this->registry->register( 'second', $args );
+
+ $all = $this->registry->get_all_registered();
+
+ $this->assertCount( 2, $all );
+ $this->assertArrayHasKey( 'first', $all );
+ $this->assertArrayHasKey( 'second', $all );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_all_registered_returns_empty_when_none() {
+ $this->assertSame( array(), $this->registry->get_all_registered() );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_removes_connector() {
+ $this->registry->register( 'to_remove', self::$default_args );
+
+ $result = $this->registry->unregister( 'to_remove' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ $this->assertFalse( $this->registry->is_registered( 'to_remove' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' );
+
+ $result = $this->registry->unregister( 'nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_instance_returns_registry() {
+ $instance = WP_Connector_Registry::get_instance();
+
+ $this->assertInstanceOf( WP_Connector_Registry::class, $instance );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_set_instance_rejects_after_init() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::set_instance' );
+
+ WP_Connector_Registry::set_instance( new WP_Connector_Registry() );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_instance_returns_same_instance() {
+ $instance1 = WP_Connector_Registry::get_instance();
+ $instance2 = WP_Connector_Registry::get_instance();
+
+ $this->assertSame( $instance1, $instance2 );
+ }
+}
diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
index f2d0aa68ee0e1..8cb7a5c5d2d90 100644
--- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
+++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
@@ -3,19 +3,19 @@
require_once dirname( __DIR__, 2 ) . '/includes/wp-ai-client-mock-provider-trait.php';
/**
- * Tests for _wp_connectors_get_connector_settings().
+ * Tests for wp_get_connectors().
*
* @group connectors
- * @covers ::_wp_connectors_get_connector_settings
+ * @covers ::wp_get_connectors
*/
-class Tests_Connectors_WpConnectorsGetConnectorSettings extends WP_UnitTestCase {
+class Tests_Connectors_WpGetConnectors extends WP_UnitTestCase {
use WP_AI_Client_Mock_Provider_Trait;
/**
* Registers the mock provider once before any tests in this class run.
*/
- public static function set_up_before_class() {
+ public static function set_up_before_class(): void {
parent::set_up_before_class();
self::register_mock_connectors_provider();
}
@@ -23,7 +23,7 @@ public static function set_up_before_class() {
/**
* Unregisters the mock provider setting added by `init`.
*/
- public static function tear_down_after_class() {
+ public static function tear_down_after_class(): void {
self::unregister_mock_connector_setting();
parent::tear_down_after_class();
}
@@ -31,8 +31,8 @@ public static function tear_down_after_class() {
/**
* @ticket 64730
*/
- public function test_returns_expected_connector_keys() {
- $connectors = _wp_connectors_get_connector_settings();
+ public function test_returns_expected_connector_keys(): void {
+ $connectors = wp_get_connectors();
$this->assertArrayHasKey( 'google', $connectors );
$this->assertArrayHasKey( 'openai', $connectors );
@@ -44,8 +44,8 @@ public function test_returns_expected_connector_keys() {
/**
* @ticket 64730
*/
- public function test_each_connector_has_required_fields() {
- $connectors = _wp_connectors_get_connector_settings();
+ public function test_each_connector_has_required_fields(): void {
+ $connectors = wp_get_connectors();
$this->assertNotEmpty( $connectors, 'Connector settings should not be empty.' );
@@ -67,8 +67,8 @@ public function test_each_connector_has_required_fields() {
/**
* @ticket 64730
*/
- public function test_api_key_connectors_have_setting_name_and_credentials_url() {
- $connectors = _wp_connectors_get_connector_settings();
+ public function test_api_key_connectors_have_setting_name_and_credentials_url(): void {
+ $connectors = wp_get_connectors();
$api_key_count = 0;
foreach ( $connectors as $connector_id => $connector_data ) {
@@ -81,10 +81,9 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url()
$this->assertArrayHasKey( 'setting_name', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'setting_name'." );
$this->assertSame(
"connectors_ai_{$connector_id}_api_key",
- $connector_data['authentication']['setting_name'],
+ $connector_data['authentication']['setting_name'] ?? null,
"Connector '{$connector_id}' setting_name does not match expected format."
);
- $this->assertArrayHasKey( 'credentials_url', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'credentials_url'." );
}
$this->assertGreaterThan( 0, $api_key_count, 'At least one connector should use api_key authentication.' );
@@ -93,8 +92,8 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url()
/**
* @ticket 64730
*/
- public function test_featured_provider_names_match_expected() {
- $connectors = _wp_connectors_get_connector_settings();
+ public function test_featured_provider_names_match_expected(): void {
+ $connectors = wp_get_connectors();
$this->assertSame( 'Google', $connectors['google']['name'] );
$this->assertSame( 'OpenAI', $connectors['openai']['name'] );
@@ -104,15 +103,15 @@ public function test_featured_provider_names_match_expected() {
/**
* @ticket 64730
*/
- public function test_includes_registered_provider_from_registry() {
- $connectors = _wp_connectors_get_connector_settings();
+ public function test_includes_registered_provider_from_registry(): void {
+ $connectors = wp_get_connectors();
$mock = $connectors['mock_connectors_test'];
$this->assertSame( 'Mock Connectors Test', $mock['name'] );
$this->assertSame( '', $mock['description'] );
$this->assertSame( 'ai_provider', $mock['type'] );
$this->assertSame( 'api_key', $mock['authentication']['method'] );
- $this->assertNull( $mock['authentication']['credentials_url'] );
- $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] );
+ $this->assertNull( $mock['authentication']['credentials_url'] ?? null );
+ $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ?? null );
}
}
diff --git a/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php
new file mode 100644
index 0000000000000..71b1628af3311
--- /dev/null
+++ b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php
@@ -0,0 +1,107 @@
+created_files as $file ) {
+ if ( is_file( $file ) ) {
+ unlink( $file );
+ }
+ }
+ foreach ( array_reverse( $this->created_dirs ) as $dir ) {
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir );
+ }
+ }
+ parent::tear_down();
+ }
+
+ /**
+ * Creates a temporary file and tracks it for cleanup.
+ *
+ * @param string $path File path.
+ */
+ private function create_file( string $path ): void {
+ $dir = dirname( $path );
+ if ( ! is_dir( $dir ) ) {
+ wp_mkdir_p( $dir );
+ $this->created_dirs[] = $dir;
+ }
+ file_put_contents( $path, '' );
+ $this->created_files[] = $path;
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_returns_null_when_path_is_empty() {
+ $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( '' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_resolves_plugin_dir_path_to_url() {
+ $logo_path = WP_PLUGIN_DIR . '/my-plugin/logo.svg';
+ $this->create_file( $logo_path );
+
+ $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path );
+
+ $this->assertSame( site_url( '/wp-content/plugins/my-plugin/logo.svg' ), $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_resolves_mu_plugin_dir_path_to_url() {
+ $logo_path = WPMU_PLUGIN_DIR . '/my-mu-plugin/logo.svg';
+ $this->create_file( $logo_path );
+
+ $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path );
+
+ $this->assertSame( site_url( '/wp-content/mu-plugins/my-mu-plugin/logo.svg' ), $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_returns_null_when_file_does_not_exist() {
+ $this->assertNull(
+ _wp_connectors_resolve_ai_provider_logo_url( WP_PLUGIN_DIR . '/nonexistent/logo.svg' )
+ );
+ }
+
+ /**
+ * @ticket 64791
+ * @expectedIncorrectUsage _wp_connectors_resolve_ai_provider_logo_url
+ */
+ public function test_returns_null_and_triggers_doing_it_wrong_for_path_outside_plugin_dirs() {
+ $tmp_file = tempnam( sys_get_temp_dir(), 'logo_' );
+ file_put_contents( $tmp_file, '' );
+ $this->created_files[] = $tmp_file;
+
+ $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( $tmp_file ) );
+ }
+}
diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php
new file mode 100644
index 0000000000000..ad55c012d97c7
--- /dev/null
+++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php
@@ -0,0 +1,63 @@
+assertTrue( wp_is_connector_registered( 'openai' ) );
+ $this->assertTrue( wp_is_connector_registered( 'google' ) );
+ $this->assertTrue( wp_is_connector_registered( 'anthropic' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_is_connector_registered_returns_false_for_unregistered() {
+ $this->assertFalse( wp_is_connector_registered( 'nonexistent_provider' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connector_returns_data_for_default() {
+ $connector = wp_get_connector( 'openai' );
+
+ $this->assertIsArray( $connector );
+ $this->assertSame( 'OpenAI', $connector['name'] );
+ $this->assertSame( 'ai_provider', $connector['type'] );
+ $this->assertSame( 'api_key', $connector['authentication']['method'] );
+ $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connector_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' );
+
+ $result = wp_get_connector( 'nonexistent_provider' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connectors_returns_all_defaults() {
+ $connectors = wp_get_connectors();
+
+ $this->assertArrayHasKey( 'openai', $connectors );
+ $this->assertArrayHasKey( 'google', $connectors );
+ $this->assertArrayHasKey( 'anthropic', $connectors );
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php
index 6050983cc5f5e..5f1c30fe4cf47 100644
--- a/tests/phpunit/tests/dependencies/scripts.php
+++ b/tests/phpunit/tests/dependencies/scripts.php
@@ -631,7 +631,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() {
scriptEventLog.push( "blocking-not-async-without-dependency: before inline" )
//# sourceURL=blocking-not-async-without-dependency-js-before
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
HTML
,
),
@@ -1031,7 +1031,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() {
$this->add_test_inline_script( $handle, 'after' );
},
'expected_markup' => <<
+
-
-
+
+
+
-
+
-
-
+
+
+
HTML
,
),
@@ -4192,6 +4192,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque
$this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_scripts() with version ' . var_export( $version, true ) . ":\n$markup" );
}
+ /**
+ * Tests that duplicate query vars and fragments are preserved in scripts.
+ *
+ * @ticket 64372
+ *
+ * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_scripts
+ *
+ * @param string $src The script's source URL.
+ * @param string|bool|null $ver The script's version.
+ * @param string $expected_url The expected URL.
+ * @param string $handle Optional. The script's registered handle. Default 'test-script'.
+ */
+ public function test_duplicate_query_vars_and_fragments_preserved_in_scripts( string $src, $ver, string $expected_url, string $handle = 'test-script' ): void {
+ wp_enqueue_script( $handle, $src, array(), $ver );
+ $output = get_echo( 'wp_print_scripts' );
+ $processor = new WP_HTML_Tag_Processor( $output );
+
+ $this->assertTrue( $processor->next_tag( 'script' ) );
+ $this->assertSame( $expected_url, $processor->get_attribute( 'src' ) );
+ }
+
+ /**
+ * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_scripts.
+ *
+ * @return array Data provider.
+ */
+ public function data_duplicate_query_vars_and_fragments_preserved_in_scripts(): array {
+ $ver = get_bloginfo( 'version' );
+
+ return array(
+ 'duplicate query vars' => array(
+ 'src' => 'https://example.com/script.js?arg=1&arg=2',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0',
+ ),
+ 'duplicate query vars, null version' => array(
+ 'src' => 'https://example.com/script.js?arg=1&arg=2',
+ 'ver' => null,
+ 'expected_url' => 'https://example.com/script.js?arg=1&arg=2',
+ ),
+ 'duplicate query vars, false version' => array(
+ 'src' => 'https://example.com/script.js?arg=1&arg=2',
+ 'ver' => false,
+ 'expected_url' => "https://example.com/script.js?arg=1&arg=2&ver=$ver",
+ ),
+ 'duplicate query vars in handle' => array(
+ 'src' => 'https://example.com/test-script.js',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/test-script.js?ver=1.0&a=1&a=2',
+ 'handle' => 'test-script?a=1&a=2',
+ ),
+ 'duplicate query vars and fragments' => array(
+ 'src' => 'https://example.com/script.js?arg=1&arg=2#anchor',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0#anchor',
+ ),
+ 'zero query var in handle' => array(
+ 'src' => 'https://example.com/test-script.js',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/test-script.js?ver=1.0&0',
+ 'handle' => 'test-script?0',
+ ),
+ );
+ }
+
/**
* Data provider for:
* - test_varying_versions_added_to_handle_args_enqueued_scripts
diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php
index bbaf9432d8df0..de6fefb94b5bb 100644
--- a/tests/phpunit/tests/dependencies/styles.php
+++ b/tests/phpunit/tests/dependencies/styles.php
@@ -961,6 +961,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque
$this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_styles() with version ' . var_export( $version, true ) . ":\n$markup" );
}
+ /**
+ * Tests that duplicate query vars and fragments are preserved in styles.
+ *
+ * @ticket 64372
+ *
+ * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_styles
+ *
+ * @param string $src The stylesheet's source URL.
+ * @param string|bool|null $ver The style's version.
+ * @param string $expected_url The expected URL.
+ * @param string $handle Optional. The style's registered handle. Default 'test-style'.
+ */
+ public function test_duplicate_query_vars_and_fragments_preserved_in_styles( string $src, $ver, string $expected_url, string $handle = 'test-style' ): void {
+ wp_enqueue_style( $handle, $src, array(), $ver );
+ $output = get_echo( 'wp_print_styles' );
+ $processor = new WP_HTML_Tag_Processor( $output );
+
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertSame( $expected_url, $processor->get_attribute( 'href' ) );
+ }
+
+ /**
+ * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_styles.
+ *
+ * @return array Data provider.
+ */
+ public function data_duplicate_query_vars_and_fragments_preserved_in_styles(): array {
+ $ver = get_bloginfo( 'version' );
+
+ return array(
+ 'duplicate query vars' => array(
+ 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=1.0',
+ ),
+ 'duplicate query vars, null version' => array(
+ 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap',
+ 'ver' => null,
+ 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap',
+ ),
+ 'duplicate query vars, false version' => array(
+ 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap',
+ 'ver' => false,
+ 'expected_url' => "https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=$ver",
+ ),
+ 'duplicate query vars in handle' => array(
+ 'src' => 'https://example.com/test-style.css',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/test-style.css?ver=1.0&a=1&a=2',
+ 'handle' => 'test-style?a=1&a=2',
+ ),
+ 'duplicate query vars and fragments' => array(
+ 'src' => 'https://example.com/style.css?arg=1&arg=2#anchor',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/style.css?arg=1&arg=2&ver=1.0#anchor',
+ ),
+ 'zero query var in handle' => array(
+ 'src' => 'https://example.com/test-style.css',
+ 'ver' => '1.0',
+ 'expected_url' => 'https://example.com/test-style.css?ver=1.0&0',
+ 'handle' => 'test-style?0',
+ ),
+ );
+ }
+
/**
* Data provider for:
* - test_varying_versions_added_to_handle_args_enqueued_styles
diff --git a/tests/phpunit/tests/functions/wpGetBranchVersion.php b/tests/phpunit/tests/functions/wpGetBranchVersion.php
new file mode 100644
index 0000000000000..37ed220478436
--- /dev/null
+++ b/tests/phpunit/tests/functions/wpGetBranchVersion.php
@@ -0,0 +1,98 @@
+assertSame( $expected, wp_get_branch_version( $version ) );
+ }
+
+ /**
+ * Data provider for major version extraction.
+ *
+ * @return array[]
+ */
+ public function data_major_version() {
+ return array(
+ 'major ending with 0 and no minor' => array( '7.0', '7.0' ),
+ 'minor number zero' => array( '7.0.0', '7.0' ),
+ 'minor with a major that ends in zero' => array( '7.0.1', '7.0' ),
+ 'double digit minor with trailing zero' => array( '7.0.10', '7.0' ),
+ 'double digit first part of major having zero' => array( '10.0.0', '10.0' ),
+ 'triple digit major' => array( '100.1.0', '100.1' ),
+ 'typical release' => array( '6.9', '6.9' ),
+ 'typical minor release' => array( '6.9.1', '6.9' ),
+ 'alpha suffix' => array( '7.0-alpha-61215', '7.0' ),
+ 'beta suffix' => array( '7.0-beta3-61849', '7.0' ),
+ 'RC suffix' => array( '7.0-RC1', '7.0' ),
+ 'src suffix' => array( '7.0-alpha-61215-src', '7.0' ),
+ 'single component' => array( '7', '7.0' ),
+ );
+ }
+
+ /**
+ * Tests that wp_get_wp_version( 'major' ) returns the expected major version.
+ *
+ * @ticket 64830
+ */
+ public function test_wp_get_wp_version_major() {
+ $expected = wp_get_branch_version( wp_get_wp_version() );
+ $this->assertSame( $expected, wp_get_wp_version( 'major' ) );
+ }
+
+ /**
+ * Tests that wp_get_wp_version( 'minor' ) returns the expected minor version.
+ *
+ * @ticket 64830
+ */
+ public function test_wp_get_wp_version_minor() {
+ $full = wp_get_wp_version();
+ $parts = preg_split( '/[.-]/', $full, 4 );
+ $expected = $parts[0] . '.' . ( $parts[1] ?? '0' ) . '.' . ( $parts[2] ?? '0' );
+ $this->assertSame( $expected, wp_get_wp_version( 'minor' ) );
+ }
+
+ /**
+ * Tests that wp_get_wp_version() with no argument still returns the full version.
+ *
+ * @ticket 64830
+ */
+ public function test_wp_get_wp_version_full_default() {
+ $this->assertSame( $GLOBALS['wp_version'], wp_get_wp_version() );
+ }
+
+ /**
+ * Tests that wp_get_wp_version( 'full' ) returns the full version.
+ *
+ * @ticket 64830
+ */
+ public function test_wp_get_wp_version_full_explicit() {
+ $this->assertSame( $GLOBALS['wp_version'], wp_get_wp_version( 'full' ) );
+ }
+
+ /**
+ * Tests that wp_get_branch_version() with no argument returns the current major version.
+ *
+ * @ticket 64830
+ */
+ public function test_wp_get_branch_version_defaults_to_current() {
+ $this->assertSame( wp_get_wp_version( 'major' ), wp_get_branch_version() );
+ }
+}
diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php
index 0822de252d1b7..060a7295f6bb4 100644
--- a/tests/phpunit/tests/media.php
+++ b/tests/phpunit/tests/media.php
@@ -3916,7 +3916,10 @@ public function test_wp_get_loading_attr_default( $context ) {
$this->assertFalse( wp_get_loading_attr_default( 'template_part_' . WP_TEMPLATE_PART_AREA_HEADER ), 'Images in the footer block template part should not be lazy-loaded.' );
}
- public function data_wp_get_loading_attr_default() {
+ /**
+ * @return array
+ */
+ public function data_wp_get_loading_attr_default(): array {
return array(
array( 'the_content' ),
array( 'the_post_thumbnail' ),
@@ -4481,7 +4484,7 @@ public function data_special_contexts_for_the_content_wp_get_loading_attr_defaul
}
/**
- * Tests that wp_get_loading_attr_default() returns the expected loading attribute value.
+ * Tests that wp_get_loading_optimization_attributes() returns the expected loading attribute value.
*
* @ticket 53675
* @ticket 56930
@@ -4494,7 +4497,7 @@ public function data_special_contexts_for_the_content_wp_get_loading_attr_defaul
*
* @param string $context
*/
- public function test_wp_get_loading_optimization_attributes( $context ) {
+ public function test_wp_get_loading_optimization_attributes( string $context ): void {
$attr = $this->get_width_height_for_high_priority();
// Return 'lazy' by default.
@@ -4513,6 +4516,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
wp_get_loading_optimization_attributes( 'img', $attr, 'wp_get_attachment_image' )
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, 'wp_get_attachment_image' );
+
// Return 'lazy' if not in the loop or the main query.
$this->assertSameSetsWithIndex(
array(
@@ -4527,6 +4532,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
while ( have_posts() ) {
the_post();
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
// Return 'lazy' if in the loop but not in the main query.
$this->assertSameSetsWithIndex(
array(
@@ -4539,6 +4546,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
// Set as main query.
$this->set_main_query( $query );
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
// First three element are not lazy loaded. However, first image is loaded with fetchpriority high.
$this->assertSameSetsWithIndex(
array(
@@ -4546,8 +4555,11 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
'fetchpriority' => 'high',
),
wp_get_loading_optimization_attributes( 'img', $attr, $context ),
- "Expected first image to not be lazy-loaded. First large image get's high fetchpriority."
+ 'Expected first image to not be lazy-loaded. First large image gets high fetchpriority.'
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
$this->assertSameSetsWithIndex(
array(
'decoding' => 'async',
@@ -4555,6 +4567,9 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
wp_get_loading_optimization_attributes( 'img', $attr, $context ),
'Expected second image to not be lazy-loaded.'
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
$this->assertSameSetsWithIndex(
array(
'decoding' => 'async',
@@ -4563,6 +4578,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
'Expected third image to not be lazy-loaded.'
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
// Return 'lazy' if in the loop and in the main query for any subsequent elements.
$this->assertSameSetsWithIndex(
array(
@@ -4572,6 +4589,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
wp_get_loading_optimization_attributes( 'img', $attr, $context )
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
// Yes, for all subsequent elements.
$this->assertSameSetsWithIndex(
array(
@@ -4580,6 +4599,161 @@ public function test_wp_get_loading_optimization_attributes( $context ) {
),
wp_get_loading_optimization_attributes( 'img', $attr, $context )
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'fetchpriority' => 'auto',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes(
+ 'img',
+ array_merge( $attr, array( 'fetchpriority' => 'auto' ) ),
+ $context
+ ),
+ 'Expected a fetchpriority=auto IMG appearing after the media count threshold to still be lazy-loaded.'
+ );
+ }
+ }
+
+ /**
+ * Tests that wp_get_loading_optimization_attributes() returns the expected loading attribute value.
+ *
+ * This test is the same as {@see self::test_wp_get_loading_optimization_attributes()} except that the IMG which
+ * previously got `fetchpriority=high` now initially has `fetchpriority=auto`. This causes the initial lazy-loaded
+ * image to be bumped down one.
+ *
+ * @ticket 64823
+ *
+ * @covers ::wp_get_loading_optimization_attributes
+ *
+ * @dataProvider data_wp_get_loading_attr_default
+ *
+ * @param string $context
+ */
+ public function test_wp_get_loading_optimization_attributes_with_fetchpriority_auto_for_lcp_candidate( string $context ): void {
+ $attr = $this->get_width_height_for_high_priority();
+
+ // Return 'lazy' by default.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, 'test' )
+ );
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, 'wp_get_attachment_image' )
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, 'wp_get_attachment_image' );
+
+ // Return 'lazy' if not in the loop or the main query.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context )
+ );
+
+ $query = $this->get_new_wp_query_for_published_post();
+
+ while ( have_posts() ) {
+ the_post();
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ // Return 'lazy' if in the loop but not in the main query.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context )
+ );
+
+ // Set as main query.
+ $this->set_main_query( $query );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ // First three element are not lazy loaded. However, first image initially has `fetchpriority=auto` which marks it as a possible LCP element.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'fetchpriority' => 'auto',
+ ),
+ wp_get_loading_optimization_attributes(
+ 'img',
+ array_merge( $attr, array( 'fetchpriority' => 'auto' ) ),
+ $context
+ ),
+ 'Expected first image to not be lazy-loaded.'
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context ),
+ 'Expected second image to not be lazy-loaded.'
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context ),
+ 'Expected third image to not be lazy-loaded.'
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ // This is the 4th subsequent image, and it still is not lazy-loaded because the first had fetchpriority=auto and so it may have been hidden with block visibility.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context )
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ // Yes, for all subsequent elements.
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes( 'img', $attr, $context )
+ );
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
+ $this->assertSameSetsWithIndex(
+ array(
+ 'decoding' => 'async',
+ 'fetchpriority' => 'auto',
+ 'loading' => 'lazy',
+ ),
+ wp_get_loading_optimization_attributes(
+ 'img',
+ array_merge( $attr, array( 'fetchpriority' => 'auto' ) ),
+ $context
+ ),
+ 'Expected a fetchpriority=auto IMG appearing after the media count threshold to still be lazy-loaded.'
+ );
}
}
@@ -4606,12 +4780,17 @@ public function test_wp_get_loading_optimization_attributes_with_arbitrary_conte
'The "loading" attribute should be "lazy" when not in the loop or the main query.'
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
$query = $this->get_new_wp_query_for_published_post();
// Set as main query.
$this->set_main_query( $query );
while ( have_posts() ) {
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
the_post();
$this->assertSameSetsWithIndex(
@@ -4656,9 +4835,13 @@ public function test_wp_get_loading_optimization_attributes_with_arbitrary_conte
'The "loading" attribute should be "lazy" before the main query loop.'
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
while ( have_posts() ) {
the_post();
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
$this->assertSameSetsWithIndex(
array(
'decoding' => 'async',
@@ -4740,6 +4923,8 @@ public function test_wp_get_loading_optimization_attributes_header_contexts( $co
wp_get_loading_optimization_attributes( 'img', $attr, $context ),
'Images in the header context should get lazy-loaded after the wp_loading_optimization_force_header_contexts filter.'
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
}
/**
@@ -4764,12 +4949,14 @@ public function test_wp_loading_optimization_force_header_contexts_filter() {
add_filter(
'wp_loading_optimization_force_header_contexts',
- function ( $context ) {
+ function ( $contexts ) {
$contexts['something_completely_arbitrary'] = true;
return $contexts;
}
);
+ $this->assert_fetchpriority_low_loading_attrs( $attr, 'something_completely_arbitrary' );
+
$this->assertSameSetsWithIndex(
array(
'decoding' => 'async',
@@ -4809,6 +4996,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_if_not_m
),
wp_get_loading_optimization_attributes( 'img', $attr, $context )
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
}
/**
@@ -4840,6 +5029,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_in_main_
),
wp_get_loading_optimization_attributes( 'img', $attr, $context )
);
+
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
}
/**
@@ -4863,6 +5054,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_if_main_
$attr = $this->get_width_height_for_high_priority();
+ $this->assert_fetchpriority_low_loading_attrs( $attr, $context );
+
// First image is loaded with high fetchpriority.
$this->assertSameSetsWithIndex(
array(
@@ -6049,52 +6242,83 @@ function ( $atts, $content = null ) {
*
* @dataProvider data_wp_maybe_add_fetchpriority_high_attr
*/
- public function test_wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr, $expected_fetchpriority ) {
+ public function test_wp_maybe_add_fetchpriority_high_attr( array $loading_attrs, string $tag_name, array $attr, ?string $expected_fetchpriority, bool $expected_high_priority_element_flag ): void {
$loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr );
- if ( $expected_fetchpriority ) {
+ if ( null !== $expected_fetchpriority ) {
$this->assertArrayHasKey( 'fetchpriority', $loading_attrs, 'fetchpriority attribute should be present' );
$this->assertSame( $expected_fetchpriority, $loading_attrs['fetchpriority'], 'fetchpriority attribute has incorrect value' );
} else {
$this->assertArrayNotHasKey( 'fetchpriority', $loading_attrs, 'fetchpriority attribute should not be present' );
}
+ $this->assertSame( $expected_high_priority_element_flag, wp_high_priority_element_flag() );
}
/**
* Data provider.
*
- * @return array[]
+ * @return array,
+ * 1: string,
+ * 2: array,
+ * 3: string|null,
+ * 4: bool,
+ * }>
*/
- public function data_wp_maybe_add_fetchpriority_high_attr() {
+ public function data_wp_maybe_add_fetchpriority_high_attr(): array {
return array(
- 'small image' => array(
+ 'small image' => array(
array(),
'img',
$this->get_insufficient_width_height_for_high_priority(),
- false,
+ null,
+ true,
+ ),
+ 'small image with fetchpriority=auto' => array(
+ array(),
+ 'img',
+ array_merge(
+ $this->get_insufficient_width_height_for_high_priority(),
+ array( 'fetchpriority' => 'auto' )
+ ),
+ null,
+ true,
),
- 'large image' => array(
+ 'large image' => array(
array(),
'img',
$this->get_width_height_for_high_priority(),
'high',
+ false,
+ ),
+ 'large image with fetchpriority=auto' => array(
+ array(),
+ 'img',
+ array_merge(
+ $this->get_width_height_for_high_priority(),
+ array( 'fetchpriority' => 'auto' )
+ ),
+ null,
+ false,
),
- 'image with loading=lazy' => array(
+ 'image with loading=lazy' => array(
array(
'loading' => 'lazy',
'decoding' => 'async',
),
'img',
$this->get_width_height_for_high_priority(),
- false,
+ null,
+ true,
),
- 'image with loading=eager' => array(
+ 'image with loading=eager' => array(
array( 'loading' => 'eager' ),
'img',
$this->get_width_height_for_high_priority(),
'high',
+ false,
),
- 'image with fetchpriority=high' => array(
+ 'image with fetchpriority=high' => array(
array(),
'img',
array_merge(
@@ -6102,21 +6326,24 @@ public function data_wp_maybe_add_fetchpriority_high_attr() {
array( 'fetchpriority' => 'high' )
),
'high',
+ false,
),
- 'image with fetchpriority=low' => array(
+ 'image with fetchpriority=low' => array(
array(),
'img',
array_merge(
$this->get_insufficient_width_height_for_high_priority(),
array( 'fetchpriority' => 'low' )
),
- false,
+ null,
+ true,
),
- 'non-image element' => array(
+ 'non-image element' => array(
array(),
'video',
$this->get_width_height_for_high_priority(),
- false,
+ null,
+ true,
),
);
}
@@ -6309,6 +6536,27 @@ static function ( $loading_attrs ) {
);
}
+ /**
+ * Asserts that loading attributes for IMG with fetchpriority=low.
+ *
+ * It must not get lazy-loaded or increase the counter since they may be in the Navigation Overlay.
+ *
+ * @param array $attr
+ * @param string $context
+ */
+ protected function assert_fetchpriority_low_loading_attrs( array $attr, string $context ): void {
+ $this->assertSameSetsWithIndex(
+ array(
+ 'fetchpriority' => 'low',
+ 'decoding' => 'async',
+ ),
+ wp_get_loading_optimization_attributes(
+ 'img',
+ array_merge( $attr, array( 'fetchpriority' => 'low' ) ),
+ $context
+ )
+ );
+ }
/**
* Test WebP lossless quality is handled correctly.
@@ -7007,9 +7255,9 @@ public function set_main_query( $query ) {
/**
* Returns an array with dimension attribute values eligible for a high priority image.
*
- * @return array Associative array with 'width' and 'height' keys.
+ * @return array{ width: int, height: int } Associative array with 'width' and 'height' keys.
*/
- private function get_width_height_for_high_priority() {
+ private function get_width_height_for_high_priority(): array {
/*
* The product of width * height must be >50000 to qualify for high priority image.
* 300 * 200 = 60000
@@ -7023,9 +7271,9 @@ private function get_width_height_for_high_priority() {
/**
* Returns an array with dimension attribute values ineligible for a high priority image.
*
- * @return array Associative array with 'width' and 'height' keys.
+ * @return array{ width: int, height: int } Associative array with 'width' and 'height' keys.
*/
- private function get_insufficient_width_height_for_high_priority() {
+ private function get_insufficient_width_height_for_high_priority(): array {
/*
* The product of width * height must be >50000 to qualify for high priority image.
* 200 * 100 = 20000
diff --git a/tests/phpunit/tests/media/wpCrossOriginIsolation.php b/tests/phpunit/tests/media/wpCrossOriginIsolation.php
index 31f2e85975ee0..4fe5723bdc426 100644
--- a/tests/phpunit/tests/media/wpCrossOriginIsolation.php
+++ b/tests/phpunit/tests/media/wpCrossOriginIsolation.php
@@ -6,19 +6,36 @@
* @group media
* @covers ::wp_set_up_cross_origin_isolation
* @covers ::wp_start_cross_origin_isolation_output_buffer
+ * @covers ::wp_is_client_side_media_processing_enabled
*/
class Tests_Media_wpCrossOriginIsolation extends WP_UnitTestCase {
/**
* Original HTTP_USER_AGENT value.
- *
- * @var string|null
*/
- private $original_user_agent;
+ private ?string $original_user_agent;
+
+ /**
+ * Original HTTP_HOST value.
+ */
+ private ?string $original_http_host;
+
+ /**
+ * Original HTTPS value.
+ */
+ private ?string $original_https;
+
+ /**
+ * Original $_GET['action'] value.
+ */
+ private ?string $original_get_action;
public function set_up() {
parent::set_up();
- $this->original_user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : null;
+ $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+ $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null;
+ $this->original_https = $_SERVER['HTTPS'] ?? null;
+ $this->original_get_action = $_GET['action'] ?? null;
}
public function tear_down() {
@@ -28,6 +45,24 @@ public function tear_down() {
$_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
}
+ if ( null === $this->original_http_host ) {
+ unset( $_SERVER['HTTP_HOST'] );
+ } else {
+ $_SERVER['HTTP_HOST'] = $this->original_http_host;
+ }
+
+ if ( null === $this->original_https ) {
+ unset( $_SERVER['HTTPS'] );
+ } else {
+ $_SERVER['HTTPS'] = $this->original_https;
+ }
+
+ if ( null === $this->original_get_action ) {
+ unset( $_GET['action'] );
+ } else {
+ $_GET['action'] = $this->original_get_action;
+ }
+
// Clean up any output buffers started during tests.
while ( ob_get_level() > 1 ) {
ob_end_clean();
@@ -124,6 +159,32 @@ public function test_does_not_start_output_buffer_for_safari() {
$this->assertSame( $level_before, $level_after, 'Output buffer should not be started for Safari.' );
}
+ /**
+ * @ticket 64803
+ */
+ public function test_client_side_processing_disabled_on_non_secure_origin() {
+ $_SERVER['HTTP_HOST'] = 'example.com';
+ $_SERVER['HTTPS'] = '';
+
+ $this->assertFalse(
+ wp_is_client_side_media_processing_enabled(),
+ 'Client-side media processing should be disabled on non-secure, non-localhost origins.'
+ );
+ }
+
+ /**
+ * @ticket 64803
+ */
+ public function test_client_side_processing_enabled_on_localhost() {
+ $_SERVER['HTTP_HOST'] = 'localhost';
+ $_SERVER['HTTPS'] = '';
+
+ $this->assertTrue(
+ wp_is_client_side_media_processing_enabled(),
+ 'Client-side media processing should be enabled on localhost.'
+ );
+ }
+
/**
* This test must run in a separate process because the output buffer
* callback sends HTTP headers via header(), which would fail in the
diff --git a/tests/phpunit/tests/post/nav-menu.php b/tests/phpunit/tests/post/nav-menu.php
index d4ece1ff1776c..5ee9fb5f57097 100644
--- a/tests/phpunit/tests/post/nav-menu.php
+++ b/tests/phpunit/tests/post/nav-menu.php
@@ -1188,6 +1188,8 @@ public function test_wp_update_nav_menu_item_with_special_characters_in_category
)
);
+ $this->assertSame( 'Test Cat - "Pre-Slashed" Cat Name & >', $category->name );
+
$category_item_id = wp_update_nav_menu_item(
$this->menu_id,
0,
@@ -1196,11 +1198,7 @@ public function test_wp_update_nav_menu_item_with_special_characters_in_category
'menu-item-object' => 'category',
'menu-item-object-id' => $category->term_id,
'menu-item-status' => 'publish',
- /*
- * Interestingly enough, if we use `$cat->name` for the menu item title,
- * we won't be able to replicate the bug because it's in htmlentities form.
- */
- 'menu-item-title' => $category_name,
+ 'menu-item-title' => $category->name,
)
);
diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php
index 93cd4211c93ba..79e9d23cf9dd3 100644
--- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php
@@ -194,6 +194,18 @@ public function tear_down() {
parent::tear_down();
}
+ /**
+ * Enables client-side media processing and reinitializes the REST server
+ * so that the sideload and finalize routes are registered.
+ */
+ private function enable_client_side_media_processing(): void {
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_true' );
+
+ global $wp_rest_server;
+ $wp_rest_server = new Spy_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+ }
+
public function test_register_routes() {
$routes = rest_get_server()->get_routes();
$this->assertArrayHasKey( '/wp/v2/media', $routes );
@@ -2929,6 +2941,80 @@ public function test_upload_unsupported_image_type_with_filter() {
$this->assertSame( 201, $response->get_status() );
}
+ /**
+ * Test that unsupported image type check is skipped when not generating sub-sizes.
+ *
+ * When the client handles image processing (generate_sub_sizes is false),
+ * the server should not check image editor support.
+ *
+ * Tests the permissions check directly with file params set, since the core
+ * check uses get_file_params() which is only populated for multipart uploads.
+ *
+ * @ticket 64836
+ */
+ public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() {
+ wp_set_current_user( self::$author_id );
+
+ add_filter( 'wp_image_editors', '__return_empty_array' );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_file_params(
+ array(
+ 'file' => array(
+ 'name' => 'avif-lossy.avif',
+ 'type' => 'image/avif',
+ 'tmp_name' => self::$test_avif_file,
+ 'error' => 0,
+ 'size' => filesize( self::$test_avif_file ),
+ ),
+ )
+ );
+ $request->set_param( 'generate_sub_sizes', false );
+
+ $controller = new WP_REST_Attachments_Controller( 'attachment' );
+ $result = $controller->create_item_permissions_check( $request );
+
+ // Should pass because generate_sub_sizes is false (client handles processing).
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test that unsupported image type check is enforced when generating sub-sizes.
+ *
+ * When the server handles image processing (generate_sub_sizes is true),
+ * the server should still check image editor support.
+ *
+ * Tests the permissions check directly with file params set, since the core
+ * check uses get_file_params() which is only populated for multipart uploads.
+ *
+ * @ticket 64836
+ */
+ public function test_upload_unsupported_image_type_enforced_when_generating_sub_sizes() {
+ wp_set_current_user( self::$author_id );
+
+ add_filter( 'wp_image_editors', '__return_empty_array' );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_file_params(
+ array(
+ 'file' => array(
+ 'name' => 'avif-lossy.avif',
+ 'type' => 'image/avif',
+ 'tmp_name' => self::$test_avif_file,
+ 'error' => 0,
+ 'size' => filesize( self::$test_avif_file ),
+ ),
+ )
+ );
+
+ $controller = new WP_REST_Attachments_Controller( 'attachment' );
+ $result = $controller->create_item_permissions_check( $request );
+
+ // Should fail because the server needs to generate sub-sizes but can't.
+ $this->assertWPError( $result );
+ $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() );
+ }
+
/**
* Test that uploading an SVG image doesn't throw a `rest_upload_image_type_not_supported` error.
*
@@ -3162,6 +3248,8 @@ static function ( $data ) use ( &$captured_data ) {
* @requires function imagejpeg
*/
public function test_sideload_scaled_image() {
+ $this->enable_client_side_media_processing();
+
wp_set_current_user( self::$author_id );
// First, create an attachment.
@@ -3215,6 +3303,8 @@ public function test_sideload_scaled_image() {
* @requires function imagejpeg
*/
public function test_sideload_scaled_image_requires_auth() {
+ $this->enable_client_side_media_processing();
+
wp_set_current_user( self::$author_id );
// Create an attachment.
@@ -3244,6 +3334,8 @@ public function test_sideload_scaled_image_requires_auth() {
* @ticket 64737
*/
public function test_sideload_route_includes_scaled_enum() {
+ $this->enable_client_side_media_processing();
+
$server = rest_get_server();
$routes = $server->get_routes();
@@ -3266,6 +3358,8 @@ public function test_sideload_route_includes_scaled_enum() {
* @requires function imagejpeg
*/
public function test_sideload_scaled_unique_filename() {
+ $this->enable_client_side_media_processing();
+
wp_set_current_user( self::$author_id );
// Create an attachment.
@@ -3300,6 +3394,8 @@ public function test_sideload_scaled_unique_filename() {
* @requires function imagejpeg
*/
public function test_sideload_scaled_unique_filename_conflict() {
+ $this->enable_client_side_media_processing();
+
wp_set_current_user( self::$author_id );
// Create the first attachment.
@@ -3343,4 +3439,106 @@ public function test_sideload_scaled_unique_filename_conflict() {
$basename = wp_basename( $new_file );
$this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' );
}
+
+ /**
+ * Tests that the finalize endpoint triggers wp_generate_attachment_metadata.
+ *
+ * @ticket 62243
+ * @covers WP_REST_Attachments_Controller::finalize_item
+ * @requires function imagejpeg
+ */
+ public function test_finalize_item(): void {
+ $this->enable_client_side_media_processing();
+
+ wp_set_current_user( self::$author_id );
+
+ // Create an attachment.
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_header( 'Content-Type', 'image/jpeg' );
+ $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
+ $request->set_body( (string) file_get_contents( self::$test_file ) );
+ $response = rest_get_server()->dispatch( $request );
+ $attachment_id = $response->get_data()['id'];
+
+ $this->assertSame( 201, $response->get_status() );
+
+ // Track whether wp_generate_attachment_metadata filter fires.
+ $filter_metadata = null;
+ $filter_id = null;
+ $filter_context = null;
+ add_filter(
+ 'wp_generate_attachment_metadata',
+ function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) {
+ $filter_metadata = $metadata;
+ $filter_id = $id;
+ $filter_context = $context;
+ $metadata['foo'] = 'bar';
+ return $metadata;
+ },
+ 10,
+ 3
+ );
+
+ // Call the finalize endpoint.
+ $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' );
+ $this->assertIsArray( $filter_metadata );
+ $this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' );
+ $this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' );
+ $this->assertSame( 'update', $filter_context, 'Filter context should be "update".' );
+ $resulting_metadata = wp_get_attachment_metadata( $attachment_id );
+ $this->assertIsArray( $resulting_metadata );
+ $this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' );
+ $this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' );
+ }
+
+ /**
+ * Tests that the finalize endpoint requires authentication.
+ *
+ * @ticket 62243
+ * @covers WP_REST_Attachments_Controller::finalize_item
+ * @requires function imagejpeg
+ */
+ public function test_finalize_item_requires_auth(): void {
+ $this->enable_client_side_media_processing();
+
+ wp_set_current_user( self::$author_id );
+
+ // Create an attachment.
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_header( 'Content-Type', 'image/jpeg' );
+ $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
+ $request->set_body( (string) file_get_contents( self::$test_file ) );
+ $response = rest_get_server()->dispatch( $request );
+ $attachment_id = $response->get_data()['id'];
+
+ // Try finalizing without authentication.
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 );
+ }
+
+ /**
+ * Tests that the finalize endpoint returns error for invalid attachment ID.
+ *
+ * @ticket 62243
+ * @covers WP_REST_Attachments_Controller::finalize_item
+ */
+ public function test_finalize_item_invalid_id(): void {
+ $this->enable_client_side_media_processing();
+
+ wp_set_current_user( self::$author_id );
+
+ $invalid_id = PHP_INT_MAX;
+ $this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' );
+ $request = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
+ }
}
diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php
index d701d12f9dd68..212ddde70dd83 100644
--- a/tests/phpunit/tests/rest-api/rest-posts-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php
@@ -3330,6 +3330,111 @@ public function test_create_update_post_with_featured_media() {
$this->assertSame( 0, (int) get_post_thumbnail_id( $new_post->ID ) );
}
+ /**
+ * Data provider for featured media link permission tests.
+ *
+ * @return array
+ */
+ public function data_featured_media_link_permissions() {
+ return array(
+ 'unauthenticated user with draft parent attachment' => array(
+ 'attachment_parent_status' => 'draft',
+ 'attachment_status' => 'inherit',
+ 'user_id' => 0,
+ 'expect_link' => false,
+ ),
+ 'authenticated editor with draft parent attachment' => array(
+ 'attachment_parent_status' => 'draft',
+ 'attachment_status' => 'inherit',
+ 'user_id' => 'editor',
+ 'expect_link' => true,
+ ),
+ 'unauthenticated user with published attachment' => array(
+ 'attachment_parent_status' => null,
+ 'attachment_status' => 'publish',
+ 'user_id' => 0,
+ 'expect_link' => true,
+ ),
+ );
+ }
+
+ /**
+ * Tests that featured media links respect attachment permissions.
+ *
+ * @ticket 64183
+ * @dataProvider data_featured_media_link_permissions
+ *
+ * @param string|null $attachment_parent_status Status of the attachment's parent post, or null for no parent.
+ * @param string $attachment_status Status to set on the attachment.
+ * @param int|string $user_id User ID (0 for unauthenticated) or 'editor' for editor role.
+ * @param bool $expect_link Whether the featured media link should be included.
+ */
+ public function test_get_item_featured_media_link_permissions( $attachment_parent_status, $attachment_status, $user_id, $expect_link ) {
+ $file = DIR_TESTDATA . '/images/canola.jpg';
+
+ // Create attachment parent if needed.
+ $parent_post_id = 0;
+ if ( null !== $attachment_parent_status ) {
+ $parent_post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Parent Post',
+ 'post_status' => $attachment_parent_status,
+ )
+ );
+ }
+
+ // Create attachment.
+ $attachment_id = self::factory()->attachment->create_object(
+ $file,
+ $parent_post_id,
+ array(
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ // Set attachment status if different from default.
+ if ( 'publish' === $attachment_status ) {
+ wp_update_post(
+ array(
+ 'ID' => $attachment_id,
+ 'post_status' => 'publish',
+ )
+ );
+ }
+
+ // Create published post with featured media.
+ $published_post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Published Post',
+ 'post_status' => 'publish',
+ )
+ );
+ set_post_thumbnail( $published_post_id, $attachment_id );
+
+ // Set current user.
+ if ( 'editor' === $user_id ) {
+ wp_set_current_user( self::$editor_id );
+ } else {
+ wp_set_current_user( $user_id );
+ }
+
+ // Make request.
+ $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $published_post_id ) );
+ $response = rest_get_server()->dispatch( $request );
+ $links = $response->get_links();
+
+ // Assert link presence based on expectation.
+ if ( $expect_link ) {
+ $this->assertArrayHasKey( 'https://api.w.org/featuredmedia', $links );
+ $this->assertSame(
+ rest_url( '/wp/v2/media/' . $attachment_id ),
+ $links['https://api.w.org/featuredmedia'][0]['href']
+ );
+ } else {
+ $this->assertArrayNotHasKey( 'https://api.w.org/featuredmedia', $links );
+ }
+ }
+
public function test_create_post_invalid_author() {
wp_set_current_user( self::$editor_id );
diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php
index 0e0e00b934359..89bf2c481c567 100644
--- a/tests/phpunit/tests/rest-api/rest-schema-setup.php
+++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php
@@ -16,6 +16,9 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase {
public function set_up() {
parent::set_up();
+ // Ensure client-side media processing is enabled so the sideload route is registered.
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_true' );
+
/** @var WP_REST_Server $wp_rest_server */
global $wp_rest_server;
$wp_rest_server = new Spy_REST_Server();
@@ -110,6 +113,7 @@ public function test_expected_routes_in_schema() {
'/wp/v2/media/(?P[\\d]+)/post-process',
'/wp/v2/media/(?P[\\d]+)/edit',
'/wp/v2/media/(?P[\\d]+)/sideload',
+ '/wp/v2/media/(?P[\\d]+)/finalize',
'/wp/v2/blocks',
'/wp/v2/blocks/(?P[\d]+)',
'/wp/v2/blocks/(?P[\d]+)/autosaves',
diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php
index ef9e72e6a6724..dd79885d2b16d 100644
--- a/tests/phpunit/tests/rest-api/rest-settings-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php
@@ -120,10 +120,6 @@ public function test_get_items() {
'default_comment_status',
'site_icon', // Registered in wp-includes/blocks/site-logo.php
'wp_enable_real_time_collaboration',
- // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php.
- 'connectors_ai_anthropic_api_key',
- 'connectors_ai_google_api_key',
- 'connectors_ai_openai_api_key',
);
if ( ! is_multisite() ) {
diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php
index 8a93c7a64047d..43525263ac5ba 100644
--- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php
+++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php
@@ -6,7 +6,7 @@
* @covers WP_REST_Abilities_V1_Categories_Controller
*
* @group abilities-api
- * @group rest-api
+ * @group restapi
*/
class Tests_REST_API_WpRestAbilitiesV1CategoriesController extends WP_UnitTestCase {
diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
index e64965242dc98..9ee564ef00069 100644
--- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
+++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
@@ -6,7 +6,7 @@
* @covers WP_REST_Abilities_V1_List_Controller
*
* @group abilities-api
- * @group rest-api
+ * @group restapi
*/
class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase {
diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php
index 0c03d72dab8a5..609b7677d7b58 100644
--- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php
+++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php
@@ -6,7 +6,7 @@
* @covers WP_REST_Abilities_V1_Run_Controller
*
* @group abilities-api
- * @group rest-api
+ * @group restapi
*/
class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase {
diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php
index e899ea2f06908..a79702554dc64 100644
--- a/tests/phpunit/tests/template.php
+++ b/tests/phpunit/tests/template.php
@@ -151,6 +151,7 @@ public function tear_down() {
$registry->unregister( 'third-party/test' );
}
+ unset( $GLOBALS['_wp_tests_development_mode'] );
parent::tear_down();
}
@@ -1477,9 +1478,17 @@ public function test_wp_load_classic_theme_block_styles_on_demand( string $theme
/**
* Data provider.
*
- * @return array
+ * @return array
*/
public function data_wp_hoist_late_printed_styles(): array {
+ $blocks_content = '