From 85be6435094358841b2aaba570027f2cc287009a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:08 -0400 Subject: [PATCH 001/104] Collaboration: Add dedicated database table and storage backend Introduces the wp_collaboration table for storing real-time editing data (document states, awareness info, undo history) and the WP_Collaboration_Table_Storage class that implements all CRUD operations against it. Bumps the database schema version to 61840. --- src/wp-admin/includes/schema.php | 12 + src/wp-admin/includes/upgrade.php | 2 +- src/wp-includes/class-wpdb.php | 10 + .../class-wp-collaboration-table-storage.php | 310 ++++++++++++++++++ src/wp-includes/version.php | 2 +- 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/collaboration/class-wp-collaboration-table-storage.php diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 2e142197dc21c..36d39b7b5d497 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,18 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { KEY post_parent (post_parent), KEY post_author (post_author), KEY type_status_author (post_type,post_status,post_author) +) $charset_collate; +CREATE TABLE $wpdb->collaboration ( + id bigint(20) unsigned NOT NULL auto_increment, + room varchar($max_index_length) NOT NULL default '', + type varchar(32) NOT NULL default '', + client_id varchar(32) NOT NULL default '', + user_id bigint(20) unsigned NOT NULL default '0', + update_value longtext NOT NULL, + date_gmt datetime NOT NULL default '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY room (room,id), + KEY date_gmt (date_gmt) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 6adb0521ff295..0fcfe4acd1077 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61644 ) { + if ( $wp_current_db_version < 61840 ) { upgrade_700(); } diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..f4da31dc57b39 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -299,6 +299,7 @@ class wpdb { 'term_relationships', 'termmeta', 'commentmeta', + 'collaboration', ); /** @@ -404,6 +405,15 @@ class wpdb { */ public $posts; + /** + * WordPress Collaboration table. + * + * @since 7.0.0 + * + * @var string + */ + public $collaboration; + /** * WordPress Terms table. * diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php new file mode 100644 index 0000000000000..f60508ad1c53d --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -0,0 +1,310 @@ +, user_id: int} + */ +class WP_Collaboration_Table_Storage { + /** + * Cache of cursors by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Adds an update to a given room. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param mixed $update Update data. + * @return bool True on success, false on failure. + */ + public function add_update( string $room, $update ): bool { + global $wpdb; + + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => $update['type'] ?? '', + 'client_id' => $update['client_id'] ?? '', + 'update_value' => wp_json_encode( $update ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%s', '%s' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * Retrieves per-client awareness rows from the collaboration table + * where type = 'awareness'. Expired rows are filtered by the WHERE + * clause; actual deletion is handled by cron via + * wp_delete_old_collaboration_data(). + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $timeout Seconds before an awareness entry is considered expired. + * @return array Awareness entries. + * @phpstan-return list + */ + public function get_awareness_state( string $room, int $timeout = 30 ): array { + global $wpdb; + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT client_id, user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", + $room, + $cutoff + ) + ); + + if ( ! is_array( $rows ) ) { + return array(); + } + + $entries = array(); + foreach ( $rows as $row ) { + $decoded = json_decode( $row->update_value, true ); + if ( is_array( $decoded ) ) { + $entries[] = array( + 'client_id' => $row->client_id, + 'state' => $decoded, + 'user_id' => (int) $row->user_id, + ); + } + } + + return $entries; + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * maximum row ID at the time updates were retrieved. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves updates from a room after a given cursor. + * + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. + * + * Only retrieves non-awareness rows — awareness rows are handled + * separately via get_awareness_state(). + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + global $wpdb; + + // Snapshot the current max ID and total row count in a single query. + // Excludes awareness rows — they are not sync updates. + $snapshot = $wpdb->get_row( + $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + if ( ! $snapshot ) { + $this->room_cursors[ $room ] = 0; + $this->room_update_counts[ $room ] = 0; + return array(); + } + + $max_id = (int) $snapshot->max_id; + $total = (int) $snapshot->total; + + $this->room_cursors[ $room ] = $max_id; + + if ( 0 === $max_id || $max_id <= $cursor ) { + // Preserve the real row count so the server can still + // trigger compaction when updates have accumulated but + // no new ones arrived since the client's last poll. + $this->room_update_counts[ $room ] = $total; + return array(); + } + + $this->room_update_counts[ $room ] = $total; + + // Fetch updates after the cursor up to the snapshot boundary. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", + $room, + $cursor, + $max_id + ) + ); + + if ( ! is_array( $rows ) ) { + return array(); + } + + $updates = array(); + foreach ( $rows as $row ) { + $decoded = json_decode( $row->update_value, true ); + if ( is_array( $decoded ) ) { + $updates[] = $decoded; + } + } + + return $updates; + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * Uses a single atomic DELETE query, avoiding the race-prone + * "delete all, re-add some" pattern. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with id <= this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + global $wpdb; + + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d", + $room, + $cursor + ) + ); + + return false !== $result; + } + + /** + * Sets awareness state for a given client in a room. + * + * Uses UPDATE-then-INSERT: tries to update the existing row first, + * and only inserts if no row was updated. Each client writes only + * its own row, eliminating the race condition inherent in shared-state + * approaches. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param string $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $user_id WordPress user ID that owns this client. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { + global $wpdb; + + $update_value = wp_json_encode( $state ); + $now = gmdate( 'Y-m-d H:i:s' ); + + // Try UPDATE first. + $updated = $wpdb->update( + $wpdb->collaboration, + array( + 'user_id' => $user_id, + 'update_value' => $update_value, + 'date_gmt' => $now, + ), + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + ) + ); + + // INSERT only if no existing row. + if ( 0 === (int) $updated ) { + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'update_value' => $update_value, + 'date_gmt' => $now, + ) + ); + + return false !== $result; + } + + return false !== $updated; + } +} diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 733850aa6eb21..02a9f4bc06025 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61833; +$wp_db_version = 61840; /** * Holds the TinyMCE version. From a4f8b9892bed463073ca69711b7d136b8231337b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:14 -0400 Subject: [PATCH 002/104] Collaboration: Replace sync server with collaboration server Replaces WP_HTTP_Polling_Sync_Server with WP_HTTP_Polling_Collaboration_Server using the wp-collaboration/v1 REST namespace. Switches to string-based client IDs, fixes the compaction race condition, adds a backward-compatible wp-sync/v1 route alias, and uses UPDATE-then-INSERT for awareness data. --- ...-wp-http-polling-collaboration-server.php} | 254 ++++++++++-------- 1 file changed, 146 insertions(+), 108 deletions(-) rename src/wp-includes/collaboration/{class-wp-http-polling-sync-server.php => class-wp-http-polling-collaboration-server.php} (61%) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php similarity index 61% rename from src/wp-includes/collaboration/class-wp-http-polling-sync-server.php rename to src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 88554a48c7d54..f36d4ba1bdb09 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -1,8 +1,9 @@ storage = $storage; } @@ -96,8 +98,9 @@ public function register_routes(): void { $typed_update_args = array( 'properties' => array( 'data' => array( - 'type' => 'string', - 'required' => true, + 'type' => 'string', + 'required' => true, + 'maxLength' => 1048576, // 1 MB — generous ceiling for base64-encoded Yjs updates. ), 'type' => array( 'type' => 'string', @@ -125,14 +128,17 @@ public function register_routes(): void { 'type' => array( 'object', 'null' ), ), 'client_id' => array( - 'minimum' => 1, - 'required' => true, - 'type' => 'integer', + 'required' => true, + 'type' => array( 'string', 'integer' ), + 'sanitize_callback' => function ( $value ) { + return (string) $value; + }, ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'maxLength' => 191, // Matches $max_index_length in wp-admin/includes/schema.php. ), 'updates' => array( 'items' => $typed_update_args, @@ -142,30 +148,51 @@ public function register_routes(): void { ), ); + $route_args = array( + 'methods' => array( WP_REST_Server::CREATABLE ), + 'callback' => array( $this, 'handle_request' ), + 'permission_callback' => array( $this, 'check_permissions' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + 'required' => true, + 'type' => 'array', + ), + ), + ); + register_rest_route( self::REST_NAMESPACE, '/updates', - array( - 'methods' => array( WP_REST_Server::CREATABLE ), - 'callback' => array( $this, 'handle_request' ), - 'permission_callback' => array( $this, 'check_permissions' ), - 'args' => array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'required' => true, - 'type' => 'array', - ), - ), - ) + $route_args + ); + + /* + * Backward-compatible alias so that the Gutenberg plugin's + * bundled sync package (which still uses wp-sync/v1) continues + * to work against WordPress 7.0+. + * + * @todo Remove once the Gutenberg plugin has transitioned to + * the wp-collaboration/v1 namespace. + */ + register_rest_route( + 'wp-sync/v1', + '/updates', + $route_args ); } /** * Checks if the current user has permission to access a room. * + * Requires `edit_posts` (contributor+), then delegates to + * can_user_collaborate_on_entity_type() for per-entity checks. + * There is no dedicated `collaborate` capability; access follows + * existing edit capabilities for the entity type. + * * @since 7.0.0 * * @param WP_REST_Request $request The REST request. @@ -176,29 +203,15 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'rest_cannot_edit', - __( 'You do not have permission to perform this action' ), + __( 'You do not have permission to perform this action.' ), array( 'status' => rest_authorization_required_code() ) ); } - $rooms = $request['rooms']; - $wp_user_id = get_current_user_id(); + $rooms = $request['rooms']; foreach ( $rooms as $room ) { - $client_id = $room['client_id']; - $room = $room['room']; - - // Check that the client_id is not already owned by another user. - $existing_awareness = $this->storage->get_awareness_state( $room ); - foreach ( $existing_awareness as $entry ) { - if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) { - return new WP_Error( - 'rest_cannot_edit', - __( 'Client ID is already in use by another user.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - } + $room = $room['room']; $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); @@ -207,13 +220,13 @@ public function check_permissions( WP_REST_Request $request ) { $entity_name = $object_parts[0]; $object_id = $object_parts[1] ?? null; - if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + if ( ! $this->can_user_collaborate_on_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( 'rest_cannot_edit', sprintf( - /* translators: %s: The room name encodes the current entity being synced. */ - __( 'You do not have permission to sync this entity: %s.' ), - $room + /* translators: %s: The room name identifying the collaborative editing session. */ + __( 'You do not have permission to collaborate on this entity: %s.' ), + esc_html( $room ) ), array( 'status' => rest_authorization_required_code() ) ); @@ -224,7 +237,7 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Handles request: stores sync updates and awareness data, and returns + * Handles request: stores updates and awareness data, and returns * updates the client is missing. * * @since 7.0.0 @@ -244,18 +257,22 @@ public function handle_request( WP_REST_Request $request ) { $cursor = $room_request['after']; $room = $room_request['room']; - // Merge awareness state. + // Merge awareness state (also validates client_id ownership). $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); + if ( is_wp_error( $merged_awareness ) ) { + return $merged_awareness; + } + // The lowest client ID is nominated to perform compaction when needed. $is_compactor = false; if ( count( $merged_awareness ) > 0 ) { - $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = (string) min( array_keys( $merged_awareness ) ) === $client_id; } // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { - $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + $result = $this->process_collaboration_update( $room, $client_id, $cursor, $update ); if ( is_wp_error( $result ) ) { return $result; } @@ -272,7 +289,7 @@ public function handle_request( WP_REST_Request $request ) { } /** - * Checks if the current user can sync a specific entity type. + * Checks if the current user can collaborate on a specific entity type. * * @since 7.0.0 * @@ -281,7 +298,7 @@ public function handle_request( WP_REST_Request $request ) { * @param string|null $object_id The object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ - private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { return current_user_can( 'edit_post', (int) $object_id ); @@ -314,7 +331,7 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return current_user_can( $post_type_object->cap->edit_posts ); } - // Collection syncing does not exchange entity data. It only signals if + // Collection collaboration does not exchange entity data. It only signals if // another user has updated an entity in the collection. Therefore, we only // compare against an allow list of collection types. $allowed_collection_entity_kinds = array( @@ -329,66 +346,66 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ /** * Processes and stores an awareness update from a client. * + * Also validates that the client_id is not already owned by another user. + * This check uses the same get_awareness_state() query that builds the + * response, eliminating a duplicate query that was previously performed + * in check_permissions(). + * * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array> Map of client ID to awareness state. + * @return array>|WP_Error Map of client ID to awareness state, or WP_Error if client_id is owned by another user. */ - private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - $existing_awareness = $this->storage->get_awareness_state( $room ); - $updated_awareness = array(); - $current_time = time(); - - foreach ( $existing_awareness as $entry ) { - // Remove this client's entry (it will be updated below). - if ( $client_id === $entry['client_id'] ) { - continue; - } + private function process_awareness_update( string $room, string $client_id, ?array $awareness_update ) { + $wp_user_id = get_current_user_id(); - // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { - continue; - } + // Check ownership before upserting so a hijacked client_id is rejected. + $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); - $updated_awareness[] = $entry; + foreach ( $entries as $entry ) { + if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['user_id'] ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Client ID is already in use by another user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } } - // Add this client's awareness state. if ( null !== $awareness_update ) { - $updated_awareness[] = array( - 'client_id' => $client_id, - 'state' => $awareness_update, - 'updated_at' => $current_time, - 'wp_user_id' => get_current_user_id(), - ); + $this->storage->set_awareness_state( $room, $client_id, $awareness_update, $wp_user_id ); } - // This action can fail, but it shouldn't fail the entire request. - $this->storage->set_awareness_state( $room, $updated_awareness ); - - // Convert to client_id => state map for response. $response = array(); - foreach ( $updated_awareness as $entry ) { + foreach ( $entries as $entry ) { $response[ $entry['client_id'] ] = $entry['state']; } + // Other clients' states were decoded from the DB. Run the current + // client's state through the same encode/decode path so the response + // is consistent — wp_json_encode may normalize values (e.g. strip + // invalid UTF-8) that would otherwise differ on the next poll. + if ( null !== $awareness_update ) { + $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); + } + return $response; } /** - * Processes a sync update based on its type. + * Processes a collaboration update based on its type. * * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param int $cursor Client cursor (marker of last seen update). - * @param array{data: string, type: string} $update Sync update. + * @param array{data: string, type: string} $update Collaboration update. * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { + private function process_collaboration_update( string $room, string $client_id, int $cursor, array $update ) { $data = $update['data']; $type = $update['type']; @@ -397,7 +414,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, /* * Compaction replaces updates the client has already seen. Only remove * updates with markers before the client's cursor to preserve updates - * that arrived since the client's last sync. + * that arrived since the client's last poll. * * Check for a newer compaction update first. If one exists, skip this * compaction to avoid overwriting it. @@ -413,15 +430,31 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { + // Insert the compaction row before deleting old rows. + // Reversing the order closes a race window where a + // client joining with cursor=0 between the DELETE and + // INSERT would see an empty room for one poll cycle. + // The compaction row always has a higher ID than the + // deleted rows, so cursor-based filtering is unaffected. + $insert_result = $this->add_update( $room, $client_id, $type, $data ); + if ( is_wp_error( $insert_result ) ) { + return $insert_result; + } + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + global $wpdb; + $error_data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $error_data['db_error'] = $wpdb->last_error; + } return new WP_Error( - 'rest_sync_storage_error', + 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), - array( 'status' => 500 ) + $error_data ); } - return $this->add_update( $room, $client_id, $type, $data ); + return true; } // Reaching this point means there's a newer compaction, so we can @@ -445,7 +478,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, return new WP_Error( 'rest_invalid_update_type', - __( 'Invalid sync update type.' ), + __( 'Invalid collaboration update type.' ), array( 'status' => 400 ) ); } @@ -456,12 +489,12 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). * @param string $data Base64-encoded update data. * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function add_update( string $room, int $client_id, string $type, string $data ) { + private function add_update( string $room, string $client_id, string $type, string $data ) { $update = array( 'client_id' => $client_id, 'data' => $data, @@ -469,10 +502,15 @@ private function add_update( string $room, int $client_id, string $type, string ); if ( ! $this->storage->add_update( $room, $update ) ) { + global $wpdb; + $data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $data['db_error'] = $wpdb->last_error; + } return new WP_Error( - 'rest_sync_storage_error', - __( 'Failed to store sync update.' ), - array( 'status' => 500 ) + 'rest_collaboration_storage_error', + __( 'Failed to store collaboration update.' ), + $data ); } @@ -480,7 +518,7 @@ private function add_update( string $room, int $client_id, string $type, string } /** - * Gets sync updates for a specific client from a room after a given cursor. + * Gets updates for a specific client from a room after a given cursor. * * Delegates cursor-based retrieval to the storage layer, then applies * client-specific filtering and compaction logic. @@ -488,7 +526,7 @@ private function add_update( string $room, int $client_id, string $type, string * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. * @return array{ @@ -499,7 +537,7 @@ private function add_update( string $room, int $client_id, string $type, string * updates: array, * } Response data for this room. */ - private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + private function get_updates( string $room, string $client_id, int $cursor, bool $is_compactor ): array { $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); $total_updates = $this->storage->get_update_count( $room ); From da8317c30f182a5c82b601387cf2b86d7c1ca067 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:21 -0400 Subject: [PATCH 003/104] Collaboration: Remove legacy post meta storage and post type Deletes WP_Sync_Post_Meta_Storage and WP_Sync_Storage interface, and removes the wp_sync_storage post type registration from post.php. These are superseded by the dedicated collaboration table. --- .../class-wp-sync-post-meta-storage.php | 322 ------------------ .../interface-wp-sync-storage.php | 86 ----- src/wp-includes/post.php | 35 -- 3 files changed, 443 deletions(-) delete mode 100644 src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php delete mode 100644 src/wp-includes/collaboration/interface-wp-sync-storage.php diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php deleted file mode 100644 index c605fa48699b7..0000000000000 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ /dev/null @@ -1,322 +0,0 @@ - - */ - private array $room_cursors = array(); - - /** - * Cache of update counts by room. - * - * @since 7.0.0 - * @var array - */ - private array $room_update_counts = array(); - - /** - * Cache of storage post IDs by room hash. - * - * @since 7.0.0 - * @var array - */ - private static array $storage_post_ids = array(); - - /** - * Adds a sync update to a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param mixed $update Sync update. - * @return bool True on success, false on failure. - */ - public function add_update( string $room, $update ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Create an envelope and stamp each update to enable cursor-based filtering. - $envelope = array( - 'timestamp' => $this->get_time_marker(), - 'value' => $update, - ); - - return (bool) add_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ), wp_slash( $envelope ), false ); - } - - /** - * Retrieves all sync updates for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Sync updates. - */ - private function get_all_updates( string $room ): array { - $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); - - if ( ! is_array( $updates ) ) { - $updates = array(); - } - - // Filter out any updates that don't have the expected structure. - $updates = array_filter( - $updates, - static function ( $update ): bool { - return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); - } - ); - - $this->room_update_counts[ $room ] = count( $updates ); - - return $updates; - } - - /** - * Gets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Awareness state. - */ - public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return array_values( $awareness ); - } - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ - public function set_awareness_state( string $room, array $awareness ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // update_post_meta returns false if the value is the same as the existing value. - update_post_meta( $post_id, wp_slash( self::AWARENESS_META_KEY ), wp_slash( $awareness ) ); - return true; - } - - /** - * Gets the current cursor for a given room. - * - * The cursor is set during get_updates_after_cursor() and represents the - * point in time just before the updates were retrieved, with a small buffer - * to ensure consistency. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Current cursor for the room. - */ - public function get_cursor( string $room ): int { - return $this->room_cursors[ $room ] ?? 0; - } - - /** - * Gets or creates the storage post for a given room. - * - * Each room gets its own dedicated post so that post meta cache - * invalidation is scoped to a single room rather than all of them. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int|null Post ID. - */ - private function get_storage_post_id( string $room ): ?int { - $room_hash = md5( $room ); - - if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { - return self::$storage_post_ids[ $room_hash ]; - } - - // Try to find an existing post for this room. - $posts = get_posts( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => $room_hash, - 'fields' => 'ids', - ) - ); - - $post_id = array_first( $posts ); - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - // Create new post for this room. - $post_id = wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'publish', - 'post_title' => 'Sync Storage', - 'post_name' => $room_hash, - ) - ); - - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - return null; - } - - /** - * Gets the current time in milliseconds as a comparable time marker. - * - * @since 7.0.0 - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return (int) floor( microtime( true ) * 1000 ); - } - - /** - * Gets the number of updates stored for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Number of updates stored for the room. - */ - public function get_update_count( string $room ): int { - return $this->room_update_counts[ $room ] ?? 0; - } - - /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array { - $all_updates = $this->get_all_updates( $room ); - $updates = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by timestamp to ensure order. - usort( - $updates, - fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] - ); - - return wp_list_pluck( $updates, 'value' ); - } - - /** - * Removes updates from a room that are older than the given cursor. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - $all_updates = $this->get_all_updates( $room ); - - // Remove all updates for the room and re-store only those that are newer than the cursor. - if ( ! delete_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ) ) ) { - return false; - } - - // Re-store envelopes directly to avoid double-wrapping by add_update(). - $add_result = true; - foreach ( $all_updates as $envelope ) { - if ( $add_result && $envelope['timestamp'] >= $cursor ) { - $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); - } - } - - return $add_result; - } -} diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php deleted file mode 100644 index d84dbeb1e4aae..0000000000000 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ /dev/null @@ -1,86 +0,0 @@ - Awareness state. - */ - public function get_awareness_state( string $room ): array; - - /** - * Gets the current cursor for a given room. This should return a monotonically - * increasing integer that represents the last update that was returned for the - * room during the current request. This allows clients to retrieve updates - * after a specific cursor on subsequent requests. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Current cursor for the room. - */ - public function get_cursor( string $room ): int; - - /** - * Gets the total number of stored updates for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Total number of updates. - */ - public function get_update_count( string $room ): int; - - /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array; - - /** - * Removes updates from a room that are older than the provided cursor. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool; - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ - public function set_awareness_state( string $room, array $awareness ): bool; -} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 896142603278b..55d934518d5f0 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,41 +657,6 @@ function create_initial_post_types() { ) ); - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - register_post_type( - 'wp_sync_storage', - array( - 'labels' => array( - 'name' => __( 'Sync Updates' ), - 'singular_name' => __( 'Sync Update' ), - ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'hierarchical' => false, - 'capabilities' => array( - 'read' => 'do_not_allow', - 'read_private_posts' => 'do_not_allow', - 'create_posts' => 'do_not_allow', - 'publish_posts' => 'do_not_allow', - 'edit_posts' => 'do_not_allow', - 'edit_others_posts' => 'do_not_allow', - 'edit_published_posts' => 'do_not_allow', - 'delete_posts' => 'do_not_allow', - 'delete_others_posts' => 'do_not_allow', - 'delete_published_posts' => 'do_not_allow', - ), - 'map_meta_cap' => false, - 'publicly_queryable' => false, - 'query_var' => false, - 'rewrite' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - register_post_status( 'publish', array( From b06269e8236141caee9a5b2e9ab7ea3725edf6a2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:28 -0400 Subject: [PATCH 004/104] Collaboration: Wire up bootstrap, feature gate, and cron cleanup Adds wp_is_collaboration_enabled() gate, injects the collaboration setting into the block editor, registers cron event for cleaning up stale collaboration data, and updates require/include paths for the new storage and server classes. --- src/wp-admin/admin.php | 8 +++++ src/wp-includes/collaboration.php | 50 ++++++++++++++++++++++++++++- src/wp-includes/default-filters.php | 1 + src/wp-includes/rest-api.php | 8 ++--- src/wp-settings.php | 5 ++- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index 82ab6b93ac99e..3634c8c29c20d 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -113,6 +113,14 @@ wp_schedule_event( time(), 'daily', 'delete_expired_transients' ); } +// Schedule collaboration data cleanup. +if ( wp_is_collaboration_enabled() + && ! wp_next_scheduled( 'wp_delete_old_collaboration_data' ) + && ! wp_installing() +) { + wp_schedule_event( time(), 'daily', 'wp_delete_old_collaboration_data' ); +} + set_screen_options(); $date_format = __( 'F j, Y' ); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 31f816c87b670..1ad489deac89a 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -6,6 +6,21 @@ * @since 7.0.0 */ +/** + * Checks whether real-time collaboration is enabled. + * + * The feature requires both the site option and the database schema + * introduced in db_version 61840. + * + * @since 7.0.0 + * + * @return bool True if collaboration is enabled, false otherwise. + */ +function wp_is_collaboration_enabled() { + return get_option( 'wp_enable_real_time_collaboration' ) + && get_option( 'db_version' ) >= 61840; +} + /** * Injects the real-time collaboration setting into a global variable. * @@ -18,7 +33,7 @@ function wp_collaboration_inject_setting() { global $pagenow; - if ( ! get_option( 'wp_enable_real_time_collaboration' ) ) { + if ( ! wp_is_collaboration_enabled() ) { return; } @@ -34,3 +49,36 @@ function wp_collaboration_inject_setting() { 'after' ); } + +/** + * Deletes stale collaboration data from the collaboration table. + * + * Removes non-awareness rows older than 7 days and awareness rows older + * than 60 seconds. Rows left behind by abandoned collaborative editing + * sessions are cleaned up to prevent unbounded table growth. + * + * @since 7.0.0 + */ +function wp_delete_old_collaboration_data() { + if ( ! wp_is_collaboration_enabled() ) { + return; + } + + global $wpdb; + + // Clean up sync rows older than 7 days. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) + ) + ); + + // Clean up awareness rows older than 60 seconds. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - 60 ) + ) + ); +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 24b808bf9cd17..68a2ecec70254 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -454,6 +454,7 @@ add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'delete_expired_transients', 'delete_expired_transients' ); +add_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ); // Navigation menu actions. add_action( 'delete_post', '_wp_delete_post_menu_item' ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index df7f262d3aa58..a1e7fd2cea7de 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,10 +430,10 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sync_server->register_routes(); + if ( wp_is_collaboration_enabled() ) { + $collaboration_storage = new WP_Collaboration_Table_Storage(); + $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); + $collaboration_server->register_routes(); } } diff --git a/src/wp-settings.php b/src/wp-settings.php index dab1d8fd4c0de..f7e09e2c10ea4 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,9 +310,8 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; -require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-collaboration-table-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-collaboration-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; From 886f0b127078b0f5e0ef301966ef60622ebecd1e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:37 -0400 Subject: [PATCH 005/104] Tests: Add collaboration server tests and remove legacy sync tests Adds 67 PHPUnit tests for WP_HTTP_Polling_Collaboration_Server covering document sync, awareness, undo/redo, compaction, permissions, cursor mechanics, race conditions, cron cleanup, and the backward-compatible wp-sync/v1 route. Adds E2E tests for 3-user presence, sync, and undo/redo. Removes the old sync server tests. Updates REST schema setup and fixtures for the new collaboration endpoints. --- .../collaboration-presence.test.js | 109 + .../collaboration/collaboration-sync.test.js | 353 ++++ .../collaboration-undo-redo.test.js | 181 ++ .../fixtures/collaboration-utils.js | 426 ++++ .../e2e/specs/collaboration/fixtures/index.js | 48 + .../rest-api/rest-collaboration-server.php | 1769 +++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 13 +- .../tests/rest-api/rest-sync-server.php | 867 -------- tests/qunit/fixtures/wp-api-generated.js | 154 +- 9 files changed, 3028 insertions(+), 892 deletions(-) create mode 100644 tests/e2e/specs/collaboration/collaboration-presence.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-sync.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-undo-redo.test.js create mode 100644 tests/e2e/specs/collaboration/fixtures/collaboration-utils.js create mode 100644 tests/e2e/specs/collaboration/fixtures/index.js create mode 100644 tests/phpunit/tests/rest-api/rest-collaboration-server.php delete mode 100644 tests/phpunit/tests/rest-api/rest-sync-server.php diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js new file mode 100644 index 0000000000000..600794405ffb5 --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -0,0 +1,109 @@ +/** + * Tests for collaborative editing presence (awareness). + * + * Verifies that collaborator avatars, names, and leave events + * propagate correctly between three concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Presence', () => { + test( 'All 3 collaborator avatars are visible', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - 3 Users', + } ); + + const { page2, page3 } = collaborationUtils; + + // Each user sees the collaborators list button (indicates others are present). + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + await expect( + page2.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + await expect( + page3.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + } ); + + test( 'Collaborator names appear in popover', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - Names', + } ); + + // User A opens the collaborators popover. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toBeVisible( { + timeout: SYNC_TIMEOUT, + } ); + await presenceButton.click(); + + // The popover should list both collaborators by name. + // Use the presence list item class to avoid matching snackbar toasts. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) + ).toBeVisible(); + + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) + ).toBeVisible(); + } ); + + test( 'User C leaves, A and B see updated presence', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - Leave', + } ); + + // Verify all 3 users see the collaborators button initially. + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + // Navigate User C away from the editor to stop their polling. + // Avoids closing the context directly which corrupts Playwright state. + await collaborationUtils.page3.goto( '/wp-admin/' ); + + // Wait for User C's awareness entry to expire on the server (30s timeout) + // by watching the button label drop from 3 to 2 collaborators. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toHaveAccessibleName( + /2 online/, + { timeout: 45000 } + ); + + // Open the popover once, then verify the list contents. + await presenceButton.click(); + + // "Another Collaborator" (User C) should no longer appear in the presence list. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) + ).not.toBeVisible(); + + // "Test Collaborator" (User B) should still be listed. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) + ).toBeVisible(); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-sync.test.js b/tests/e2e/specs/collaboration/collaboration-sync.test.js new file mode 100644 index 0000000000000..5bf51d2a979fe --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-sync.test.js @@ -0,0 +1,353 @@ +/** + * Tests for collaborative editing sync (CRDT document replication). + * + * Verifies that block insertions, deletions, edits, title changes, + * and late-join state transfer propagate correctly between three + * concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Sync', () => { + test( 'User A adds a paragraph block, Users B and C both see it', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Fan Out', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a paragraph block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + } ); + + // User B should see the paragraph after sync propagation. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + + // User C should also see the paragraph. + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + } ); + + test( 'User C adds a paragraph block, Users A and B see it', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - C to A and B', + } ); + + const { editor2, page3 } = collaborationUtils; + + // User C inserts a paragraph block via the data API. + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'Hello from User C' } + ); + + // User A should see the paragraph. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + + // User B should also see the paragraph. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + } ); + + test( 'All 3 users add blocks simultaneously, all changes appear everywhere', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - 3-Way Merge', + } ); + + const { page2, page3 } = collaborationUtils; + + // All 3 users insert blocks concurrently. + await Promise.all( [ + editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ), + collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ), + collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ), + ] ); + + // All 3 users should eventually see all 3 blocks. + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); + } ); + + test( 'Title change from User A propagates to B and C', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Title', + } ); + + const { page2, page3 } = collaborationUtils; + + // User A changes the title. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/editor' ) + .editPost( { title: 'New Title from User A' } ); + } ); + + // User B should see the updated title. + await expect + .poll( + () => + page2.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: SYNC_TIMEOUT } + ) + .toBe( 'New Title from User A' ); + + // User C should also see the updated title. + await expect + .poll( + () => + page3.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: SYNC_TIMEOUT } + ) + .toBe( 'New Title from User A' ); + } ); + + test( 'User C joins late and sees existing content from A and B', async ( { + collaborationUtils, + editor, + } ) => { + const post = await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Late Join', + } ); + + const { page2, page3, editor3 } = collaborationUtils; + + // Navigate User C away from the editor to simulate not being + // present while A and B make edits. + await page3.goto( '/wp-admin/' ); + + // User A and B each add a block while User C is away. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Block from A (early)' }, + } ); + + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'Block from B (early)' } + ); + + // Wait for A and B to sync with each other. + await collaborationUtils.assertEditorHasContent( editor, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); + + // Now User C joins late by navigating back to the editor. + await collaborationUtils.navigateToEditor( page3, post.id ); + await collaborationUtils.waitForCollaborationReady( page3 ); + + // User C should see all existing blocks from A and B after sync. + await collaborationUtils.assertEditorHasContent( editor3, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); + } ); + + test( 'Block deletion syncs to all users', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Block Deletion', + content: + '

Block to delete

', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Block to delete' }, + }, + ] ); + } + + // User A removes the block. + await page.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .removeBlock( blocks[ 0 ].clientId ); + } ); + + // Users B and C should see 0 blocks after sync. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + } ); + + test( 'Editing existing block content syncs to all users', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Edit Content', + content: + '

Original text

', + } ); + + const { editor2, editor3, page2 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Original text' }, + }, + ] ); + } + + // User B updates the block content. + await page2.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .updateBlockAttributes( blocks[ 0 ].clientId, { + content: 'Edited by User B', + } ); + } ); + + // Users A and C should see the updated content. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + } ); + + test( 'Non-paragraph block type syncs to all users', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Heading Block', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a heading block. + await editor.insertBlock( { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + } ); + + // User B should see the heading with correct attributes. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + + // User C should also see the heading with correct attributes. + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js new file mode 100644 index 0000000000000..dce4e5b2e548b --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js @@ -0,0 +1,181 @@ +/** + * Tests for collaborative editing undo/redo. + * + * Verifies that undo and redo operations affect only the originating + * user's changes while preserving other collaborators' edits. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Undo/Redo', () => { + test( 'User A undo only affects their own changes, B and C blocks remain', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Undo Test - 3 Users', + } ); + + const { page2, page3 } = collaborationUtils; + + // User B adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); + + // User C adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ); + + // Wait for both blocks to appear on User A. + await collaborationUtils.assertEditorHasContent( editor, [ + 'From User B', + 'From User C', + ] ); + + // User A adds their own block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for all 3 blocks to appear on all editors. + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); + + // User A performs undo via the data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // All users should see only B and C's blocks (A's is undone). + await collaborationUtils.assertAllEditorsHaveContent( + [ 'From User B', 'From User C' ], + { not: [ 'From User A' ] } + ); + } ); + + test( 'Redo restores the undone change across all users', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Redo Test - 3 Users', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + } ); + + // Verify the block exists on all editors. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + + // Undo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + + // Redo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).redo(); + } ); + + // All users should see the restored block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + } ); + + test( 'Bystander sees correct state after undo', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Undo Test - Bystander', + } ); + + const { editor3, page2 } = collaborationUtils; + + // User B adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); + + // Wait for User B's block to appear on User A. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'From User B' }, + }, + ] ); + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for both blocks to appear on the bystander (User C). + await collaborationUtils.assertEditorHasContent( editor3, [ + 'From User A', + 'From User B', + ] ); + + // User A undoes their own block. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // Bystander (User C) should see only User B's block. + await collaborationUtils.assertEditorHasContent( + editor3, + [ 'From User B' ], + { not: [ 'From User A' ] } + ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js new file mode 100644 index 0000000000000..9db8a8db23c49 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -0,0 +1,426 @@ +/** + * Collaboration E2E test utilities. + * + * Provides helpers for setting up multi-user collaborative editing + * sessions, managing browser contexts, and waiting for sync cycles. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * External dependencies + */ +import { expect } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { Editor } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Credentials for the second collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ +export const SECOND_USER = { + username: 'collaborator', + email: 'collaborator@example.com', + firstName: 'Test', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +/** + * Credentials for the third collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ +export const THIRD_USER = { + username: 'collaborator2', + email: 'collaborator2@example.com', + firstName: 'Another', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; + +/** + * Default timeout (ms) for sync-related assertions. + * + * @since 7.0.0 + * @type {number} + */ +export const SYNC_TIMEOUT = 10_000; + +/** + * Manages multi-user collaborative editing sessions for E2E tests. + * + * Handles browser context creation, user login, editor navigation, + * and sync-cycle waiting for up to three concurrent users. + * + * @since 7.0.0 + */ +export default class CollaborationUtils { + constructor( { admin, editor, requestUtils, page } ) { + this.admin = admin; + this.editor = editor; + this.requestUtils = requestUtils; + this.primaryPage = page; + + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + + /** + * Set the real-time collaboration WordPress setting. + * + * @param {boolean} enabled Whether to enable or disable collaboration. + */ + async setCollaboration( enabled ) { + await this.requestUtils.updateSiteSettings( { + wp_enable_real_time_collaboration: enabled, + } ); + } + + /** + * Log a user into WordPress via the login form on a given page. + * + * @param {import('@playwright/test').Page} page The page to log in on. + * @param {Object} userInfo User credentials. + */ + async loginUser( page, userInfo ) { + await page.goto( '/wp-login.php' ); + + // Retry filling if the page resets during a cold Docker start. + await expect( async () => { + await page.locator( '#user_login' ).fill( userInfo.username ); + await page.locator( '#user_pass' ).fill( userInfo.password ); + await expect( page.locator( '#user_pass' ) ).toHaveValue( + userInfo.password + ); + } ).toPass( { timeout: 15_000 } ); + + await page.getByRole( 'button', { name: 'Log In' } ).click(); + await page.waitForURL( '**/wp-admin/**' ); + } + + /** + * Set up a new browser context for a collaborator user. + * + * @param {Object} userInfo User credentials and info. + * @return {Object} An object with context, page, and editor. + */ + async setupCollaboratorContext( userInfo ) { + const context = await this.admin.browser.newContext( { + baseURL: BASE_URL, + } ); + const page = await context.newPage(); + + await this.loginUser( page, userInfo ); + + return { context, page }; + } + + /** + * Navigate a page to the post editor and dismiss the welcome guide. + * + * @param {import('@playwright/test').Page} page The page to navigate. + * @param {number} postId The post ID to edit. + */ + async navigateToEditor( page, postId ) { + await page.goto( + `/wp-admin/post.php?post=${ postId }&action=edit` + ); + await page.waitForFunction( + () => window?.wp?.data && window?.wp?.blocks + ); + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', false ); + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + } ); + } + + /** + * Open a collaborative editing session where all 3 users are editing + * the same post. + * + * @param {number} postId The post ID to collaboratively edit. + */ + async openCollaborativeSession( postId ) { + // Set up the second and third browser contexts. + const second = await this.setupCollaboratorContext( SECOND_USER ); + this._secondContext = second.context; + this._secondPage = second.page; + + const third = await this.setupCollaboratorContext( THIRD_USER ); + this._thirdContext = third.context; + this._thirdPage = third.page; + + // Navigate User 1 (admin) to the post editor. + await this.admin.visitAdminPage( + 'post.php', + `post=${ postId }&action=edit` + ); + await this.editor.setPreferences( 'core/edit-post', { + welcomeGuide: false, + fullscreenMode: false, + } ); + + // Wait for collaboration to be enabled on User 1's page. + await this.waitForCollaborationReady( this.primaryPage ); + + // Navigate User 2 and User 3 to the same post editor. + await this.navigateToEditor( this._secondPage, postId ); + await this.navigateToEditor( this._thirdPage, postId ); + + // Create Editor instances for the additional pages. + this._secondEditor = new Editor( { page: this._secondPage } ); + this._thirdEditor = new Editor( { page: this._thirdPage } ); + + // Wait for collaboration to be enabled on all pages. + await Promise.all( [ + this.waitForCollaborationReady( this._secondPage ), + this.waitForCollaborationReady( this._thirdPage ), + ] ); + + // Wait for all users to discover each other via awareness. + await Promise.all( [ + this.primaryPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._secondPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._thirdPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + ] ); + + // Allow a full round of polling after awareness is established + // so all CRDT docs are synchronized. + await this.waitForAllSynced(); + } + + /** + * Wait for the collaboration runtime to be ready on a page. + * + * @param {import('@playwright/test').Page} page The Playwright page to wait on. + */ + async waitForCollaborationReady( page ) { + await page.waitForFunction( + () => + window._wpCollaborationEnabled === true && + window?.wp?.data && + window?.wp?.blocks, + { timeout: 15000 } + ); + } + + /** + * Wait for sync polling cycles to complete on the given page. + * + * @param {import('@playwright/test').Page} page The page to wait on. + * @param {number} cycles Number of sync responses to wait for. + */ + async waitForSyncCycle( page, cycles = 3 ) { + for ( let i = 0; i < cycles; i++ ) { + await page.waitForResponse( + ( response ) => + response.url().includes( 'wp-collaboration' ) && + response.status() === 200, + { timeout: SYNC_TIMEOUT } + ); + } + } + + /** + * Wait for sync cycles on all 3 pages in parallel. + * + * @param {number} cycles Number of sync responses to wait for per page. + */ + async waitForAllSynced( cycles = 3 ) { + const pages = [ this.primaryPage ]; + if ( this._secondPage ) { + pages.push( this._secondPage ); + } + if ( this._thirdPage ) { + pages.push( this._thirdPage ); + } + await Promise.all( + pages.map( ( page ) => this.waitForSyncCycle( page, cycles ) ) + ); + } + + /** + * Get the second user's Page instance. + */ + get page2() { + if ( ! this._secondPage ) { + throw new Error( + 'Second page not available. Call openCollaborativeSession() first.' + ); + } + return this._secondPage; + } + + /** + * Get the second user's Editor instance. + */ + get editor2() { + if ( ! this._secondEditor ) { + throw new Error( + 'Second editor not available. Call openCollaborativeSession() first.' + ); + } + return this._secondEditor; + } + + /** + * Get the third user's Page instance. + */ + get page3() { + if ( ! this._thirdPage ) { + throw new Error( + 'Third page not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdPage; + } + + /** + * Get the third user's Editor instance. + */ + get editor3() { + if ( ! this._thirdEditor ) { + throw new Error( + 'Third editor not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdEditor; + } + + /** + * Create a draft post and open a collaborative session on it. + * + * @since 7.0.0 + * + * @param {Object} options Options forwarded to `requestUtils.createPost()`. + * @return {Object} The created post object. + */ + async createCollaborativePost( options = {} ) { + const post = await this.requestUtils.createPost( { + status: 'draft', + date_gmt: new Date().toISOString(), + ...options, + } ); + await this.openCollaborativeSession( post.id ); + return post; + } + + /** + * Insert a block on a secondary page via `page.evaluate()`. + * + * @since 7.0.0 + * + * @param {import('@playwright/test').Page} page The page to insert on. + * @param {string} blockName Block name, e.g. 'core/paragraph'. + * @param {Object} attributes Block attributes. + */ + async insertBlockViaEvaluate( page, blockName, attributes ) { + await page.evaluate( + ( { name, attrs } ) => { + const block = window.wp.blocks.createBlock( name, attrs ); + window.wp.data + .dispatch( 'core/block-editor' ) + .insertBlock( block ); + }, + { name: blockName, attrs: attributes } + ); + } + + /** + * Assert that an editor contains (or does not contain) blocks with + * the given content strings. + * + * @since 7.0.0 + * + * @param {Editor} ed Editor instance to check. + * @param {string[]} expected Content strings that must be present. + * @param {Object} options + * @param {string[]} options.not Content strings that must NOT be present. + * @param {number} options.timeout Assertion timeout in ms. + */ + async assertEditorHasContent( + ed, + expected, + { not: notExpected = [], timeout = SYNC_TIMEOUT } = {} + ) { + await expect( async () => { + const blocks = await ed.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + for ( const item of expected ) { + expect( contents ).toContain( item ); + } + for ( const item of notExpected ) { + expect( contents ).not.toContain( item ); + } + } ).toPass( { timeout } ); + } + + /** + * Assert content across all open editors (primary + collaborators). + * + * @since 7.0.0 + * + * @param {string[]} expected Content strings that must be present. + * @param {Object} options Options forwarded to `assertEditorHasContent()`. + */ + async assertAllEditorsHaveContent( expected, options = {} ) { + const editors = [ this.editor ]; + if ( this._secondEditor ) { + editors.push( this._secondEditor ); + } + if ( this._thirdEditor ) { + editors.push( this._thirdEditor ); + } + for ( const ed of editors ) { + await this.assertEditorHasContent( ed, expected, options ); + } + } + + /** + * Clean up: close extra browser contexts, disable collaboration, + * delete test users. + */ + async teardown() { + if ( this._thirdContext ) { + await this._thirdContext.close(); + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + if ( this._secondContext ) { + await this._secondContext.close(); + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + } + await this.setCollaboration( false ); + await this.requestUtils.deleteAllUsers(); + } +} diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js new file mode 100644 index 0000000000000..446e6e88c459c --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,48 @@ +/** + * Collaboration E2E test fixtures. + * + * Extends the base Playwright test with a `collaborationUtils` fixture + * that provisions three users and enables real-time collaboration. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import CollaborationUtils, { SECOND_USER, THIRD_USER, SYNC_TIMEOUT } from './collaboration-utils'; +export { SYNC_TIMEOUT }; + +export const test = base.extend( { + collaborationUtils: async ( + { admin, editor, requestUtils, page }, + use + ) => { + const utils = new CollaborationUtils( { + admin, + editor, + requestUtils, + page, + } ); + await utils.setCollaboration( true ); + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + await requestUtils.createUser( THIRD_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + await use( utils ); + await utils.teardown(); + }, +} ); diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php new file mode 100644 index 0000000000000..c4f95f0380efe --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -0,0 +1,1769 @@ +user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$post_id, true ); + } + + public function set_up() { + parent::set_up(); + + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support + // in the test suite. TRUNCATE implicitly commits the transaction. + global $wpdb; + $wpdb->query( "DELETE FROM {$wpdb->collaboration}" ); + } + + /** + * Builds a room request array for the collaboration endpoint. + * + * @param string $room Room identifier. + * @param string $client_id Client ID. + * @param int $cursor Cursor value for the 'after' parameter. + * @param array $awareness Awareness state. + * @param array $updates Array of updates. + * @return array Room request data. + */ + private function build_room( $room, $client_id = '1', $cursor = 0, $awareness = array(), $updates = array() ) { + if ( empty( $awareness ) ) { + $awareness = array( 'user' => 'test' ); + } + + return array( + 'after' => $cursor, + 'awareness' => $awareness, + 'client_id' => $client_id, + 'room' => $room, + 'updates' => $updates, + ); + } + + /** + * Dispatches a collaboration request with the given rooms. + * + * @param array $rooms Array of room request data. + * @param string $_namespace REST namespace to use. Defaults to the primary namespace. + * @return WP_REST_Response Response object. + */ + private function dispatch_collaboration( $rooms, $_namespace = 'wp-collaboration/v1' ) { + $request = new WP_REST_Request( 'POST', '/' . $_namespace . '/updates' ); + $request->set_body_params( array( 'rooms' => $rooms ) ); + return rest_get_server()->dispatch( $request ); + } + + /** + * Returns the default room identifier for the test post. + * + * @return string Room identifier. + */ + private function get_post_room() { + return 'postType/post:' . self::$post_id; + } + + /* + * Required abstract method implementations. + * + * The collaboration endpoint is a single POST endpoint, not a standard CRUD controller. + * Methods that don't apply are stubbed with @doesNotPerformAssertions. + */ + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * Verifies the collaboration route is registered when relying on the option's default + * value (option not stored in the database). + * + * This covers the upgrade scenario where a site has never explicitly saved + * the collaboration setting. + * + * @ticket 64814 + */ + public function test_register_routes_with_default_option() { + global $wp_rest_server; + + // Ensure the option is not in the database. + delete_option( 'wp_enable_real_time_collaboration' ); + + // Reset the REST server so routes are re-registered from scratch. + $wp_rest_server = null; + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Not applicable for collaboration endpoint. + } + + public function test_create_item() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Not applicable for collaboration endpoint. + } + + /* + * Permission tests. + */ + + public function test_collaboration_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + } + + public function test_collaboration_post_requires_edit_capability() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_post_allowed_with_edit_capability() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_post_type_collection_requires_edit_posts_capability() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_root_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_taxonomy_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_unknown_collection_kind_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_non_posttype_entity_with_object_id_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_nonexistent_post_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_permission_checked_per_room() { + wp_set_current_user( self::$editor_id ); + + // First room is allowed, second room is forbidden. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + $this->build_room( 'unknown/entity' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Validation tests. + */ + + public function test_collaboration_invalid_room_format_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'invalid-room-format' ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + } + + /* + * Response format tests. + */ + + public function test_collaboration_response_structure() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data ); + $this->assertCount( 1, $data['rooms'] ); + + $room_data = $data['rooms'][0]; + $this->assertArrayHasKey( 'room', $room_data ); + $this->assertArrayHasKey( 'awareness', $room_data ); + $this->assertArrayHasKey( 'updates', $room_data ); + $this->assertArrayHasKey( 'end_cursor', $room_data ); + $this->assertArrayHasKey( 'total_updates', $room_data ); + $this->assertArrayHasKey( 'should_compact', $room_data ); + } + + public function test_collaboration_response_room_matches_request() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $response = $this->dispatch_collaboration( array( $this->build_room( $room ) ) ); + + $data = $response->get_data(); + $this->assertSame( $room, $data['rooms'][0]['room'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_end_cursor_is_non_negative_integer() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); + // Cursor is 0 for an empty room (no rows in the table yet). + $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); + } + + public function test_collaboration_empty_updates_returns_zero_total() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); + $this->assertEmpty( $data['rooms'][0]['updates'] ); + } + + /* + * Update tests. + */ + + public function test_collaboration_update_delivered_to_other_client() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdCBkYXRh', + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 requests updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + $this->assertNotEmpty( $updates ); + + $types = wp_list_pluck( $updates, 'type' ); + $this->assertContains( 'update', $types ); + } + + public function test_collaboration_own_updates_not_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'b3duIGRhdGE=', + ); + + // Client 1 sends an update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + // Client 1 should not see its own non-compaction update. + $this->assertEmpty( $updates ); + } + + public function test_collaboration_step1_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ); + + // Client 1 sends sync_step1. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step1 update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step1', $types ); + } + + public function test_collaboration_step2_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step2', + 'data' => 'c3RlcDI=', + ); + + // Client 1 sends sync_step2. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step2 update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step2', $types ); + } + + public function test_collaboration_multiple_updates_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array( + array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ), + array( + 'type' => 'update', + 'data' => 'dXBkYXRl', + ), + ); + + // Client 1 sends multiple updates. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), $updates ), + ) + ); + + // Client 2 should see both updates. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertCount( 2, $room_updates ); + $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); + } + + public function test_collaboration_update_data_preserved() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'cHJlc2VydmVkIGRhdGE=', + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should receive the exact same data. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] ); + $this->assertSame( 'update', $room_updates[0]['type'] ); + } + + public function test_collaboration_total_updates_increments() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Send three updates from different clients. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ), array( $update ) ), + ) + ); + + // Any client should see total_updates = 3. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0 ), + ) + ); + + $data = $response->get_data(); + $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); + } + + /* + * Compaction tests. + */ + + public function test_collaboration_should_compact_is_false_below_threshold() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Client 1 sends a single update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Client 1 polls again. It is the lowest (only) client, so it is the compactor. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'] ); + } + + /** + * Verifies that a caught-up compactor client still receives the + * should_compact signal when the room has accumulated updates + * beyond the compaction threshold. + * + * Regression test: the update count was previously cached as 0 + * when the cursor matched the latest update ID, preventing + * compaction from ever triggering for idle rooms. + * + * @ticket 64696 + */ + public function test_collaboration_should_compact_when_compactor_is_caught_up() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Grab the end_cursor so the client is fully caught up. + $data = $response->get_data(); + $end_cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 polls again with cursor = end_cursor (caught up, no new updates). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $end_cursor, array( 'user' => 'c1' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Compactor should receive should_compact even when caught up.' ); + } + + public function test_collaboration_should_compact_is_false_for_non_compactor() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Client 2 (higher ID than client 1) should not be the compactor. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Client 1 sends an update to seed the room. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $end_cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 2 sends a compaction at the current cursor. + $compaction = array( + 'type' => 'compaction', + 'data' => 'Y29tcGFjdGVk', + ); + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), + ) + ); + + // Client 3 sends a stale compaction at cursor 0. The server should find + // client 2's compaction in the updates after cursor 0 and silently discard + // this one. + $stale_compaction = array( + 'type' => 'compaction', + 'data' => 'c3RhbGU=', + ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ), array( $stale_compaction ) ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + // Verify the newer compaction is preserved and the stale one was not stored. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0, array( 'user' => 'c4' ) ), + ) + ); + $update_data = wp_list_pluck( $response->get_data()['rooms'][0]['updates'], 'data' ); + + $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' ); + $this->assertNotContains( 'c3RhbGU=', $update_data, 'The stale compaction should not be stored.' ); + } + + /* + * Awareness tests. + */ + + public function test_collaboration_awareness_returned() { + wp_set_current_user( self::$editor_id ); + + $awareness = array( 'name' => 'Editor' ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room(), '1', 0, $awareness ), + ) + ); + + $data = $response->get_data(); + $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'] ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); + } + + public function test_collaboration_awareness_shows_multiple_clients() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 connects. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Client 1' ) ), + ) + ); + + // Client 2 connects. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'name' => 'Client 2' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayHasKey( '1', $awareness ); + $this->assertArrayHasKey( '2', $awareness ); + $this->assertSame( array( 'name' => 'Client 1' ), $awareness['1'] ); + $this->assertSame( array( 'name' => 'Client 2' ), $awareness['2'] ); + } + + public function test_collaboration_awareness_updates_existing_client() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 connects with initial awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'start' ) ), + ) + ); + + // Client 1 updates its awareness. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'updated' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + // Should have exactly one entry for client 1 with updated state. + $this->assertCount( 1, $awareness ); + $this->assertSame( array( 'cursor' => 'updated' ), $awareness['1'] ); + } + + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Editor establishes awareness with client_id 1. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Editor' ) ), + ) + ); + + // A different user tries to use the same client_id. + $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor_id_2 ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Impostor' ) ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Multiple rooms tests. + */ + + public function test_collaboration_multiple_rooms_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room1 = $this->get_post_room(); + $room2 = 'taxonomy/category'; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room1 ), + $this->build_room( $room2 ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data['rooms'] ); + $this->assertSame( $room1, $data['rooms'][0]['room'] ); + $this->assertSame( $room2, $data['rooms'][1]['room'] ); + } + + public function test_collaboration_rooms_are_isolated() { + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $room1 = $this->get_post_room(); + $room2 = 'postType/post:' . $post_id_2; + + $update = array( + 'type' => 'update', + 'data' => 'cm9vbTEgb25seQ==', + ); + + // Client 1 sends an update to room 1 only. + $this->dispatch_collaboration( + array( + $this->build_room( $room1, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 queries both rooms. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room1, '2', 0 ), + $this->build_room( $room2, '2', 0 ), + ) + ); + + $data = $response->get_data(); + + // Room 1 should have the update. + $this->assertNotEmpty( $data['rooms'][0]['updates'] ); + + // Room 2 should have no updates. + $this->assertEmpty( $data['rooms'][1]['updates'] ); + } + + /* + * Cursor tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_empty_room_cursor_is_zero(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['end_cursor'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_cursor_advances_monotonically(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // First request. + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $cursor1 = $response1->get_data()['rooms'][0]['end_cursor']; + + // Second request with more updates. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor1, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + $cursor2 = $response2->get_data()['rooms'][0]['end_cursor']; + + $this->assertGreaterThan( $cursor1, $cursor2, 'Cursor should advance monotonically with new updates.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_cursor_prevents_re_delivery(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'first-batch' ), + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + // Client 2 fetches updates and gets a cursor. + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + $data1 = $response1->get_data(); + $cursor1 = $data1['rooms'][0]['end_cursor']; + + $this->assertNotEmpty( $data1['rooms'][0]['updates'], 'First poll should return updates.' ); + + // Client 2 polls again using the cursor from the first poll, with no new updates. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor1, array( 'user' => 'c2' ) ), + ) + ); + $data2 = $response2->get_data(); + + $this->assertEmpty( $data2['rooms'][0]['updates'], 'Second poll with cursor should not re-deliver updates.' ); + } + + /* + * Cache thrashing tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_operations_do_not_affect_posts_last_changed(): void { + wp_set_current_user( self::$editor_id ); + + // Prime the posts last changed cache. + wp_cache_set_posts_last_changed(); + $last_changed_before = wp_cache_get_last_changed( 'posts' ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Perform several collaboration operations. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + + $last_changed_after = wp_cache_get_last_changed( 'posts' ); + + $this->assertSame( $last_changed_before, $last_changed_after, 'Collaboration operations should not invalidate the posts last changed cache.' ); + } + + /* + * Race condition tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_compaction_does_not_lose_concurrent_updates(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sends an initial batch of updates. + $initial_updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $initial_updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "initial-$i" ), + ); + } + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $initial_updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 2 sends a new update (simulating a concurrent write). + $concurrent_update = array( + 'type' => 'update', + 'data' => base64_encode( 'concurrent' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $concurrent_update ) ), + ) + ); + + // Client 1 sends a compaction update using its cursor. + $compaction_update = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction_update ) ), + ) + ); + + // Client 3 requests all updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ) ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + $update_data = wp_list_pluck( $room_updates, 'data' ); + + // The concurrent update must not be lost. + $this->assertContains( base64_encode( 'concurrent' ), $update_data, 'Concurrent update should not be lost during compaction.' ); + + // The compaction update should be present. + $this->assertContains( base64_encode( 'compacted-state' ), $update_data, 'Compaction update should be present.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_compaction_reduces_total_updates(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends 10 updates. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction to replace the 10 updates. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + // Client 2 checks the state. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); + } + + /* + * Cron cleanup tests. + */ + + /** + * Inserts a row directly into the collaboration table with a given age. + * + * @param positive-int $age_in_seconds How old the row should be. + * @param string $label A label stored in the update_value for identification. + */ + private function insert_collaboration_row( int $age_in_seconds, string $label = 'test' ): void { + global $wpdb; + + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'update', + 'client_id' => '1', + 'update_value' => wp_json_encode( + array( + 'type' => 'update', + 'data' => $label, + ) + ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + ), + array( '%s', '%s', '%s', '%s', '%s' ) + ); + } + + /** + * Returns the number of non-awareness rows in the collaboration table. + * + * @return positive-int Row count. + */ + private function get_collaboration_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type != 'awareness'" ); + } + + /** + * Returns the number of awareness rows in the collaboration table. + * + * @return positive-int Row count. + */ + private function get_awareness_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness'" ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_old_rows(): void { + $this->insert_collaboration_row( 8 * DAY_IN_SECONDS ); + + $this->assertSame( 1, $this->get_collaboration_row_count() ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 0, $this->get_collaboration_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_recent_rows(): void { + $this->insert_collaboration_row( DAY_IN_SECONDS ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_collaboration_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { + $this->insert_collaboration_row( WEEK_IN_SECONDS + 1, 'expired' ); + $this->insert_collaboration_row( WEEK_IN_SECONDS - 1, 'just-inside' ); + + wp_delete_old_collaboration_data(); + + global $wpdb; + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->collaboration}" ); + + $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); + $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_selectively_deletes_mixed_rows(): void { + // 3 expired rows. + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + + // 2 recent rows. + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + + $this->assertSame( 5, $this->get_collaboration_row_count() ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 2, $this->get_collaboration_row_count(), 'Only the 2 recent rows should survive cleanup.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_hook_is_registered(): void { + $this->assertSame( + 10, + has_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ), + 'The wp_delete_old_collaboration_data action should be hooked in default-filters.php.' + ); + } + + /* + * Route registration guard tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_routes_not_registered_when_db_version_is_old(): void { + update_option( 'db_version', 61839 ); + + // Reset the global REST server so rest_get_server() builds a fresh instance. + $GLOBALS['wp_rest_server'] = null; + + $server = rest_get_server(); + $routes = $server->get_routes(); + + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61840.' ); + + // Reset again so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } + + /* + * Awareness race condition tests. + */ + + /** + * Awareness state set by separate clients should be preserved across sequential dispatches. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_preserved_across_separate_upserts(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sets awareness (simulating a concurrent request). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should be present.' ); + $this->assertArrayHasKey( '2', $awareness, 'Client 2 awareness should be present.' ); + $this->assertSame( array( 'cursor' => 'pos-a' ), $awareness['1'] ); + $this->assertSame( array( 'cursor' => 'pos-b' ), $awareness['2'] ); + } + + /** + * Awareness rows should not affect get_updates_after_cursor() or get_cursor(). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness (creates awareness row in table). + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // With no updates, cursor should be 0. + $data1 = $response1->get_data(); + $this->assertSame( 0, $data1['rooms'][0]['end_cursor'], 'Awareness rows should not affect the cursor.' ); + $this->assertSame( 0, $data1['rooms'][0]['total_updates'], 'Awareness rows should not count as updates.' ); + $this->assertEmpty( $data1['rooms'][0]['updates'], 'Awareness rows should not appear as updates.' ); + + // Now add an update. + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ), array( $update ) ), + ) + ); + + $data2 = $response2->get_data(); + $this->assertSame( 1, $data2['rooms'][0]['total_updates'], 'Only updates should count toward total.' ); + } + + /** + * Compaction (remove_updates_before_cursor) should not delete awareness rows. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_does_not_delete_awareness_rows(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sends updates. + $updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ), $updates ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 2 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor, array( 'cursor' => 'pos-b' ), array( $compaction ) ), + ) + ); + + // Client 3 checks awareness — client 1 should still be present. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should survive compaction.' ); + } + + /** + * Expired awareness rows should be filtered from results but remain in the + * table until cron cleanup runs. + * + * @ticket 64696 + */ + public function test_collaboration_expired_awareness_rows_cleaned_up(): void { + wp_set_current_user( self::$editor_id ); + + global $wpdb; + + $room = $this->get_post_room(); + + // Insert an awareness row clearly older than the 60-second cron threshold. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Client 1 polls — the expired row should not appear in results. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( '99', $awareness, 'Expired awareness entry should not appear.' ); + $this->assertArrayHasKey( '1', $awareness, 'Fresh client awareness should appear.' ); + + // The expired row still exists in the table (no inline DELETE on the read path). + $expired_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '99' + ) + ); + $this->assertSame( 1, $expired_count, 'Expired awareness row should still exist in the table until cron runs.' ); + + // Cron cleanup removes the expired row. + wp_delete_old_collaboration_data(); + + $post_cron_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '99' + ) + ); + $this->assertSame( 0, $post_cron_count, 'Expired awareness row should be deleted after cron cleanup.' ); + } + + /** + * Cron cleanup should remove expired awareness rows. + * + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_expired_awareness_rows(): void { + global $wpdb; + + // Insert an awareness row older than 60 seconds. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'old' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Insert a recent collaboration row (should survive). + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Collaboration table should have 1 sync row.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Collaboration table should have 1 awareness row.' ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync row should survive cron cleanup.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Expired awareness row should be deleted after cron cleanup.' ); + } + + /** + * Verifies that user_id is stored as a dedicated column, + * not embedded inside the update_value JSON blob. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_user_id_round_trip() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $rooms = array( $this->build_room( $room, '1', 0, array( 'cursor' => array( 'x' => 10 ) ) ) ); + + $response = $this->dispatch_collaboration( $rooms ); + $this->assertSame( 200, $response->get_status(), 'Dispatch should succeed.' ); + + // Query the collaboration table directly for the awareness row. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + $room, + '1' + ) + ); + + $this->assertNotNull( $row, 'Awareness row should exist.' ); + $this->assertSame( self::$editor_id, (int) $row->user_id, 'user_id column should match the editor.' ); + $this->assertStringNotContainsString( 'user_id', $row->update_value, 'update_value should not contain user_id.' ); + } + + /** + * Verifies that the is_array() guard in get_awareness_state() skips + * rows where update_value contains valid JSON that is not an array. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_non_array_json_ignored() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Insert a malformed awareness row with a JSON string (not an array). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'update_value' => '"hello"', + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Dispatch as a different client so the response includes other clients' awareness. + $rooms = array( $this->build_room( $room, '2', 0, array( 'cursor' => 'here' ) ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayNotHasKey( '99', $awareness, 'Non-array JSON row should not appear in awareness.' ); + $this->assertArrayHasKey( '2', $awareness, 'The dispatching client should appear in awareness.' ); + } + + /** + * Validates that REST accepts room names at the column width boundary (191 chars). + * + * @ticket 64696 + */ + public function test_collaboration_room_name_at_max_length_accepted() { + wp_set_current_user( self::$editor_id ); + + // 191 characters using a collection room: 'root/' (5) + 186 chars. + $room = 'root/' . str_repeat( 'a', 186 ); + $this->assertSame( 191, strlen( $room ), 'Room name should be 191 characters.' ); + + $rooms = array( $this->build_room( $room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status(), 'REST should accept room names at 191 characters.' ); + } + + /** + * Validates that REST rejects room names exceeding the column width (191 chars). + * + * @ticket 64696 + */ + public function test_collaboration_room_name_max_length_rejected() { + wp_set_current_user( self::$editor_id ); + + // 192 characters: 'postType/' (9) + 183 chars. + $long_room = 'postType/' . str_repeat( 'a', 183 ); + $this->assertSame( 192, strlen( $long_room ), 'Room name should be 192 characters.' ); + + $rooms = array( $this->build_room( $long_room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'REST should reject room names exceeding 191 characters.' ); + } + + /** + * Verifies that sending awareness as null reads existing state without writing. + * + * @ticket 64696 + */ + public function test_collaboration_null_awareness_skips_write() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 dispatches with awareness state (writes a row). + $rooms = array( $this->build_room( $room, '1', 0, array( 'cursor' => 'active' ) ) ); + $this->dispatch_collaboration( $rooms ); + + // Client 2 dispatches with awareness = null (should not write). + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => null, + 'client_id' => '2', + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Null awareness dispatch should succeed.' ); + + // Assert collaboration table has exactly 1 awareness row (client 1 only). + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness'" ); + $this->assertSame( 1, $row_count, 'Only client 1 should have an awareness row.' ); + + // Assert response still contains client 1's awareness (read still works). + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should be readable by client 2.' ); + $this->assertSame( array( 'cursor' => 'active' ), $awareness['1'], 'Client 1 awareness state should match.' ); + } + + /* + * Query count tests. + */ + + /* + * Deprecated route tests. + */ + + /** + * Verifies the deprecated wp-sync/v1 route alias works identically to + * the canonical wp-collaboration/v1 namespace. + * + * @ticket 64696 + */ + public function test_collaboration_deprecated_sync_route() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'c3luYyByb3V0ZQ==', + ); + + // Send an update via the deprecated namespace. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); + + // Verify the update is retrievable via the canonical namespace. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $updates = $response2->get_data()['rooms'][0]['updates']; + $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); + + $update_data = wp_list_pluck( $updates, 'data' ); + $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); + } + + /** + * An idle poll (no new updates) should use at most 4 queries per room: + * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) + * 2. UPDATE … collaboration (awareness upsert — update path) + * 3. SELECT MAX(id), COUNT(*) FROM collaboration (snapshot + count) + * 4. INSERT … collaboration (awareness upsert — insert path, only on first poll) + * + * @ticket 64696 + */ + public function test_collaboration_idle_poll_query_count(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Prime awareness so subsequent polls are idle heartbeats. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), + ) + ); + + $cursor = 0; + + // Count queries for an idle poll (no updates to fetch). + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'test' ) ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); + + $query_count = $wpdb->num_queries - $queries_before; + + $this->assertLessThanOrEqual( + 4, + $query_count, + sprintf( 'Idle poll should use at most 4 queries per room, used %d.', $query_count ) + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..3b7a8c99e4e97 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,8 +16,8 @@ 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' ); + // Ensure collaboration routes are registered. + add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; @@ -113,7 +113,6 @@ 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', @@ -208,6 +207,10 @@ public function test_expected_routes_in_schema() { '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', '/wp-abilities/v1/abilities', + '/wp-collaboration/v1', + '/wp-collaboration/v1/updates', + '/wp-sync/v1', + '/wp-sync/v1/updates', ); $this->assertSameSets( $expected_routes, $routes ); @@ -219,7 +222,9 @@ private function is_builtin_route( $route ) { preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) || preg_match( '#^/wp/v2(/.+)?$#', $route ) || preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) || - preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) + preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-collaboration/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-sync/v1(/.+)?$#', $route ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php deleted file mode 100644 index 3f82a50b35f81..0000000000000 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ /dev/null @@ -1,867 +0,0 @@ -user->create( array( 'role' => 'editor' ) ); - self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$editor_id ); - self::delete_user( self::$subscriber_id ); - wp_delete_post( self::$post_id, true ); - } - - public function set_up() { - parent::set_up(); - - // Enable option for tests. - add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); - } - - /** - * Builds a room request array for the sync endpoint. - * - * @param string $room Room identifier. - * @param int $client_id Client ID. - * @param int $cursor Cursor value for the 'after' parameter. - * @param array $awareness Awareness state. - * @param array $updates Array of updates. - * @return array Room request data. - */ - private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = array(), $updates = array() ) { - if ( empty( $awareness ) ) { - $awareness = array( 'user' => 'test' ); - } - - return array( - 'after' => $cursor, - 'awareness' => $awareness, - 'client_id' => $client_id, - 'room' => $room, - 'updates' => $updates, - ); - } - - /** - * Dispatches a sync request with the given rooms. - * - * @param array $rooms Array of room request data. - * @return WP_REST_Response Response object. - */ - private function dispatch_sync( $rooms ) { - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( array( 'rooms' => $rooms ) ); - return rest_get_server()->dispatch( $request ); - } - - /** - * Returns the default room identifier for the test post. - * - * @return string Room identifier. - */ - private function get_post_room() { - return 'postType/post:' . self::$post_id; - } - - /* - * Required abstract method implementations. - * - * The sync endpoint is a single POST endpoint, not a standard CRUD controller. - * Methods that don't apply are stubbed with @doesNotPerformAssertions. - */ - - public function test_register_routes() { - $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes ); - } - - /** - * Verifies the sync route is registered when relying on the option's default - * value (option not stored in the database). - * - * This covers the upgrade scenario where a site has never explicitly saved - * the collaboration setting. - * - * @ticket 64814 - */ - public function test_register_routes_with_default_option() { - global $wp_rest_server; - - // Remove the pre_option filter added in ::set_up() so get_option() uses its default logic. - remove_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); - - // Ensure the option is not in the database. - delete_option( 'wp_enable_real_time_collaboration' ); - - // Reset the REST server so routes are re-registered from scratch. - $wp_rest_server = null; - - $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_context_param() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_items() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_item() { - // Not applicable for sync endpoint. - } - - public function test_create_item() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_update_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_delete_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_prepare_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_item_schema() { - // Not applicable for sync endpoint. - } - - /* - * Permission tests. - */ - - public function test_sync_requires_authentication() { - wp_set_current_user( 0 ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); - } - - public function test_sync_post_requires_edit_capability() { - wp_set_current_user( self::$subscriber_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_post_allowed_with_edit_capability() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_post_type_collection_requires_edit_posts_capability() { - wp_set_current_user( self::$subscriber_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_post_type_collection_allowed_with_edit_posts_capability() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_root_collection_allowed() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/site' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_taxonomy_collection_allowed() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_unknown_collection_kind_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_non_posttype_entity_with_object_id_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/site:123' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_nonexistent_post_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_permission_checked_per_room() { - wp_set_current_user( self::$editor_id ); - - // First room is allowed, second room is forbidden. - $response = $this->dispatch_sync( - array( - $this->build_room( $this->get_post_room() ), - $this->build_room( 'unknown/entity' ), - ) - ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /* - * Validation tests. - */ - - public function test_sync_invalid_room_format_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( - array( - $this->build_room( 'invalid-room-format' ), - ) - ); - - $this->assertSame( 400, $response->get_status() ); - } - - /* - * Response format tests. - */ - - public function test_sync_response_structure() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'rooms', $data ); - $this->assertCount( 1, $data['rooms'] ); - - $room_data = $data['rooms'][0]; - $this->assertArrayHasKey( 'room', $room_data ); - $this->assertArrayHasKey( 'awareness', $room_data ); - $this->assertArrayHasKey( 'updates', $room_data ); - $this->assertArrayHasKey( 'end_cursor', $room_data ); - $this->assertArrayHasKey( 'total_updates', $room_data ); - $this->assertArrayHasKey( 'should_compact', $room_data ); - } - - public function test_sync_response_room_matches_request() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $response = $this->dispatch_sync( array( $this->build_room( $room ) ) ); - - $data = $response->get_data(); - $this->assertSame( $room, $data['rooms'][0]['room'] ); - } - - public function test_sync_end_cursor_is_positive_integer() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $data = $response->get_data(); - $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); - $this->assertGreaterThan( 0, $data['rooms'][0]['end_cursor'] ); - } - - public function test_sync_empty_updates_returns_zero_total() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $data = $response->get_data(); - $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); - $this->assertEmpty( $data['rooms'][0]['updates'] ); - } - - /* - * Update tests. - */ - - public function test_sync_update_delivered_to_other_client() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdCBkYXRh', - ); - - // Client 1 sends an update. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 requests updates from the beginning. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $updates = $data['rooms'][0]['updates']; - - $this->assertNotEmpty( $updates ); - - $types = wp_list_pluck( $updates, 'type' ); - $this->assertContains( 'update', $types ); - } - - public function test_sync_own_updates_not_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'b3duIGRhdGE=', - ); - - // Client 1 sends an update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - $data = $response->get_data(); - $updates = $data['rooms'][0]['updates']; - - // Client 1 should not see its own non-compaction update. - $this->assertEmpty( $updates ); - } - - public function test_sync_step1_update_stored_and_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ); - - // Client 1 sends sync_step1. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should see the sync_step1 update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); - $this->assertContains( 'sync_step1', $types ); - } - - public function test_sync_step2_update_stored_and_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'sync_step2', - 'data' => 'c3RlcDI=', - ); - - // Client 1 sends sync_step2. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should see the sync_step2 update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); - $this->assertContains( 'sync_step2', $types ); - } - - public function test_sync_multiple_updates_in_single_request() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array( - array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ), - array( - 'type' => 'update', - 'data' => 'dXBkYXRl', - ), - ); - - // Client 1 sends multiple updates. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), $updates ), - ) - ); - - // Client 2 should see both updates. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $room_updates = $data['rooms'][0]['updates']; - - $this->assertCount( 2, $room_updates ); - $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); - } - - public function test_sync_update_data_preserved() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'cHJlc2VydmVkIGRhdGE=', - ); - - // Client 1 sends an update. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should receive the exact same data. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $room_updates = $data['rooms'][0]['updates']; - - $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] ); - $this->assertSame( 'update', $room_updates[0]['type'] ); - } - - public function test_sync_total_updates_increments() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Send three updates from different clients. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ), - ) - ); - $this->dispatch_sync( - array( - $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $update ) ), - ) - ); - - // Any client should see total_updates = 3. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 4, 0 ), - ) - ); - - $data = $response->get_data(); - $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); - } - - /* - * Compaction tests. - */ - - public function test_sync_should_compact_is_false_below_threshold() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Client 1 sends a single update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - - $data = $response->get_data(); - $this->assertFalse( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_should_compact_is_true_above_threshold_for_compactor() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array(); - for ( $i = 0; $i < 51; $i++ ) { - $updates[] = array( - 'type' => 'update', - 'data' => base64_encode( "update-$i" ), - ); - } - - // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), - ) - ); - - // Client 1 polls again. It is the lowest (only) client, so it is the compactor. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ), - ) - ); - - $data = $response->get_data(); - $this->assertTrue( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_should_compact_is_false_for_non_compactor() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array(); - for ( $i = 0; $i < 51; $i++ ) { - $updates[] = array( - 'type' => 'update', - 'data' => base64_encode( "update-$i" ), - ); - } - - // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), - ) - ); - - // Client 2 (higher ID than client 1) should not be the compactor. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), - ) - ); - - $data = $response->get_data(); - $this->assertFalse( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Client 1 sends an update to seed the room. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - - $end_cursor = $response->get_data()['rooms'][0]['end_cursor']; - - // Client 2 sends a compaction at the current cursor. - $compaction = array( - 'type' => 'compaction', - 'data' => 'Y29tcGFjdGVk', - ); - - $this->dispatch_sync( - array( - $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), - ) - ); - - // Client 3 sends a stale compaction at cursor 0. The server should find - // client 2's compaction in the updates after cursor 0 and silently discard - // this one. - $stale_compaction = array( - 'type' => 'compaction', - 'data' => 'c3RhbGU=', - ); - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $stale_compaction ) ), - ) - ); - - $this->assertSame( 200, $response->get_status() ); - - // Verify the newer compaction is preserved and the stale one was not stored. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ), - ) - ); - $update_data = wp_list_pluck( $response->get_data()['rooms'][0]['updates'], 'data' ); - - $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' ); - $this->assertNotContains( 'c3RhbGU=', $update_data, 'The stale compaction should not be stored.' ); - } - - /* - * Awareness tests. - */ - - public function test_sync_awareness_returned() { - wp_set_current_user( self::$editor_id ); - - $awareness = array( 'name' => 'Editor' ); - $response = $this->dispatch_sync( - array( - $this->build_room( $this->get_post_room(), 1, 0, $awareness ), - ) - ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 1, $data['rooms'][0]['awareness'] ); - $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); - } - - public function test_sync_awareness_shows_multiple_clients() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Client 1 connects. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Client 1' ) ), - ) - ); - - // Client 2 connects. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'name' => 'Client 2' ) ), - ) - ); - - $data = $response->get_data(); - $awareness = $data['rooms'][0]['awareness']; - - $this->assertArrayHasKey( 1, $awareness ); - $this->assertArrayHasKey( 2, $awareness ); - $this->assertSame( array( 'name' => 'Client 1' ), $awareness[1] ); - $this->assertSame( array( 'name' => 'Client 2' ), $awareness[2] ); - } - - public function test_sync_awareness_updates_existing_client() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Client 1 connects with initial awareness. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'cursor' => 'start' ) ), - ) - ); - - // Client 1 updates its awareness. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'cursor' => 'updated' ) ), - ) - ); - - $data = $response->get_data(); - $awareness = $data['rooms'][0]['awareness']; - - // Should have exactly one entry for client 1 with updated state. - $this->assertCount( 1, $awareness ); - $this->assertSame( array( 'cursor' => 'updated' ), $awareness[1] ); - } - - public function test_sync_awareness_client_id_cannot_be_used_by_another_user() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Editor establishes awareness with client_id 1. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Editor' ) ), - ) - ); - - // A different user tries to use the same client_id. - $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); - wp_set_current_user( $editor_id_2 ); - - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Impostor' ) ), - ) - ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /* - * Multiple rooms tests. - */ - - public function test_sync_multiple_rooms_in_single_request() { - wp_set_current_user( self::$editor_id ); - - $room1 = $this->get_post_room(); - $room2 = 'taxonomy/category'; - - $response = $this->dispatch_sync( - array( - $this->build_room( $room1 ), - $this->build_room( $room2 ), - ) - ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertCount( 2, $data['rooms'] ); - $this->assertSame( $room1, $data['rooms'][0]['room'] ); - $this->assertSame( $room2, $data['rooms'][1]['room'] ); - } - - public function test_sync_rooms_are_isolated() { - wp_set_current_user( self::$editor_id ); - - $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); - $room1 = $this->get_post_room(); - $room2 = 'postType/post:' . $post_id_2; - - $update = array( - 'type' => 'update', - 'data' => 'cm9vbTEgb25seQ==', - ); - - // Client 1 sends an update to room 1 only. - $this->dispatch_sync( - array( - $this->build_room( $room1, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 queries both rooms. - $response = $this->dispatch_sync( - array( - $this->build_room( $room1, 2, 0 ), - $this->build_room( $room2, 2, 0 ), - ) - ); - - $data = $response->get_data(); - - // Room 1 should have the update. - $this->assertNotEmpty( $data['rooms'][0]['updates'] ); - - // Room 2 should have no updates. - $this->assertEmpty( $data['rooms'][1]['updates'] ); - } -} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4f925d35c82f6..58a2871c22a74 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -21,6 +21,7 @@ mockedApiResponse.Schema = { "wp-site-health/v1", "wp-block-editor/v1", "wp-abilities/v1", + "wp-collaboration/v1", "wp-sync/v1" ], "authentication": { @@ -3719,26 +3720,6 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/media/(?P[\\d]+)/finalize": { - "namespace": "wp/v2", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "id": { - "description": "Unique identifier for the attachment.", - "type": "integer", - "required": false - } - } - } - ] - }, "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11086,6 +11067,24 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { + "connectors_ai_anthropic_api_key": { + "title": "Anthropic API Key", + "description": "API key for the Anthropic AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_google_api_key": { + "title": "Google API Key", + "description": "API key for the Google AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_openai_api_key": { + "title": "OpenAI API Key", + "description": "API key for the OpenAI AI provider.", + "type": "string", + "required": false + }, "title": { "title": "Title", "description": "Site title.", @@ -12774,6 +12773,115 @@ mockedApiResponse.Schema = { } ] }, + "/wp-collaboration/v1": { + "namespace": "wp-collaboration/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-collaboration/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1" + } + ] + } + }, + "/wp-collaboration/v1/updates": { + "namespace": "wp-collaboration/v1", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "rooms": { + "items": { + "properties": { + "after": { + "minimum": 0, + "required": true, + "type": "integer" + }, + "awareness": { + "required": true, + "type": [ + "object", + "null" + ] + }, + "client_id": { + "minimum": 1, + "required": true, + "type": "integer" + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "type": "array", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1/updates" + } + ] + } + }, "/wp-sync/v1": { "namespace": "wp-sync/v1", "methods": [ @@ -12838,7 +12946,8 @@ mockedApiResponse.Schema = { "room": { "required": true, "type": "string", - "pattern": "^[^/]+/[^/:]+(?::\\S+)?$" + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 }, "updates": { "items": { @@ -14764,6 +14873,9 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { + "connectors_ai_anthropic_api_key": "", + "connectors_ai_google_api_key": "", + "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 6827989cbebd3fad9f7f87bc529ffbfde50e96d1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:13:45 -0400 Subject: [PATCH 006/104] Collaboration: Use persistent object cache for awareness reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cache-first read path to get_awareness_state() following the transient pattern: check the persistent object cache, fall back to the database on miss, and prime the cache with the result. set_awareness_state() updates the cached entries in-place after the DB write rather than invalidating, so the cache stays warm for the next reader in the room. This is application-level deduplication: the shared collaboration table cannot carry a UNIQUE KEY on (room, client_id) because sync rows need multiple entries per room+client pair. Sites without a persistent cache see no behavior change — the in-memory WP_Object_Cache provides no cross-request benefit but keeps the code path identical. --- .../class-wp-collaboration-table-storage.php | 69 ++++++++++++++-- .../rest-api/rest-collaboration-server.php | 79 +++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index f60508ad1c53d..d9718784a5620 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -11,7 +11,9 @@ * updates and awareness data during a collaborative session. * * All data is stored in the single `collaboration` database table, - * discriminated by the `type` column. + * discriminated by the `type` column. Awareness reads are served from + * the persistent object cache when available, falling back to the + * database — similar to the transient pattern but without wp_options. * * This class intentionally fires no actions or filters. Collaboration * queries run on every poll (0.5–1 s per editor tab), so hook overhead @@ -72,9 +74,14 @@ public function add_update( string $room, $update ): bool { /** * Gets awareness state for a given room. * - * Retrieves per-client awareness rows from the collaboration table - * where type = 'awareness'. Expired rows are filtered by the WHERE - * clause; actual deletion is handled by cron via + * Checks the persistent object cache first. On a cache miss, queries + * the collaboration table for awareness rows and primes the cache + * with the result. When no persistent cache is available the in-memory + * WP_Object_Cache is used, which provides no cross-request benefit + * but keeps the code path identical. + * + * Expired rows are filtered by the WHERE clause on cache miss; + * actual deletion is handled by cron via * wp_delete_old_collaboration_data(). * * @since 7.0.0 @@ -87,6 +94,13 @@ public function add_update( string $room, $update ): bool { * @phpstan-return list */ public function get_awareness_state( string $room, int $timeout = 30 ): array { + $cache_key = 'awareness:' . $room; + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + return $cached; + } + global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); @@ -115,6 +129,8 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { } } + wp_cache_set( $cache_key, $entries, 'collaboration', $timeout ); + return $entries; } @@ -257,6 +273,13 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * its own row, eliminating the race condition inherent in shared-state * approaches. * + * After writing, the cached awareness entries for the room are updated + * in-place so that subsequent get_awareness_state() calls from other + * clients hit the cache instead of the database. This is application- + * level deduplication: the shared collaboration table cannot carry a + * UNIQUE KEY on (room, client_id) because sync rows need multiple + * entries per room+client pair. + * * @since 7.0.0 * * @global wpdb $wpdb WordPress database abstraction object. @@ -302,9 +325,43 @@ public function set_awareness_state( string $room, string $client_id, array $sta ) ); - return false !== $result; + if ( false === $result ) { + return false; + } + } elseif ( false === $updated ) { + return false; + } + + // Update the cached entries in-place so the next reader in this + // room gets a cache hit with fresh data. If the cache is cold, + // skip — the next get_awareness_state() call will prime it. + $cache_key = 'awareness:' . $room; + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + $normalized_state = json_decode( $update_value, true ); + $found = false; + + foreach ( $cached as $i => $entry ) { + if ( $client_id === $entry['client_id'] ) { + $cached[ $i ]['state'] = $normalized_state; + $cached[ $i ]['user_id'] = $user_id; + $found = true; + break; + } + } + + if ( ! $found ) { + $cached[] = array( + 'client_id' => $client_id, + 'state' => $normalized_state, + 'user_id' => $user_id, + ); + } + + wp_cache_set( $cache_key, $cached, 'collaboration', 30 ); } - return false !== $updated; + return true; } } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index c4f95f0380efe..9cd88a949dc17 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1671,6 +1671,85 @@ public function test_collaboration_null_awareness_skips_write() { $this->assertSame( array( 'cursor' => 'active' ), $awareness['1'], 'Client 1 awareness state should match.' ); } + /* + * Cache tests. + */ + + /** + * Verifies that a normal awareness write updates the cache in-place + * so the next client's poll hits the cache instead of the database. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_cache_hit_after_write(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 polls with awareness — primes cache via get, then + // updates it in-place via set. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 polls — awareness read should hit the warm cache. + $queries_before = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $queries_after = $wpdb->num_queries; + + // With cache hit: awareness read is free, so: + // awareness UPDATE (1) + snapshot SELECT (1) + awareness INSERT (1) = 3. + // Without cache: adds awareness SELECT = 4. + $this->assertLessThanOrEqual( + 3, + $queries_after - $queries_before, + 'Awareness cache hit should skip the awareness SELECT query.' + ); + } + + /** + * Verifies that the in-place cache update after a write produces + * correct data, not stale state. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_cache_reflects_latest_write(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets initial awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'initial' ) ), + ) + ); + + // Client 1 updates awareness to a new value. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'updated' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertSame( + array( 'cursor' => 'updated' ), + $awareness['1'], + 'Awareness should reflect the updated state, not a stale cache.' + ); + } + /* * Query count tests. */ From 9bcbfe69068b4fcfc3903bd1016168c813d8d6fe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 14:39:14 -0400 Subject: [PATCH 007/104] Tests: Fix REST schema and multisite test failures Restore the `wp_client_side_media_processing_enabled` filter and the `finalize` route that were accidentally removed from the REST schema test. Add the `collaboration` table to the list of tables expected to be empty after multisite site creation. --- tests/phpunit/tests/multisite/site.php | 2 +- tests/phpunit/tests/rest-api/rest-schema-setup.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/multisite/site.php b/tests/phpunit/tests/multisite/site.php index 920a76f6a7e30..cf371c8c30da6 100644 --- a/tests/phpunit/tests/multisite/site.php +++ b/tests/phpunit/tests/multisite/site.php @@ -179,7 +179,7 @@ public function test_created_site_details() { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_results( "SELECT * FROM $prefix$table LIMIT 1" ); - if ( 'commentmeta' === $table || 'termmeta' === $table || 'links' === $table ) { + if ( 'commentmeta' === $table || 'termmeta' === $table || 'links' === $table || 'collaboration' === $table ) { $this->assertEmpty( $result ); } else { $this->assertNotEmpty( $result ); diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 3b7a8c99e4e97..24b36a46a0fb3 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' ); + // Ensure collaboration routes are registered. add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); @@ -113,6 +116,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', From 09d0b86326fa3b0e4bb1b26e280fb9f90dba17b8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 15:19:09 -0400 Subject: [PATCH 008/104] Tests: Remove erroneous connector fixtures from merge artifact The connectors API key entries in wp-api-generated.js were incorrectly carried over during the trunk merge. Trunk does not include them in the generated fixtures since the settings are dynamically registered and not present in the CI test context. --- tests/qunit/fixtures/wp-api-generated.js | 65 +++++++++++++----------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 58a2871c22a74..418c53add3c60 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3720,6 +3720,26 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/media/(?P[\\d]+)/finalize": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "id": { + "description": "Unique identifier for the attachment.", + "type": "integer", + "required": false + } + } + } + ] + }, "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11067,24 +11087,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_google_api_key": { - "title": "Google API Key", - "description": "API key for the Google AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_openai_api_key": { - "title": "OpenAI API Key", - "description": "API key for the OpenAI AI provider.", - "type": "string", - "required": false - }, "title": { "title": "Title", "description": "Site title.", @@ -12830,22 +12832,26 @@ mockedApiResponse.Schema = { ] }, "client_id": { - "minimum": 1, "required": true, - "type": "integer" + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} }, "room": { "required": true, "type": "string", "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", - "maxLength": 255 + "maxLength": 191 }, "updates": { "items": { "properties": { "data": { "type": "string", - "required": true + "required": true, + "maxLength": 1048576 }, "type": { "type": "string", @@ -12939,22 +12945,26 @@ mockedApiResponse.Schema = { ] }, "client_id": { - "minimum": 1, "required": true, - "type": "integer" + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} }, "room": { "required": true, "type": "string", "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", - "maxLength": 255 + "maxLength": 191 }, "updates": { "items": { "properties": { "data": { "type": "string", - "required": true + "required": true, + "maxLength": 1048576 }, "type": { "type": "string", @@ -14873,9 +14883,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "connectors_ai_anthropic_api_key": "", - "connectors_ai_google_api_key": "", - "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 7455141251c543220ce0238e821a6e3891fe91c0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:47 -0400 Subject: [PATCH 009/104] Collaboration: Rename update_value column to data Rename the `update_value` column to `data` in the collaboration table storage class and tests, and fix array arrow alignment to satisfy PHPCS. The shorter name is consistent with WordPress meta tables and avoids confusion with the `update_value()` method in `WP_REST_Meta_Fields`. --- .../class-wp-collaboration-table-storage.php | 42 ++++++------- .../rest-api/rest-collaboration-server.php | 60 +++++++++---------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index d9718784a5620..7827a71dd63f5 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -59,11 +59,11 @@ public function add_update( string $room, $update ): bool { $result = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => $update['type'] ?? '', - 'client_id' => $update['client_id'] ?? '', - 'update_value' => wp_json_encode( $update ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'room' => $room, + 'type' => $update['type'] ?? '', + 'client_id' => $update['client_id'] ?? '', + 'data' => wp_json_encode( $update ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); @@ -107,7 +107,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT client_id, user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", + "SELECT client_id, user_id, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", $room, $cutoff ) @@ -119,7 +119,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $entries = array(); foreach ( $rows as $row ) { - $decoded = json_decode( $row->update_value, true ); + $decoded = json_decode( $row->data, true ); if ( is_array( $decoded ) ) { $entries[] = array( 'client_id' => $row->client_id, @@ -215,7 +215,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -228,7 +228,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { - $decoded = json_decode( $row->update_value, true ); + $decoded = json_decode( $row->data, true ); if ( is_array( $decoded ) ) { $updates[] = $decoded; } @@ -293,16 +293,16 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { global $wpdb; - $update_value = wp_json_encode( $state ); - $now = gmdate( 'Y-m-d H:i:s' ); + $data = wp_json_encode( $state ); + $now = gmdate( 'Y-m-d H:i:s' ); // Try UPDATE first. $updated = $wpdb->update( $wpdb->collaboration, array( - 'user_id' => $user_id, - 'update_value' => $update_value, - 'date_gmt' => $now, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, ), array( 'room' => $room, @@ -316,12 +316,12 @@ public function set_awareness_state( string $room, string $client_id, array $sta $result = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => $client_id, - 'user_id' => $user_id, - 'update_value' => $update_value, - 'date_gmt' => $now, + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, ) ); @@ -339,7 +339,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { - $normalized_state = json_decode( $update_value, true ); + $normalized_state = json_decode( $data, true ); $found = false; foreach ( $cached as $i => $entry ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 9cd88a949dc17..122dbbe378c06 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1157,7 +1157,7 @@ public function test_collaboration_compaction_reduces_total_updates(): void { * Inserts a row directly into the collaboration table with a given age. * * @param positive-int $age_in_seconds How old the row should be. - * @param string $label A label stored in the update_value for identification. + * @param string $label A label stored in the data column for identification. */ private function insert_collaboration_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; @@ -1165,16 +1165,16 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $this->get_post_room(), - 'type' => 'update', - 'client_id' => '1', - 'update_value' => wp_json_encode( + 'room' => $this->get_post_room(), + 'type' => 'update', + 'client_id' => '1', + 'data' => wp_json_encode( array( 'type' => 'update', 'data' => $label, ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); @@ -1236,7 +1236,7 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { wp_delete_old_collaboration_data(); global $wpdb; - $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->collaboration}" ); + $remaining = $wpdb->get_col( "SELECT data FROM {$wpdb->collaboration}" ); $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); @@ -1289,7 +1289,7 @@ public function test_collaboration_routes_not_registered_when_db_version_is_old( $server = rest_get_server(); $routes = $server->get_routes(); - $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61840.' ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61841.' ); // Reset again so subsequent tests get a server with the correct db_version. $GLOBALS['wp_rest_server'] = null; @@ -1441,12 +1441,12 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => '99', - 'user_id' => self::$editor_id, - 'update_value' => wp_json_encode( array( 'cursor' => 'stale' ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); @@ -1497,12 +1497,12 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $this->get_post_room(), - 'type' => 'awareness', - 'client_id' => '42', - 'user_id' => self::$editor_id, - 'update_value' => wp_json_encode( array( 'cursor' => 'old' ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'old' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); @@ -1521,7 +1521,7 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { /** * Verifies that user_id is stored as a dedicated column, - * not embedded inside the update_value JSON blob. + * not embedded inside the data JSON blob. * * @ticket 64696 */ @@ -1539,7 +1539,7 @@ public function test_collaboration_awareness_user_id_round_trip() { // Query the collaboration table directly for the awareness row. $row = $wpdb->get_row( $wpdb->prepare( - "SELECT user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + "SELECT user_id, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", $room, '1' ) @@ -1547,12 +1547,12 @@ public function test_collaboration_awareness_user_id_round_trip() { $this->assertNotNull( $row, 'Awareness row should exist.' ); $this->assertSame( self::$editor_id, (int) $row->user_id, 'user_id column should match the editor.' ); - $this->assertStringNotContainsString( 'user_id', $row->update_value, 'update_value should not contain user_id.' ); + $this->assertStringNotContainsString( 'user_id', $row->data, 'data column should not contain user_id.' ); } /** * Verifies that the is_array() guard in get_awareness_state() skips - * rows where update_value contains valid JSON that is not an array. + * rows where the data column contains valid JSON that is not an array. * * @ticket 64696 */ @@ -1567,12 +1567,12 @@ public function test_collaboration_awareness_non_array_json_ignored() { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => '99', - 'user_id' => self::$editor_id, - 'update_value' => '"hello"', - 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'data' => '"hello"', + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); From d4e27d4748391f00384cae3dd7d20972ddad8d00 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:53 -0400 Subject: [PATCH 010/104] Collaboration: Add type_client_id index and bump db_version Add a composite index on (type, client_id) to the collaboration table to speed up awareness upserts, which filter on both columns. Bump $wp_db_version from 61840 to 61841 so existing installations pick up the schema change via dbDelta on upgrade. --- src/wp-admin/includes/schema.php | 3 ++- src/wp-admin/includes/upgrade.php | 2 +- src/wp-includes/collaboration.php | 4 ++-- src/wp-includes/version.php | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 36d39b7b5d497..2269bcc2c1156 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -193,9 +193,10 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { type varchar(32) NOT NULL default '', client_id varchar(32) NOT NULL default '', user_id bigint(20) unsigned NOT NULL default '0', - update_value longtext NOT NULL, + data longtext NOT NULL, date_gmt datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), + KEY type_client_id (type,client_id), KEY room (room,id), KEY date_gmt (date_gmt) ) $charset_collate;\n"; diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 0fcfe4acd1077..a601be26bc659 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61840 ) { + if ( $wp_current_db_version < 61841 ) { upgrade_700(); } diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 1ad489deac89a..a9efe66451c9a 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -10,7 +10,7 @@ * Checks whether real-time collaboration is enabled. * * The feature requires both the site option and the database schema - * introduced in db_version 61840. + * introduced in db_version 61841. * * @since 7.0.0 * @@ -18,7 +18,7 @@ */ function wp_is_collaboration_enabled() { return get_option( 'wp_enable_real_time_collaboration' ) - && get_option( 'db_version' ) >= 61840; + && get_option( 'db_version' ) >= 61841; } /** diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 02a9f4bc06025..b2c69c45c3f8c 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61840; +$wp_db_version = 61841; /** * Holds the TinyMCE version. From 9b4517400806b7eaf43914fc6d61c46e1d9bf897 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:19 -0400 Subject: [PATCH 011/104] Collaboration: Add payload limit constants and request validation Introduce MAX_BODY_SIZE (16 MB), MAX_ROOMS_PER_REQUEST (50), and MAX_UPDATE_DATA_SIZE (1 MB) constants to cap request payloads. Wire a validate_callback on the route to reject oversized request bodies with a 413, add maxItems to the rooms schema, and replace the hardcoded maxLength with the new constant. --- ...s-wp-http-polling-collaboration-server.php | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f36d4ba1bdb09..f205f4cb98181 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -38,6 +38,30 @@ class WP_HTTP_Polling_Collaboration_Server { */ const COMPACTION_THRESHOLD = 50; + /** + * Maximum allowed request body size in bytes. + * + * @since 7.0.0 + * @var int + */ + const MAX_BODY_SIZE = 16 * MB_IN_BYTES; + + /** + * Maximum number of rooms allowed per request. + * + * @since 7.0.0 + * @var int + */ + const MAX_ROOMS_PER_REQUEST = 50; + + /** + * Maximum allowed size for a single update's data field in bytes. + * + * @since 7.0.0 + * @var int + */ + const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES; + /** * Collaboration update type: compaction. * @@ -100,7 +124,7 @@ public function register_routes(): void { 'data' => array( 'type' => 'string', 'required' => true, - 'maxLength' => 1048576, // 1 MB — generous ceiling for base64-encoded Yjs updates. + 'maxLength' => self::MAX_UPDATE_DATA_SIZE, ), 'type' => array( 'type' => 'string', @@ -152,12 +176,14 @@ public function register_routes(): void { 'methods' => array( WP_REST_Server::CREATABLE ), 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_permissions' ), + 'validate_callback' => array( $this, 'validate_request' ), 'args' => array( 'rooms' => array( 'items' => array( 'properties' => $room_args, 'type' => 'object', ), + 'maxItems' => self::MAX_ROOMS_PER_REQUEST, 'required' => true, 'type' => 'array', ), @@ -236,6 +262,28 @@ public function check_permissions( WP_REST_Request $request ) { return true; } + /** + * Validates the incoming REST request. + * + * Checks that the raw request body does not exceed the maximum allowed size. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return true|WP_Error True if valid, WP_Error if body is too large. + */ + public function validate_request( WP_REST_Request $request ) { + $body = $request->get_body(); + if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { + return new WP_Error( + 'rest_collaboration_body_too_large', + __( 'Request body is too large.' ), + array( 'status' => 413 ) + ); + } + return true; + } + /** * Handles request: stores updates and awareness data, and returns * updates the client is missing. From dd319b8356342bc5b160baf8ebaf5b84b06441a6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:33 -0400 Subject: [PATCH 012/104] Collaboration: Harden entity permission checks Reject non-numeric object IDs early in can_user_collaborate_on_entity_type(). Verify that a post's actual type matches the room's claimed entity name before granting access. For taxonomy rooms, confirm the term exists in the specified taxonomy and simplify the capability check to use assign_term with the term's object ID. --- .../class-wp-http-polling-collaboration-server.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f205f4cb98181..ca501aa387028 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -347,15 +347,25 @@ public function handle_request( WP_REST_Request $request ) { * @return bool True if user has permission, otherwise false. */ private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + // Reject non-numeric object IDs early. + if ( ! is_null( $object_id ) && ! is_numeric( $object_id ) ) { + return false; + } + // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + if ( get_post_type( $object_id ) !== $entity_name ) { + return false; + } return current_user_can( 'edit_post', (int) $object_id ); } // Handle single taxonomy term entities with a defined object ID. if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { - $taxonomy = get_taxonomy( $entity_name ); - return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + if ( ! term_exists( (int) $object_id, $entity_name ) ) { + return false; + } + return current_user_can( 'assign_term', (int) $object_id ); } // Handle single comment entities with a defined object ID. From cd4a69fba53ee88c08cae64192471063c72a2f03 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:44 -0400 Subject: [PATCH 013/104] Collaboration: Add tests for payload limits and permission hardening Cover oversized request body (413), exceeding max rooms (400), non-numeric object ID, post type mismatch, nonexistent taxonomy term, and term in the wrong taxonomy. --- .../rest-api/rest-collaboration-server.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 122dbbe378c06..198862fbbc23a 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1801,6 +1801,132 @@ public function test_collaboration_deprecated_sync_route() { $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); } + /* + * Payload limit and permission hardening tests. + */ + + /** + * Verifies that a request body exceeding MAX_BODY_SIZE returns a 413 error. + * + * @ticket 64696 + */ + public function test_collaboration_oversized_body_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + // Set a body larger than MAX_BODY_SIZE (16 MB). + $request->set_body( str_repeat( 'x', 16 * MB_IN_BYTES + 1 ) ); + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + $server = new WP_HTTP_Polling_Collaboration_Server( + new WP_Collaboration_Table_Storage() + ); + + $result = $server->validate_request( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_collaboration_body_too_large', $result->get_error_code() ); + $this->assertSame( 413, $result->get_error_data()['status'] ); + } + + /** + * Verifies that more than MAX_ROOMS_PER_REQUEST rooms is rejected by schema validation. + * + * @ticket 64696 + */ + public function test_collaboration_too_many_rooms_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $rooms = array(); + for ( $i = 0; $i <= WP_HTTP_Polling_Collaboration_Server::MAX_ROOMS_PER_REQUEST; $i++ ) { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $rooms[] = $this->build_room( 'postType/post:' . $post_id, (string) $i ); + } + + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'Exceeding MAX_ROOMS_PER_REQUEST should return 400.' ); + } + + /** + * Verifies that a non-numeric object ID in a room name is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_non_numeric_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:abc' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a post type mismatch (room says page but post is a post) is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_post_type_mismatch_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // self::$post_id is a 'post', but the room claims 'page'. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/page:' . self::$post_id ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term that doesn't exist is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_nonexistent_taxonomy_term_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/category:999999' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term in the wrong taxonomy is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // Create a term in 'category' taxonomy. + $term = self::factory()->term->create( array( 'taxonomy' => 'category' ) ); + + // Try to access it as a 'post_tag' term. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/post_tag:' . $term ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + /** * An idle poll (no new updates) should use at most 4 queries per room: * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) From 442798f904d2f13a44f2aeb8ba79b1b7669b3b85 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:42:54 -0400 Subject: [PATCH 014/104] Collaboration: Apply coding standards and clarifications to table storage Convert consecutive single-line comments to block comment style per WordPress coding standards, replace forward slashes with colons in cache keys to avoid ambiguity, hoist `global $wpdb` above the cache check in `get_awareness_state()`, and clarify the `$cursor` param docblock in `remove_updates_before_cursor()`. --- .../class-wp-collaboration-table-storage.php | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 7827a71dd63f5..6f31e293e473e 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -94,15 +94,15 @@ public function add_update( string $room, $update ): bool { * @phpstan-return list */ public function get_awareness_state( string $room, int $timeout = 30 ): array { - $cache_key = 'awareness:' . $room; + global $wpdb; + + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { return $cached; } - global $wpdb; - $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); $rows = $wpdb->get_results( @@ -182,8 +182,10 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - // Snapshot the current max ID and total row count in a single query. - // Excludes awareness rows — they are not sync updates. + /* + * Snapshot the current max ID and total row count in a single query. + * Excludes awareness rows — they are not sync updates. + */ $snapshot = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", @@ -203,16 +205,18 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $this->room_cursors[ $room ] = $max_id; if ( 0 === $max_id || $max_id <= $cursor ) { - // Preserve the real row count so the server can still - // trigger compaction when updates have accumulated but - // no new ones arrived since the client's last poll. + /* + * Preserve the real row count so the server can still + * trigger compaction when updates have accumulated but + * no new ones arrived since the client's last poll. + */ $this->room_update_counts[ $room ] = $total; return array(); } $this->room_update_counts[ $room ] = $total; - // Fetch updates after the cursor up to the snapshot boundary. + /* Fetch updates after the cursor up to the snapshot boundary. */ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", @@ -248,7 +252,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @global wpdb $wpdb WordPress database abstraction object. * * @param string $room Room identifier. - * @param int $cursor Remove updates with id <= this cursor. + * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ public function remove_updates_before_cursor( string $room, int $cursor ): bool { @@ -296,7 +300,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta $data = wp_json_encode( $state ); $now = gmdate( 'Y-m-d H:i:s' ); - // Try UPDATE first. + /* Try UPDATE first. */ $updated = $wpdb->update( $wpdb->collaboration, array( @@ -311,7 +315,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta ) ); - // INSERT only if no existing row. + /* INSERT only if no existing row. */ if ( 0 === (int) $updated ) { $result = $wpdb->insert( $wpdb->collaboration, @@ -332,10 +336,12 @@ public function set_awareness_state( string $room, string $client_id, array $sta return false; } - // Update the cached entries in-place so the next reader in this - // room gets a cache hit with fresh data. If the cache is cold, - // skip — the next get_awareness_state() call will prime it. - $cache_key = 'awareness:' . $room; + /* + * Update the cached entries in-place so the next reader in this + * room gets a cache hit with fresh data. If the cache is cold, + * skip — the next get_awareness_state() call will prime it. + */ + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { From 2e7c177b573d4e4e72c3e8a4bd16fad803b9bd68 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:43:01 -0400 Subject: [PATCH 015/104] Collaboration: Clean up stale data and unschedule cron when disabled When collaboration is disabled, run both DELETE queries (sync and awareness rows) before unscheduling the cron hook so leftover data is removed. Hoist `global $wpdb` to the top of the function so the disabled branch can use it. Add a comment noting future persistent types may also need exclusion from the sync cleanup query. --- src/wp-includes/collaboration.php | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index a9efe66451c9a..f91e6a201d775 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -60,13 +60,39 @@ function wp_collaboration_inject_setting() { * @since 7.0.0 */ function wp_delete_old_collaboration_data() { + global $wpdb; + if ( ! wp_is_collaboration_enabled() ) { + /* + * Collaboration was enabled in the past but has since been disabled. + * Clean up any remaining stale data and unschedule the cron job + * so this callback does not continue to run. + */ + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) + ) + ); + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - 60 ) + ) + ); + + wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); return; } - global $wpdb; - - // Clean up sync rows older than 7 days. + /* + * Clean up sync rows older than 7 days. + * + * The type != 'awareness' exclusion keeps awareness rows untouched — + * they are cleaned up separately below. Future persistent types + * (e.g. persisted_crdt_doc) may also need exclusion here. + */ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", From 24f4fdc37bba9c874984134cae49679754e091e1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:43:05 -0400 Subject: [PATCH 016/104] Collaboration: Remove backward-compatible wp-sync/v1 route alias The wp-sync/v1 namespace was a transitional alias for the Gutenberg plugin. Remove it so only wp-collaboration/v1 is registered. --- .../class-wp-http-polling-collaboration-server.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index ca501aa387028..fb9023e09382d 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -195,20 +195,6 @@ public function register_routes(): void { '/updates', $route_args ); - - /* - * Backward-compatible alias so that the Gutenberg plugin's - * bundled sync package (which still uses wp-sync/v1) continues - * to work against WordPress 7.0+. - * - * @todo Remove once the Gutenberg plugin has transitioned to - * the wp-collaboration/v1 namespace. - */ - register_rest_route( - 'wp-sync/v1', - '/updates', - $route_args - ); } /** From 14ba5608e3a4706aebe1a1bd58177e1653c880d6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:57:13 -0400 Subject: [PATCH 017/104] Collaboration: Move implementation details from docblock to code comment --- .../class-wp-collaboration-table-storage.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 6f31e293e473e..2ae81a929eb59 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -164,13 +164,6 @@ public function get_update_count( string $room ): int { /** * Retrieves updates from a room after a given cursor. * - * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single - * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates - * arriving after the snapshot are deferred to the next poll, never lost. - * - * Only retrieves non-awareness rows — awareness rows are handled - * separately via get_awareness_state(). - * * @since 7.0.0 * * @global wpdb $wpdb WordPress database abstraction object. @@ -183,9 +176,15 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; /* - * Snapshot the current max ID and total row count in a single query. - * Excludes awareness rows — they are not sync updates. + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. + * + * Only retrieves non-awareness rows — awareness rows are handled + * separately via get_awareness_state(). */ + + /* Snapshot the current max ID and total row count in a single query. */ $snapshot = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", From 318051fb63469768ae76c807ea2cbc96de01985c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:58:47 -0400 Subject: [PATCH 018/104] Collaboration: Remove deprecated wp-sync/v1 route test The backward-compatible wp-sync/v1 route alias was removed in 24f4fdc37b, making this test invalid. --- .../rest-api/rest-collaboration-server.php | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 198862fbbc23a..1fe4b98ceff6e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1754,53 +1754,6 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * Query count tests. */ - /* - * Deprecated route tests. - */ - - /** - * Verifies the deprecated wp-sync/v1 route alias works identically to - * the canonical wp-collaboration/v1 namespace. - * - * @ticket 64696 - */ - public function test_collaboration_deprecated_sync_route() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'c3luYyByb3V0ZQ==', - ); - - // Send an update via the deprecated namespace. - $response = $this->dispatch_collaboration( - array( - $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), - ), - 'wp-sync/v1' - ); - - $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); - $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); - - // Verify the update is retrievable via the canonical namespace. - $response2 = $this->dispatch_collaboration( - array( - $this->build_room( $room, '2', 0 ), - ) - ); - - $updates = $response2->get_data()['rooms'][0]['updates']; - $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); - - $update_data = wp_list_pluck( $updates, 'data' ); - $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); - } - /* * Payload limit and permission hardening tests. */ From 543bc6b1c227ae3bf6f0c68bf56e3c21bb157810 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:32:25 -0400 Subject: [PATCH 019/104] Collaboration: Add test for client ID reactivation after awareness expiry --- .../rest-api/rest-collaboration-server.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1fe4b98ceff6e..1f33e4feffc3e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -846,6 +846,76 @@ public function test_collaboration_awareness_client_id_cannot_be_used_by_another $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * Verifies that a client can reactivate with the same client ID after + * its awareness entry has expired (e.g., laptop closed and reopened). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_client_reactivates_after_expiry() { + wp_set_current_user( self::$editor_id ); + global $wpdb; + + $room = $this->get_post_room(); + + // Client 1 registers awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'before-sleep' ) ), + ) + ); + + // Simulate the client going idle beyond the awareness timeout + // by backdating its awareness row. + $wpdb->update( + $wpdb->collaboration, + array( 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ) ), + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '1', + ) + ); + + // Flush the object cache so get_awareness_state() hits the DB. + wp_cache_flush(); + + // Another client polls — the expired client should not appear. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'observer' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( '1', $awareness, 'Expired client should not appear in awareness.' ); + + // Original user returns and reconnects with the same client_id. + wp_set_current_user( self::$editor_id ); + wp_cache_flush(); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'after-sleep' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertSame( 200, $response->get_status(), 'Reactivation should succeed.' ); + $this->assertArrayHasKey( '1', $awareness, 'Reactivated client should appear in awareness.' ); + $this->assertSame( array( 'cursor' => 'after-sleep' ), $awareness['1'], 'Reactivated client should have updated state.' ); + + // Verify no duplicate rows were created. + $row_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '1' + ) + ); + $this->assertSame( 1, $row_count, 'Should have exactly one awareness row after reactivation.' ); + } + /* * Multiple rooms tests. */ From ba4ab78691459fd61d79e7de6b3c94ae405445b1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:34:16 -0400 Subject: [PATCH 020/104] Revert "Collaboration: Remove deprecated wp-sync/v1 route test" This reverts commit 318051fb63469768ae76c807ea2cbc96de01985c. --- .../rest-api/rest-collaboration-server.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1f33e4feffc3e..4273333ddf71e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1824,6 +1824,53 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * Query count tests. */ + /* + * Deprecated route tests. + */ + + /** + * Verifies the deprecated wp-sync/v1 route alias works identically to + * the canonical wp-collaboration/v1 namespace. + * + * @ticket 64696 + */ + public function test_collaboration_deprecated_sync_route() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'c3luYyByb3V0ZQ==', + ); + + // Send an update via the deprecated namespace. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); + + // Verify the update is retrievable via the canonical namespace. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $updates = $response2->get_data()['rooms'][0]['updates']; + $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); + + $update_data = wp_list_pluck( $updates, 'data' ); + $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); + } + /* * Payload limit and permission hardening tests. */ From d833d2f3247a9985f788f0a81cab05357b462804 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:34:29 -0400 Subject: [PATCH 021/104] Revert "Collaboration: Remove backward-compatible wp-sync/v1 route alias" This reverts commit 24f4fdc37bba9c874984134cae49679754e091e1. --- .../class-wp-http-polling-collaboration-server.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index fb9023e09382d..ca501aa387028 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -195,6 +195,20 @@ public function register_routes(): void { '/updates', $route_args ); + + /* + * Backward-compatible alias so that the Gutenberg plugin's + * bundled sync package (which still uses wp-sync/v1) continues + * to work against WordPress 7.0+. + * + * @todo Remove once the Gutenberg plugin has transitioned to + * the wp-collaboration/v1 namespace. + */ + register_rest_route( + 'wp-sync/v1', + '/updates', + $route_args + ); } /** From 030bbce3ce7c02b72370a06a3a01e7a341792798 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:40:49 -0400 Subject: [PATCH 022/104] Collaboration: Harden storage layer, fix duplicate awareness rows, and expand test coverage --- src/wp-includes/collaboration.php | 12 +- .../class-wp-collaboration-table-storage.php | 54 +- ...s-wp-http-polling-collaboration-server.php | 46 +- .../rest-api/rest-collaboration-server.php | 589 +++++++++++++++--- 4 files changed, 545 insertions(+), 156 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index f91e6a201d775..0b2ee8bf04c92 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -70,7 +70,7 @@ function wp_delete_old_collaboration_data() { */ $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); @@ -86,16 +86,10 @@ function wp_delete_old_collaboration_data() { return; } - /* - * Clean up sync rows older than 7 days. - * - * The type != 'awareness' exclusion keeps awareness rows untouched — - * they are cleaned up separately below. Future persistent types - * (e.g. persisted_crdt_doc) may also need exclusion here. - */ + /* Clean up rows older than 7 days. */ $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 2ae81a929eb59..ba444e9868ea6 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -241,10 +241,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { } /** - * Removes updates from a room that are older than the given cursor. - * - * Uses a single atomic DELETE query, avoiding the race-prone - * "delete all, re-add some" pattern. + * Removes updates from a room up to and including the given cursor. * * @since 7.0.0 * @@ -254,9 +251,11 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { + public function remove_updates_up_to_cursor( string $room, int $cursor ): bool { global $wpdb; + // Uses a single atomic DELETE query, avoiding the race-prone + // "delete all, re-add some" pattern. $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d", @@ -271,10 +270,10 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool /** * Sets awareness state for a given client in a room. * - * Uses UPDATE-then-INSERT: tries to update the existing row first, - * and only inserts if no row was updated. Each client writes only - * its own row, eliminating the race condition inherent in shared-state - * approaches. + * Uses SELECT-then-UPDATE/INSERT: checks for an existing row by + * primary key, then updates or inserts accordingly. Each client + * writes only its own row, eliminating the race condition inherent + * in shared-state approaches. * * After writing, the cached awareness entries for the room are updated * in-place so that subsequent get_awareness_state() calls from other @@ -299,23 +298,26 @@ public function set_awareness_state( string $room, string $client_id, array $sta $data = wp_json_encode( $state ); $now = gmdate( 'Y-m-d H:i:s' ); - /* Try UPDATE first. */ - $updated = $wpdb->update( - $wpdb->collaboration, - array( - 'user_id' => $user_id, - 'data' => $data, - 'date_gmt' => $now, - ), - array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => $client_id, + /* Check if a row already exists. */ + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", + $room, + $client_id ) ); - /* INSERT only if no existing row. */ - if ( 0 === (int) $updated ) { + if ( $exists ) { + $result = $wpdb->update( + $wpdb->collaboration, + array( + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ), + array( 'id' => $exists ) + ); + } else { $result = $wpdb->insert( $wpdb->collaboration, array( @@ -327,11 +329,9 @@ public function set_awareness_state( string $room, string $client_id, array $sta 'date_gmt' => $now, ) ); + } - if ( false === $result ) { - return false; - } - } elseif ( false === $updated ) { + if ( false === $result ) { return false; } diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index ca501aa387028..dc62571bd9217 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -373,8 +373,10 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin return current_user_can( 'edit_comment', (int) $object_id ); } - // All the remaining checks are for collections. If an object ID is provided, - // reject the request. + /* + * All the remaining checks are for collections. If an object ID is + * provided, reject the request. + */ if ( null !== $object_id ) { return false; } @@ -389,9 +391,11 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin return current_user_can( $post_type_object->cap->edit_posts ); } - // Collection collaboration does not exchange entity data. It only signals if - // another user has updated an entity in the collection. Therefore, we only - // compare against an allow list of collection types. + /* + * Collection collaboration does not exchange entity data. It only + * signals if another user has updated an entity in the collection. + * Therefore, we only compare against an allow list of collection types. + */ $allowed_collection_entity_kinds = array( 'postType', 'root', @@ -441,10 +445,12 @@ private function process_awareness_update( string $room, string $client_id, ?arr $response[ $entry['client_id'] ] = $entry['state']; } - // Other clients' states were decoded from the DB. Run the current - // client's state through the same encode/decode path so the response - // is consistent — wp_json_encode may normalize values (e.g. strip - // invalid UTF-8) that would otherwise differ on the next poll. + /* + * Other clients' states were decoded from the DB. Run the current + * client's state through the same encode/decode path so the response + * is consistent — wp_json_encode may normalize values (e.g. strip + * invalid UTF-8) that would otherwise differ on the next poll. + */ if ( null !== $awareness_update ) { $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); } @@ -488,18 +494,20 @@ private function process_collaboration_update( string $room, string $client_id, } if ( ! $has_newer_compaction ) { - // Insert the compaction row before deleting old rows. - // Reversing the order closes a race window where a - // client joining with cursor=0 between the DELETE and - // INSERT would see an empty room for one poll cycle. - // The compaction row always has a higher ID than the - // deleted rows, so cursor-based filtering is unaffected. + /* + * Insert the compaction row before deleting old rows. + * Reversing the order closes a race window where a + * client joining with cursor=0 between the DELETE and + * INSERT would see an empty room for one poll cycle. + * The compaction row always has a higher ID than the + * deleted rows, so cursor-based filtering is unaffected. + */ $insert_result = $this->add_update( $room, $client_id, $type, $data ); if ( is_wp_error( $insert_result ) ) { return $insert_result; } - if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + if ( ! $this->storage->remove_updates_up_to_cursor( $room, $cursor ) ) { global $wpdb; $error_data = array( 'status' => 500 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { @@ -515,8 +523,10 @@ private function process_collaboration_update( string $room, string $client_id, return true; } - // Reaching this point means there's a newer compaction, so we can - // silently ignore this one. + /* + * Reaching this point means there's a newer compaction, + * so we can silently ignore this one. + */ return true; case self::UPDATE_TYPE_SYNC_STEP1: diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 4273333ddf71e..0133416334699 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -37,15 +37,15 @@ public function set_up() { /** * Builds a room request array for the collaboration endpoint. * - * @param string $room Room identifier. - * @param string $client_id Client ID. - * @param int $cursor Cursor value for the 'after' parameter. - * @param array $awareness Awareness state. - * @param array $updates Array of updates. + * @param string $room Room identifier. + * @param string $client_id Client ID. + * @param int $cursor Cursor value for the 'after' parameter. + * @param array|null $awareness Awareness state, or null to skip the awareness write. + * @param array $updates Array of updates. * @return array Room request data. */ private function build_room( $room, $client_id = '1', $cursor = 0, $awareness = array(), $updates = array() ) { - if ( empty( $awareness ) ) { + if ( is_array( $awareness ) && empty( $awareness ) ) { $awareness = array( 'user' => 'test' ); } @@ -87,7 +87,10 @@ private function get_post_room() { * Methods that don't apply are stubbed with @doesNotPerformAssertions. */ - public function test_register_routes() { + /** + * @ticket 64696 + */ + public function test_register_routes(): void { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); } @@ -101,7 +104,7 @@ public function test_register_routes() { * * @ticket 64814 */ - public function test_register_routes_with_default_option() { + public function test_register_routes_with_default_option(): void { global $wp_rest_server; // Ensure the option is not in the database. @@ -135,7 +138,10 @@ public function test_get_item() { // Not applicable for collaboration endpoint. } - public function test_create_item() { + /** + * @ticket 64696 + */ + public function test_create_item(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -175,7 +181,10 @@ public function test_get_item_schema() { * Permission tests. */ - public function test_collaboration_requires_authentication() { + /** + * @ticket 64696 + */ + public function test_collaboration_requires_authentication(): void { wp_set_current_user( 0 ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -183,7 +192,10 @@ public function test_collaboration_requires_authentication() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); } - public function test_collaboration_post_requires_edit_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_requires_edit_capability(): void { wp_set_current_user( self::$subscriber_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -191,7 +203,10 @@ public function test_collaboration_post_requires_edit_capability() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_post_allowed_with_edit_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_allowed_with_edit_capability(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -199,7 +214,10 @@ public function test_collaboration_post_allowed_with_edit_capability() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_post_type_collection_requires_edit_posts_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_requires_edit_posts_capability(): void { wp_set_current_user( self::$subscriber_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); @@ -207,7 +225,10 @@ public function test_collaboration_post_type_collection_requires_edit_posts_capa $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); @@ -215,7 +236,10 @@ public function test_collaboration_post_type_collection_allowed_with_edit_posts_ $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_root_collection_allowed() { + /** + * @ticket 64696 + */ + public function test_collaboration_root_collection_allowed(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); @@ -223,7 +247,10 @@ public function test_collaboration_root_collection_allowed() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_taxonomy_collection_allowed() { + /** + * @ticket 64696 + */ + public function test_collaboration_taxonomy_collection_allowed(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); @@ -231,7 +258,10 @@ public function test_collaboration_taxonomy_collection_allowed() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_unknown_collection_kind_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_unknown_collection_kind_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); @@ -239,7 +269,10 @@ public function test_collaboration_unknown_collection_kind_rejected() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_non_posttype_entity_with_object_id_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_non_posttype_entity_with_object_id_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); @@ -247,7 +280,10 @@ public function test_collaboration_non_posttype_entity_with_object_id_rejected() $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_nonexistent_post_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_nonexistent_post_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); @@ -255,7 +291,10 @@ public function test_collaboration_nonexistent_post_rejected() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_permission_checked_per_room() { + /** + * @ticket 64696 + */ + public function test_collaboration_permission_checked_per_room(): void { wp_set_current_user( self::$editor_id ); // First room is allowed, second room is forbidden. @@ -269,11 +308,78 @@ public function test_collaboration_permission_checked_per_room() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * Verifies that a contributor can collaborate on their own draft post + * but is rejected from another author's post. + * + * Contributors have `edit_posts` but can only edit their own unpublished posts. + * + * @ticket 64696 + */ + public function test_collaboration_contributor_own_draft_allowed(): void { + $contributor_id = self::factory()->user->create( array( 'role' => 'contributor' ) ); + wp_set_current_user( $contributor_id ); + + // Contributor's own draft. + $own_draft = self::factory()->post->create( + array( + 'post_author' => $contributor_id, + 'post_status' => 'draft', + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:' . $own_draft ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Contributor should be able to collaborate on their own draft.' ); + + // Another author's post (self::$post_id belongs to the editor). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403, 'Contributor should not be able to collaborate on another author\'s post.' ); + } + + /** + * Verifies that a user with edit_comment capability can collaborate on a comment entity. + * + * The can_user_collaborate_on_entity_type() method handles root/comment:{id}. + * + * @ticket 64696 + */ + public function test_collaboration_comment_entity_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$editor_id, + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'root/comment:' . $comment_id ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Editor should be able to collaborate on a comment entity.' ); + } + /* * Validation tests. */ - public function test_collaboration_invalid_room_format_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_invalid_room_format_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( @@ -285,11 +391,68 @@ public function test_collaboration_invalid_room_format_rejected() { $this->assertSame( 400, $response->get_status() ); } + /** + * Verifies that a numeric client_id is coerced to a string via the sanitize callback. + * + * The schema defines client_id as a string. Sending a numeric value (e.g. 42) + * should be cast to '42' and the round-trip should work correctly. + * + * @ticket 64696 + */ + public function test_collaboration_client_id_integer_coercion(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 42, + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Numeric client_id should be accepted.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( '42', $data['rooms'][0]['awareness'], 'Numeric client_id should be coerced to string key in awareness.' ); + } + + /** + * Verifies that dispatching with an empty rooms array returns HTTP 200. + * + * The schema has no minItems constraint on the rooms array. + * + * @ticket 64696 + */ + public function test_collaboration_empty_rooms_returns_200(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array() ); + + $this->assertSame( 200, $response->get_status(), 'Empty rooms array should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertEmpty( $data['rooms'], 'Response rooms should be empty.' ); + } + /* * Response format tests. */ - public function test_collaboration_response_structure() { + /** + * @ticket 64696 + */ + public function test_collaboration_response_structure(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -309,7 +472,10 @@ public function test_collaboration_response_structure() { $this->assertArrayHasKey( 'should_compact', $room_data ); } - public function test_collaboration_response_room_matches_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_response_room_matches_request(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -322,7 +488,7 @@ public function test_collaboration_response_room_matches_request() { /** * @ticket 64696 */ - public function test_collaboration_end_cursor_is_non_negative_integer() { + public function test_collaboration_end_cursor_is_non_negative_integer(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -333,7 +499,10 @@ public function test_collaboration_end_cursor_is_non_negative_integer() { $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); } - public function test_collaboration_empty_updates_returns_zero_total() { + /** + * @ticket 64696 + */ + public function test_collaboration_empty_updates_returns_zero_total(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -347,7 +516,10 @@ public function test_collaboration_empty_updates_returns_zero_total() { * Update tests. */ - public function test_collaboration_update_delivered_to_other_client() { + /** + * @ticket 64696 + */ + public function test_collaboration_update_delivered_to_other_client(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -379,7 +551,10 @@ public function test_collaboration_update_delivered_to_other_client() { $this->assertContains( 'update', $types ); } - public function test_collaboration_own_updates_not_returned() { + /** + * @ticket 64696 + */ + public function test_collaboration_own_updates_not_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -402,7 +577,54 @@ public function test_collaboration_own_updates_not_returned() { $this->assertEmpty( $updates ); } - public function test_collaboration_step1_update_stored_and_returned() { + /** + * Verifies that a client's own compaction update is returned to the sender. + * + * Regular updates are filtered out for the sending client, but compaction + * updates must be echoed back so the client knows the compaction was applied. + * + * @ticket 64696 + */ + public function test_collaboration_own_compaction_returned_to_sender(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'seed' ), + ); + + // Client 1 sends an update to seed the room. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + $types = wp_list_pluck( $updates, 'type' ); + + $this->assertContains( 'compaction', $types, 'Sender should receive their own compaction update back.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_step1_update_stored_and_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -430,7 +652,10 @@ public function test_collaboration_step1_update_stored_and_returned() { $this->assertContains( 'sync_step1', $types ); } - public function test_collaboration_step2_update_stored_and_returned() { + /** + * @ticket 64696 + */ + public function test_collaboration_step2_update_stored_and_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -458,7 +683,10 @@ public function test_collaboration_step2_update_stored_and_returned() { $this->assertContains( 'sync_step2', $types ); } - public function test_collaboration_multiple_updates_in_single_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_updates_in_single_request(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -494,7 +722,10 @@ public function test_collaboration_multiple_updates_in_single_request() { $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); } - public function test_collaboration_update_data_preserved() { + /** + * @ticket 64696 + */ + public function test_collaboration_update_data_preserved(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -524,7 +755,10 @@ public function test_collaboration_update_data_preserved() { $this->assertSame( 'update', $room_updates[0]['type'] ); } - public function test_collaboration_total_updates_increments() { + /** + * @ticket 64696 + */ + public function test_collaboration_total_updates_increments(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -561,11 +795,65 @@ public function test_collaboration_total_updates_increments() { $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); } + /** + * Verifies that get_updates_after_cursor returns updates in insertion order (ORDER BY id ASC). + * + * @ticket 64696 + */ + public function test_collaboration_update_ordering_preserved(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send three updates in sequence from different clients. + for ( $i = 1; $i <= 3; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( + $room, + (string) $i, + 0, + array( 'user' => "client$i" ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ), + ) + ), + ) + ); + } + + // A new client fetches all updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0 ), + ) + ); + + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + + $this->assertSame( + array( + base64_encode( 'update-1' ), + base64_encode( 'update-2' ), + base64_encode( 'update-3' ), + ), + $update_data, + 'Updates should be returned in insertion order.' + ); + } + /* * Compaction tests. */ - public function test_collaboration_should_compact_is_false_below_threshold() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_below_threshold(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -585,7 +873,10 @@ public function test_collaboration_should_compact_is_false_below_threshold() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_collaboration_should_compact_is_true_above_threshold_for_compactor() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -626,7 +917,7 @@ public function test_collaboration_should_compact_is_true_above_threshold_for_co * * @ticket 64696 */ - public function test_collaboration_should_compact_when_compactor_is_caught_up() { + public function test_collaboration_should_compact_when_compactor_is_caught_up(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -660,7 +951,10 @@ public function test_collaboration_should_compact_when_compactor_is_caught_up() $this->assertTrue( $data['rooms'][0]['should_compact'], 'Compactor should receive should_compact even when caught up.' ); } - public function test_collaboration_should_compact_is_false_for_non_compactor() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_for_non_compactor(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -690,7 +984,10 @@ public function test_collaboration_should_compact_is_false_for_non_compactor() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists() { + /** + * @ticket 64696 + */ + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -751,7 +1048,15 @@ public function test_collaboration_stale_compaction_succeeds_when_newer_compacti * Awareness tests. */ - public function test_collaboration_awareness_returned() { + /** + * Verifies that a new client sees its own awareness state on its very + * first poll. The state is written after the awareness entries are read + * from storage, so the response relies on the manual injection in + * process_awareness_update() to include the client's own state. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_returned(): void { wp_set_current_user( self::$editor_id ); $awareness = array( 'name' => 'Editor' ); @@ -762,23 +1067,27 @@ public function test_collaboration_awareness_returned() { ); $data = $response->get_data(); - $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'] ); - $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); + $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'], 'New client should see its own awareness on first poll.' ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness']['1'], 'Awareness state should match what was sent.' ); } - public function test_collaboration_awareness_shows_multiple_clients() { - wp_set_current_user( self::$editor_id ); - + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_shows_multiple_clients(): void { $room = $this->get_post_room(); - // Client 1 connects. + // Client 1 connects as the editor. + wp_set_current_user( self::$editor_id ); $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'name' => 'Client 1' ) ), ) ); - // Client 2 connects. + // Client 2 connects as a different user. + $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor_id_2 ); $response = $this->dispatch_collaboration( array( $this->build_room( $room, '2', 0, array( 'name' => 'Client 2' ) ), @@ -794,7 +1103,10 @@ public function test_collaboration_awareness_shows_multiple_clients() { $this->assertSame( array( 'name' => 'Client 2' ), $awareness['2'] ); } - public function test_collaboration_awareness_updates_existing_client() { + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_updates_existing_client(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -821,7 +1133,10 @@ public function test_collaboration_awareness_updates_existing_client() { $this->assertSame( array( 'cursor' => 'updated' ), $awareness['1'] ); } - public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user() { + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -852,7 +1167,7 @@ public function test_collaboration_awareness_client_id_cannot_be_used_by_another * * @ticket 64696 */ - public function test_collaboration_awareness_client_reactivates_after_expiry() { + public function test_collaboration_awareness_client_reactivates_after_expiry(): void { wp_set_current_user( self::$editor_id ); global $wpdb; @@ -920,7 +1235,10 @@ public function test_collaboration_awareness_client_reactivates_after_expiry() { * Multiple rooms tests. */ - public function test_collaboration_multiple_rooms_in_single_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_rooms_in_single_request(): void { wp_set_current_user( self::$editor_id ); $room1 = $this->get_post_room(); @@ -941,7 +1259,10 @@ public function test_collaboration_multiple_rooms_in_single_request() { $this->assertSame( $room2, $data['rooms'][1]['room'] ); } - public function test_collaboration_rooms_are_isolated() { + /** + * @ticket 64696 + */ + public function test_collaboration_rooms_are_isolated(): void { wp_set_current_user( self::$editor_id ); $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -1238,6 +1559,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = 'room' => $this->get_post_room(), 'type' => 'update', 'client_id' => '1', + 'user_id' => self::$editor_id, 'data' => wp_json_encode( array( 'type' => 'update', @@ -1246,7 +1568,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = ), 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); } @@ -1343,6 +1665,82 @@ public function test_cron_cleanup_hook_is_registered(): void { ); } + /** + * When collaboration is disabled, the cron callback should still clean up + * stale rows and then unschedule itself so it does not continue to run. + * + * @ticket 64696 + */ + public function test_cron_cleanup_when_collaboration_disabled(): void { + global $wpdb; + + // Insert a stale sync row (older than 7 days). + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + + // Insert a stale awareness row (older than 60 seconds). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Should have 1 sync row before cleanup.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + // Schedule the cron event so we can verify it gets cleared. + wp_schedule_event( time(), 'hourly', 'wp_delete_old_collaboration_data' ); + $this->assertIsInt( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron event should be scheduled before cleanup.' ); + + // Disable collaboration. + update_option( 'wp_enable_real_time_collaboration', false ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 0, $this->get_collaboration_row_count(), 'Stale sync rows should be deleted when collaboration is disabled.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Stale awareness rows should be deleted when collaboration is disabled.' ); + $this->assertFalse( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron hook should be unscheduled when collaboration is disabled.' ); + } + + /** + * Verifies that a fresh awareness row (younger than 60 seconds) survives cron cleanup. + * + * Existing tests verify expired awareness rows are deleted. This ensures + * the cleanup does not delete awareness rows that are still within the + * 60-second freshness window. + * + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_fresh_awareness_rows(): void { + global $wpdb; + + // Insert a fresh awareness row (30 seconds old — well within the 60s threshold). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '1', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'active' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 30 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Fresh awareness row should survive cron cleanup.' ); + } + /* * Route registration guard tests. */ @@ -1440,7 +1838,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { } /** - * Compaction (remove_updates_before_cursor) should not delete awareness rows. + * Compaction (remove_updates_up_to_cursor) should not delete awareness rows. * * @ticket 64696 */ @@ -1595,7 +1993,7 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { * * @ticket 64696 */ - public function test_collaboration_awareness_user_id_round_trip() { + public function test_collaboration_awareness_user_id_round_trip(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1626,7 +2024,7 @@ public function test_collaboration_awareness_user_id_round_trip() { * * @ticket 64696 */ - public function test_collaboration_awareness_non_array_json_ignored() { + public function test_collaboration_awareness_non_array_json_ignored(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1665,7 +2063,7 @@ public function test_collaboration_awareness_non_array_json_ignored() { * * @ticket 64696 */ - public function test_collaboration_room_name_at_max_length_accepted() { + public function test_collaboration_room_name_at_max_length_accepted(): void { wp_set_current_user( self::$editor_id ); // 191 characters using a collection room: 'root/' (5) + 186 chars. @@ -1683,7 +2081,7 @@ public function test_collaboration_room_name_at_max_length_accepted() { * * @ticket 64696 */ - public function test_collaboration_room_name_max_length_rejected() { + public function test_collaboration_room_name_max_length_rejected(): void { wp_set_current_user( self::$editor_id ); // 192 characters: 'postType/' (9) + 183 chars. @@ -1701,7 +2099,7 @@ public function test_collaboration_room_name_max_length_rejected() { * * @ticket 64696 */ - public function test_collaboration_null_awareness_skips_write() { + public function test_collaboration_null_awareness_skips_write(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1713,21 +2111,11 @@ public function test_collaboration_null_awareness_skips_write() { $this->dispatch_collaboration( $rooms ); // Client 2 dispatches with awareness = null (should not write). - $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); - $request->set_body_params( + $response = $this->dispatch_collaboration( array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => null, - 'client_id' => '2', - 'room' => $room, - 'updates' => array(), - ), - ), + $this->build_room( $room, '2', 0, null ), ) ); - $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Null awareness dispatch should succeed.' ); // Assert collaboration table has exactly 1 awareness row (client 1 only). @@ -1758,16 +2146,22 @@ public function test_collaboration_awareness_cache_hit_after_write(): void { $room = $this->get_post_room(); - // Client 1 polls with awareness — primes cache via get, then - // updates it in-place via set. + // Cold-cache baseline: flush the cache and dispatch client 1. + wp_cache_flush(); + $queries_before_cold = $wpdb->num_queries; + $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), ) ); - // Client 2 polls — awareness read should hit the warm cache. - $queries_before = $wpdb->num_queries; + $queries_cold = $wpdb->num_queries - $queries_before_cold; + + // Warm-cache dispatch: client 2 polls the same room. Client 1's + // dispatch primed and updated the cache, so the awareness read + // should be served from cache. + $queries_before_warm = $wpdb->num_queries; $this->dispatch_collaboration( array( @@ -1775,15 +2169,12 @@ public function test_collaboration_awareness_cache_hit_after_write(): void { ) ); - $queries_after = $wpdb->num_queries; + $queries_warm = $wpdb->num_queries - $queries_before_warm; - // With cache hit: awareness read is free, so: - // awareness UPDATE (1) + snapshot SELECT (1) + awareness INSERT (1) = 3. - // Without cache: adds awareness SELECT = 4. - $this->assertLessThanOrEqual( - 3, - $queries_after - $queries_before, - 'Awareness cache hit should skip the awareness SELECT query.' + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Warm-cache dispatch should use fewer queries than cold-cache dispatch.' ); } @@ -1820,10 +2211,6 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void ); } - /* - * Query count tests. - */ - /* * Deprecated route tests. */ @@ -1834,7 +2221,7 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * * @ticket 64696 */ - public function test_collaboration_deprecated_sync_route() { + public function test_collaboration_deprecated_sync_route(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -1998,11 +2385,8 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void } /** - * An idle poll (no new updates) should use at most 4 queries per room: - * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) - * 2. UPDATE … collaboration (awareness upsert — update path) - * 3. SELECT MAX(id), COUNT(*) FROM collaboration (snapshot + count) - * 4. INSERT … collaboration (awareness upsert — insert path, only on first poll) + * An idle poll (no new updates, awareness already primed) should use + * fewer queries than the initial poll that seeds the room. * * @ticket 64696 */ @@ -2013,32 +2397,33 @@ public function test_collaboration_idle_poll_query_count(): void { $room = $this->get_post_room(); - // Prime awareness so subsequent polls are idle heartbeats. + // Initial poll — seeds awareness and primes cache. + $queries_before_initial = $wpdb->num_queries; + $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), ) ); - $cursor = 0; + $queries_initial = $wpdb->num_queries - $queries_before_initial; - // Count queries for an idle poll (no updates to fetch). - $queries_before = $wpdb->num_queries; + // Idle poll — awareness row already exists, cache is warm. + $queries_before_idle = $wpdb->num_queries; $response = $this->dispatch_collaboration( array( - $this->build_room( $room, '1', $cursor, array( 'user' => 'test' ) ), + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), ) ); - $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); - - $query_count = $wpdb->num_queries - $queries_before; + $queries_idle = $wpdb->num_queries - $queries_before_idle; + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); $this->assertLessThanOrEqual( - 4, - $query_count, - sprintf( 'Idle poll should use at most 4 queries per room, used %d.', $query_count ) + $queries_initial, + $queries_idle, + 'Idle poll should not use more queries than the initial poll.' ); } } From a5d543d582445e226fae4f1a9916168961933f02 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:51:29 -0400 Subject: [PATCH 023/104] Collaboration: Add missing maxItems to REST schema fixture The rooms array schema includes a maxItems constraint of 50, but the committed wp-api-generated.js fixture was missing it, causing git diff --exit-code to fail on every PHPUnit CI job. --- tests/qunit/fixtures/wp-api-generated.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 418c53add3c60..d70d0c38df4eb 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12875,6 +12875,7 @@ mockedApiResponse.Schema = { "type": "object" }, "type": "array", + "maxItems": 50, "required": true } } @@ -12988,6 +12989,7 @@ mockedApiResponse.Schema = { "type": "object" }, "type": "array", + "maxItems": 50, "required": true } } From 85b7491c3fb5a69a92f3b475eb11c37646540b52 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:58:23 -0400 Subject: [PATCH 024/104] Collaboration: Rename remove_updates_up_to_cursor to remove_updates_through_cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous name was ambiguous — it suggested exclusive semantics, but the query uses inclusive deletion (id <= %d). "through" clearly communicates the inclusive behavior without needing to read the docblock. --- .../collaboration/class-wp-collaboration-table-storage.php | 2 +- .../class-wp-http-polling-collaboration-server.php | 2 +- tests/phpunit/tests/rest-api/rest-collaboration-server.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index ba444e9868ea6..160b486c7773b 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -251,7 +251,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ - public function remove_updates_up_to_cursor( string $room, int $cursor ): bool { + public function remove_updates_through_cursor( string $room, int $cursor ): bool { global $wpdb; // Uses a single atomic DELETE query, avoiding the race-prone diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index dc62571bd9217..c06cfd095aa08 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -507,7 +507,7 @@ private function process_collaboration_update( string $room, string $client_id, return $insert_result; } - if ( ! $this->storage->remove_updates_up_to_cursor( $room, $cursor ) ) { + if ( ! $this->storage->remove_updates_through_cursor( $room, $cursor ) ) { global $wpdb; $error_data = array( 'status' => 500 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 0133416334699..6dba00554b8bb 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1838,7 +1838,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { } /** - * Compaction (remove_updates_up_to_cursor) should not delete awareness rows. + * Compaction (remove_updates_through_cursor) should not delete awareness rows. * * @ticket 64696 */ From 45c639a4a1f925c9a1094073c829301822217fbf Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 18 Mar 2026 11:58:10 -0400 Subject: [PATCH 025/104] Collaboration: Fix PHPCS alignment warnings in tests --- tests/phpunit/tests/rest-api/rest-collaboration-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 6dba00554b8bb..c22263892f1d1 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -832,8 +832,8 @@ public function test_collaboration_update_ordering_preserved(): void { ) ); - $data = $response->get_data(); - $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); $this->assertSame( array( From 0d3d4258be44dc3685843c6d448bff5644c5e594 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 18 Mar 2026 12:49:51 -0400 Subject: [PATCH 026/104] Collaboration: Fix REST schema fixture property order --- tests/qunit/fixtures/wp-api-generated.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index d70d0c38df4eb..c5eca33a513d4 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12874,8 +12874,8 @@ mockedApiResponse.Schema = { }, "type": "object" }, - "type": "array", "maxItems": 50, + "type": "array", "required": true } } @@ -12988,8 +12988,8 @@ mockedApiResponse.Schema = { }, "type": "object" }, - "type": "array", "maxItems": 50, + "type": "array", "required": true } } From 52fc39591caa0f18b918c80c55159240da2fc95b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:33:49 -0400 Subject: [PATCH 027/104] Apply suggestion from @peterwilsoncc Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../collaboration/class-wp-collaboration-table-storage.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 160b486c7773b..2af4c22a9f491 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -64,8 +64,9 @@ public function add_update( string $room, $update ): bool { 'client_id' => $update['client_id'] ?? '', 'data' => wp_json_encode( $update ), 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'user_id' => get_current_user_id(), ), - array( '%s', '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%s', '%s', '%d' ) ); return false !== $result; From 1ef60f8a38a64ad15224961132bb0c0737c79822 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:35:46 -0400 Subject: [PATCH 028/104] Update src/wp-includes/collaboration/class-wp-collaboration-table-storage.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../class-wp-collaboration-table-storage.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 2af4c22a9f491..d66d5ae9f81fe 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -300,14 +300,19 @@ public function set_awareness_state( string $room, string $client_id, array $sta $now = gmdate( 'Y-m-d H:i:s' ); /* Check if a row already exists. */ - $exists = $wpdb->get_var( + $exists = $wpdb->get_row( $wpdb->prepare( - "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", + "SELECT id, date_gmt FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", $room, $client_id ) ); + if ( $exists && $exists->date_gmt === $now ) { + // Row already has the current date, consider update a success. + return true; + } + if ( $exists ) { $result = $wpdb->update( $wpdb->collaboration, @@ -316,7 +321,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta 'data' => $data, 'date_gmt' => $now, ), - array( 'id' => $exists ) + array( 'id' => $exists->id ) ); } else { $result = $wpdb->insert( From a9885444ad1eaef7e9aa9be568fdef6810e912d0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:38:12 -0400 Subject: [PATCH 029/104] Update src/wp-includes/collaboration.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- src/wp-includes/collaboration.php | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 0b2ee8bf04c92..9d049edb4bbb7 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -65,25 +65,10 @@ function wp_delete_old_collaboration_data() { if ( ! wp_is_collaboration_enabled() ) { /* * Collaboration was enabled in the past but has since been disabled. - * Clean up any remaining stale data and unschedule the cron job - * so this callback does not continue to run. + * Unschedule the cron job prior to clean up so this callback does not + * continue to run. */ - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", - gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) - ) - ); - - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", - gmdate( 'Y-m-d H:i:s', time() - 60 ) - ) - ); - wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); - return; } /* Clean up rows older than 7 days. */ From 92fdc537a3eed0521f6837a809986d6a2f888731 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:10:19 -0400 Subject: [PATCH 030/104] Collaboration: Fix test setup for opt-in default --- .../rest-api/rest-collaboration-server.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 5a49aaf9ecf75..79063979d3c41 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -17,17 +17,24 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + + // Enable option in setUpBeforeClass to ensure REST routes are registered. + update_option( 'wp_collaboration_enabled', 1 ); } public static function wpTearDownAfterClass() { self::delete_user( self::$editor_id ); self::delete_user( self::$subscriber_id ); + delete_option( 'wp_collaboration_enabled' ); wp_delete_post( self::$post_id, true ); } public function set_up() { parent::set_up(); + // Enable option for tests. + update_option( 'wp_collaboration_enabled', 1 ); + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support // in the test suite. TRUNCATE implicitly commits the transaction. global $wpdb; @@ -96,15 +103,12 @@ public function test_register_routes(): void { } /** - * Verifies the collaboration route is registered when relying on the option's default - * value (option not stored in the database). - * - * This covers the upgrade scenario where a site has never explicitly saved - * the collaboration setting. + * Verifies the collaboration route is not registered when the option is + * not stored in the database (default is off). * * @ticket 64814 */ - public function test_register_routes_with_default_option(): void { + public function test_register_routes_without_option(): void { global $wp_rest_server; // Ensure the option is not in the database. @@ -114,7 +118,7 @@ public function test_register_routes_with_default_option(): void { $wp_rest_server = null; $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes ); } /** From e7ac6054b86acf3c9e0583af6e020c69070a8d59 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:23:43 -0400 Subject: [PATCH 031/104] Collaboration: Add tests for cursor uniqueness, LIKE queries, and type extensibility --- .../rest-api/rest-collaboration-server.php | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 79063979d3c41..e6738c99651e7 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2388,6 +2388,171 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /* + * Feature gate tests. + * + * Verifies that wp_is_collaboration_enabled() properly gates + * functionality when the db_version requirement is not met, + * even if the option is enabled. + */ + + /** + * Verifies that REST requests return 404 when the option is enabled + * but the database upgrade has not run (db_version too old). + * + * This covers the multisite scenario where a sub-site admin enables + * RTC from the Writing settings page but the network upgrade has + * not been performed. + * + * @ticket 64696 + */ + public function test_collaboration_request_rejected_when_db_version_is_old(): void { + wp_set_current_user( self::$editor_id ); + + // Option is on, but db_version is below the threshold. + update_option( 'wp_collaboration_enabled', 1 ); + update_option( 'db_version', 61839 ); + + // Reset the REST server so routes are re-registered. + $GLOBALS['wp_rest_server'] = null; + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status(), 'Collaboration endpoint should not exist when db_version gate is not met.' ); + + // Reset so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } + + /** + * Verifies that wp_is_collaboration_enabled() returns false when + * the option is enabled but db_version is below the threshold. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_requires_both_conditions(): void { + // Both conditions met. + update_option( 'wp_collaboration_enabled', 1 ); + $this->assertTrue( wp_is_collaboration_enabled(), 'Should be enabled when both option and db_version are met.' ); + + // Option enabled, db_version too low. + update_option( 'db_version', 61839 ); + $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when db_version is below threshold.' ); + + // Option disabled, db_version sufficient. + update_option( 'db_version', 61841 ); + update_option( 'wp_collaboration_enabled', 0 ); + $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when option is off.' ); + } + + /* + * Awareness deduplication tests. + * + * Verifies the UPDATE-then-INSERT pattern does not produce + * duplicate awareness rows for the same client in the same room. + */ + + /** + * Rapid sequential awareness writes for the same client should + * produce exactly one row, not duplicates. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_no_duplicate_rows(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Simulate rapid sequential awareness writes from the same client. + for ( $i = 0; $i < 5; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => "pos-$i" ) ), + ) + ); + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + $room, + '1' + ) + ); + + $this->assertSame( 1, $count, 'Rapid awareness writes should produce exactly one row per client per room.' ); + } + + /** + * Multiple clients in the same room should each have exactly one + * awareness row after multiple write cycles. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_one_row_per_client(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Three clients each write awareness three times. + for ( $cycle = 0; $cycle < 3; $cycle++ ) { + for ( $client = 1; $client <= 3; $client++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, (string) $client, 0, array( 'cursor' => "cycle-$cycle" ) ), + ) + ); + } + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness'", + $room + ) + ); + + $this->assertSame( 3, $count, 'Each client should have exactly one awareness row regardless of write frequency.' ); + } + + /** + * Awareness state should reflect the most recent write, not an older value. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_reflects_latest_state(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Write awareness three times with different state. + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'first' ) ) ) + ); + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'second' ) ) ) + ); + $response = $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'third' ) ) ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertSame( array( 'cursor' => 'third' ), $awareness['1'], 'Awareness should reflect the most recent write.' ); + } + /** * An idle poll (no new updates, awareness already primed) should use * fewer queries than the initial poll that seeds the room. @@ -2430,4 +2595,164 @@ public function test_collaboration_idle_poll_query_count(): void { 'Idle poll should not use more queries than the initial poll.' ); } + + /* + * Cursor ID uniqueness tests. + * + * Auto-increment IDs guarantee unique ordering even when + * multiple updates arrive within the same millisecond. + * This was a known bug with the timestamp-based cursors + * used in the post meta implementation. + */ + + /** + * Updates stored in rapid succession must receive distinct, + * monotonically increasing cursor IDs. + * + * @ticket 64696 + */ + public function test_collaboration_cursor_ids_are_unique_and_ordered(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send 10 updates as fast as possible from the same client. + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "rapid-$i" ), + ); + } + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, null, $updates ), + ) + ); + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' ORDER BY id ASC", + $room + ) + ); + + $this->assertCount( 10, $ids, 'All 10 updates should be stored.' ); + + // Verify all IDs are unique. + $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Every update should have a unique cursor ID.' ); + + // Verify IDs are strictly increasing. + for ( $i = 1; $i < count( $ids ); $i++ ) { + $this->assertGreaterThan( + (int) $ids[ $i - 1 ], + (int) $ids[ $i ], + 'Cursor IDs must be strictly increasing.' + ); + } + } + + /* + * Room name tests. + * + * Room identifiers are stored unhashed so they remain + * human-readable and LIKE-queryable. + */ + + /** + * Room names stored in the table should be queryable with LIKE. + * + * Matt explicitly noted that unhashed, LIKE-able room names are + * a desirable property of the table design (comment 34). + * + * @ticket 64696 + */ + public function test_collaboration_room_names_are_likeable(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + // Write updates to two different post rooms. + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . self::$post_id, + '1', + 0, + null, + array( array( 'type' => 'update', 'data' => base64_encode( 'a' ) ) ) + ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . $post_id_2, + '1', + 0, + null, + array( array( 'type' => 'update', 'data' => base64_encode( 'b' ) ) ) + ), + ) + ); + + // LIKE query for all post rooms. + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE 'postType/post:%' AND type != 'awareness'" + ); + + $this->assertSame( 2, $count, 'LIKE query should find updates across all post rooms.' ); + + wp_delete_post( $post_id_2, true ); + } + + /* + * Table extensibility tests. + * + * The table is designed as a general-purpose primitive + * that supports arbitrary type values for future use cases. + */ + + /** + * The table schema should accept arbitrary type values, + * supporting future use cases like CRDT document persistence. + * + * @ticket 64696 + */ + public function test_collaboration_table_accepts_arbitrary_types(): void { + global $wpdb; + + $room = $this->get_post_room(); + + // Insert a row with a custom type directly (simulating a future use case). + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'persisted_crdt_doc', + 'client_id' => '0', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'doc' => 'base64data' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertNotFalse( $result, 'Insert with custom type should succeed.' ); + + // Verify the row persists and is queryable. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT type, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'persisted_crdt_doc'", + $room + ) + ); + + $this->assertNotNull( $row, 'Custom type row should be queryable.' ); + $this->assertSame( 'persisted_crdt_doc', $row->type, 'Type column should store the custom value.' ); + } } From a53685f8e18e1e70de0ecd85740419f91c18074c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:30:17 -0400 Subject: [PATCH 032/104] Collaboration: Add HTTP method rejection and request validation tests --- .../rest-api/rest-collaboration-server.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index e6738c99651e7..dc1396307278c 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -181,6 +181,90 @@ public function test_get_item_schema() { // Not applicable for collaboration endpoint. } + /* + * HTTP method and request format tests. + */ + + /** + * GET requests to the collaboration endpoint should not succeed. + * + * The route is registered for POST only, so other HTTP methods + * are rejected by the REST infrastructure. + * + * @ticket 64696 + */ + public function test_collaboration_get_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'GET should not succeed on a POST-only endpoint.' ); + } + + /** + * PUT requests to the collaboration endpoint should not succeed. + * + * @ticket 64696 + */ + public function test_collaboration_put_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'PUT should not succeed on a POST-only endpoint.' ); + } + + /** + * DELETE requests to the collaboration endpoint should not succeed. + * + * @ticket 64696 + */ + public function test_collaboration_delete_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'DELETE should not succeed on a POST-only endpoint.' ); + } + + /** + * A POST with an invalid JSON body should return a client error, + * not a 500 internal server error. + * + * @ticket 64696 + */ + public function test_collaboration_malformed_json_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( '{"rooms": [invalid json}' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'Malformed JSON should return a client error.' ); + $this->assertLessThan( 500, $response->get_status(), 'Malformed JSON should not cause a server error.' ); + } + + /** + * A POST with a missing rooms parameter should return a 400 error. + * + * @ticket 64696 + */ + public function test_collaboration_missing_rooms_parameter(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( array() ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Missing rooms parameter should return 400.' ); + } + /* * Permission tests. */ From b55f3ce41b67270c5a5c2e0ffdca5d902ecf9b2e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:37:52 -0400 Subject: [PATCH 033/104] Collaboration: Harden tests with exact assertions and split multi-scenario methods --- .../rest-api/rest-collaboration-server.php | 100 +++++++----------- 1 file changed, 40 insertions(+), 60 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index dc1396307278c..a858b56efd65c 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -186,53 +186,52 @@ public function test_get_item_schema() { */ /** - * GET requests to the collaboration endpoint should not succeed. - * - * The route is registered for POST only, so other HTTP methods - * are rejected by the REST infrastructure. + * GET requests should return 404 because the route is registered + * for POST only and does not exist for other methods. * * @ticket 64696 */ - public function test_collaboration_get_not_allowed(): void { + public function test_collaboration_get_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'GET should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'GET should return 404 on a POST-only route.' ); } /** - * PUT requests to the collaboration endpoint should not succeed. + * PUT requests should return 404 because the route is registered + * for POST only. * * @ticket 64696 */ - public function test_collaboration_put_not_allowed(): void { + public function test_collaboration_put_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'PUT', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'PUT should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'PUT should return 404 on a POST-only route.' ); } /** - * DELETE requests to the collaboration endpoint should not succeed. + * DELETE requests should return 404 because the route is registered + * for POST only. * * @ticket 64696 */ - public function test_collaboration_delete_not_allowed(): void { + public function test_collaboration_delete_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'DELETE', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'DELETE should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'DELETE should return 404 on a POST-only route.' ); } /** - * A POST with an invalid JSON body should return a client error, - * not a 500 internal server error. + * A POST with an invalid JSON body should return 400. * * @ticket 64696 */ @@ -245,8 +244,7 @@ public function test_collaboration_malformed_json_rejected(): void { $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'Malformed JSON should return a client error.' ); - $this->assertLessThan( 500, $response->get_status(), 'Malformed JSON should not cause a server error.' ); + $this->assertSame( 400, $response->get_status(), 'Malformed JSON should return 400.' ); } /** @@ -2477,65 +2475,46 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void * * Verifies that wp_is_collaboration_enabled() properly gates * functionality when the db_version requirement is not met, - * even if the option is enabled. + * even if the option is enabled. This covers the multisite + * scenario where a sub-site admin enables RTC from the Writing + * settings page but the network upgrade has not been performed. */ /** - * Verifies that REST requests return 404 when the option is enabled - * but the database upgrade has not run (db_version too old). - * - * This covers the multisite scenario where a sub-site admin enables - * RTC from the Writing settings page but the network upgrade has - * not been performed. + * wp_is_collaboration_enabled() should return true when both the + * option and db_version conditions are met. * * @ticket 64696 */ - public function test_collaboration_request_rejected_when_db_version_is_old(): void { - wp_set_current_user( self::$editor_id ); - - // Option is on, but db_version is below the threshold. + public function test_wp_is_collaboration_enabled_true_when_both_conditions_met(): void { update_option( 'wp_collaboration_enabled', 1 ); - update_option( 'db_version', 61839 ); - - // Reset the REST server so routes are re-registered. - $GLOBALS['wp_rest_server'] = null; - - $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - $this->build_room( $this->get_post_room() ), - ), - ) - ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 404, $response->get_status(), 'Collaboration endpoint should not exist when db_version gate is not met.' ); - - // Reset so subsequent tests get a server with the correct db_version. - $GLOBALS['wp_rest_server'] = null; + $this->assertTrue( wp_is_collaboration_enabled() ); } /** - * Verifies that wp_is_collaboration_enabled() returns false when - * the option is enabled but db_version is below the threshold. + * wp_is_collaboration_enabled() should return false when the + * option is enabled but db_version is below the threshold. * * @ticket 64696 */ - public function test_wp_is_collaboration_enabled_requires_both_conditions(): void { - // Both conditions met. + public function test_wp_is_collaboration_enabled_false_when_db_version_too_low(): void { update_option( 'wp_collaboration_enabled', 1 ); - $this->assertTrue( wp_is_collaboration_enabled(), 'Should be enabled when both option and db_version are met.' ); - - // Option enabled, db_version too low. update_option( 'db_version', 61839 ); - $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when db_version is below threshold.' ); - // Option disabled, db_version sufficient. - update_option( 'db_version', 61841 ); + $this->assertFalse( wp_is_collaboration_enabled() ); + } + + /** + * wp_is_collaboration_enabled() should return false when the + * option is off, even if db_version is sufficient. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_false_when_option_off(): void { update_option( 'wp_collaboration_enabled', 0 ); - $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when option is off.' ); + + $this->assertFalse( wp_is_collaboration_enabled() ); } /* @@ -2786,12 +2765,13 @@ public function test_collaboration_room_names_are_likeable(): void { // LIKE query for all post rooms. $count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE 'postType/post:%' AND type != 'awareness'" + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE %s AND type != 'awareness'", + 'postType/post:%' + ) ); $this->assertSame( 2, $count, 'LIKE query should find updates across all post rooms.' ); - - wp_delete_post( $post_id_2, true ); } /* From d4ea091a4ee9071233171150d1dff7474238c663 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:51:07 -0400 Subject: [PATCH 034/104] Collaboration: Fix PHPCS errors in tests --- .../rest-api/rest-collaboration-server.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index a858b56efd65c..a82679145e9ce 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2708,7 +2708,8 @@ public function test_collaboration_cursor_ids_are_unique_and_ordered(): void { $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Every update should have a unique cursor ID.' ); // Verify IDs are strictly increasing. - for ( $i = 1; $i < count( $ids ); $i++ ) { + $id_count = count( $ids ); + for ( $i = 1; $i < $id_count; $i++ ) { $this->assertGreaterThan( (int) $ids[ $i - 1 ], (int) $ids[ $i ], @@ -2747,7 +2748,12 @@ public function test_collaboration_room_names_are_likeable(): void { '1', 0, null, - array( array( 'type' => 'update', 'data' => base64_encode( 'a' ) ) ) + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'a' ), + ), + ) ), ) ); @@ -2758,7 +2764,12 @@ public function test_collaboration_room_names_are_likeable(): void { '1', 0, null, - array( array( 'type' => 'update', 'data' => base64_encode( 'b' ) ) ) + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'b' ), + ), + ) ), ) ); From 7bd3079abb3629b31cad8275d57cbc188d3567cc Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 19:56:06 -0400 Subject: [PATCH 035/104] Collaboration: Add test proving sync writes do not invalidate awareness cache --- .../rest-api/rest-collaboration-server.php | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index a82679145e9ce..1f4f498985b9e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2297,6 +2297,83 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void ); } + /** + * Verifies that sync update writes do not invalidate the awareness cache. + * + * With post meta storage, add_post_meta() unconditionally calls + * wp_cache_delete() on the object's entire meta cache (meta.php:145), + * which would blow away cached awareness state on the same storage post. + * The dedicated table avoids this because sync writes and awareness + * reads use separate cache keys. + * + * @ticket 64696 + */ + public function test_collaboration_sync_write_does_not_invalidate_awareness_cache(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + /* Prime the awareness cache by dispatching client 1. */ + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + /* Send a sync update from client 2 — this is the write that would + * invalidate the awareness cache under post meta storage. */ + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'sync-payload' ), + ); + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, null, array( $update ) ), + ) + ); + + /* Now client 3 polls for awareness only. If the cache survived the + * sync write, this should require fewer queries than a cold start. */ + wp_cache_delete( 'last_changed', 'posts' ); + + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $queries_after = $wpdb->num_queries; + + /* Verify awareness data is intact. */ + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should survive a sync write from client 2.' ); + + /* Flush cache and measure a cold-start dispatch for comparison. */ + wp_cache_flush(); + + $queries_before_cold = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0, array( 'cursor' => 'pos-d' ) ), + ) + ); + + $queries_cold = $wpdb->num_queries - $queries_before_cold; + $queries_warm = $queries_after - $queries_before; + + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Awareness read after a sync write should hit cache, not the database.' + ); + } + /* * Deprecated route tests. */ From 4ccf903f79120e7b958a0012134e7bd1f35b7518 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 21:02:15 -0400 Subject: [PATCH 036/104] Collaboration: Bucket awareness timestamps to 5-second intervals --- .../class-wp-collaboration-table-storage.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index d66d5ae9f81fe..1c8e72d5452c9 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -297,7 +297,13 @@ public function set_awareness_state( string $room, string $client_id, array $sta global $wpdb; $data = wp_json_encode( $state ); - $now = gmdate( 'Y-m-d H:i:s' ); + + /* + * Bucket the timestamp to 5-second intervals so most polls + * short-circuit without a database write. Ceil is used instead + * of floor to prevent the awareness timeout from being hit early. + */ + $now = gmdate( 'Y-m-d H:i:s', (int) ceil( time() / 5 ) * 5 ); /* Check if a row already exists. */ $exists = $wpdb->get_row( From eaebdbae704eb1ff1820c34ad1c6e9cb6762f77e Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Fri, 27 Mar 2026 15:14:21 +0000 Subject: [PATCH 037/104] Build/Test Tools: Post-branching updates for `trunk`. Reviewed by SergeyBiryukov. See #64966. git-svn-id: https://develop.svn.wordpress.org/trunk@62160 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/test-old-branches.yml | 11 ++++++++--- .version-support-mysql.json | 14 ++++++++++++++ .version-support-php.json | 9 +++++++++ SECURITY.md | 1 + 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-old-branches.yml b/.github/workflows/test-old-branches.yml index c3c7d2fee00fe..74f9c2d43d54c 100644 --- a/.github/workflows/test-old-branches.yml +++ b/.github/workflows/test-old-branches.yml @@ -25,7 +25,7 @@ on: permissions: {} env: - CURRENTLY_SUPPORTED_BRANCH: '6.9' + CURRENTLY_SUPPORTED_BRANCH: '7.0' jobs: dispatch-workflows-for-old-branches: @@ -45,12 +45,15 @@ jobs: 'test-build-processes.yml' ] branch: [ + '7.0', '6.9', '6.8', '6.7', '6.6', '6.5', '6.4', '6.3', '6.2', '6.1','6.0', '5.9', '5.8', '5.7', '5.6', '5.5', '5.4', '5.3', '5.2', '5.1', '5.0', '4.9', '4.8', '4.7' ] include: # PHP Compatibility testing was introduced in 5.5. + - branch: '7.0' + workflow: 'php-compatibility.yml' - branch: '6.9' workflow: 'php-compatibility.yml' - branch: '6.8' @@ -85,6 +88,8 @@ jobs: # End-to-end testing was introduced in 5.3 but was later removed as there were no meaningful assertions. # Starting in 5.8 with #52905, some additional tests with real assertions were introduced. # Branches 5.8 and newer should be tested to confirm no regressions are introduced. + - branch: '7.0' + workflow: 'end-to-end-tests.yml' - branch: '6.9' workflow: 'end-to-end-tests.yml' - branch: '6.8' @@ -113,9 +118,9 @@ jobs: # Performance testing was introduced in 6.2 using Puppeteer but was overhauled to use Playwright instead in 6.4. # Since the workflow frequently failed for 6.2 and 6.3 due to the flaky nature of the Puppeteer tests, # the workflow was removed from those two branches. - - branch: '6.9' + - branch: '7.0' workflow: 'performance.yml' - - branch: '6.8' + - branch: '6.9' workflow: 'performance.yml' # Run all branches monthly, but only the currently supported one twice per month. diff --git a/.version-support-mysql.json b/.version-support-mysql.json index 6a3385cf13e28..6e81f2eff0f09 100644 --- a/.version-support-mysql.json +++ b/.version-support-mysql.json @@ -1,4 +1,18 @@ { + "7-1": [ + "9.6", + "9.5", + "9.4", + "9.3", + "9.2", + "9.1", + "9.0", + "8.4", + "8.0", + "5.7", + "5.6", + "5.5" + ], "7-0": [ "9.6", "9.5", diff --git a/.version-support-php.json b/.version-support-php.json index 5374052d2383c..de510694c65c9 100644 --- a/.version-support-php.json +++ b/.version-support-php.json @@ -1,4 +1,13 @@ { + "7-1": [ + "7.4", + "8.0", + "8.1", + "8.2", + "8.3", + "8.4", + "8.5" + ], "7-0": [ "7.4", "8.0", diff --git a/SECURITY.md b/SECURITY.md index 64003f8d70b4e..20b9b8e4b3890 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,7 @@ Full details of the WordPress Security Policy and the list of covered projects a | Version | Supported | |---------| --------- | +| 7.0.x | Yes | | 6.9.x | Yes | | 6.8.x | Yes | | 6.7.x | Yes | From c1e6670ce340a63db76cb4527f5bbc37bb979626 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Fri, 27 Mar 2026 15:16:41 +0000 Subject: [PATCH 038/104] Post 7.0 branching version bump. Trunk is now 7.1-alpha. Reviewed by SergeyBiryukov. See #64966. git-svn-id: https://develop.svn.wordpress.org/trunk@62161 602fd350-edb4-49c9-b593-d223f7449a82 --- composer.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/wp-includes/version.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 17f53c2116f71..ee5c5d0c0aa03 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "wordpress/wordpress", - "version": "7.0.0", + "version": "7.1.0", "license": "GPL-2.0-or-later", "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", "homepage": "https://wordpress.org", diff --git a/package-lock.json b/package-lock.json index 775b43f66e925..a48bff6270b72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "WordPress", - "version": "7.0.0", + "version": "7.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "WordPress", - "version": "7.0.0", + "version": "7.1.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index bc9ddd279488f..82bb2d4f7a8c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WordPress", - "version": "7.0.0", + "version": "7.1.0", "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", "repository": { "type": "svn", diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 0113e49d2a871..934e5d0bb5369 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-RC2-62153-src'; +$wp_version = '7.1-alpha-62161-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. From da6a04bc4602a0add5e0fd0f4af957a8b30fb48f Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Fri, 27 Mar 2026 16:06:07 +0000 Subject: [PATCH 039/104] Upgrade/Install: Use new default admin color scheme for language dropdown on the setup screen. This changeset ensures the hover/focus color of the setup screen's language dropdown use the new default admin color scheme. Reviewed by SergeyBiryukov. Props huzaifaalmesbah, noruzzaman. Fixes #64961. See #64308. git-svn-id: https://develop.svn.wordpress.org/trunk@62163 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/install.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/css/install.css b/src/wp-admin/css/install.css index 4173e9a228fda..71ea71c7d2863 100644 --- a/src/wp-admin/css/install.css +++ b/src/wp-admin/css/install.css @@ -340,7 +340,7 @@ body.language-chooser { .language-chooser select option:hover, .language-chooser select option:focus { - color: #0a4b78; + color: var(--wp-admin-theme-color-darker-20); } .language-chooser .step { From 86e469970abcd2bec7ca13af25a9e109dc8589f2 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Fri, 27 Mar 2026 17:33:28 +0000 Subject: [PATCH 040/104] Build/Test Tools: Add 7.0-RC2 to upgrade testing workflows. Since this workflow tests based on tagged versions available through WP-CLI, the pre-release versions need to be used. Follow up to [62160]. See #64966. git-svn-id: https://develop.svn.wordpress.org/trunk@62166 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/upgrade-develop-testing.yml | 4 ++-- .github/workflows/upgrade-testing.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/upgrade-develop-testing.yml b/.github/workflows/upgrade-develop-testing.yml index 2b00536adeb66..7dfe96271f459 100644 --- a/.github/workflows/upgrade-develop-testing.yml +++ b/.github/workflows/upgrade-develop-testing.yml @@ -75,7 +75,7 @@ jobs: db-type: [ 'mysql' ] db-version: [ '5.7', '8.4' ] # WordPress 5.3 is the oldest version that supports PHP 7.4. - wp: [ '5.3', '6.8', '6.9' ] + wp: [ '5.3', '6.8', '6.9', '7.0-RC2' ] multisite: [ false, true ] with: os: ${{ matrix.os }} @@ -101,7 +101,7 @@ jobs: php: [ '7.4', '8.4' ] db-type: [ 'mysql' ] db-version: [ '8.4' ] - wp: [ '6.8', '6.9' ] + wp: [ '6.8', '6.9', '7.0-RC2' ] multisite: [ false, true ] with: os: ${{ matrix.os }} diff --git a/.github/workflows/upgrade-testing.yml b/.github/workflows/upgrade-testing.yml index f042131bd7c26..b8953bad20def 100644 --- a/.github/workflows/upgrade-testing.yml +++ b/.github/workflows/upgrade-testing.yml @@ -71,7 +71,7 @@ jobs: php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] db-type: [ 'mysql' ] db-version: [ '5.7', '8.0', '8.4', '9.6' ] - wp: [ '6.8', '6.9' ] + wp: [ '6.8', '6.9', '7.0-RC2' ] multisite: [ false, true ] with: os: ${{ matrix.os }} From 2f7a23ac708df1865d752208d3bf3f4f48c124cc Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 27 Mar 2026 22:18:14 +0000 Subject: [PATCH 041/104] Code Quality: Unwrap `sprintf()` with one argument. This removes unnecessary uses of the `sprintf()` function when localizing or outputting static strings. These changes simplify the code and improve readability without affecting functionality. Props Soean. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62167 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/plugin-editor.php | 2 +- src/wp-includes/class-wp-customize-widgets.php | 4 +--- src/wp-includes/class-wp-xmlrpc-server.php | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/plugin-editor.php b/src/wp-admin/plugin-editor.php index 3e58a7ec60b6a..4beee7b8409da 100644 --- a/src/wp-admin/plugin-editor.php +++ b/src/wp-admin/plugin-editor.php @@ -164,7 +164,7 @@ ); wp_enqueue_script( 'wp-theme-plugin-editor' ); wp_add_inline_script( 'wp-theme-plugin-editor', sprintf( 'jQuery( function( $ ) { wp.themePluginEditor.init( $( "#template" ), %s ); } )', wp_json_encode( $settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ) ); -wp_add_inline_script( 'wp-theme-plugin-editor', sprintf( 'wp.themePluginEditor.themeOrPlugin = "plugin";' ) ); +wp_add_inline_script( 'wp-theme-plugin-editor', 'wp.themePluginEditor.themeOrPlugin = "plugin";' ); require_once ABSPATH . 'wp-admin/admin-header.php'; diff --git a/src/wp-includes/class-wp-customize-widgets.php b/src/wp-includes/class-wp-customize-widgets.php index 10f178e10fd84..14132b6e55728 100644 --- a/src/wp-includes/class-wp-customize-widgets.php +++ b/src/wp-includes/class-wp-customize-widgets.php @@ -775,9 +775,7 @@ public function enqueue_scripts() { if ( 1 === $registered_sidebar_count ) { $no_areas_shown_message = html_entity_decode( - sprintf( - __( 'Your theme has 1 widget area, but this particular page does not display it.' ) - ), + __( 'Your theme has 1 widget area, but this particular page does not display it.' ), ENT_QUOTES, get_bloginfo( 'charset' ) ); diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 995ade9508a9a..6b9c7dd3efec8 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -296,7 +296,7 @@ public function login( $password ) { if ( ! $this->is_enabled ) { - $this->error = new IXR_Error( 405, sprintf( __( 'XML-RPC services are disabled on this site.' ) ) ); + $this->error = new IXR_Error( 405, __( 'XML-RPC services are disabled on this site.' ) ); return false; } From 993b89ddd6577c83c844031804dd320b7aaaffb6 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 27 Mar 2026 22:46:01 +0000 Subject: [PATCH 042/104] Exports: Exclude `wp_sync_storage` post type from exports. Configured the Real Time Collaboration post type to be excluded from exports by default. The data is considered ephemeral and includes data on post IDs that may not match the IDs of posts on the importing site. Introduces a test to the export test suite to ensure that post types set to be excluded from exports are, in fact, excluded from exports. Props peterwilsoncc, desrosj, westonruter, jorbin, mukesh27, czarate. Fixes #64964. git-svn-id: https://develop.svn.wordpress.org/trunk@62168 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/post.php | 1 + tests/phpunit/tests/admin/exportWp.php | 37 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 88deb1090fc5c..215fa153f7495 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -687,6 +687,7 @@ function create_initial_post_types() { 'show_in_menu' => false, 'show_in_rest' => false, 'show_ui' => false, + 'can_export' => false, 'supports' => array( 'custom-fields' ), ) ); diff --git a/tests/phpunit/tests/admin/exportWp.php b/tests/phpunit/tests/admin/exportWp.php index 11c615af6f497..f17ef0d4ad343 100644 --- a/tests/phpunit/tests/admin/exportWp.php +++ b/tests/phpunit/tests/admin/exportWp.php @@ -474,4 +474,41 @@ public function test_export_with_null_term_meta_values() { $this->assertNotFalse( $xml, 'Export should not fail with NULL term meta values' ); $this->assertGreaterThan( 0, count( $xml->channel->item ), 'Export should contain items' ); } + + /** + * Ensure that posts types with 'can_export' set to false are not included in the export. + * + * @ticket 64964 + */ + public function test_export_does_not_include_excluded_post_types() { + register_post_type( + 'wpexport_excluded', + array( 'can_export' => false ) + ); + + $excluded_post_id = self::factory()->post->create( + array( + 'post_title' => 'Excluded Post Type', + 'post_type' => 'wpexport_excluded', + 'post_status' => 'publish', + ) + ); + + $xml = $this->get_the_export( + array( + 'content' => 'all', + ) + ); + + $found_post = false; + foreach ( $xml->channel->item as $item ) { + $wp_item = $item->children( 'wp', true ); + if ( (int) $wp_item->post_id === $excluded_post_id ) { + $found_post = true; + break; + } + } + + $this->assertFalse( $found_post, 'Posts of excluded post types should not be included in export' ); + } } From 42531603c6a57bf9c6e13c126f4057a927aad344 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Sat, 28 Mar 2026 00:18:48 +0000 Subject: [PATCH 043/104] Tests: Fix test that checks MySQL version recommendation. A test in the `external-http` group that detects when the recommended version of MySQL is too old is failing due to an upstream change to the content of version documentation pages. Because the required information is no longer on the page, this switches to using the public API from endoflife.date. This site is an open source (MIT licensed) resource that is actively maintained. Props peterwilsoncc. See #64894. git-svn-id: https://develop.svn.wordpress.org/trunk@62170 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/readme.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/readme.php b/tests/phpunit/tests/readme.php index 036abf93c25cf..bcab29f69a368 100644 --- a/tests/phpunit/tests/readme.php +++ b/tests/phpunit/tests/readme.php @@ -36,10 +36,15 @@ public function test_readme_mysql_version() { preg_match( '#Recommendations.*MySQL version ([0-9.]*)#s', $readme, $matches ); - $response_body = $this->get_response_body( "https://dev.mysql.com/doc/relnotes/mysql/{$matches[1]}/en/" ); + $response_body = json_decode( $this->get_response_body( 'https://endoflife.date/api/mysql.json' ) ); + $eol_date = ''; - // Retrieve the date of the first GA release for the recommended branch. - preg_match( '#.*(\d{4}-\d{2}-\d{2}), General Availability#s', $response_body, $mysql_matches ); + foreach ( $response_body as $version ) { + if ( $version->cycle === $matches[1] && false !== $version->eol ) { + $eol_date = $version->eol; + break; + } + } /* * Per https://www.mysql.com/support/, Oracle actively supports MySQL releases for 5 years from GA release. @@ -50,7 +55,7 @@ public function test_readme_mysql_version() { * * TODO: Reduce this back to 5 years once MySQL 8.1 compatibility is achieved. */ - $mysql_eol = gmdate( 'Y-m-d', strtotime( $mysql_matches[1] . ' +8 years' ) ); + $mysql_eol = gmdate( 'Y-m-d', strtotime( $eol_date . ' +8 years' ) ); $current_date = gmdate( 'Y-m-d' ); $this->assertLessThan( $mysql_eol, $current_date, "readme.html's Recommended MySQL version is too old. Remember to update the WordPress.org Requirements page, too." ); From 19d4be93bb59a1318eb95947612985cb21f7be7d Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 28 Mar 2026 12:26:38 +0000 Subject: [PATCH 044/104] Admin reskin: Remove line-height from input fields. `line-height` values that were previously used to match the height of input fields affect the height of the background shown when text inside those fields is selected. Removing these `line-height` declarations allows the text selection highlight to render more naturally. Additionally, update the height of the custom Date/Time format input fields on the General Settings screen to `32px` to align with the new design system. Props arkaprabhachowdhury, audrasjb, hmrisad, huzaifaalmesbah, manhar, manishxdp, noruzzaman, ozgursar, r1k0, sachinrajcp123, wildworks. Fixes #64763. git-svn-id: https://develop.svn.wordpress.org/trunk@62171 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/color-picker.css | 1 - src/wp-admin/css/common.css | 1 - src/wp-admin/css/customize-controls.css | 1 - src/wp-admin/css/dashboard.css | 1 - src/wp-admin/css/forms.css | 6 +----- src/wp-admin/css/list-tables.css | 2 -- src/wp-admin/css/media.css | 1 - 7 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/wp-admin/css/color-picker.css b/src/wp-admin/css/color-picker.css index 2e038353b7cca..1e7525799e855 100644 --- a/src/wp-admin/css/color-picker.css +++ b/src/wp-admin/css/color-picker.css @@ -94,7 +94,6 @@ width: 4rem; font-size: 12px; font-family: monospace; - line-height: 2.33333333; /* 28px */ margin: 0; padding: 0 5px; vertical-align: top; diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index f3128b9e657ca..211cf0022c1e0 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -1115,7 +1115,6 @@ th.action-links { .wp-filter .search-form input[type="search"] { min-height: 32px; - line-height: 2.14285714; /* 30px for 32px height with 14px font */ padding: 0 8px; } diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css index 9e7b4d3185eba..2b4e87daa7ce7 100644 --- a/src/wp-admin/css/customize-controls.css +++ b/src/wp-admin/css/customize-controls.css @@ -2184,7 +2184,6 @@ p.customize-section-description { } .themes-filter-bar .wp-filter-search { - line-height: 2.14285714; /* 30px for 32px compact input */ padding: 0 10px 0 30px; max-width: 100%; width: 40%; diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index 562e730d026a1..324637a7a7b08 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -533,7 +533,6 @@ width: 40%; margin: 0; min-height: 32px; - line-height: 2.14285714; padding: 0 8px; } diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index 35d67a9bdb666..e4e09ca1b6023 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -69,7 +69,6 @@ input[type="url"], input[type="week"] { padding: 0 12px; /* inherits font size 14px */ - line-height: 2.71428571; /* 38px for 40px min-height */ min-height: 40px; } @@ -816,7 +815,6 @@ p.search-box { p.search-box input[type="search"], p.search-box input[type="text"] { min-height: 32px; - line-height: 2.14285714; /* 30px for 32px height with 14px font */ padding: 0 8px; } @@ -1237,8 +1235,7 @@ table.form-table td .updated p { .options-general-php input.small-text { width: 56px; margin: -2px 0; - min-height: 24px; - line-height: 1.71428571; /* 24px for 14px font size */ + min-height: 32px; } .options-general-php .spinner { @@ -1601,7 +1598,6 @@ table.form-table td .updated p { -webkit-appearance: none; padding: 0 12px; min-height: 40px; - line-height: 2.5; /* 40px for 16px font */ } ::-webkit-datetime-edit { diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index e2b7e30f1dd63..2108f4644b406 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -686,7 +686,6 @@ th.sorted a span { font-size: 13px; text-align: center; min-height: 32px; - line-height: 2.30769231; /* 30px for 32px height with 13px font */ padding: 0 8px; } @@ -1099,7 +1098,6 @@ tr.inline-edit-row td { .inline-edit-row select, .inline-edit-row input:where(:not([type=checkbox],[type=radio],[type=submit],[type=button])) { - line-height: 2.14285714; min-height: 32px; padding: 0 8px 0 8px; } diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css index 13378c2cafbaa..20806972d3aa1 100644 --- a/src/wp-admin/css/media.css +++ b/src/wp-admin/css/media.css @@ -568,7 +568,6 @@ border color while dragging a file over the uploader drop area */ .media-frame.mode-grid .media-toolbar input[type="search"] { min-height: 32px; - line-height: 2.14285714; /* 30px for 32px height with 14px font */ padding: 0 8px; } From 1967f10684b221dfbfbb8ddbb2e7f13599dce838 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 28 Mar 2026 23:49:49 +0000 Subject: [PATCH 045/104] Code Quality: Simplify boolean assignments. This makes minor code simplifications by removing unnecessary ternary operations and directly assigning boolean expressions. These changes make the code easier to read and maintain, but do not alter the underlying logic. Props Soean. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62173 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-ms-themes-list-table.php | 2 +- src/wp-admin/includes/upgrade.php | 2 +- src/wp-admin/nav-menus.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/class-wp-ms-themes-list-table.php b/src/wp-admin/includes/class-wp-ms-themes-list-table.php index 6a1fbdfb8dfd2..a0fca2fd60fe4 100644 --- a/src/wp-admin/includes/class-wp-ms-themes-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-themes-list-table.php @@ -59,7 +59,7 @@ public function __construct( $args = array() ) { $page = $this->get_pagenum(); - $this->is_site_themes = ( 'site-themes-network' === $this->screen->id ) ? true : false; + $this->is_site_themes = 'site-themes-network' === $this->screen->id; if ( $this->is_site_themes ) { $this->site_id = isset( $_REQUEST['id'] ) ? (int) $_REQUEST['id'] : 0; diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 6adb0521ff295..914113bde00d0 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -3282,7 +3282,7 @@ function dbDelta( $queries = '', $execute = true ) { // phpcs:ignore WordPress.N 'fieldname' => $tableindex->Column_name, 'subpart' => $tableindex->Sub_part, ); - $index_ary[ $keyname ]['unique'] = ( '0' === (string) $tableindex->Non_unique ) ? true : false; + $index_ary[ $keyname ]['unique'] = '0' === (string) $tableindex->Non_unique; $index_ary[ $keyname ]['index_type'] = $tableindex->Index_type; } diff --git a/src/wp-admin/nav-menus.php b/src/wp-admin/nav-menus.php index 8dc68582f3e86..808574f1250d6 100644 --- a/src/wp-admin/nav-menus.php +++ b/src/wp-admin/nav-menus.php @@ -549,9 +549,9 @@ $menu_count = count( $nav_menus ); // Are we on the add new screen? -$add_new_screen = ( isset( $_GET['menu'] ) && 0 === (int) $_GET['menu'] ) ? true : false; +$add_new_screen = isset( $_GET['menu'] ) && 0 === (int) $_GET['menu']; -$locations_screen = ( isset( $_GET['action'] ) && 'locations' === $_GET['action'] ) ? true : false; +$locations_screen = isset( $_GET['action'] ) && 'locations' === $_GET['action']; $page_count = wp_count_posts( 'page' ); From 9cee59516df60b1b51ff163763082a0464617995 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 29 Mar 2026 02:40:33 +0000 Subject: [PATCH 046/104] Tests: Prevent unconditional HTTP request in `PluralFormsTest` data provider. Developed in https://github.com/WordPress/wordpress-develop/pull/11382 Follow-up to r41722. Props westonruter, swissspidy, SergeyBiryukov. See #41562. Fixes #41562. git-svn-id: https://develop.svn.wordpress.org/trunk@62174 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/pomo/pluralForms.php | 35 +++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/pomo/pluralForms.php b/tests/phpunit/tests/pomo/pluralForms.php index b676735c59bac..0329374e94df8 100644 --- a/tests/phpunit/tests/pomo/pluralForms.php +++ b/tests/phpunit/tests/pomo/pluralForms.php @@ -39,25 +39,26 @@ protected static function parenthesize_plural_expression( $expression ) { /** * @ticket 41562 - * @dataProvider data_locales * @group external-http */ - public function test_regression( $lang, $nplurals, $expression ) { + public function test_regression(): void { require_once dirname( __DIR__, 2 ) . '/includes/plural-form-function.php'; - $parenthesized = self::parenthesize_plural_expression( $expression ); - $old_style = tests_make_plural_form_function( $nplurals, $parenthesized ); - $plural_forms = new Plural_Forms( $expression ); + foreach ( self::data_locales() as list( $lang, $nplurals, $expression ) ) { + $parenthesized = self::parenthesize_plural_expression( $expression ); + $old_style = tests_make_plural_form_function( $nplurals, $parenthesized ); + $plural_forms = new Plural_Forms( $expression ); - $generated_old = array(); - $generated_new = array(); + $generated_old = array(); + $generated_new = array(); - foreach ( range( 0, 200 ) as $i ) { - $generated_old[] = $old_style( $i ); - $generated_new[] = $plural_forms->get( $i ); - } + foreach ( range( 0, 200 ) as $i ) { + $generated_old[] = $old_style( $i ); + $generated_new[] = $plural_forms->get( $i ); + } - $this->assertSame( $generated_old, $generated_new ); + $this->assertSame( $generated_old, $generated_new ); + } } /** @@ -70,7 +71,15 @@ public function test_locales_file_not_empty() { $this->assertNotEmpty( $locales, 'Unable to retrieve GP_Locales file' ); } - public static function data_locales() { + /** + * Gets locale data. + * + * Note: Do not use this method directly as a data provider, or else it may cause an unconditional HTTP request + * during PHPUnit initialization. See . + * + * @return array + */ + public static function data_locales(): array { if ( ! class_exists( 'GP_Locales' ) ) { $filename = download_url( 'https://raw.githubusercontent.com/GlotPress/GlotPress-WP/develop/locales/locales.php' ); if ( is_wp_error( $filename ) ) { From 3b3b726485364f0e1d99eadc1a05aab44cb35ac5 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sun, 29 Mar 2026 22:29:55 +0000 Subject: [PATCH 047/104] Code Quality: Replace `is_integer()` with `is_int()` for consistency. [https://www.php.net/manual/en/function.is-integer.php is_integer()] is an alias for [https://www.php.net/manual/en/function.is-int.php is_int()]. While they function identically, the WordPress Coding Standards and modern PHP practices generally lean towards using the official function name rather than its alias to maintain consistency across the codebase. Props Soean, westonruter, SergeyBiryukov. See #64913. git-svn-id: https://develop.svn.wordpress.org/trunk@62175 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-pclzip.php | 14 +++++++------- src/wp-includes/IXR/class-IXR-value.php | 2 +- src/wp-includes/functions.php | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/wp-admin/includes/class-pclzip.php b/src/wp-admin/includes/class-pclzip.php index f1128d900c9e6..1fdc8b9f41296 100644 --- a/src/wp-admin/includes/class-pclzip.php +++ b/src/wp-admin/includes/class-pclzip.php @@ -296,7 +296,7 @@ function create($p_filelist) $v_size--; // ----- Look for first arg - if ((is_integer($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { + if ((is_int($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { // ----- Parse the options $v_result = $this->privParseOptions($v_arg_list, $v_size, $v_options, @@ -479,7 +479,7 @@ function add($p_filelist) $v_size--; // ----- Look for first arg - if ((is_integer($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { + if ((is_int($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { // ----- Parse the options $v_result = $this->privParseOptions($v_arg_list, $v_size, $v_options, @@ -732,7 +732,7 @@ function extract() $v_arg_list = func_get_args(); // ----- Look for first arg - if ((is_integer($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { + if ((is_int($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { // ----- Parse the options $v_result = $this->privParseOptions($v_arg_list, $v_size, $v_options, @@ -893,7 +893,7 @@ function extractByIndex($p_index) $v_size--; // ----- Look for first arg - if ((is_integer($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { + if ((is_int($v_arg_list[0])) && ($v_arg_list[0] > 77000)) { // ----- Parse the options $v_result = $this->privParseOptions($v_arg_list, $v_size, $v_options, @@ -1479,7 +1479,7 @@ function privParseOptions(&$p_options_list, $p_size, &$v_result_list, $v_request // ----- Check the value $v_value = $p_options_list[$i+1]; - if ((!is_integer($v_value)) || ($v_value<0)) { + if ((!is_int($v_value)) || ($v_value<0)) { PclZip::privErrorLog(PCLZIP_ERR_INVALID_OPTION_VALUE, "Integer expected for option '".PclZipUtilOptionText($p_options_list[$i])."'"); return PclZip::errorCode(); } @@ -1646,7 +1646,7 @@ function privParseOptions(&$p_options_list, $p_size, &$v_result_list, $v_request // ----- Parse items $v_work_list = explode(",", $p_options_list[$i+1]); } - else if (is_integer($p_options_list[$i+1])) { + else if (is_int($p_options_list[$i+1])) { $v_work_list[0] = $p_options_list[$i+1].'-'.$p_options_list[$i+1]; } else if (is_array($p_options_list[$i+1])) { @@ -1944,7 +1944,7 @@ function privFileDescrParseAtt(&$p_file_list, &$p_filedescr, $v_options, $v_requ break; case PCLZIP_ATT_FILE_MTIME : - if (!is_integer($v_value)) { + if (!is_int($v_value)) { PclZip::privErrorLog(PCLZIP_ERR_INVALID_ATTRIBUTE_VALUE, "Invalid type ".gettype($v_value).". Integer expected for attribute '".PclZipUtilOptionText($v_key)."'"); return PclZip::errorCode(); } diff --git a/src/wp-includes/IXR/class-IXR-value.php b/src/wp-includes/IXR/class-IXR-value.php index 0fd878bcac351..20c205d128580 100644 --- a/src/wp-includes/IXR/class-IXR-value.php +++ b/src/wp-includes/IXR/class-IXR-value.php @@ -44,7 +44,7 @@ function calculateType() if ($this->data === true || $this->data === false) { return 'boolean'; } - if (is_integer($this->data)) { + if (is_int($this->data)) { return 'int'; } if (is_double($this->data)) { diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 262b069e6da22..991ebb6f45ab3 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -5105,7 +5105,7 @@ function _wp_array_get( $input_array, $path, $default_value = null ) { } if ( is_string( $path_element ) - || is_integer( $path_element ) + || is_int( $path_element ) || null === $path_element ) { /* @@ -5182,7 +5182,7 @@ function _wp_array_set( &$input_array, $path, $value = null ) { foreach ( $path as $path_element ) { if ( - ! is_string( $path_element ) && ! is_integer( $path_element ) && + ! is_string( $path_element ) && ! is_int( $path_element ) && ! is_null( $path_element ) ) { return; From c2f2558aaa89531854b9e68aab96edeeeddd1958 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Mar 2026 00:16:18 +0000 Subject: [PATCH 048/104] Media: Guard against `false` return values from `wp_get_attachment_image_src()` and `wp_getimagesize()`. * Add `is_array()` checks before accessing return values from `wp_get_attachment_image_src()` in `get_oembed_response_data_rich()`, `wp_playlist_shortcode()`, and `wp_prepare_attachment_for_js()`. * Guard `wp_getimagesize()` calls within `wp_get_attachment_image_src()` itself. * Ensure `wp_get_attachment_image_src()` always returns the expected `array{0: string, 1: int, 2: int, 3: bool}` type or `false` by normalizing the filter result with explicit type casting and default values. * Add `@phpstan-return` annotations to both `wp_get_attachment_image_src()` and `wp_getimagesize()` for the specific array shapes. Developed in https://github.com/WordPress/wordpress-develop/pull/11073 Props hbhalodia, westonruter, mukesh27, edent, ozgursar, roshniahuja14. Fixes #64742. git-svn-id: https://develop.svn.wordpress.org/trunk@62176 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/embed.php | 11 +++--- src/wp-includes/media.php | 71 ++++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/embed.php b/src/wp-includes/embed.php index dd21b6cf22fe1..3fb8968c7c62c 100644 --- a/src/wp-includes/embed.php +++ b/src/wp-includes/embed.php @@ -739,10 +739,13 @@ function get_oembed_response_data_rich( $data, $post, $width, $height ) { } if ( $thumbnail_id ) { - list( $thumbnail_url, $thumbnail_width, $thumbnail_height ) = wp_get_attachment_image_src( $thumbnail_id, array( $width, 0 ) ); - $data['thumbnail_url'] = $thumbnail_url; - $data['thumbnail_width'] = $thumbnail_width; - $data['thumbnail_height'] = $thumbnail_height; + $thumbnail_src = wp_get_attachment_image_src( $thumbnail_id, array( $width, 0 ) ); + + if ( is_array( $thumbnail_src ) ) { + $data['thumbnail_url'] = $thumbnail_src[0]; + $data['thumbnail_width'] = $thumbnail_src[1]; + $data['thumbnail_height'] = $thumbnail_src[2]; + } } return $data; diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 7ff250413ad2b..5277ec351bc49 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -972,12 +972,15 @@ function wp_get_registered_image_subsizes() { * @type int $2 Image height in pixels. * @type bool $3 Whether the image is a resized image. * } + * @phpstan-return array{ 0: string, 1: int, 2: int, 3: bool }|false */ function wp_get_attachment_image_src( $attachment_id, $size = 'thumbnail', $icon = false ) { // Get a thumbnail or intermediate image if there is one. $image = image_downsize( $attachment_id, $size ); if ( ! $image ) { - $src = false; + $src = false; + $width = 0; + $height = 0; if ( $icon ) { $src = wp_mime_type_icon( $attachment_id, '.svg' ); @@ -988,7 +991,11 @@ function wp_get_attachment_image_src( $attachment_id, $size = 'thumbnail', $icon $src_file = $icon_dir . '/' . wp_basename( $src ); - list( $width, $height ) = wp_getimagesize( $src_file ); + $image_size = wp_getimagesize( $src_file ); + if ( is_array( $image_size ) ) { + $width = $image_size[0]; + $height = $image_size[1]; + } $ext = strtolower( substr( $src_file, -4 ) ); @@ -997,7 +1004,11 @@ function wp_get_attachment_image_src( $attachment_id, $size = 'thumbnail', $icon $width = 48; $height = 64; } else { - list( $width, $height ) = wp_getimagesize( $src_file ); + $image_size = wp_getimagesize( $src_file ); + if ( is_array( $image_size ) ) { + $width = $image_size[0]; + $height = $image_size[1]; + } } } } @@ -1024,7 +1035,16 @@ function wp_get_attachment_image_src( $attachment_id, $size = 'thumbnail', $icon * an array of width and height values in pixels (in that order). * @param bool $icon Whether the image should be treated as an icon. */ - return apply_filters( 'wp_get_attachment_image_src', $image, $attachment_id, $size, $icon ); + $source = apply_filters( 'wp_get_attachment_image_src', $image, $attachment_id, $size, $icon ); + if ( is_array( $source ) && isset( $source[0] ) && is_string( $source[0] ) ) { + return array( + $source[0], + (int) ( $source[1] ?? 0 ), + (int) ( $source[2] ?? 0 ), + (bool) ( $source[3] ?? false ), + ); + } + return false; } /** @@ -3230,10 +3250,23 @@ function wp_playlist_shortcode( $attr ) { if ( $atts['images'] ) { $thumb_id = get_post_thumbnail_id( $attachment->ID ); if ( ! empty( $thumb_id ) ) { - list( $src, $width, $height ) = wp_get_attachment_image_src( $thumb_id, 'full' ); - $track['image'] = compact( 'src', 'width', 'height' ); - list( $src, $width, $height ) = wp_get_attachment_image_src( $thumb_id, 'thumbnail' ); - $track['thumb'] = compact( 'src', 'width', 'height' ); + $image_src_full = wp_get_attachment_image_src( $thumb_id, 'full' ); + if ( is_array( $image_src_full ) ) { + $track['image'] = array( + 'src' => $image_src_full[0], + 'width' => $image_src_full[1], + 'height' => $image_src_full[2], + ); + } + + $image_src_thumb = wp_get_attachment_image_src( $thumb_id, 'thumbnail' ); + if ( is_array( $image_src_thumb ) ) { + $track['thumb'] = array( + 'src' => $image_src_thumb[0], + 'width' => $image_src_thumb[1], + 'height' => $image_src_thumb[2], + ); + } } else { $src = wp_mime_type_icon( $attachment->ID, '.svg' ); $width = 48; @@ -4711,10 +4744,23 @@ function wp_prepare_attachment_for_js( $attachment ) { $id = get_post_thumbnail_id( $attachment->ID ); if ( ! empty( $id ) ) { - list( $src, $width, $height ) = wp_get_attachment_image_src( $id, 'full' ); - $response['image'] = compact( 'src', 'width', 'height' ); - list( $src, $width, $height ) = wp_get_attachment_image_src( $id, 'thumbnail' ); - $response['thumb'] = compact( 'src', 'width', 'height' ); + $response_image_full = wp_get_attachment_image_src( $id, 'full' ); + if ( is_array( $response_image_full ) ) { + $response['image'] = array( + 'src' => $response_image_full[0], + 'width' => $response_image_full[1], + 'height' => $response_image_full[2], + ); + } + + $response_image_thumb = wp_get_attachment_image_src( $id, 'thumbnail' ); + if ( is_array( $response_image_thumb ) ) { + $response['thumb'] = array( + 'src' => $response_image_thumb[0], + 'width' => $response_image_thumb[1], + 'height' => $response_image_thumb[2], + ); + } } else { $src = wp_mime_type_icon( $attachment->ID, '.svg' ); $width = 48; @@ -5724,6 +5770,7 @@ function wp_show_heic_upload_error( $plupload_settings ) { * @param string $filename The file path. * @param array $image_info Optional. Extended image information (passed by reference). * @return array|false Array of image information or false on failure. + * @phpstan-return array{ 0: int, 1: int, 2: int, 3: string, mime: string, bits?: int, channels?: int }|false */ function wp_getimagesize( $filename, ?array &$image_info = null ) { // Don't silence errors when in debug mode, unless running unit tests. From 232266b90c4ead9e3770bdb3be472425b70e6fe3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Mar 2026 05:06:31 +0000 Subject: [PATCH 049/104] Code Quality: Replace `void` with proper return types in `wpdb` and related functions. Replace `void` in union return types with `null`, `false`, or `never` as appropriate, and add explicit `return null` statements where methods previously fell through without a return value. Methods updated in `wpdb`: `prepare()`, `print_error()`, `check_connection()`, `get_row()`, `get_col_info()`, `bail()`, `check_database_version()`. Also adds `@return never` to `dead_db()` and fixes the `@phpstan-return` syntax for `wp_die()`. Developed in https://github.com/WordPress/wordpress-develop/pull/11009 Props apermo, westonruter, xate, mukesh27, SergeyBiryukov. Fixes #64703. git-svn-id: https://develop.svn.wordpress.org/trunk@62177 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wpdb.php | 25 ++++++++++++++++--------- src/wp-includes/functions.php | 4 +++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..e5300e6d75122 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -1453,11 +1453,11 @@ private function _escape_identifier_value( $identifier ) { * individual arguments. * @param mixed ...$args Further variables to substitute into the query's placeholders * if being called with individual arguments. - * @return string|void Sanitized query string, if there is a query to prepare. + * @return string|null Sanitized query string, if there is a query to prepare. */ public function prepare( $query, ...$args ) { if ( is_null( $query ) ) { - return; + return null; } /* @@ -1666,7 +1666,7 @@ public function prepare( $query, ...$args ) { '6.2.0' ); - return; + return null; } $args_count = count( $args ); @@ -1684,7 +1684,7 @@ public function prepare( $query, ...$args ) { '4.9.0' ); - return; + return null; } else { /* * If we don't have the right number of placeholders, @@ -1794,7 +1794,7 @@ public function esc_like( $text ) { * @global array $EZSQL_ERROR Stores error information of query and error string. * * @param string $str The error to display. - * @return void|false Void if the showing of errors is enabled, false if disabled. + * @return null|false Null if the showing of errors is enabled, false if disabled. */ public function print_error( $str = '' ) { global $EZSQL_ERROR; @@ -1855,6 +1855,8 @@ public function print_error( $str = '' ) { $query ); } + + return null; } /** @@ -2117,7 +2119,7 @@ public function parse_db_host( $host ) { * @since 3.9.0 * * @param bool $allow_bail Optional. Allows the function to bail. Default true. - * @return bool|void True if the connection is up. + * @return bool Whether the connection is up. Exits if down and $allow_bail is true. */ public function check_connection( $allow_bail = true ) { // Check if the connection is alive. @@ -3056,7 +3058,7 @@ public function get_var( $query = null, $x = 0, $y = 0 ) { * correspond to an stdClass object, an associative array, or a numeric array, * respectively. Default OBJECT. * @param int $y Optional. Row to return. Indexed from 0. Default 0. - * @return array|object|null|void Database query result in format specified by $output or null on failure. + * @return array|object|null Database query result in format specified by $output or null on failure. */ public function get_row( $query = null, $output = OBJECT, $y = 0 ) { $this->func_call = "\$db->get_row(\"$query\",$output,$y)"; @@ -3087,6 +3089,7 @@ public function get_row( $query = null, $output = OBJECT, $y = 0 ) { } else { $this->print_error( ' $db->get_row(string query, output type, int offset) -- Output type must be one of: OBJECT, ARRAY_A, ARRAY_N' ); } + return null; } /** @@ -3902,6 +3905,8 @@ public function get_col_info( $info_type = 'name', $col_offset = -1 ) { return $this->col_info[ $col_offset ]->{$info_type}; } } + + return null; } /** @@ -3937,7 +3942,7 @@ public function timer_stop() { * @param string $message The error message. * @param string $error_code Optional. A computer-readable string to identify the error. * Default '500'. - * @return void|false Void if the showing of errors is enabled, false if disabled. + * @return false False if the showing of errors is disabled. */ public function bail( $message, $error_code = '500' ) { if ( $this->show_errors ) { @@ -3995,7 +4000,7 @@ public function close() { * @since 2.5.0 * * @global string $required_mysql_version The minimum required MySQL version string. - * @return void|WP_Error + * @return WP_Error|null */ public function check_database_version() { global $required_mysql_version; @@ -4006,6 +4011,8 @@ public function check_database_version() { /* translators: 1: WordPress version number, 2: Minimum required MySQL version number. */ return new WP_Error( 'database_version', sprintf( __( 'Error: WordPress %1$s requires MySQL %2$s or higher' ), $wp_version, $required_mysql_version ) ); } + + return null; } /** diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 991ebb6f45ab3..cbd4143b4c72e 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -3776,7 +3776,7 @@ function wp_nonce_ays( $action ) { * } * @return never|void Returns void if `$args['exit']` is false, otherwise exits. * - * @phpstan-return ( $args['exit'] is false ? void : never ) + * @phpstan-return ( $args is array{exit: false} ? void : never ) */ function wp_die( $message = '', $title = '', $args = array() ) { global $wp_query; @@ -5511,6 +5511,8 @@ function wp_ob_end_flush_all() { * @since 2.3.2 * * @global wpdb $wpdb WordPress database abstraction object. + * + * @return never */ function dead_db() { global $wpdb; From 317adff562dfe2dfcd0aade85b2c7532c6441194 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Mar 2026 05:31:56 +0000 Subject: [PATCH 050/104] Code Quality: Replace `void` with proper return types in union PHPDoc annotations. In PHP's type system, `void` means a function does not return a value and cannot be part of a union type. Many functions in core were documented as returning e.g. `string|void` while actually returning `null` implicitly via bare `return;` statements. This replaces `void` with `null` in union return types, adds explicit `return null;` statements, and updates `@return` annotations across 22 files in `wp-includes`. Additionally: * Adds `@return never` for `WP_Recovery_Mode::redirect_protected()`. * Fixes `WP_Theme_JSON::set_spacing_sizes()` to use `@return void` instead of `@return null|void`. * Removes `void` from return types where the function always returns a value or dies: `remove_theme_support()`, `WP_Recovery_Mode::handle_error()`. * Fixes `wp_die()` return type from `never|void` to `void` with clarified description. * Initializes `$primary` variable in `get_active_blog_for_user()` to prevent a possible undefined variable notice. Developed in https://github.com/WordPress/wordpress-develop/pull/11012 Follow-up to r62177, r61766, r61719. Props apermo, xateman, westonruter, parthvataliya, nimeshatxecurify. See #64704. git-svn-id: https://develop.svn.wordpress.org/trunk@62178 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/canonical.php | 14 ++++++------- src/wp-includes/capabilities.php | 4 ++-- src/wp-includes/class-wp-admin-bar.php | 20 ++++++++++--------- src/wp-includes/class-wp-block-type.php | 6 +++--- .../class-wp-plugin-dependencies.php | 4 ++-- src/wp-includes/class-wp-recovery-mode.php | 8 +++++--- src/wp-includes/class-wp-roles.php | 4 ++-- src/wp-includes/class-wp-scripts.php | 6 +++--- src/wp-includes/class-wp-theme-json.php | 6 +++--- src/wp-includes/class-wp-widget.php | 2 +- src/wp-includes/class-wp-xmlrpc-server.php | 3 ++- src/wp-includes/comment.php | 9 +++++---- src/wp-includes/formatting.php | 3 ++- src/wp-includes/functions.php | 8 +++++--- src/wp-includes/media.php | 20 +++++++++---------- src/wp-includes/ms-functions.php | 12 ++++++----- src/wp-includes/post.php | 17 +++++++++------- src/wp-includes/revision.php | 16 +++++++-------- src/wp-includes/script-loader.php | 4 ++-- .../class-wp-style-engine-css-rules-store.php | 10 +++++----- src/wp-includes/theme.php | 5 +++-- src/wp-includes/widgets.php | 10 ++++++---- 22 files changed, 104 insertions(+), 87 deletions(-) diff --git a/src/wp-includes/canonical.php b/src/wp-includes/canonical.php index 9315ba7fb7ff9..6b8c17c07d55a 100644 --- a/src/wp-includes/canonical.php +++ b/src/wp-includes/canonical.php @@ -37,13 +37,13 @@ * @param string $requested_url Optional. The URL that was requested, used to * figure if redirect is needed. * @param bool $do_redirect Optional. Redirect to the new URL. - * @return string|void The string of the URL, if redirect needed. + * @return string|null The string of the URL, if redirect needed. Never returns if a redirect occurs, depending on $do_redirect. */ function redirect_canonical( $requested_url = null, $do_redirect = true ) { global $wp_rewrite, $is_IIS, $wp_query, $wpdb, $wp; if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( strtoupper( $_SERVER['REQUEST_METHOD'] ), array( 'GET', 'HEAD' ), true ) ) { - return; + return null; } /* @@ -62,7 +62,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) { if ( is_admin() || is_search() || is_preview() || is_trackback() || is_favicon() || ( $is_IIS && ! iis7_supports_permalinks() ) ) { - return; + return null; } if ( ! $requested_url && isset( $_SERVER['HTTP_HOST'] ) ) { @@ -74,7 +74,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) { $original = parse_url( $requested_url ); if ( false === $original ) { - return; + return null; } // Notice fixing. @@ -771,7 +771,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) { } if ( ! $redirect_url || $redirect_url === $requested_url ) { - return; + return null; } // Hex-encoded octets are case-insensitive. @@ -830,7 +830,7 @@ function lowercase_octets( $matches ) { // Yes, again -- in case the filter aborted the request. if ( ! $redirect_url || strip_fragment_from_url( $redirect_url ) === strip_fragment_from_url( $requested_url ) ) { - return; + return null; } if ( $do_redirect ) { @@ -841,7 +841,7 @@ function lowercase_octets( $matches ) { } else { // Debug. // die("1: $redirect_url
2: " . redirect_canonical( $redirect_url, false ) ); - return; + return null; } } else { return $redirect_url; diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index c5f4099127aab..028e61ec414a8 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1130,11 +1130,11 @@ function get_role( $role ) { * @param string $display_name Display name for role. * @param array|array $capabilities Capabilities to be added to the role. * Default empty array. - * @return WP_Role|void WP_Role object, if the role is added. + * @return WP_Role|null WP_Role object, if the role is added. */ function add_role( $role, $display_name, $capabilities = array() ) { if ( empty( $role ) ) { - return; + return null; } return wp_roles()->add_role( $role, $display_name, $capabilities ); diff --git a/src/wp-includes/class-wp-admin-bar.php b/src/wp-includes/class-wp-admin-bar.php index e1f7282f82ab9..9e7b54823b900 100644 --- a/src/wp-includes/class-wp-admin-bar.php +++ b/src/wp-includes/class-wp-admin-bar.php @@ -193,24 +193,25 @@ final protected function _set_node( $args ) { * @since 3.3.0 * * @param string $id - * @return object|void Node. + * @return object|null Node. */ final public function get_node( $id ) { $node = $this->_get_node( $id ); if ( $node ) { return clone $node; } + return null; } /** * @since 3.3.0 * * @param string $id - * @return object|void + * @return object|null */ final protected function _get_node( $id ) { if ( $this->bound ) { - return; + return null; } if ( empty( $id ) ) { @@ -220,17 +221,18 @@ final protected function _get_node( $id ) { if ( isset( $this->nodes[ $id ] ) ) { return $this->nodes[ $id ]; } + return null; } /** * @since 3.3.0 * - * @return array|void + * @return array|null */ final public function get_nodes() { $nodes = $this->_get_nodes(); if ( ! $nodes ) { - return; + return null; } foreach ( $nodes as &$node ) { @@ -242,11 +244,11 @@ final public function get_nodes() { /** * @since 3.3.0 * - * @return array|void + * @return array|null */ final protected function _get_nodes() { if ( $this->bound ) { - return; + return null; } return $this->nodes; @@ -307,11 +309,11 @@ public function render() { /** * @since 3.3.0 * - * @return object|void + * @return object|null */ final protected function _bind() { if ( $this->bound ) { - return; + return null; } /* diff --git a/src/wp-includes/class-wp-block-type.php b/src/wp-includes/class-wp-block-type.php index 461efbcc20bac..86f0ea21a2a3c 100644 --- a/src/wp-includes/class-wp-block-type.php +++ b/src/wp-includes/class-wp-block-type.php @@ -358,8 +358,8 @@ public function __construct( $block_type, $args = array() ) { * * @param string $name Deprecated property name. * - * @return string|string[]|null|void The value read from the new property if the first item in the array provided, - * null when value not found, or void when unknown property name provided. + * @return string|string[]|null The value read from the new property if the first item in the array provided, + * null when value not found or when unknown property name provided. */ public function __get( $name ) { if ( 'variations' === $name ) { @@ -371,7 +371,7 @@ public function __get( $name ) { } if ( ! in_array( $name, $this->deprecated_properties, true ) ) { - return; + return null; } $new_name = $name . '_handles'; diff --git a/src/wp-includes/class-wp-plugin-dependencies.php b/src/wp-includes/class-wp-plugin-dependencies.php index 67110a8fd2374..fa27098c07816 100644 --- a/src/wp-includes/class-wp-plugin-dependencies.php +++ b/src/wp-includes/class-wp-plugin-dependencies.php @@ -643,13 +643,13 @@ protected static function get_dependency_filepaths() { * * @global string $pagenow The filename of the current screen. * - * @return array|void An array of dependency API data, or void on early exit. + * @return array|null An array of dependency API data, or null on early exit. */ protected static function get_dependency_api_data() { global $pagenow; if ( ! is_admin() || ( 'plugins.php' !== $pagenow && 'plugin-install.php' !== $pagenow ) ) { - return; + return null; } if ( is_array( self::$dependency_api_data ) ) { diff --git a/src/wp-includes/class-wp-recovery-mode.php b/src/wp-includes/class-wp-recovery-mode.php index 7d1af1164185e..8fa6bf22cbdea 100644 --- a/src/wp-includes/class-wp-recovery-mode.php +++ b/src/wp-includes/class-wp-recovery-mode.php @@ -161,9 +161,9 @@ public function is_initialized() { * @since 5.2.0 * * @param array $error Error details from `error_get_last()`. - * @return true|WP_Error|void True if the error was handled and headers have already been sent. - * Or the request will exit to try and catch multiple errors at once. - * WP_Error if an error occurred preventing it from being handled. + * @return true|WP_Error True if the error was handled and headers have already been sent. + * Or the request will exit to try and catch multiple errors at once. + * WP_Error if an error occurred preventing it from being handled. */ public function handle_error( array $error ) { @@ -455,6 +455,8 @@ protected function store_error( $error ) { * next request again. Otherwise it will create a redirect loop. * * @since 5.2.0 + * + * @return never */ protected function redirect_protected() { // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality. diff --git a/src/wp-includes/class-wp-roles.php b/src/wp-includes/class-wp-roles.php index 6f7a7fbc84ba4..2cb9cdfe95296 100644 --- a/src/wp-includes/class-wp-roles.php +++ b/src/wp-includes/class-wp-roles.php @@ -171,11 +171,11 @@ public function reinit() { * @param string $display_name Role display name. * @param array|array $capabilities Capabilities to be added to the role. * Default empty array. - * @return WP_Role|void WP_Role object, if the role is added. + * @return WP_Role|null WP_Role object, if the role is added. */ public function add_role( $role, $display_name, $capabilities = array() ) { if ( empty( $role ) || isset( $this->roles[ $role ] ) ) { - return; + return null; } if ( wp_is_numeric_array( $capabilities ) ) { diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index b098de7e20e56..cb37b2b653877 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -201,7 +201,7 @@ public function print_scripts( $handles = false, $group = false ) { * @param string $handle The script's registered handle. * @param bool $display Optional. Whether to print the extra script * instead of just returning it. Default true. - * @return bool|string|void Void if no data exists, extra scripts if `$display` is true, + * @return bool|string|null Null if no data exists, extra scripts if `$display` is true, * true otherwise. */ public function print_scripts_l10n( $handle, $display = true ) { @@ -217,13 +217,13 @@ public function print_scripts_l10n( $handle, $display = true ) { * @param string $handle The script's registered handle. * @param bool $display Optional. Whether to print the extra script * instead of just returning it. Default true. - * @return bool|string|void Void if no data exists, extra scripts if `$display` is true, + * @return bool|string|null Null if no data exists, extra scripts if `$display` is true, * true otherwise. */ public function print_extra_script( $handle, $display = true ) { $output = $this->get_data( $handle, 'data' ); if ( ! $output ) { - return; + return null; } /* diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 5abd2817b8aa4..2c1cf07d160cd 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -4267,7 +4267,7 @@ public function get_data() { * generated in the constructor and merge methods instead * of manually after instantiation. * - * @return null|void + * @return void */ public function set_spacing_sizes() { _deprecated_function( __METHOD__, '6.6.0' ); @@ -4296,12 +4296,12 @@ public function set_spacing_sizes() { E_USER_NOTICE ); } - return null; + return; } // If theme authors want to prevent the generation of the core spacing scale they can set their theme.json spacingScale.steps to 0. if ( 0 === $spacing_scale['steps'] ) { - return null; + return; } $spacing_sizes = static::compute_spacing_sizes( $spacing_scale ); diff --git a/src/wp-includes/class-wp-widget.php b/src/wp-includes/class-wp-widget.php index 5ad32f49378a8..dc84f54774162 100644 --- a/src/wp-includes/class-wp-widget.php +++ b/src/wp-includes/class-wp-widget.php @@ -138,7 +138,7 @@ public function update( $new_instance, $old_instance ) { * @since 2.8.0 * * @param array $instance The settings for the particular instance of the widget. - * @return string|void Default return is 'noform'. + * @return string|null Default return is 'noform'. A subclass may opt to return null. */ public function form( $instance ) { echo '

' . __( 'There are no options for this widget.' ) . '

'; diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 6b9c7dd3efec8..8cbf6d977f5a2 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -353,7 +353,7 @@ public function login_pass_ok( * @since 1.5.2 * * @param string|array $data Escape single string or array of strings. - * @return string|void Returns with string is passed, alters by-reference + * @return string|null Returns with string if passed, alters by-reference * when array is passed. */ public function escape( &$data ) { @@ -368,6 +368,7 @@ public function escape( &$data ) { $v = wp_slash( $v ); } } + return null; } /** diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 0f102d1ea80ee..5395997ecd0ef 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2806,7 +2806,7 @@ function wp_defer_comment_counting( $defer = null ) { * @param int|null $post_id Post ID. * @param bool $do_deferred Optional. Whether to process previously deferred * post comment counts. Default false. - * @return bool|void True on success, false on failure or if post with ID does + * @return bool|null True on success, false on failure or if post with ID does * not exist. */ function wp_update_comment_count( $post_id, $do_deferred = false ) { @@ -2831,6 +2831,7 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { } elseif ( $post_id ) { return wp_update_comment_count_now( $post_id ); } + return null; } /** @@ -3313,13 +3314,13 @@ function privacy_ping_filter( $sites ) { * @param string $title Title of post. * @param string $excerpt Excerpt of post. * @param int $post_id Post ID. - * @return int|false|void Database query from update. + * @return int|false|null Database query from update. */ function trackback( $trackback_url, $title, $excerpt, $post_id ) { global $wpdb; if ( empty( $trackback_url ) ) { - return; + return null; } $options = array(); @@ -3334,7 +3335,7 @@ function trackback( $trackback_url, $title, $excerpt, $post_id ) { $response = wp_safe_remote_post( $trackback_url, $options ); if ( is_wp_error( $response ) ) { - return; + return null; } $wpdb->query( $wpdb->prepare( "UPDATE $wpdb->posts SET pinged = CONCAT(pinged, '\n', %s) WHERE ID = %d", $trackback_url, $post_id ) ); diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 2b32b5aafb05d..498d676f5c20f 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -6235,7 +6235,7 @@ function url_shorten( $url, $length = 35 ) { * @since 3.4.0 * * @param string $color - * @return string|void + * @return string|null The sanitized hex color, or null if invalid. */ function sanitize_hex_color( $color ) { if ( '' === $color ) { @@ -6246,6 +6246,7 @@ function sanitize_hex_color( $color ) { if ( preg_match( '|^#([A-Fa-f0-9]{3}){1,2}$|', $color ) ) { return $color; } + return null; } /** diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index cbd4143b4c72e..85b6043b0b5c8 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -3004,7 +3004,7 @@ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) { * @since 2.5.0 * * @param string $ext The extension to search. - * @return string|void The file type, example: audio, video, document, spreadsheet, etc. + * @return string|null The file type, example: audio, video, document, spreadsheet, etc. */ function wp_ext2type( $ext ) { $ext = strtolower( $ext ); @@ -3015,6 +3015,7 @@ function wp_ext2type( $ext ) { return $type; } } + return null; } /** @@ -3774,7 +3775,7 @@ function wp_nonce_ays( $action ) { * is a WP_Error. * @type bool $exit Whether to exit the process after completion. Default true. * } - * @return never|void Returns void if `$args['exit']` is false, otherwise exits. + * @return void Never returns if `$args['exit']` is true (the default), otherwise returns void. * * @phpstan-return ( $args is array{exit: false} ? void : never ) */ @@ -8580,7 +8581,7 @@ function wp_get_default_update_php_url() { * @param string $after Markup to output after the annotation. Default `

`. * @param bool $display Whether to echo or return the markup. Default `true` for echo. * - * @return string|void + * @return string|null Update PHP page annotation if available and $display is false, null otherwise. */ function wp_update_php_annotation( $before = '

', $after = '

', $display = true ) { $annotation = wp_get_update_php_annotation(); @@ -8592,6 +8593,7 @@ function wp_update_php_annotation( $before = '

', $after = return $before . $annotation . $after; } } + return null; } /** diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 5277ec351bc49..8f6ec1cef4e26 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -3422,7 +3422,7 @@ function wp_get_attachment_id3_keys( $attachment, $context = 'display' ) { * @type string $style The 'style' attribute for the `

  • {{{ data.message || data.code }}} <# if ( data.dismissible ) { #> - + <# } #>
  • @@ -395,14 +395,14 @@

    From 7919efbf22dde7225fc60b7c768cc0754d9d2e05 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Tue, 31 Mar 2026 19:25:33 +0000 Subject: [PATCH 055/104] I18N: Provide gettext context to disambiguate translation strings for "Notes". "Notes" translation string is used in both the Notes features and in the Link Manager, and they can have different meaning in some Locales, like in German for example. This changeset helps disambuguating these different contexts. Props westonruter, dmsnell, johnbillion. Fixes #64980. git-svn-id: https://develop.svn.wordpress.org/trunk@62185 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/meta-boxes.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/meta-boxes.php b/src/wp-admin/includes/meta-boxes.php index a1859f45c7422..0884c110b65bd 100644 --- a/src/wp-admin/includes/meta-boxes.php +++ b/src/wp-admin/includes/meta-boxes.php @@ -1453,7 +1453,14 @@ function link_advanced_meta_box( $link ) { - + + + From a2479dae529924b5ea8c401f443b9938c9af5127 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Tue, 31 Mar 2026 20:17:34 +0000 Subject: [PATCH 056/104] I18N: Provide gettext context to disambiguate translation strings for "Bulk Edit". The "Bulk Edit" translation string is used for both verbs and nouns, and may have different translations in some Locales. This changeset helps disambuguating these different contexts. Follow-up to [61255]. Props audrasjb, shailu25. Fixes #64994. git-svn-id: https://develop.svn.wordpress.org/trunk@62186 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-posts-list-table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index fc039a7573f19..c7d10fca217ef 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -437,7 +437,7 @@ protected function get_bulk_actions() { if ( $this->is_trash ) { $actions['untrash'] = __( 'Restore' ); } else { - $actions['edit'] = __( 'Bulk edit' ); + $actions['edit'] = _x( 'Bulk edit', 'verb' ); } } From 88734d4c323f459d558be7143a34f1cc08790356 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Tue, 31 Mar 2026 20:27:12 +0000 Subject: [PATCH 057/104] Administration: Prevent horizontal scrollbar in contextual help panel. In [62145], an `::after` CSS rule was added that caused an overflow, resulting in an unintended scrollbar always appearing on Windows OS for example. This changeset removes the related CSS rule which is unnecessary to fix the initial issue. Follow-up to [62145]. Props wildworks, SergeyBiryukov, sabernhardt, audrasjb, huzaifaalmesbah, mehrazmorshed, mukesh27. Fixes #64744. git-svn-id: https://develop.svn.wordpress.org/trunk@62187 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index 211cf0022c1e0..c691383019f6d 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -2077,17 +2077,6 @@ p.auto-update-status { box-shadow: 0 2px 0 rgba(0, 0, 0, 0.02), 0 1px 0 rgba(0, 0, 0, 0.02); } -.contextual-help-tabs .active::after { - content: ""; - position: absolute; - top: 0; - right: -1px; - width: 2px; - height: 100%; - background: inherit; - z-index: 2; -} - .contextual-help-tabs .active a { border-color: #c3c4c7; color: #2c3338; From abf9109166099011904710d1e8c63f444d0b862a Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 31 Mar 2026 22:03:29 +0000 Subject: [PATCH 058/104] I18N: Add context for Next/Previous strings in the jQuery UI datepicker. Follow-up to [37849]. Props timse201, anupkankale. Fixes #65005. git-svn-id: https://develop.svn.wordpress.org/trunk@62188 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/script-loader.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index e164da51bc248..42d42b3f8781d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2020,8 +2020,8 @@ function wp_localize_jquery_ui_datepicker() { 'currentText' => __( 'Today' ), 'monthNames' => array_values( $wp_locale->month ), 'monthNamesShort' => array_values( $wp_locale->month_abbrev ), - 'nextText' => __( 'Next' ), - 'prevText' => __( 'Previous' ), + 'nextText' => _x( 'Next', 'datepicker: navigate to next month' ), + 'prevText' => _x( 'Previous', 'datepicker: navigate to previous month' ), 'dayNames' => array_values( $wp_locale->weekday ), 'dayNamesShort' => array_values( $wp_locale->weekday_abbrev ), 'dayNamesMin' => array_values( $wp_locale->weekday_initial ), From 1893a30a778a4e190a06cd4a210bcc9194f95aeb Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 1 Apr 2026 02:33:56 +0000 Subject: [PATCH 059/104] Build/Test Tools: Copy vendor scripts earlier in the build. Relocates the `copy-vendor-scripts` to run during the the `build:js` portion of the build script. This ensures the JavaScript files are in place before the `uglify:all` task is run. Follow up to r61438 Props desrosj. Fixes #65006. See #64393. git-svn-id: https://develop.svn.wordpress.org/trunk@62189 602fd350-edb4-49c9-b593-d223f7449a82 --- Gruntfile.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 1c4280aff213b..5f9109fac3cb0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1837,6 +1837,7 @@ module.exports = function(grunt) { 'clean:js', 'build:webpack', 'copy:js', + 'copy-vendor-scripts', 'file_append', 'uglify:all', 'concat:tinymce', @@ -2133,7 +2134,6 @@ module.exports = function(grunt) { 'build:css', 'build:codemirror', 'build:gutenberg', - 'copy-vendor-scripts', 'build:certificates' ] ); } else { @@ -2145,7 +2145,6 @@ module.exports = function(grunt) { 'build:css', 'build:codemirror', 'build:gutenberg', - 'copy-vendor-scripts', 'replace:source-maps', 'verify:build' ] ); From b60f8bae9462280dcb694d375d89fcc87d1db7bc Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 1 Apr 2026 09:18:43 +0000 Subject: [PATCH 060/104] Admin Reskin: Change color picker height to match new design system. Update min-height from 30px to 32px for the color picker button and related elements to match new design system. Props audrasjb, hmbashar, huzaifaalmesbah, joedolson, juanmaguitar, mukesh27, noruzzaman, ozgursar, rahultank, rcorrales, sajib1223, tusharaddweb, vgnavada, wildworks. Fixes #64761. git-svn-id: https://develop.svn.wordpress.org/trunk@62191 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/color-picker.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/css/color-picker.css b/src/wp-admin/css/color-picker.css index 1e7525799e855..8264432dd39cc 100644 --- a/src/wp-admin/css/color-picker.css +++ b/src/wp-admin/css/color-picker.css @@ -10,7 +10,7 @@ /* Needs higher specificity to override `.wp-core-ui .button`. */ .wp-picker-container .wp-color-result.button { - min-height: 30px; + min-height: 32px; margin: 0 6px 6px 0; padding: 0 0 0 30px; font-size: 11px; @@ -22,7 +22,7 @@ border-left: 1px solid #c3c4c7; color: #50575e; display: block; - line-height: 2.54545455; /* 28px */ + line-height: 2.72727273; /* 30px */ padding: 0 6px; text-align: center; } @@ -76,8 +76,8 @@ .wp-customizer .wp-picker-input-wrap .button.wp-picker-clear { margin-left: 6px; padding: 0 8px; - line-height: 2.54545455; /* 28px */ - min-height: 30px; + line-height: 2.72727273; /* 30px */ + min-height: 32px; } .wp-picker-container .iris-square-slider .ui-slider-handle:focus { @@ -97,7 +97,7 @@ margin: 0; padding: 0 5px; vertical-align: top; - min-height: 30px; + min-height: 32px; } .wp-color-picker::-webkit-input-placeholder { From 8ae6d5a6d8d2c458c016bcfb8af9272deeefd869 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:15:49 -0400 Subject: [PATCH 061/104] Collaboration: Add input validation, compaction test, and remove old post meta tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add empty-field guards to `add_update()` and `set_awareness_state()` so rows with blank room, type, or client_id are rejected rather than inserted with default empty values. Enforce `minimum` and `minLength` on the REST `client_id` parameter. Add a dedicated test asserting that the lowest client ID is identified as the compactor and that compaction actually removes old rows. Remove `wpSyncPostMetaStorage.php` — the class it tested no longer exists in core now that storage uses the `wp_collaboration` table. --- .../class-wp-collaboration-table-storage.php | 8 + ...s-wp-http-polling-collaboration-server.php | 2 + .../collaboration/wpSyncPostMetaStorage.php | 707 ------------------ .../rest-api/rest-collaboration-server.php | 155 ++++ 4 files changed, 165 insertions(+), 707 deletions(-) delete mode 100644 tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 1c8e72d5452c9..2fdf56d820057 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -56,6 +56,10 @@ class WP_Collaboration_Table_Storage { public function add_update( string $room, $update ): bool { global $wpdb; + if ( '' === $room || empty( $update['type'] ) || empty( $update['client_id'] ) ) { + return false; + } + $result = $wpdb->insert( $wpdb->collaboration, array( @@ -296,6 +300,10 @@ public function remove_updates_through_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { global $wpdb; + if ( '' === $room || '' === $client_id ) { + return false; + } + $data = wp_json_encode( $state ); /* diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index c06cfd095aa08..d58629b2de438 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -152,6 +152,8 @@ public function register_routes(): void { 'type' => array( 'object', 'null' ), ), 'client_id' => array( + 'minimum' => 1, + 'minLength' => 1, 'required' => true, 'type' => array( 'string', 'integer' ), 'sanitize_callback' => function ( $value ) { diff --git a/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php b/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php deleted file mode 100644 index 8286fa643b45e..0000000000000 --- a/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php +++ /dev/null @@ -1,707 +0,0 @@ -user->create( array( 'role' => 'editor' ) ); - self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); - update_option( 'wp_collaboration_enabled', 1 ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$editor_id ); - delete_option( 'wp_collaboration_enabled' ); - wp_delete_post( self::$post_id, true ); - } - - public function set_up() { - parent::set_up(); - update_option( 'wp_collaboration_enabled', 1 ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); - } - - /** - * Returns the room identifier for the test post. - * - * @return string Room identifier. - */ - private function get_room(): string { - return 'postType/post:' . self::$post_id; - } - - /** - * Creates the storage post for the room and returns its ID. - * - * Adds a seed update to trigger storage post creation, then looks up - * the resulting post ID. - * - * @param WP_Sync_Post_Meta_Storage $storage Storage instance. - * @param string $room Room identifier. - * @return int Storage post ID. - */ - private function create_storage_post( WP_Sync_Post_Meta_Storage $storage, string $room ): int { - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'seed', - ) - ); - - $posts = get_posts( - array( - 'post_type' => 'wp_sync_storage', - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => md5( $room ), - 'fields' => 'ids', - ) - ); - - $storage_post_id = array_first( $posts ); - $this->assertIsInt( $storage_post_id ); - - return $storage_post_id; - } - - /** - * Primes the post meta object cache for a given post and returns the cached value. - * - * @param int $post_id Post ID. - * @return array Cached meta data. - */ - private function prime_and_get_meta_cache( int $post_id ): array { - update_meta_cache( 'post', array( $post_id ) ); - - $cached = wp_cache_get( $post_id, 'post_meta' ); - $this->assertNotFalse( $cached, 'Post meta cache should be primed.' ); - - return $cached; - } - - /** - * Adding a sync update must not invalidate the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_add_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'add_update() must not invalidate the post meta cache.' - ); - } - - /** - * Setting awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_insert_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // First call triggers an INSERT (no existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() INSERT path must not invalidate the post meta cache.' - ); - } - - /** - * Updating awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - // Prime cache after the insert. - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() UPDATE path must not invalidate the post meta cache.' - ); - } - - /** - * Removing updates / compaction must not invalidate the post meta cache for - * the storage post. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Get a cursor after the seed update. - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'remove_updates_before_cursor() must not invalidate the post meta cache.' - ); - } - - /** - * Adding a sync update must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_add_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'add_update() must not update posts last_changed.' - ); - } - - /** - * Setting awareness state must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_set_awareness_state_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Updating awareness state must not update the posts last_changed value. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Removing sync updates / compaction must not update the posts last_changed - * value. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'remove_updates_before_cursor() must not update posts last_changed.' - ); - } - - /** - * Getting awareness state must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_awareness_state_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Populate awareness so there is data to read. - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_awareness_state( $room ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_awareness_state() must not prime the post meta cache.' - ); - } - - /** - * Getting sync updates must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_updates_after_cursor( $room, 0 ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_updates_after_cursor() must not prime the post meta cache.' - ); - } - - /* - * Data integrity tests. - */ - - public function test_get_updates_after_cursor_drops_malformed_json() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Advance cursor past the seed update from create_storage_post(). - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - // Insert a valid update. - $valid_update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update ) ); - - // Insert a malformed JSON row directly into the database. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => '{invalid json', - ), - array( '%d', '%s', '%s' ) - ); - - // Insert another valid update after the malformed one. - $valid_update_2 = array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update_2 ) ); - - $updates = $storage->get_updates_after_cursor( $room, $cursor ); - - // The malformed row should be dropped; only the valid updates should appear. - $this->assertCount( 2, $updates ); - $this->assertSame( $valid_update, $updates[0] ); - $this->assertSame( $valid_update_2, $updates[1] ); - } - - public function test_duplicate_awareness_rows_coalesces_obn_latest_row() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Simulate a race: insert two awareness rows directly. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Stale' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Latest' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - // get_awareness_state and set_awareness_state should target the latest row. - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Latest' ), $awareness[0] ); - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Current' ) ) ); - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Current' ), $awareness[0] ); - } - - /* - * Race-condition tests. - * - * These use a $wpdb proxy to inject concurrent writes between internal - * query steps, verifying that the cursor-bounded query window prevents - * data loss. - */ - - public function test_cursor_does_not_skip_update_inserted_during_fetch_window() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - $seed_update = array( - 'client_id' => 1, - 'type' => 'update', - 'data' => 'c2VlZA==', - ); - - $this->assertTrue( $storage->add_update( $room, $seed_update ) ); - - $initial_updates = $storage->get_updates_after_cursor( $room, 0 ); - $baseline_cursor = $storage->get_cursor( $room ); - - // The seed from create_storage_post() plus the one we just added. - $this->assertGreaterThan( 0, $baseline_cursor ); - - $injected_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'injected-during-fetch' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $injected_update ) { - private $wpdb; - private $storage_post_id; - private $injected_update; - public $postmeta; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $injected_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->injected_update = $injected_update; - $this->postmeta = $wpdb->postmeta; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function get_row( $query = null, $output = OBJECT, $y = 0 ) { - $result = $this->wpdb->get_row( $query, $output, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_var( $query = null, $x = 0, $y = 0 ) { - $result = $this->wpdb->get_var( $query, $x, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_results( $query = null, $output = OBJECT ) { - return $this->wpdb->get_results( $query, $output ); - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - - private function inject_update(): void { - if ( $this->did_inject ) { - return; - } - - $this->did_inject = true; - - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->injected_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - private function maybe_inject_after_sync_query( $query ): void { - if ( $this->did_inject || ! is_string( $query ) ) { - return; - } - - $targets_postmeta = false !== strpos( $query, $this->postmeta ); - $targets_post_id = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query ); - $targets_meta_key = 1 === preg_match( - "/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/", - $query - ); - - if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) { - $this->inject_update(); - } - } - }; - - $wpdb = $proxy_wpdb; - try { - $race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor ); - $race_cursor = $storage->get_cursor( $room ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' ); - $this->assertEmpty( $race_updates ); - $this->assertSame( $baseline_cursor, $race_cursor ); - - $follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor ); - $follow_up_cursor = $storage->get_cursor( $room ); - - $this->assertCount( 1, $follow_up_updates ); - $this->assertSame( $injected_update, $follow_up_updates[0] ); - $this->assertGreaterThan( $race_cursor, $follow_up_cursor ); - } - - public function test_compaction_does_not_delete_update_inserted_during_delete() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Seed three updates so there's something to compact. - for ( $i = 1; $i <= 3; $i++ ) { - $this->assertTrue( - $storage->add_update( - $room, - array( - 'client_id' => $i, - 'type' => 'update', - 'data' => base64_encode( "seed-$i" ), - ) - ) - ); - } - - // Capture the cursor after all seeds are in place. - $storage->get_updates_after_cursor( $room, 0 ); - $compaction_cursor = $storage->get_cursor( $room ); - $this->assertGreaterThan( 0, $compaction_cursor ); - - $concurrent_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'arrived-during-compaction' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $concurrent_update ) { - private $wpdb; - private $storage_post_id; - private $concurrent_update; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->concurrent_update = $concurrent_update; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function query( $query ) { - $result = $this->wpdb->query( $query ); - - // After the DELETE executes, inject a concurrent update via - // raw SQL through the real $wpdb to avoid metadata cache - // interactions while the proxy is active. - if ( ! $this->did_inject - && is_string( $query ) - && 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" ) - && false !== strpos( $query, "post_id = {$this->storage_post_id}" ) - ) { - $this->did_inject = true; - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->concurrent_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - return $result; - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - }; - - // Run compaction through the proxy so the concurrent update - // is injected immediately after the DELETE executes. - $wpdb = $proxy_wpdb; - try { - $result = $storage->remove_updates_before_cursor( $room, $compaction_cursor ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $result ); - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' ); - - // The concurrent update must survive the compaction delete. - $updates = $storage->get_updates_after_cursor( $room, 0 ); - - $update_data = wp_list_pluck( $updates, 'data' ); - $this->assertContains( - $concurrent_update['data'], - $update_data, - 'Concurrent update should survive compaction.' - ); - } -} diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1f4f498985b9e..bb2b957702e77 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1626,6 +1626,109 @@ public function test_collaboration_compaction_reduces_total_updates(): void { $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); } + /** + * Verifies that the lowest client ID is correctly identified as the compactor + * and that compaction actually removes old rows from the database. + * + * @ticket 64696 + */ + public function test_collaboration_compactor_is_lowest_client_id(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 10 and client 5 both join and send updates. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), + ) ), + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', 0, array( 'user' => 'c5' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-5' ) ), + ) ), + ) + ); + + $data = $response->get_data(); + + // Client 5 is the lowest ID, so it should be the compactor candidate. + // Verify both clients appear in awareness (keys are client IDs). + $this->assertArrayHasKey( '5', $data['rooms'][0]['awareness'], 'Client 5 should appear in awareness.' ); + $this->assertArrayHasKey( '10', $data['rooms'][0]['awareness'], 'Client 10 should appear in awareness.' ); + + // Now add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Lowest client ID should be nominated as compactor.' ); + + // Client 10 (higher) polls — should NOT be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'], 'Higher client ID should not be nominated as compactor.' ); + + // Count rows before compaction. + $count_before = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + // Client 5 sends a compaction update. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', $cursor, array( 'user' => 'c5' ), array( $compaction ) ), + ) + ); + + // Count rows after compaction. + $count_after = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + $this->assertLessThan( $count_before, $count_after, 'Compaction should delete old rows from the database.' ); + } + /* * Cron cleanup tests. */ @@ -2907,4 +3010,56 @@ public function test_collaboration_table_accepts_arbitrary_types(): void { $this->assertNotNull( $row, 'Custom type row should be queryable.' ); $this->assertSame( 'persisted_crdt_doc', $row->type, 'Type column should store the custom value.' ); } + + /* + * Storage validation tests. + * + * Verify that storage methods reject empty required fields + * rather than inserting rows with default empty values. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( '', array( 'type' => 'update', 'client_id' => '1', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_type(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( 'postType/post:1', array( 'type' => '', 'client_id' => '1', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty type.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( 'postType/post:1', array( 'type' => 'update', 'client_id' => '', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty client_id.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( '', '1', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( 'postType/post:1', '', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty client_id.' ); + } } From 0298cdd267eae5f9343338478459b5568fc00251 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:21:19 -0400 Subject: [PATCH 062/104] Tests: Add compaction test for integer client IDs Add a test that passes integer client IDs (as JSON payloads would produce) and asserts the lowest client is nominated as compactor. This currently fails because the `(string)` cast on only one side of a strict comparison always evaluates to `false`. --- .../rest-api/rest-collaboration-server.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index bb2b957702e77..d5f9711078f59 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1729,6 +1729,54 @@ public function test_collaboration_compactor_is_lowest_client_id(): void { $this->assertLessThan( $count_before, $count_after, 'Compaction should delete old rows from the database.' ); } + /** + * Verifies that compaction works when client IDs are integers. + * + * JSON payloads may decode numeric client IDs as integers rather + * than strings. The compactor comparison must handle both types. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_with_integer_client_ids(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Both clients join with integer client IDs. + $this->dispatch_collaboration( + array( + $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), + ) ), + ) + ); + + // Add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest, integer) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 5, 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Integer client ID should be correctly identified as compactor.' ); + } + /* * Cron cleanup tests. */ From 05e48e90817298ba9afc192bab67a985ff9fa2e5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:21:24 -0400 Subject: [PATCH 063/104] Collaboration: Fix compactor nomination for integer client IDs Cast both sides of the strict comparison to string so the compactor is correctly identified when client IDs arrive as integers from JSON-decoded payloads. --- .../class-wp-http-polling-collaboration-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index d58629b2de438..b6c36dadae542 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -317,7 +317,7 @@ public function handle_request( WP_REST_Request $request ) { // The lowest client ID is nominated to perform compaction when needed. $is_compactor = false; if ( count( $merged_awareness ) > 0 ) { - $is_compactor = (string) min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = (string) min( array_keys( $merged_awareness ) ) === (string) $client_id; } // Process each update according to its type. From 4df841c330e663e548c18301b0f104500f8e7bc4 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:38:18 -0400 Subject: [PATCH 064/104] Tests: Fix PHPCS coding standards in collaboration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format multi-line function calls and associative arrays to comply with WordPress coding standards — one argument/value per line. --- .../rest-api/rest-collaboration-server.php | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index d5f9711078f59..640c214ead4a8 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1642,17 +1642,35 @@ public function test_collaboration_compactor_is_lowest_client_id(): void { // Client 10 and client 5 both join and send updates. $this->dispatch_collaboration( array( - $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), - ) ), + $this->build_room( + $room, + '10', + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), ) ); $response = $this->dispatch_collaboration( array( - $this->build_room( $room, '5', 0, array( 'user' => 'c5' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-5' ) ), - ) ), + $this->build_room( + $room, + '5', + 0, + array( 'user' => 'c5' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-5' ), + ), + ) + ), ) ); @@ -1745,9 +1763,18 @@ public function test_collaboration_compaction_with_integer_client_ids(): void { // Both clients join with integer client IDs. $this->dispatch_collaboration( array( - $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), - ) ), + $this->build_room( + $room, + 10, + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), ) ); @@ -3071,7 +3098,14 @@ public function test_collaboration_table_accepts_arbitrary_types(): void { */ public function test_collaboration_storage_add_update_rejects_empty_room(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( '', array( 'type' => 'update', 'client_id' => '1', 'data' => 'test' ) ); + $result = $storage->add_update( + '', + array( + 'type' => 'update', + 'client_id' => '1', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty room.' ); } @@ -3080,7 +3114,14 @@ public function test_collaboration_storage_add_update_rejects_empty_room(): void */ public function test_collaboration_storage_add_update_rejects_empty_type(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( 'postType/post:1', array( 'type' => '', 'client_id' => '1', 'data' => 'test' ) ); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => '', + 'client_id' => '1', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty type.' ); } @@ -3089,7 +3130,14 @@ public function test_collaboration_storage_add_update_rejects_empty_type(): void */ public function test_collaboration_storage_add_update_rejects_empty_client_id(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( 'postType/post:1', array( 'type' => 'update', 'client_id' => '', 'data' => 'test' ) ); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => 'update', + 'client_id' => '', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty client_id.' ); } From d368a44b94dbf718aa0fab7832dc819228301b11 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 1 Apr 2026 14:56:47 +0000 Subject: [PATCH 065/104] Connectors: Replace `plugin.slug` with `plugin.file` in connector registration. Use the plugin's main file path relative to the plugins directory (e.g. `akismet/akismet.php` or `hello.php`) instead of the WordPress.org slug to identify a connector's associated plugin. This lets `_wp_connectors_get_connector_script_module_data()` check plugin status with `file_exists()` and `is_plugin_active()` directly, removing the `get_plugins()` slug-to-file mapping that was previously needed. Props jorgefilipecosta, mukesh27, gziolo. Fixes #65002. git-svn-id: https://develop.svn.wordpress.org/trunk@62192 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-connector-registry.php | 9 +++-- src/wp-includes/connectors.php | 40 ++++++++----------- .../tests/connectors/wpConnectorRegistry.php | 4 +- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 18a5f80c94dbd..d7643360efeeb 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -40,7 +40,7 @@ * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * } */ @@ -109,7 +109,8 @@ final class WP_Connector_Registry { * @type array $plugin { * Optional. Plugin data for install/activate UI. * - * @type string $slug The WordPress.org plugin slug. + * @type string $file The plugin's main file path relative to the plugins + * directory (e.g. 'akismet/akismet.php' or 'hello.php'). * } * } * @return array|null The registered connector data on success, null on failure. @@ -242,8 +243,8 @@ public function register( string $id, array $args ): ?array { } } - if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { - $connector['plugin'] = $args['plugin']; + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) && ! empty( $args['plugin']['file'] ) ) { + $connector['plugin'] = array( 'file' => $args['plugin']['file'] ); } $this->registered_connectors[ $id ] = $connector; diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 06683ccaaa25c..68c8b4c1570d0 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -58,7 +58,8 @@ function wp_is_connector_registered( string $id ): bool { * @type array $plugin { * Optional. Plugin data for install/activate UI. * - * @type string $slug The WordPress.org plugin slug. + * @type string $file The plugin's main file path relative to the plugins + * directory (e.g. 'akismet/akismet.php' or 'hello.php'). * } * } * @phpstan-return ?array{ @@ -74,7 +75,7 @@ function wp_is_connector_registered( string $id ): bool { * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * } */ @@ -118,7 +119,8 @@ function wp_get_connector( string $id ): ?array { * @type array $plugin { * Optional. Plugin data for install/activate UI. * - * @type string $slug The WordPress.org plugin slug. + * @type string $file The plugin's main file path relative to the plugins + * directory (e.g. 'akismet/akismet.php' or 'hello.php'). * } * } * } @@ -135,7 +137,7 @@ function wp_get_connector( string $id ): ?array { * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * }> */ @@ -256,7 +258,7 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re 'description' => __( 'Text generation with Claude.' ), 'type' => 'ai_provider', 'plugin' => array( - 'slug' => 'ai-provider-for-anthropic', + 'file' => 'ai-provider-for-anthropic/plugin.php', ), 'authentication' => array( 'method' => 'api_key', @@ -268,7 +270,7 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re 'description' => __( 'Text and image generation with Gemini and Imagen.' ), 'type' => 'ai_provider', 'plugin' => array( - 'slug' => 'ai-provider-for-google', + 'file' => 'ai-provider-for-google/plugin.php', ), 'authentication' => array( 'method' => 'api_key', @@ -280,7 +282,7 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re 'description' => __( 'Text and image generation with GPT and Dall-E.' ), 'type' => 'ai_provider', 'plugin' => array( - 'slug' => 'ai-provider-for-openai', + 'file' => 'ai-provider-for-openai/plugin.php', ), 'authentication' => array( 'method' => 'api_key', @@ -636,15 +638,9 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { function _wp_connectors_get_connector_script_module_data( array $data ): array { $registry = AiClient::defaultRegistry(); - // Build a slug-to-file map for plugin installation status. - if ( ! function_exists( 'get_plugins' ) ) { + if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - $plugin_files_by_slug = array(); - foreach ( array_keys( get_plugins() ) as $plugin_file ) { - $slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); - $plugin_files_by_slug[ $slug ] = $plugin_file; - } $connectors = array(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { @@ -676,18 +672,14 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { 'authentication' => $auth_out, ); - if ( ! empty( $connector_data['plugin']['slug'] ) ) { - $plugin_slug = $connector_data['plugin']['slug']; - $plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null; - - $is_installed = null !== $plugin_file; - $is_activated = $is_installed && is_plugin_active( $plugin_file ); + if ( ! empty( $connector_data['plugin']['file'] ) ) { + $file = $connector_data['plugin']['file']; + $is_installed = file_exists( wp_normalize_path( WP_PLUGIN_DIR . '/' . $file ) ); + $is_activated = $is_installed && is_plugin_active( $file ); $connector_out['plugin'] = array( - 'slug' => $plugin_slug, - 'pluginFile' => $is_installed - ? ( str_ends_with( $plugin_file, '.php' ) ? substr( $plugin_file, 0, -4 ) : $plugin_file ) - : null, + 'file' => $file, + 'isInstalled' => $is_installed, 'isActivated' => $is_activated, ); } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index cab030d930dcd..d1a46dc0981fe 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -294,12 +294,12 @@ public function test_register_omits_logo_url_when_empty() { */ public function test_register_includes_plugin_data() { $args = self::$default_args; - $args['plugin'] = array( 'slug' => 'my-plugin' ); + $args['plugin'] = array( 'file' => 'my-plugin/my-plugin.php' ); $result = $this->registry->register( 'with-plugin', $args ); $this->assertArrayHasKey( 'plugin', $result ); - $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] ); + $this->assertSame( array( 'file' => 'my-plugin/my-plugin.php' ), $result['plugin'] ); } /** From e724afbde93f547f46d7625ad25945877f3de755 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 11:13:28 -0400 Subject: [PATCH 066/104] Tests: Update REST API fixture for client_id schema changes Regenerate wp-api-generated.js to include the minimum and minLength constraints added to the collaboration endpoint client_id parameter. --- tests/qunit/fixtures/wp-api-generated.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 9412af8a54053..79d977f1b17b6 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12758,6 +12758,8 @@ mockedApiResponse.Schema = { ] }, "client_id": { + "minimum": 1, + "minLength": 1, "required": true, "type": [ "string", @@ -12872,6 +12874,8 @@ mockedApiResponse.Schema = { ] }, "client_id": { + "minimum": 1, + "minLength": 1, "required": true, "type": [ "string", From c5627a124998b20ee492e922edbf4d75d5fd927c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 1 Apr 2026 15:53:17 +0000 Subject: [PATCH 067/104] Fix: Register Akismet Anti-Spam as a connector. Akismet comes with core but the connectors screen was not showing akismet even if akismet was on the file system. This commit fixes the issue. Props jorgefilipecosta, bluefuton, gziolo. Fixes #65012. git-svn-id: https://develop.svn.wordpress.org/trunk@62193 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/connectors.php | 19 ++++++++++++++++++ .../wpConnectorsGetConnectorSettings.php | 20 ++++++++++++------- .../rest-api/rest-settings-controller.php | 1 + tests/qunit/fixtures/wp-api-generated.js | 7 +++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 68c8b4c1570d0..a11faeb637623 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -210,6 +210,25 @@ function _wp_connectors_init(): void { _wp_connectors_register_default_ai_providers( $registry ); } + // Non-AI default connectors. + $registry->register( + 'akismet', + array( + 'name' => __( 'Akismet Anti-spam' ), + 'description' => __( 'Protect your site from spam.' ), + 'type' => 'spam_filtering', + 'plugin' => array( + 'file' => 'akismet/akismet.php', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://akismet.com/get/', + 'setting_name' => 'wordpress_api_key', + 'constant_name' => 'WPCOM_API_KEY', + ), + ) + ); + /** * Fires when the connector registry is ready for plugins to register connectors. * diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index cedac90111101..9d6c4b8486d9c 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -37,8 +37,9 @@ public function test_returns_expected_connector_keys(): void { $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'openai', $connectors ); $this->assertArrayHasKey( 'anthropic', $connectors ); + $this->assertArrayHasKey( 'akismet', $connectors ); $this->assertArrayHasKey( 'mock-connectors-test', $connectors ); - $this->assertCount( 4, $connectors ); + $this->assertCount( 5, $connectors ); } /** @@ -56,7 +57,7 @@ public function test_each_connector_has_required_fields(): void { $this->assertArrayHasKey( 'description', $connector_data, "Connector '{$connector_id}' is missing 'description'." ); $this->assertIsString( $connector_data['description'], "Connector '{$connector_id}' description should be a string." ); $this->assertArrayHasKey( 'type', $connector_data, "Connector '{$connector_id}' is missing 'type'." ); - $this->assertContains( $connector_data['type'], array( 'ai_provider' ), "Connector '{$connector_id}' has unexpected type '{$connector_data['type']}'." ); + $this->assertContains( $connector_data['type'], array( 'ai_provider', 'spam_filtering' ), "Connector '{$connector_id}' has unexpected type '{$connector_data['type']}'." ); $this->assertArrayHasKey( 'authentication', $connector_data, "Connector '{$connector_id}' is missing 'authentication'." ); $this->assertIsArray( $connector_data['authentication'], "Connector '{$connector_id}' authentication should be an array." ); $this->assertArrayHasKey( 'method', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'method'." ); @@ -79,11 +80,16 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url(): ++$api_key_count; $this->assertArrayHasKey( 'setting_name', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'setting_name'." ); - $this->assertSame( - 'connectors_ai_' . str_replace( '-', '_', $connector_id ) . '_api_key', - $connector_data['authentication']['setting_name'] ?? null, - "Connector '{$connector_id}' setting_name does not match expected format." - ); + + // AI providers use the connectors_ai_{id}_api_key convention. + // Non-AI connectors may use custom setting names. + if ( 'ai_provider' === $connector_data['type'] ) { + $this->assertSame( + 'connectors_ai_' . str_replace( '-', '_', $connector_id ) . '_api_key', + $connector_data['authentication']['setting_name'] ?? null, + "Connector '{$connector_id}' setting_name does not match expected format." + ); + } } $this->assertGreaterThan( 0, $api_key_count, 'At least one connector should use api_key authentication.' ); diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index b83cef41d2cf3..7f2ea9eba71f7 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -119,6 +119,7 @@ public function test_get_items() { 'default_ping_status', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php + 'wordpress_api_key', // Registered by Akismet connector. 'wp_collaboration_enabled', ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 003dc397ae305..c3ca057691308 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11011,6 +11011,12 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { + "wordpress_api_key": { + "title": "Akismet Anti-spam API Key", + "description": "API key for the Akismet Anti-spam connector.", + "type": "string", + "required": false + }, "title": { "title": "Title", "description": "Site title.", @@ -14544,6 +14550,7 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { + "wordpress_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 870e4e0d2719bc1a975666bcb1ce9d1c8749aa5f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 13:07:31 -0400 Subject: [PATCH 068/104] Tests: Skip collaboration E2E tests when JS runtime is unavailable The collaboration client-side code lives in Gutenberg and may not be bundled in every CI environment. Detect whether the runtime loaded after navigating to the editor and skip tests gracefully instead of timing out after 15 seconds. --- .../e2e/specs/collaboration/fixtures/index.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js index 446e6e88c459c..5c34a5d88d901 100644 --- a/tests/e2e/specs/collaboration/fixtures/index.js +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -23,7 +23,8 @@ export { SYNC_TIMEOUT }; export const test = base.extend( { collaborationUtils: async ( { admin, editor, requestUtils, page }, - use + use, + testInfo ) => { const utils = new CollaborationUtils( { admin, @@ -31,7 +32,27 @@ export const test = base.extend( { requestUtils, page, } ); + + /* + * Skip collaboration tests when the JS runtime is not available. + * + * The collaboration client-side code lives in Gutenberg and may not + * be bundled in every CI environment. Enable the setting, navigate + * to the editor, and check whether the runtime loaded. + */ await utils.setCollaboration( true ); + await admin.visitAdminPage( 'post-new.php' ); + await page.waitForFunction( () => window?.wp?.data && window?.wp?.blocks, { + timeout: 15000, + } ); + const hasRuntime = await page.evaluate( + () => !! window._wpCollaborationEnabled + ); + if ( ! hasRuntime ) { + testInfo.skip( true, 'Collaboration JS runtime is not available.' ); + return; + } + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { if ( error?.code !== 'existing_user_login' ) { throw error; From b5da8deadc4bd937f358163d1a6a8fe5451a95ca Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 1 Apr 2026 22:02:10 +0000 Subject: [PATCH 069/104] =?UTF-8?q?Admin=20Reskin:=20Correct=20=E2=80=9DCo?= =?UTF-8?q?pied!=E2=80=9D=20text=20alignment=20on=20Privacy=20Policy=20Gui?= =?UTF-8?q?de=20screen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to [61645]. Props mukesh27, wildworks, audrasjb, shailu25, anupkankale, kapilpaul, SergeyBiryukov. Fixes #65009. git-svn-id: https://develop.svn.wordpress.org/trunk@62196 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/edit.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/css/edit.css b/src/wp-admin/css/edit.css index f2ff6a485767a..b98dd889c59fe 100644 --- a/src/wp-admin/css/edit.css +++ b/src/wp-admin/css/edit.css @@ -994,15 +994,16 @@ form#tags-filter { } .privacy-settings-accordion-actions { - text-align: right; - display: block; + justify-content: right; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1em; } .privacy-settings-accordion-actions .success { display: none; color: #007017; - padding-right: 1em; - padding-top: 6px; } .privacy-settings-accordion-actions .success.visible { From 2183f2394182a6074ac0c85237344ab706897bb2 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 2 Apr 2026 01:23:45 +0000 Subject: [PATCH 070/104] REST API: Harden Real Time Collaboration endpoint. Adds additional validation and permission checks the the Real Time Collaboration endpoint to ensure only input in the expected format is supported. Props czarate, westonruter, joefusco. Fixes #64890. git-svn-id: https://develop.svn.wordpress.org/trunk@62198 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-http-polling-sync-server.php | 102 +++++- .../tests/rest-api/rest-sync-server.php | 293 +++++++++++++++++- 2 files changed, 378 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index 88554a48c7d54..a90821ab78d3e 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -37,6 +37,30 @@ class WP_HTTP_Polling_Sync_Server { */ const COMPACTION_THRESHOLD = 50; + /** + * Maximum total size (in bytes) of the request body. + * + * @since 7.0.0 + * @var int + */ + const MAX_BODY_SIZE = 16 * MB_IN_BYTES; + + /** + * Maximum number of rooms allowed per request. + * + * @since 7.0.0 + * @var int + */ + const MAX_ROOMS_PER_REQUEST = 50; + + /** + * Maximum length of a single update data string. + * + * @since 7.0.0 + * @var int + */ + const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES; + /** * Sync update type: compaction. * @@ -96,8 +120,9 @@ public function register_routes(): void { $typed_update_args = array( 'properties' => array( 'data' => array( - 'type' => 'string', - 'required' => true, + 'type' => 'string', + 'required' => true, + 'maxLength' => self::MAX_UPDATE_DATA_SIZE, ), 'type' => array( 'type' => 'string', @@ -149,12 +174,14 @@ public function register_routes(): void { 'methods' => array( WP_REST_Server::CREATABLE ), 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_permissions' ), + 'validate_callback' => array( $this, 'validate_request' ), 'args' => array( 'rooms' => array( 'items' => array( 'properties' => $room_args, 'type' => 'object', ), + 'maxItems' => self::MAX_ROOMS_PER_REQUEST, 'required' => true, 'type' => 'array', ), @@ -223,6 +250,30 @@ public function check_permissions( WP_REST_Request $request ) { return true; } + /** + * Validates that the request body does not exceed the maximum allowed size. + * + * Runs as the route-level validate_callback, after per-arg schema + * validation has already passed. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return true|WP_Error True if valid, WP_Error if the body is too large. + */ + public function validate_request( WP_REST_Request $request ) { + $body = $request->get_body(); + if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { + return new WP_Error( + 'rest_sync_body_too_large', + __( 'Request body is too large.' ), + array( 'status' => 413 ) + ); + } + + return true; + } + /** * Handles request: stores sync updates and awareness data, and returns * updates the client is missing. @@ -278,24 +329,47 @@ public function handle_request( WP_REST_Request $request ) { * * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'. * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'. - * @param string|null $object_id The object ID / entity key for single entities, null for collections. + * @param string|null $object_id The numeric object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { - // Handle single post type entities with a defined object ID. - if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', (int) $object_id ); + if ( is_string( $object_id ) ) { + if ( ! ctype_digit( $object_id ) ) { + return false; + } + $object_id = (int) $object_id; } - - // Handle single taxonomy term entities with a defined object ID. - if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { - $taxonomy = get_taxonomy( $entity_name ); - return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + if ( null !== $object_id && $object_id <= 0 ) { + // Object ID must be numeric if provided. + return false; } - // Handle single comment entities with a defined object ID. - if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) { - return current_user_can( 'edit_comment', (int) $object_id ); + // Validate permissions for the provided object ID. + if ( is_int( $object_id ) ) { + // Handle single post type entities with a defined object ID. + if ( 'postType' === $entity_kind ) { + if ( get_post_type( $object_id ) !== $entity_name ) { + // Post is not of the specified post type. + return false; + } + return current_user_can( 'edit_post', $object_id ); + } + + // Handle single taxonomy term entities with a defined object ID. + if ( 'taxonomy' === $entity_kind ) { + $term_exists = term_exists( $object_id, $entity_name ); + if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) { + // Either term doesn't exist OR term is not in specified taxonomy. + return false; + } + + return current_user_can( 'edit_term', $object_id ); + } + + // Handle single comment entities with a defined object ID. + if ( 'root' === $entity_kind && 'comment' === $entity_name ) { + return current_user_can( 'edit_comment', $object_id ); + } } // All the remaining checks are for collections. If an object ID is provided, diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 7a04226ced8c9..7ded16bd3b033 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -9,14 +9,20 @@ */ class WP_Test_REST_Sync_Server extends WP_Test_REST_Controller_Testcase { - protected static $editor_id; - protected static $subscriber_id; - protected static $post_id; + protected static int $editor_id; + protected static int $subscriber_id; + protected static int $post_id; + protected static int $category_id; + protected static int $tag_id; + protected static int $comment_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + self::$category_id = $factory->category->create(); + self::$tag_id = $factory->tag->create(); + self::$comment_id = $factory->comment->create( array( 'comment_post_ID' => self::$post_id ) ); // Enable option in setUpBeforeClass to ensure REST routes are registered. update_option( 'wp_collaboration_enabled', 1 ); @@ -27,6 +33,9 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$subscriber_id ); delete_option( 'wp_collaboration_enabled' ); wp_delete_post( self::$post_id, true ); + wp_delete_term( self::$category_id, 'category' ); + wp_delete_term( self::$tag_id, 'post_tag' ); + wp_delete_comment( self::$comment_id, true ); } public function set_up() { @@ -277,6 +286,107 @@ public function test_sync_permission_checked_per_room() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * @ticket 64890 + */ + public function test_sync_malformed_object_id_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:1abc' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_zero_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:0' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_post_type_mismatch_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // The test post is of type 'post', not 'page'. + $response = $this->dispatch_sync( array( $this->build_room( 'postType/page:' . self::$post_id ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_taxonomy_term_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$category_id ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_taxonomy_term_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_taxonomy_term_wrong_taxonomy_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // The tag term exists in 'post_tag', not 'category'. + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$tag_id ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_comment_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:' . self::$comment_id ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_comment_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_post_type_collection_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/nonexistent_type' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + /* * Validation tests. */ @@ -293,6 +403,183 @@ public function test_sync_invalid_room_format_rejected() { $this->assertSame( 400, $response->get_status() ); } + /** + * Verifies that schema type validation rejects a non-string value for the + * update 'data' field, confirming that per-arg schema validation still runs + * with a route-level validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_non_string_update_data(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => 12345, + 'type' => 'update', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that schema enum validation rejects an invalid update type, + * confirming that per-arg schema validation still runs with a route-level + * validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_invalid_update_type_enum(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => 'dGVzdA==', + 'type' => 'invalid_type', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that schema required-field validation rejects a room missing + * the 'client_id' field, confirming that per-arg schema validation still + * runs with a route-level validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_missing_required_room_field(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + // 'client_id' deliberately omitted. + 'room' => $this->get_post_room(), + 'updates' => array(), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the maxItems constraint rejects a request with more rooms + * than MAX_ROOMS_PER_REQUEST. + * + * @ticket 64890 + */ + public function test_sync_rejects_rooms_exceeding_max_items(): void { + wp_set_current_user( self::$editor_id ); + + $rooms = array(); + for ( $i = 0; $i < WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST + 1; $i++ ) { + $rooms[] = $this->build_room( 'root/site', $i + 1 ); + } + + $response = $this->dispatch_sync( $rooms ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the maxLength constraint rejects update data exceeding + * MAX_UPDATE_DATA_SIZE. + * + * @ticket 64890 + */ + public function test_sync_rejects_update_data_exceeding_max_length(): void { + wp_set_current_user( self::$editor_id ); + + $oversized_data = str_repeat( 'a', WP_HTTP_Polling_Sync_Server::MAX_UPDATE_DATA_SIZE + 1 ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => $oversized_data, + 'type' => 'update', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the route-level validate_callback rejects a request body + * exceeding MAX_BODY_SIZE. + * + * @ticket 64890 + */ + public function test_sync_rejects_oversized_request_body(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + + // Set valid parsed params so per-arg schema validation passes first. + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + // Set an oversized raw body to trigger the route-level validate_callback. + $request->set_body( str_repeat( 'x', WP_HTTP_Polling_Sync_Server::MAX_BODY_SIZE + 1 ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_sync_body_too_large', $response, 413 ); + } + /* * Response format tests. */ From d0c6277a5fa116af994bf6438a82834c9c2c2199 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Thu, 2 Apr 2026 23:09:08 +0000 Subject: [PATCH 071/104] Media: Update upload file overlay colors. Update the colors used for the file upload overlay mask to use the new admin theme colors. Props opurockey, huzaifaalmesbah, wildworks, audrasjb, manhar, joedolson. Fixes #65001. git-svn-id: https://develop.svn.wordpress.org/trunk@62199 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/css/media-views.css | 44 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 1b3c6edd7678f..f78a946c260f7 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -56,7 +56,7 @@ .media-frame a:focus { border-radius: 2px; box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); - color: #043959; + color: var(--wp-admin-theme-color-darker-20, #183ad6); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; } @@ -244,13 +244,13 @@ .media-modal-close:hover, .media-modal-close:active { - color: #135e96; + color: var(--wp-admin-theme-color, #3858e9); } .media-modal-close:focus { - color: #135e96; - border-color: #4f94d4; - box-shadow: 0 0 3px rgba(34, 113, 177, 0.8); + color: var(--wp-admin-theme-color, #3858e9); + border-color: var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 0 3px rgba(var(--wp-admin-theme-color--rgb, 56, 88, 233), 0.8); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; } @@ -673,7 +673,7 @@ font-size: 14px; line-height: 1.28571428; background: transparent; - color: #2271b1; + color: var(--wp-admin-theme-color, #3858e9); text-align: left; text-decoration: none; cursor: pointer; @@ -684,7 +684,7 @@ } .media-menu .media-menu-item:active { - color: #2271b1; + color: var(--wp-admin-theme-color, #3858e9); outline: none; } @@ -696,7 +696,7 @@ .media-menu .media-menu-item:focus { box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); - color: #043959; + color: var(--wp-admin-theme-color-darker-20, #183ad6); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; } @@ -739,7 +739,7 @@ .media-router .media-menu-item:hover, .media-router .media-menu-item:active { - color: #2271b1; + color: var(--wp-admin-theme-color, #3858e9); } .media-router .active, @@ -749,7 +749,7 @@ .media-router .media-menu-item:focus { box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); - color: #043959; + color: var(--wp-admin-theme-color-darker-20, #183ad6); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; z-index: 1; @@ -1321,8 +1321,8 @@ } .uploader-inline .close:focus { - outline: 1px solid #4f94d4; - box-shadow: 0 0 3px rgba(34, 113, 177, 0.8); + outline: 1px solid var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 0 3px rgba(var(--wp-admin-theme-color--rgb, 56, 88, 233), 0.8); } .attachments-browser.hide-sidebar .attachments, @@ -1409,7 +1409,7 @@ height: 10px; min-width: 20px; width: 0; - background: #2271b1; + background: var(--wp-admin-theme-color, #3858e9); border-radius: 10px; transition: width 300ms; } @@ -1527,7 +1527,7 @@ .uploader-window, .wp-editor-wrap .uploader-editor.droppable { - background: rgba(10, 75, 120, 0.9); + background-color: rgba(var(--wp-admin-theme-color--rgb, 56, 88, 233), 0.9); } .uploader-window-content, @@ -1688,13 +1688,13 @@ margin: 1px 8px 1px -8px; line-height: 1.4; border-right: 1px solid #dcdcde; - color: #2271b1; + color: var(--wp-admin-theme-color, #3858e9); text-decoration: none; } .media-selection .button-link:hover, .media-selection .button-link:focus { - color: #135e96; + color: var(--wp-admin-theme-color-darker-20, #183ad6); } .media-selection .button-link:last-child { @@ -1752,7 +1752,7 @@ .wp-core-ui .media-selection .attachment.details:focus { box-shadow: 0 0 0 1px #fff, - 0 0 2px 3px #4f94d4; + 0 0 2px 3px var(--wp-admin-theme-color, #3858e9); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; } @@ -1764,7 +1764,7 @@ .wp-core-ui .media-selection .attachment.details { box-shadow: 0 0 0 1px #fff, - 0 0 0 3px #2271b1; + 0 0 0 3px var(--wp-admin-theme-color, #3858e9); } .media-selection:after { @@ -2044,7 +2044,7 @@ margin: 0; padding: 0; background: transparent; - color: #2271b1; + color: var(--wp-admin-theme-color, #3858e9); font-size: 20px; line-height: 1; cursor: pointer; @@ -2053,9 +2053,9 @@ } .wp-core-ui.media-modal .image-editor .imgedit-help-toggle:focus { - color: #2271b1; - border-color: #2271b1; - box-shadow: 0 0 0 1px #2271b1; + color: var(--wp-admin-theme-color, #3858e9); + border-color: var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #3858e9); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; } From d508d24dd2722ad20c837016d66901f2794f0726 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Thu, 2 Apr 2026 23:33:50 +0000 Subject: [PATCH 072/104] Admin: Limit scope of admin notice link design. The design changes to admin notices links in the admin refresh were applied broadly to `.notice, .error, and .updated` classes, but these classes are sometimes used outside the context of an admin notice. Change selectors from `.notice a, .error a, .updated a` to `div.notice a, div.error a, div.updated a`. Props opurockey, audrasjb, vgnavada, gaisma22, shailu25, rbcorrales, joedolson. Fixes #64976. git-svn-id: https://develop.svn.wordpress.org/trunk@62200 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index c691383019f6d..28b881d363c7e 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -1473,22 +1473,22 @@ div.error p, color: #1e1e1e; } -.notice a, -.error a, -.updated a { +div.notice a, +div.error a, +div.updated a { color: var(--wp-admin-theme-color-darker-10); text-decoration: underline; } -.notice a:hover, -.error a:hover, -.updated a:hover { +div.notice a:hover, +div.error a:hover, +div.updated a:hover { color: var(--wp-admin-theme-color-darker-20); } -.notice a:focus, -.error a:focus, -.updated a:focus { +div.notice a:focus, +div.error a:focus, +div.updated a:focus { box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); outline: 2px solid transparent; border-radius: 2px; From 85108188d02f77712e4f1b88fb08e3e6e87c0216 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 2 Apr 2026 23:48:08 +0000 Subject: [PATCH 073/104] Code Quality: Remove unused variable in `WP_Block_Patterns_Registry`. Follow-up to [56805], [59101]. Props Soean, mukesh27. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62201 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-block-patterns-registry.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-block-patterns-registry.php b/src/wp-includes/class-wp-block-patterns-registry.php index c9bcd63549ab4..782ee9030c19e 100644 --- a/src/wp-includes/class-wp-block-patterns-registry.php +++ b/src/wp-includes/class-wp-block-patterns-registry.php @@ -227,10 +227,9 @@ public function get_registered( $pattern_name ) { * and per style. */ public function get_all_registered( $outside_init_only = false ) { - $patterns = $outside_init_only - ? $this->registered_patterns_outside_init - : $this->registered_patterns; - $hooked_blocks = get_hooked_blocks(); + $patterns = $outside_init_only + ? $this->registered_patterns_outside_init + : $this->registered_patterns; foreach ( $patterns as $index => $pattern ) { $content = $this->get_content( $pattern['name'], $outside_init_only ); From 54593bce56136640b5616dd2ce2ef388bba11975 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 3 Apr 2026 21:58:33 +0000 Subject: [PATCH 074/104] Tests: Move data providers and helpers in `Tests_REST_Server` for consistency. This ensures that data providers or helper functions used by a single test are located next to the test, for consistency with the rest of the test suite. Follow-up to [37905], [37943], [45809], [47239], [47260], [47351], [48947], [49252], [49257], [51960], [53110], [56096], [59032]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62205 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/rest-api/rest-server.php | 148 +++++++++---------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 440effe4fe6f7..57b7bbb38abcd 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -151,6 +151,21 @@ public function test_envelope_param( $_embed ) { $this->assertSame( $headers, $enveloped['headers'] ); } + /** + * Data provider. + * + * @return array + */ + public function data_envelope_params() { + return array( + array( '1' ), + array( 'true' ), + array( false ), + array( 'alternate' ), + array( array( 'alternate' ) ), + ); + } + public function test_default_param() { register_rest_route( @@ -1721,6 +1736,32 @@ public function test_rest_send_refreshed_nonce_invalid_nonce() { $this->assertArrayNotHasKey( 'X-WP-Nonce', $headers ); } + /** + * Helper to setup a users and auth cookie global for the + * rest_send_refreshed_nonce related tests. + */ + protected function helper_setup_user_for_rest_send_refreshed_nonce_tests() { + $author = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $author ); + + global $wp_rest_auth_cookie; + + $wp_rest_auth_cookie = true; + } + + /** + * Helper to make the request and get the headers for the + * rest_send_refreshed_nonce related tests. + * + * @return array + */ + protected function helper_make_request_and_return_headers_for_rest_send_refreshed_nonce_tests() { + $request = new WP_REST_Request( 'GET', '/', array() ); + $result = rest_get_server()->serve_request( '/' ); + + return rest_get_server()->sent_headers; + } + /** * Refreshed nonce should be present in header when a valid nonce is * passed for logged in/anonymous user and not present when nonce is not @@ -1751,6 +1792,23 @@ public function test_rest_send_refreshed_nonce( $has_logged_in_user, $has_nonce } } + /** + * @return array { + * @type array { + * @type bool $has_logged_in_user Are we registering a user for the test. + * @type bool $has_nonce Is the nonce passed. + * } + * } + */ + public function data_rest_send_refreshed_nonce() { + return array( + array( true, true ), + array( true, false ), + array( false, true ), + array( false, false ), + ); + } + /** * Make sure that a sanitization that transforms the argument type will not * cause the validation to fail. @@ -1790,6 +1848,22 @@ public function test_rest_validate_before_sanitization() { $this->assertSame( 200, $response->get_status() ); } + public function _validate_as_integer_123( $value, $request, $key ) { + if ( ! is_int( $value ) ) { + return new WP_Error( 'some-error', 'This is not valid!' ); + } + + return true; + } + + public function _validate_as_string_foo( $value, $request, $key ) { + if ( ! is_string( $value ) ) { + return new WP_Error( 'some-error', 'This is not valid!' ); + } + + return true; + } + /** * @ticket 43691 */ @@ -2637,78 +2711,4 @@ public function test_prefers_developer_defined_target_hints() { $this->assertArrayHasKey( 'allow', $link['targetHints'] ); $this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] ); } - - public function _validate_as_integer_123( $value, $request, $key ) { - if ( ! is_int( $value ) ) { - return new WP_Error( 'some-error', 'This is not valid!' ); - } - - return true; - } - - public function _validate_as_string_foo( $value, $request, $key ) { - if ( ! is_string( $value ) ) { - return new WP_Error( 'some-error', 'This is not valid!' ); - } - - return true; - } - - /** - * @return array { - * @type array { - * @type bool $has_logged_in_user Are we registering a user for the test. - * @type bool $has_nonce Is the nonce passed. - * } - * } - */ - public function data_rest_send_refreshed_nonce() { - return array( - array( true, true ), - array( true, false ), - array( false, true ), - array( false, false ), - ); - } - - /** - * Helper to setup a users and auth cookie global for the - * rest_send_refreshed_nonce related tests. - */ - protected function helper_setup_user_for_rest_send_refreshed_nonce_tests() { - $author = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $author ); - - global $wp_rest_auth_cookie; - - $wp_rest_auth_cookie = true; - } - - /** - * Helper to make the request and get the headers for the - * rest_send_refreshed_nonce related tests. - * - * @return array - */ - protected function helper_make_request_and_return_headers_for_rest_send_refreshed_nonce_tests() { - $request = new WP_REST_Request( 'GET', '/', array() ); - $result = rest_get_server()->serve_request( '/' ); - - return rest_get_server()->sent_headers; - } - - /** - * Data provider. - * - * @return array - */ - public function data_envelope_params() { - return array( - array( '1' ), - array( 'true' ), - array( false ), - array( 'alternate' ), - array( array( 'alternate' ) ), - ); - } } From e2d6d2b3174e22a6d37e8e8bf34565fe847211e9 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Sat, 4 Apr 2026 06:37:50 +0000 Subject: [PATCH 075/104] Administration: Improve dashboard widgets border styles. This changeset fixes a CSS glitch on dashboard widgets bottom border when they are collapsed. Follow-up to [61646]. Props pratik-jain, audrasjb, ankitkumarshah. Fixes #65017. See #64549. git-svn-id: https://develop.svn.wordpress.org/trunk@62206 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index 28b881d363c7e..b317af45e023e 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -2281,7 +2281,7 @@ html.wp-toolbar { line-height: 1; } -.postbox.closed { +.postbox.closed .postbox-header { border-bottom: 0; } From b10d2f90d7ae85b78ab1e0bbf38ed56209abb58b Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 4 Apr 2026 23:17:45 +0000 Subject: [PATCH 076/104] Tests: Adjust Unicode tests for consistency. Includes: * Adding missing `@covers` tags. * Correcting test class names as per the naming conventions. * Moving `wp_check_invalid_utf8()` tests to their own file, separate from `wp_scrub_utf8()`. Follow-up to [60630], [60793], [61000]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62207 602fd350-edb4-49c9-b593-d223f7449a82 --- .../tests/unicode/wpCheckInvalidUtf8.php | 110 ++++++++++++++++++ .../tests/unicode/wpHasNoncharacters.php | 4 +- tests/phpunit/tests/unicode/wpIsValidUtf8.php | 5 +- tests/phpunit/tests/unicode/wpScrubUtf8.php | 45 +------ 4 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 tests/phpunit/tests/unicode/wpCheckInvalidUtf8.php diff --git a/tests/phpunit/tests/unicode/wpCheckInvalidUtf8.php b/tests/phpunit/tests/unicode/wpCheckInvalidUtf8.php new file mode 100644 index 0000000000000..f477683eafd06 --- /dev/null +++ b/tests/phpunit/tests/unicode/wpCheckInvalidUtf8.php @@ -0,0 +1,110 @@ +assertSame( + $bytes, + wp_check_invalid_utf8( $bytes ), + 'Should have returned the unchanged string for valid UTF-8 input when not stripping invalid bytes.' + ); + + $this->assertSame( + $bytes, + wp_check_invalid_utf8( $bytes, true ), + 'Should have returned the unchanged string for valid UTF-8 input when stripping invalid bytes.' + ); + } else { + $this->assertSame( + '', + wp_check_invalid_utf8( $bytes ), + 'Should have rejected invalid input, returning an empty string when not stripping invalid bytes.' + ); + + $this->assertSame( + $scrubbed, + wp_check_invalid_utf8( $bytes, true ), + 'Failed to properly scrub the invalid spans of UTF-8 from the input string.' + ); + } + } + + /** + * Data provider. + * + * @throws Exception + * + * @return Generator + */ + public static function data_utf8_test_data() { + $test_file = fopen( __DIR__ . '/../../data/unicode/utf8tests/utf8tests.txt', 'r' ); + $line_number = 0; + $last_description = ''; + + while ( false !== ( $line = fgets( $test_file ) ) ) { + ++$line_number; + + if ( empty( trim( $line ) ) ) { + continue; + } + + if ( str_starts_with( $line, '#' ) ) { + $last_description = trim( substr( $line, 1 ) ); + continue; + } + + $test_parts = explode( ':', $line ); + if ( count( $test_parts ) < 3 ) { + throw new Exception( 'Wrong test data: check utf8tests.txt' ); + } + + list( $reference, $classification, $test_data ) = $test_parts; + + $reference = trim( $reference ); + $classification = trim( $classification ); + $test_data = trim( $test_data ); + + switch ( $classification ) { + case 'valid': + yield "{$reference} {$last_description}" => array( $test_data, null ); + break; + + case 'valid hex': + case 'invalid hex': + if ( 'invalid hex' === $classification && count( $test_parts ) < 5 ) { + throw new Exception( "Test data missing expected “scrubbed” value: check utf8tests.txt:{$line_number}" ); + } + + $bytes = hex2bin( str_replace( ' ', '', $test_data ) ); + $scrubbed = 'invalid hex' === $classification + ? hex2bin( str_replace( ' ', '', trim( $test_parts[4] ) ) ) + : null; + + yield "{$reference} {$last_description}" => array( $bytes, $scrubbed ); + break; + + default: + throw new Exception( "Test input file contains unrecognized input classification '{$classification}' (see utf8tests.txt): {$line}" ); + } + } + } +} diff --git a/tests/phpunit/tests/unicode/wpHasNoncharacters.php b/tests/phpunit/tests/unicode/wpHasNoncharacters.php index d3022dd922df2..880f89c4f8e45 100644 --- a/tests/phpunit/tests/unicode/wpHasNoncharacters.php +++ b/tests/phpunit/tests/unicode/wpHasNoncharacters.php @@ -4,9 +4,11 @@ * * @package WordPress * @group unicode + * + * @covers ::wp_has_noncharacters */ +class Tests_Unicode_WpHasNoncharacters extends WP_UnitTestCase { -class Tests_WpHasNoncharacters extends WP_UnitTestCase { /** * Ensures that a noncharacter inside a string will be properly detected. * diff --git a/tests/phpunit/tests/unicode/wpIsValidUtf8.php b/tests/phpunit/tests/unicode/wpIsValidUtf8.php index 43876a7eee8e6..386ff8cf2d6ee 100644 --- a/tests/phpunit/tests/unicode/wpIsValidUtf8.php +++ b/tests/phpunit/tests/unicode/wpIsValidUtf8.php @@ -1,12 +1,15 @@ assertSame( - $bytes, - wp_check_invalid_utf8( $bytes ), - 'Should have returned the unchanged string for valid UTF-8 input when not stripping invalid bytes.' - ); - - $this->assertSame( - $bytes, - wp_check_invalid_utf8( $bytes, true ), - 'Should have returned the unchanged string for valid UTF-8 input when stripping invalid bytes.' - ); - } else { - $this->assertSame( - '', - wp_check_invalid_utf8( $bytes ), - 'Should have rejected invalid input, returning an empty string when not stripping invalid bytes.' - ); - - $this->assertSame( - $scrubbed, - wp_check_invalid_utf8( $bytes, true ), - 'Failed to properly scrub the invalid spans of UTF-8 from the input string.' - ); - } - } +class Tests_Unicode_WpScrubUtf8 extends WP_UnitTestCase { /** * Verifies that WordPress can properly detect valid UTF-8 while replacing invalid byte sequences. @@ -82,7 +47,7 @@ public function test_properly_scrubs_utf8( string $bytes, ?string $scrubbed = nu * @param string $bytes Bytes as a PHP string. * @param string|null $scrubbed Expected checked value, if string isn’t valid UTF-8. */ - public function test_fallback_properly_checks_utf8( string $bytes, ?string $scrubbed = null ) { + public function test_fallback_properly_scrubs_utf8( string $bytes, ?string $scrubbed = null ) { if ( null === $scrubbed ) { $this->assertSame( $bytes, From 609f25f9c5bed003d4cb9c0645c6e81c0e62e334 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sun, 5 Apr 2026 23:13:50 +0000 Subject: [PATCH 077/104] Tests: Move `wp_dropdown_languages()` tests to their own file. This aims to make the tests more discoverable and easier to expand. Follow-up to [36631], [39169], [43359], [44514]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62208 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/l10n.php | 169 ------------------ .../tests/l10n/wpDropdownLanguages.php | 167 +++++++++++++++++ 2 files changed, 167 insertions(+), 169 deletions(-) create mode 100644 tests/phpunit/tests/l10n/wpDropdownLanguages.php diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index 2f7992c34069f..88d867b38f529 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -126,175 +126,6 @@ public function test_wp_get_installed_translations_for_core() { $this->assertSame( 'GlotPress/4.0.0-beta.2', $data_en_gb['X-Generator'] ); } - /** - * @ticket 35294 - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'de_DE' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'de_DE', - 'echo' => false, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringContainsString( 'id="foo"', $actual ); - $this->assertStringContainsString( 'name="bar"', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - } - - /** - * @ticket 38632 - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages_site_default() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'de_DE' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'de_DE', - 'echo' => false, - 'show_option_site_default' => true, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringContainsString( 'id="foo"', $actual ); - $this->assertStringContainsString( 'name="bar"', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - } - - /** - * @ticket 44494 - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages_exclude_en_us() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'de_DE' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'de_DE', - 'echo' => false, - 'show_option_en_us' => false, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringNotContainsString( '', $actual ); - } - - /** - * @ticket 38632 - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages_en_US_selected() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'de_DE' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'en_US', - 'echo' => false, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringContainsString( 'id="foo"', $actual ); - $this->assertStringContainsString( 'name="bar"', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - } - - /** - * Add site default language to ja_JP in dropdown - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages_site_default_ja_JP() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'ja_JP' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'ja_JP', - 'echo' => false, - 'show_option_site_default' => true, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringContainsString( 'id="foo"', $actual ); - $this->assertStringContainsString( 'name="bar"', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - } - - /** - * Select dropdown language from de_DE to ja_JP - * - * @covers ::wp_dropdown_languages - */ - public function test_wp_dropdown_languages_ja_JP_selected() { - $args = array( - 'id' => 'foo', - 'name' => 'bar', - 'languages' => array( 'de_DE' ), - 'translations' => $this->wp_dropdown_languages_filter(), - 'selected' => 'ja_JP', - 'echo' => false, - ); - $actual = wp_dropdown_languages( $args ); - - $this->assertStringContainsString( 'id="foo"', $actual ); - $this->assertStringContainsString( 'name="bar"', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - $this->assertStringContainsString( '', $actual ); - } - - /** - * We don't want to call the API when testing. - * - * @return array - */ - private function wp_dropdown_languages_filter() { - return array( - 'de_DE' => array( - 'language' => 'de_DE', - 'native_name' => 'Deutsch', - 'iso' => array( 'de' ), - ), - 'it_IT' => array( - 'language' => 'it_IT', - 'native_name' => 'Italiano', - 'iso' => array( 'it', 'ita' ), - ), - 'ja_JP' => array( - 'language' => 'ja_JP', - 'native_name' => '日本語', - 'iso' => array( 'ja' ), - ), - ); - } - /** * @ticket 35284 * diff --git a/tests/phpunit/tests/l10n/wpDropdownLanguages.php b/tests/phpunit/tests/l10n/wpDropdownLanguages.php new file mode 100644 index 0000000000000..3d1b7a08bb02e --- /dev/null +++ b/tests/phpunit/tests/l10n/wpDropdownLanguages.php @@ -0,0 +1,167 @@ + 'foo', + 'name' => 'bar', + 'languages' => array( 'de_DE' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'de_DE', + 'echo' => false, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringContainsString( 'id="foo"', $actual ); + $this->assertStringContainsString( 'name="bar"', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + } + + /** + * @ticket 38632 + */ + public function test_wp_dropdown_languages_site_default() { + $args = array( + 'id' => 'foo', + 'name' => 'bar', + 'languages' => array( 'de_DE' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'de_DE', + 'echo' => false, + 'show_option_site_default' => true, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringContainsString( 'id="foo"', $actual ); + $this->assertStringContainsString( 'name="bar"', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + } + + /** + * @ticket 44494 + */ + public function test_wp_dropdown_languages_exclude_en_us() { + $args = array( + 'id' => 'foo', + 'name' => 'bar', + 'languages' => array( 'de_DE' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'de_DE', + 'echo' => false, + 'show_option_en_us' => false, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringNotContainsString( '', $actual ); + } + + /** + * @ticket 38632 + */ + public function test_wp_dropdown_languages_en_US_selected() { + $args = array( + 'id' => 'foo', + 'name' => 'bar', + 'languages' => array( 'de_DE' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'en_US', + 'echo' => false, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringContainsString( 'id="foo"', $actual ); + $this->assertStringContainsString( 'name="bar"', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + } + + /** + * Add site default language to ja_JP in dropdown + */ + public function test_wp_dropdown_languages_site_default_ja_JP() { + $args = array( + 'id' => 'foo', + 'name' => 'bar', + 'languages' => array( 'ja_JP' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'ja_JP', + 'echo' => false, + 'show_option_site_default' => true, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringContainsString( 'id="foo"', $actual ); + $this->assertStringContainsString( 'name="bar"', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + } + + /** + * Select dropdown language from de_DE to ja_JP + */ + public function test_wp_dropdown_languages_ja_JP_selected() { + $args = array( + 'id' => 'foo', + 'name' => 'bar', + 'languages' => array( 'de_DE' ), + 'translations' => $this->wp_dropdown_languages_filter(), + 'selected' => 'ja_JP', + 'echo' => false, + ); + $actual = wp_dropdown_languages( $args ); + + $this->assertStringContainsString( 'id="foo"', $actual ); + $this->assertStringContainsString( 'name="bar"', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + $this->assertStringContainsString( '', $actual ); + } + + /** + * We don't want to call the API when testing. + * + * @return array + */ + private function wp_dropdown_languages_filter() { + return array( + 'de_DE' => array( + 'language' => 'de_DE', + 'native_name' => 'Deutsch', + 'iso' => array( 'de' ), + ), + 'it_IT' => array( + 'language' => 'it_IT', + 'native_name' => 'Italiano', + 'iso' => array( 'it', 'ita' ), + ), + 'ja_JP' => array( + 'language' => 'ja_JP', + 'native_name' => '日本語', + 'iso' => array( 'ja' ), + ), + ); + } +} From fd34506e34fa440e4234ef814b280f1d11ae50d6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 17:32:13 +0000 Subject: [PATCH 078/104] Editor: Bump pinned hash for the Gutenberg repository. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates the pinned hash from the `gutenberg` from `0d133bf7e7437d65d68a06551f3d613a7d8e4361` to `e2970ba736edb99e08fb369d4fb0c378189468ee`. The following changes are included: - https://github.com/WordPress/gutenberg/pull/76478 Boot: Fix black area below content when sidebar is taller than page c… (https://github.com/WordPress/gutenberg/pull/76764) - Style Book: Fix missing styles for classic themes in stylebook route (https://github.com/WordPress/gutenberg/pull/76843) - RTC: Fix stuck "Join" link in post list when lock expires (https://github.com/WordPress/gutenberg/pull/76795) - Icon: Fix center alignment in the editor for classic themes (https://github.com/WordPress/gutenberg/pull/76878) - RTC: Fix notes not syncing between collaborative editors (https://github.com/WordPress/gutenberg/pull/76873) - Latest Comments: Fix v1 deprecated block missing supports (https://github.com/WordPress/gutenberg/pull/76877) - Connectors: Add Akismet as a default connector (https://github.com/WordPress/gutenberg/pull/76828) - Restore with compaction update (https://github.com/WordPress/gutenberg/pull/76872) - Improve JSDoc for abilities API (https://github.com/WordPress/gutenberg/pull/76824) - Connectors: Replace plugin.slug with plugin.file (https://github.com/WordPress/gutenberg/pull/76909) - Block visibility badge: use canvas iframe for viewport detection (https://github.com/WordPress/gutenberg/pull/76889) - Connectors: Update help text from 'reset' to 'manage' (https://github.com/WordPress/gutenberg/pull/76963) - Connectors: Hide Akismet unless already installed (https://github.com/WordPress/gutenberg/pull/76962) - Wrap sync update processing in try/catch (https://github.com/WordPress/gutenberg/pull/76968) - Backport: Improve validation and permission checks for `WP_HTTP_Polling_Sync_Server` (https://github.com/WordPress/gutenberg/pull/76987) - Connectors: account for mu-plugins when resolving plugin.file status (https://github.com/WordPress/gutenberg/pull/76994) A full list of changes can be found on GitHub: https://github.com/WordPress/gutenberg/compare/0d133bf7e7437d65d68a06551f3d613a7d8e4361…e2970ba736edb99e08fb369d4fb0c378189468ee. Log created with: git log --reverse --format="- %s" 0d133bf7e7437d65d68a06551f3d613a7d8e4361..e2970ba736edb99e08fb369d4fb0c378189468ee | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' | pbcopy See #64595. git-svn-id: https://develop.svn.wordpress.org/trunk@62209 602fd350-edb4-49c9-b593-d223f7449a82 --- package.json | 2 +- .../assets/script-loader-packages.php | 10 ++-- .../assets/script-modules-packages.php | 4 +- .../build/routes/connectors-home/content.js | 56 +++++++++++++++---- .../connectors-home/content.min.asset.php | 2 +- .../routes/connectors-home/content.min.js | 2 +- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 82bb2d4f7a8c9..4d0b8110e0a9f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "0d133bf7e7437d65d68a06551f3d613a7d8e4361", + "sha": "e2970ba736edb99e08fb369d4fb0c378189468ee", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/wp-includes/assets/script-loader-packages.php b/src/wp-includes/assets/script-loader-packages.php index 04eef1a8a00f5..10af74b63ce36 100644 --- a/src/wp-includes/assets/script-loader-packages.php +++ b/src/wp-includes/assets/script-loader-packages.php @@ -100,7 +100,7 @@ 'wp-url', 'wp-warning' ), - 'version' => '0c1dfcebf759791c9a8b' + 'version' => '2300d40abe29e438beda' ), 'block-library.js' => array( 'dependencies' => array( @@ -142,7 +142,7 @@ 'import' => 'dynamic' ) ), - 'version' => 'd72ed53f961f90f21ed4' + 'version' => '67d1a681ec0100a25d78' ), 'block-serialization-default-parser.js' => array( 'dependencies' => array( @@ -428,7 +428,7 @@ 'import' => 'static' ) ), - 'version' => 'a688ac97344ffdfcca99' + 'version' => 'd36eb0c37b644e4cd4c8' ), 'edit-widgets.js' => array( 'dependencies' => array( @@ -519,7 +519,7 @@ 'import' => 'static' ) ), - 'version' => '49ff59c135229f1cc371' + 'version' => '63782008412a6163c9f0' ), 'element.js' => array( 'dependencies' => array( @@ -817,7 +817,7 @@ 'wp-hooks', 'wp-private-apis' ), - 'version' => '89ec294039260fd01952' + 'version' => '8186bfbc15b827d261f5' ), 'theme.js' => array( 'dependencies' => array( diff --git a/src/wp-includes/assets/script-modules-packages.php b/src/wp-includes/assets/script-modules-packages.php index d035354c60036..534ce123add0f 100644 --- a/src/wp-includes/assets/script-modules-packages.php +++ b/src/wp-includes/assets/script-modules-packages.php @@ -166,7 +166,7 @@ 'import' => 'static' ) ), - 'version' => '105defe2f1526f8a43e8' + 'version' => '42d3f09bba14cce3054d' ), 'connectors/index.js' => array( 'dependencies' => array( @@ -177,7 +177,7 @@ 'wp-i18n', 'wp-private-apis' ), - 'version' => 'e973aa806299e3d70144' + 'version' => '274797868955a828dfdc' ), 'core-abilities/index.js' => array( 'dependencies' => array( diff --git a/src/wp-includes/build/routes/connectors-home/content.js b/src/wp-includes/build/routes/connectors-home/content.js index f71de0935092c..c285e273ea082 100644 --- a/src/wp-includes/build/routes/connectors-home/content.js +++ b/src/wp-includes/build/routes/connectors-home/content.js @@ -702,7 +702,7 @@ var import_element4 = __toESM(require_element()); var import_i18n = __toESM(require_i18n()); import { speak } from "@wordpress/a11y"; function useConnectorPlugin({ - pluginSlug, + file: pluginFileFromServer, settingName, connectorName, isInstalled, @@ -714,6 +714,8 @@ function useConnectorPlugin({ const [isBusy, setIsBusy] = (0, import_element4.useState)(false); const [connectedState, setConnectedState] = (0, import_element4.useState)(initialIsConnected); const [pluginStatusOverride, setPluginStatusOverride] = (0, import_element4.useState)(null); + const pluginBasename = pluginFileFromServer?.replace(/\.php$/, ""); + const pluginSlug = pluginBasename?.includes("/") ? pluginBasename.split("/")[0] : pluginBasename; const { derivedPluginStatus, canManagePlugins, @@ -728,7 +730,7 @@ function useConnectorPlugin({ kind: "root", name: "plugin" }); - if (!pluginSlug) { + if (!pluginFileFromServer) { const hasLoaded = store2.hasFinishedResolution( "getEntityRecord", ["root", "site"] @@ -740,15 +742,14 @@ function useConnectorPlugin({ canInstallPlugins: canCreate }; } - const pluginId = `${pluginSlug}/plugin`; const plugin = store2.getEntityRecord( "root", "plugin", - pluginId + pluginBasename ); const hasFinished = store2.hasFinishedResolution( "getEntityRecord", - ["root", "plugin", pluginId] + ["root", "plugin", pluginBasename] ); if (!hasFinished) { return { @@ -779,7 +780,7 @@ function useConnectorPlugin({ canInstallPlugins: canCreate }; }, - [pluginSlug, settingName, isInstalled, isActivated] + [pluginBasename, settingName, isInstalled, isActivated] ); const pluginStatus = pluginStatusOverride ?? derivedPluginStatus; const canActivatePlugins = canManagePlugins; @@ -823,7 +824,7 @@ function useConnectorPlugin({ } }; const activatePlugin = async () => { - if (!pluginSlug) { + if (!pluginFileFromServer) { return; } setIsBusy(true); @@ -831,7 +832,10 @@ function useConnectorPlugin({ await saveEntityRecord( "root", "plugin", - { plugin: `${pluginSlug}/plugin`, status: "active" }, + { + plugin: pluginBasename, + status: "active" + }, { throwOnError: true } ); setPluginStatusOverride("active"); @@ -1030,6 +1034,27 @@ var DefaultConnectorLogo = () => /* @__PURE__ */ React.createElement( } ) ); +var AkismetLogo = () => /* @__PURE__ */ React.createElement( + "svg", + { + width: "40", + height: "40", + viewBox: "0 0 44 44", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": "true" + }, + /* @__PURE__ */ React.createElement("rect", { width: "44", height: "44", fill: "#357B49", rx: "6" }), + /* @__PURE__ */ React.createElement( + "path", + { + fill: "#fff", + fillRule: "evenodd", + d: "m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z", + clipRule: "evenodd" + } + ) +); var GeminiLogo = () => /* @__PURE__ */ React.createElement( "svg", { @@ -1123,7 +1148,8 @@ function getConnectorData() { var CONNECTOR_LOGOS = { google: GeminiLogo, openai: OpenAILogo, - anthropic: ClaudeLogo + anthropic: ClaudeLogo, + akismet: AkismetLogo }; function getConnectorLogo(connectorId, logoUrl) { if (logoUrl) { @@ -1161,7 +1187,8 @@ function ApiKeyConnector({ const auth = authentication?.method === "api_key" ? authentication : void 0; const settingName = auth?.settingName ?? ""; const helpUrl = auth?.credentialsUrl ?? void 0; - const pluginSlug = plugin?.slug; + const pluginFile = plugin?.file?.replace(/\.php$/, ""); + const pluginSlug = pluginFile?.includes("/") ? pluginFile.split("/")[0] : pluginFile; let helpLabel; try { if (helpUrl) { @@ -1184,7 +1211,7 @@ function ApiKeyConnector({ saveApiKey, removeApiKey } = useConnectorPlugin({ - pluginSlug, + file: plugin?.file, settingName, connectorName: name, isInstalled: plugin?.isInstalled, @@ -1259,16 +1286,20 @@ function registerDefaultConnectors() { const connectors = getConnectorData(); const sanitize = (s) => s.replace(/[^a-z0-9-_]/gi, "-"); for (const [connectorId, data] of Object.entries(connectors)) { + if (connectorId === "akismet" && !data.plugin?.isInstalled) { + continue; + } const { authentication } = data; const connectorName = sanitize(connectorId); const args = { name: data.name, description: data.description, + type: data.type, logo: getConnectorLogo(connectorId, data.logoUrl), authentication, plugin: data.plugin }; - if (data.type === "ai_provider" && authentication.method === "api_key") { + if (authentication.method === "api_key") { args.render = ApiKeyConnector; } registerConnector(connectorName, args); @@ -1562,6 +1593,7 @@ function ConnectorsPage() { slug: connector.slug, name: connector.name, description: connector.description, + type: connector.type, logo: connector.logo, authentication: connector.authentication, plugin: connector.plugin diff --git a/src/wp-includes/build/routes/connectors-home/content.min.asset.php b/src/wp-includes/build/routes/connectors-home/content.min.asset.php index 9ef1fdf96351c..ef57aa56cd29b 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.asset.php +++ b/src/wp-includes/build/routes/connectors-home/content.min.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => 'e598f70e4e13735c7300'); \ No newline at end of file + array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '067df442b07dc9245aee'); \ No newline at end of file diff --git a/src/wp-includes/build/routes/connectors-home/content.min.js b/src/wp-includes/build/routes/connectors-home/content.min.js index 1ea2ff593417f..ffe9257b61415 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.js +++ b/src/wp-includes/build/routes/connectors-home/content.min.js @@ -1 +1 @@ -var qt=Object.create;var qe=Object.defineProperty;var Tt=Object.getOwnPropertyDescriptor;var Vt=Object.getOwnPropertyNames;var Nt=Object.getPrototypeOf,Xt=Object.prototype.hasOwnProperty;var z=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Yt=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of Vt(t))!Xt.call(e,r)&&r!==n&&qe(e,r,{get:()=>t[r],enumerable:!(o=Tt(t,r))||o.enumerable});return e};var s=(e,t,n)=>(n=e!=null?qt(Nt(e)):{},Yt(t||!e||!e.__esModule?qe(n,"default",{value:e,enumerable:!0}):n,e));var I=z((bn,Te)=>{Te.exports=window.wp.i18n});var k=z((wn,Ve)=>{Ve.exports=window.wp.components});var ne=z((Ln,Ne)=>{Ne.exports=window.ReactJSXRuntime});var j=z((xn,Ye)=>{Ye.exports=window.wp.element});var C=z((Mn,Ae)=>{Ae.exports=window.React});var st=z((ir,it)=>{it.exports=window.wp.privateApis});var ae=z((yr,gt)=>{gt.exports=window.wp.data});var ie=z((xr,mt)=>{mt.exports=window.wp.coreData});var ht=z((Gr,vt)=>{vt.exports=window.wp.url});function Xe(e){var t,n,o="";if(typeof e=="string"||typeof e=="number")o+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;t(0,Ce.jsx)(o,{ref:a,className:S("admin-ui-navigable-region",t),"aria-label":n,role:"region",tabIndex:"-1",...r,children:e}));Ze.displayName="NavigableRegion";var Ee=Ze;var Ke=s(C(),1),We={};function pe(e,t){let n=Ke.useRef(We);return n.current===We&&(n.current=e(t)),n}function ge(e,...t){let n=new URL(`https://base-ui.com/production-error/${e}`);return t.forEach(o=>n.searchParams.append("args[]",o)),`Base UI error #${e}; visit ${n} for the full message.`}var re=s(C(),1);function me(e,t,n,o){let r=pe(ke).current;return Ct(r,e,t,n,o)&&Ue(r,[e,t,n,o]),r.callback}function Ie(e){let t=pe(ke).current;return Zt(t,e)&&Ue(t,e),t.callback}function ke(){return{callback:null,cleanup:null,refs:[]}}function Ct(e,t,n,o,r){return e.refs[0]!==t||e.refs[1]!==n||e.refs[2]!==o||e.refs[3]!==r}function Zt(e,t){return e.refs.length!==t.length||e.refs.some((n,o)=>n!==t[o])}function Ue(e,t){if(e.refs=t,t.every(n=>n==null)){e.callback=null;return}e.callback=n=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),n!=null){let o=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function ve(e){if(!Fe.isValidElement(e))return null;let t=e,n=t.props;return(Je(19)?n?.ref:t.ref)??null}function U(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function _e(e,t){let n={};for(let o in e){let r=e[o];if(t?.hasOwnProperty(o)){let a=t[o](r);a!=null&&Object.assign(n,a);continue}r===!0?n[`data-${o.toLowerCase()}`]="":r&&(n[`data-${o.toLowerCase()}`]=r.toString())}return n}function $e(e,t){return typeof e=="function"?e(t):e}function et(e,t){return typeof e=="function"?e(t):e}var J={};function Z(e,t,n,o,r){let a={...he(e,J)};return t&&(a=Q(a,t)),n&&(a=Q(a,n)),o&&(a=Q(a,o)),r&&(a=Q(a,r)),a}function tt(e){if(e.length===0)return J;if(e.length===1)return he(e[0],J);let t={...he(e[0],J)};for(let n=1;n=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function nt(e){return typeof e=="function"}function he(e,t){return nt(e)?e(t):e??J}function Kt(e,t){return t?e?n=>{if(kt(n)){let r=n;It(r);let a=t(r);return r.baseUIHandlerPrevented||e?.(r),a}let o=t(n);return e?.(n),o}:t:e}function It(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Pe(e,t){return t?e?t+" "+e:t:e}function kt(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var Ut=Object.freeze([]),B=Object.freeze({});var be=s(C(),1);function rt(e,t,n={}){let o=t.render,r=Qt(t,n);if(n.enabled===!1)return null;let a=n.state??B;return Jt(e,o,r,a)}function Qt(e,t={}){let{className:n,style:o,render:r}=e,{state:a=B,ref:i,props:l,stateAttributesMapping:p,enabled:u=!0}=t,d=u?$e(n,a):void 0,M=u?et(o,a):void 0,O=u?_e(a,p):B,f=u?U(O,Array.isArray(l)?tt(l):l)??B:B;return typeof document<"u"&&(u?Array.isArray(i)?f.ref=Ie([f.ref,ve(r),...i]):f.ref=me(f.ref,ve(r),i):me(null,null)),u?(d!==void 0&&(f.className=Pe(f.className,d)),M!==void 0&&(f.style=U(f.style,M)),f):B}function Jt(e,t,n,o){if(t){if(typeof t=="function")return t(n,o);let r=Z(n,t.props);return r.ref=n.ref,re.cloneElement(t,r)}if(e&&typeof e=="string")return Ft(e,n);throw new Error(ge(8))}function Ft(e,t){return e==="button"?(0,be.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,be.createElement)("img",{alt:"",...t,key:t.key}):re.createElement(e,t)}function oe(e){return rt(e.defaultTagName??"div",e,e)}var at=s(j(),1);if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='244b5c59c0']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","244b5c59c0"),e.appendChild(document.createTextNode('@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;@layer wp-ui-components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);font-family:var(--wpds-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-font-size-sm,12px);font-weight:var(--wpds-font-weight-regular,400);line-height:var(--wpds-font-line-height-xs,16px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-bg-surface-error,#f6e6e3);color:var(--wpds-color-fg-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-bg-surface-warning,#fde6bd);color:var(--wpds-color-fg-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-bg-surface-caution,#fee994);color:var(--wpds-color-fg-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-bg-surface-success,#c5f7cc);color:var(--wpds-color-fg-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-bg-surface-info,#deebfa);color:var(--wpds-color-fg-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-bg-surface-neutral-weak,#f0f0f0);color:var(--wpds-color-fg-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-bg-surface-neutral,#f8f8f8);color:var(--wpds-color-fg-content-neutral-weak,#6d6d6d)}}')),document.head.appendChild(e)}var ot={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},we=(0,at.forwardRef)(function({children:t,intent:n="none",render:o,className:r,...a},i){return oe({render:o,defaultTagName:"span",ref:i,props:Z(a,{className:S(ot.badge,ot[`is-${n}-intent`],r),children:t})})});var ct=s(j(),1);if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='71d20935c2']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","71d20935c2"),e.appendChild(document.createTextNode("@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;@layer wp-ui-components{._19ce0419607e1896__stack{display:flex}}")),document.head.appendChild(e)}var _t={stack:"_19ce0419607e1896__stack"},$t={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},E=(0,ct.forwardRef)(function({direction:t,gap:n,align:o,justify:r,wrap:a,render:i,...l},p){let u={gap:n&&$t[n],alignItems:o,justifyContent:r,flexDirection:t,flexWrap:a};return oe({render:i,ref:p,props:Z(l,{style:u,className:_t.stack})})});var lt=s(k(),1),{Fill:dt,Slot:ut}=(0,lt.createSlotFill)("SidebarToggle");var P=s(ne(),1);function ft({headingLevel:e=2,breadcrumbs:t,badges:n,title:o,subTitle:r,actions:a,showSidebarToggle:i=!0}){let l=`h${e}`;return(0,P.jsxs)(E,{direction:"column",className:"admin-ui-page__header",render:(0,P.jsx)("header",{}),children:[(0,P.jsxs)(E,{direction:"row",justify:"space-between",gap:"sm",children:[(0,P.jsxs)(E,{direction:"row",gap:"sm",align:"center",justify:"start",children:[i&&(0,P.jsx)(ut,{bubblesVirtually:!0,className:"admin-ui-page__sidebar-toggle-slot"}),o&&(0,P.jsx)(l,{className:"admin-ui-page__header-title",children:o}),t,n]}),(0,P.jsx)(E,{direction:"row",gap:"sm",style:{width:"auto",flexShrink:0},className:"admin-ui-page__header-actions",align:"center",children:a})]}),r&&(0,P.jsx)("p",{className:"admin-ui-page__header-subtitle",children:r})]})}var F=s(ne(),1);function pt({headingLevel:e,breadcrumbs:t,badges:n,title:o,subTitle:r,children:a,className:i,actions:l,hasPadding:p=!1,showSidebarToggle:u=!0}){let d=S("admin-ui-page",i);return(0,F.jsxs)(Ee,{className:d,ariaLabel:o,children:[(o||t||n)&&(0,F.jsx)(ft,{headingLevel:e,breadcrumbs:t,badges:n,title:o,subTitle:r,actions:l,showSidebarToggle:u}),p?(0,F.jsx)("div",{className:"admin-ui-page__content has-padding",children:a}):a]})}pt.SidebarToggleFill=dt;var Le=pt;var w=s(k()),Bt=s(ae()),Ht=s(j()),N=s(I()),Rt=s(ie());import{privateApis as un}from"@wordpress/connectors";if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='1b00f16b8d']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","1b00f16b8d"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background:linear-gradient(90deg,#fff9,#fff9),linear-gradient(90deg,#89dcdc,#c7eb5c 46.15%,#a920c1);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:220px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background:linear-gradient(270deg,#fff9,#fff9),linear-gradient(270deg,#89dcdc,#c7eb5c 46.15%,#a920c1)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:248px;inset-inline-end:8px;position:absolute;top:-15px;width:248px}.connectors-page>p{color:#949494;text-align:center}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:84px}.connectors-page .ai-plugin-callout__decoration{height:134px;inset-inline-end:4px;top:-8px;width:134px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var ee=s(k()),Me=s(ie()),de=s(ae()),b=s(j()),m=s(I()),Mt=s(ht());import{speak as le}from"@wordpress/a11y";var ce=s(k()),$=s(j()),xe=s(I());import{__experimentalRegisterConnector as en,__experimentalConnectorItem as tn,__experimentalDefaultConnectorSettings as nn}from"@wordpress/connectors";var ye=s(ie()),se=s(ae()),_=s(j()),c=s(I());import{speak as V}from"@wordpress/a11y";function Pt({pluginSlug:e,settingName:t,connectorName:n,isInstalled:o,isActivated:r,keySource:a="none",initialIsConnected:i=!1}){let[l,p]=(0,_.useState)(!1),[u,d]=(0,_.useState)(!1),[M,O]=(0,_.useState)(i),[f,X]=(0,_.useState)(null),{derivedPluginStatus:D,canManagePlugins:L,currentApiKey:y,canInstallPlugins:v}=(0,se.useSelect)(R=>{let q=R(ye.store),K=q.getEntityRecord("root","site")?.[t]??"",T=!!q.canUser("create",{kind:"root",name:"plugin"});if(!e)return{derivedPluginStatus:q.hasFinishedResolution("getEntityRecord",["root","site"])?"active":"checking",canManagePlugins:void 0,currentApiKey:K,canInstallPlugins:T};let He=`${e}/plugin`,Re=q.getEntityRecord("root","plugin",He);if(!q.hasFinishedResolution("getEntityRecord",["root","plugin",He]))return{derivedPluginStatus:"checking",canManagePlugins:void 0,currentApiKey:K,canInstallPlugins:T};if(Re)return{derivedPluginStatus:Re.status==="active"?"active":"inactive",canManagePlugins:!0,currentApiKey:K,canInstallPlugins:T};let fe="not-installed";return r?fe="active":o&&(fe="inactive"),{derivedPluginStatus:fe,canManagePlugins:!1,currentApiKey:K,canInstallPlugins:T}},[e,t,o,r]),g=f??D,x=L,Y=g==="active"&&M||f==="active"&&!!y,{saveEntityRecord:h,invalidateResolution:G}=(0,se.useDispatch)(ye.store),A=async()=>{if(e){d(!0);try{await h("root","plugin",{slug:e,status:"active"},{throwOnError:!0}),X("active"),G("getEntityRecord",["root","site"]),p(!0),V((0,c.sprintf)((0,c.__)("Plugin for %s installed and activated successfully."),n))}catch{V((0,c.sprintf)((0,c.__)("Failed to install plugin for %s."),n),"assertive")}finally{d(!1)}}},W=async()=>{if(e){d(!0);try{await h("root","plugin",{plugin:`${e}/plugin`,status:"active"},{throwOnError:!0}),X("active"),G("getEntityRecord",["root","site"]),p(!0),V((0,c.sprintf)((0,c.__)("Plugin for %s activated successfully."),n))}catch{V((0,c.sprintf)((0,c.__)("Failed to activate plugin for %s."),n),"assertive")}finally{d(!1)}}};return{pluginStatus:g,canInstallPlugins:v,canActivatePlugins:x,isExpanded:l,setIsExpanded:p,isBusy:u,isConnected:Y,currentApiKey:y,keySource:a,handleButtonClick:()=>{if(g==="not-installed"){if(v===!1)return;A()}else if(g==="inactive"){if(x===!1)return;W()}else p(!l)},getButtonLabel:()=>{if(u)return g==="not-installed"?(0,c.__)("Installing\u2026"):(0,c.__)("Activating\u2026");if(l)return(0,c.__)("Cancel");if(Y)return(0,c.__)("Edit");switch(g){case"checking":return(0,c.__)("Checking\u2026");case"not-installed":return(0,c.__)("Install");case"inactive":return(0,c.__)("Activate");case"active":return(0,c.__)("Set up")}},saveApiKey:async R=>{let q=y;try{let T=(await h("root","site",{[t]:R},{throwOnError:!0}))?.[t];if(R&&(T===q||!T))throw new Error("It was not possible to connect to the provider using this key.");O(!0),V((0,c.sprintf)((0,c.__)("%s connected successfully."),n))}catch(te){throw console.error("Failed to save API key:",te),te}},removeApiKey:async()=>{try{await h("root","site",{[t]:""},{throwOnError:!0}),O(!1),V((0,c.sprintf)((0,c.__)("%s disconnected."),n))}catch(R){throw console.error("Failed to remove API key:",R),V((0,c.sprintf)((0,c.__)("Failed to disconnect %s."),n),"assertive"),R}}}}var bt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),wt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Lt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),yt=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));function Ge(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"")?.connectors??{}}catch{return{}}}var rn={google:yt,openai:bt,anthropic:wt};function on(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let n=rn[e];return React.createElement(n||Lt,null)}var an=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:500,whiteSpace:"nowrap"}},(0,xe.__)("Connected")),sn=()=>React.createElement(we,null,(0,xe.__)("Not available"));function cn({name:e,description:t,logo:n,authentication:o,plugin:r}){let a=o?.method==="api_key"?o:void 0,i=a?.settingName??"",l=a?.credentialsUrl??void 0,p=r?.slug,u;try{l&&(u=new URL(l).hostname)}catch{}let{pluginStatus:d,canInstallPlugins:M,canActivatePlugins:O,isExpanded:f,setIsExpanded:X,isBusy:D,isConnected:L,currentApiKey:y,keySource:v,handleButtonClick:g,getButtonLabel:x,saveApiKey:Y,removeApiKey:h}=Pt({pluginSlug:p,settingName:i,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:a?.keySource,initialIsConnected:a?.isConnected}),G=v==="env"||v==="constant",A=d==="not-installed"&&M===!1||d==="inactive"&&O===!1,W=!A,ue=(0,$.useRef)(null),H=(0,$.useRef)(!1);(0,$.useEffect)(()=>{H.current&&!D&&(H.current=!1,ue.current?.focus())},[D,f,L]);let je=()=>{(d==="not-installed"||d==="inactive")&&(H.current=!0),g()};return React.createElement(tn,{className:p?`connector-item--${p}`:void 0,logo:n,name:e,description:t,actionArea:React.createElement(ce.__experimentalHStack,{spacing:3,expanded:!1},L&&React.createElement(an,null),A&&React.createElement(sn,null),W&&React.createElement(ce.Button,{ref:ue,variant:f||L?"tertiary":"secondary",size:"compact",onClick:je,disabled:d==="checking"||D,isBusy:D},x()))},f&&d==="active"&&React.createElement(nn,{key:L?"connected":"setup",initialValue:G?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":y,helpUrl:l,helpLabel:u,readOnly:L||G,keySource:v,onRemove:G?void 0:async()=>{H.current=!0;try{await h()}catch{H.current=!1}},onSave:async Be=>{await Y(Be),H.current=!0,X(!1)}}))}function xt(){let e=Ge(),t=n=>n.replace(/[^a-z0-9-_]/gi,"-");for(let[n,o]of Object.entries(e)){let{authentication:r}=o,a=t(n),i={name:o.name,description:o.description,logo:on(n,o.logoUrl),authentication:r,plugin:o.plugin};o.type==="ai_provider"&&r.method==="api_key"&&(i.render=cn),en(a,i)}}function Gt(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,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",width:"248",height:"248",style:{mixBlendMode:"multiply"}}),React.createElement("rect",{x:"184.055",y:"54.995",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"170.059",y:"44.06",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"200.238",y:"77.302",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"212.048",y:"87.8",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"206.799",y:"83.425",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"204.175",y:"85.612",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"219.046",y:"103.108",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"154.751",y:"30.064",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"188.866",y:"63.742",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"148.189",y:"34",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"134.051",y:"31.707",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"126.124",y:"24.771",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"115.385",y:"29.19",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"95.702",y:"31.376",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"91.766",y:"27.002",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"90.454",y:"32.688",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"184.389",y:"45.58",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"162.185",y:"41.873",width:"2.187",height:"2.187"})))}var zt="ai",ze="ai/ai",ln="https://wordpress.org/plugins/ai/",Oe=Object.values(Ge()),dn=Oe.some(e=>e.type==="ai_provider"),Ot=[];for(let e of Oe)e.type==="ai_provider"&&e.authentication.method==="api_key"&&Ot.push(e.authentication.settingName);function Dt(){let[e,t]=(0,b.useState)(!1),[n,o]=(0,b.useState)(!1),r=(0,b.useRef)(null);(0,b.useEffect)(()=>{n&&r.current?.focus()},[n]);let a=(0,b.useRef)(Oe.some(v=>v.type==="ai_provider"&&v.authentication.method==="api_key"&&v.authentication.isConnected)).current,{pluginStatus:i,canInstallPlugins:l,canManagePlugins:p,hasConnectedProvider:u}=(0,de.useSelect)(v=>{let g=v(Me.store),x=!!g.canUser("create",{kind:"root",name:"plugin"}),Y=g.getEntityRecord("root","site"),h=a||Ot.some(W=>!!Y?.[W]),G=g.getEntityRecord("root","plugin",ze);return g.hasFinishedResolution("getEntityRecord",["root","plugin",ze])?G?{pluginStatus:G.status==="active"?"active":"inactive",canInstallPlugins:x,canManagePlugins:!0,hasConnectedProvider:h}:{pluginStatus:"not-installed",canInstallPlugins:x,canManagePlugins:x,hasConnectedProvider:h}:{pluginStatus:"checking",canInstallPlugins:x,canManagePlugins:void 0,hasConnectedProvider:h}},[]),{saveEntityRecord:d}=(0,de.useDispatch)(Me.store),M=async()=>{t(!0);try{await d("root","plugin",{slug:zt,status:"active"},{throwOnError:!0}),o(!0),le((0,m.__)("AI plugin installed and activated successfully."))}catch{le((0,m.__)("Failed to install the AI plugin."),"assertive")}finally{t(!1)}},O=async()=>{t(!0);try{await d("root","plugin",{plugin:ze,status:"active"},{throwOnError:!0}),o(!0),le((0,m.__)("AI plugin activated successfully."))}catch{le((0,m.__)("Failed to activate the AI plugin."),"assertive")}finally{t(!1)}};if(!dn||i==="checking"||i==="active"&&a&&!n||i==="not-installed"&&l===!1||i==="inactive"&&p===!1)return null;let f=i==="active"&&!u,X=i==="active"&&u&&(!a||n),D=i==="not-installed"||i==="inactive",L=()=>X?(0,m.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):f?(0,m.__)("The AI plugin is installed. Connect a provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,m.__)("The AI plugin can use your connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),y=()=>i==="not-installed"?{label:e?(0,m.__)("Installing\u2026"):(0,m.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:M}:{label:e?(0,m.__)("Activating\u2026"):(0,m.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:O};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,b.createInterpolateElement)(L(),{strong:React.createElement("strong",null),a:React.createElement(ee.ExternalLink,{href:ln})})),D?React.createElement(ee.Button,{variant:"primary",size:"compact",isBusy:e,disabled:y().disabled,accessibleWhenDisabled:!0,onClick:y().onClick},y().label):React.createElement(ee.Button,{ref:r,variant:"secondary",size:"compact",href:(0,Mt.addQueryArgs)("options-general.php",{page:zt})},(0,m.__)("Control features in the AI plugin"))),React.createElement(Gt,null))}var jt=s(st()),{lock:Yr,unlock:De}=(0,jt.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");var{store:fn}=De(un);xt();function pn(){let{connectors:e,canInstallPlugins:t}=(0,Bt.useSelect)(r=>({connectors:De(r(fn)).getConnectors(),canInstallPlugins:r(Rt.store).canUser("create",{kind:"root",name:"plugin"})}),[]),o=e.filter(r=>r.render).length===0;return React.createElement(Le,{title:(0,N.__)("Connectors"),headingLevel:1,subTitle:(0,N.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${o?" connectors-page--empty":""}`},o?React.createElement(w.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(w.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(w.__experimentalHeading,{level:2,size:15,weight:600},(0,N.__)("No connectors yet")),React.createElement(w.__experimentalText,{size:12},(0,N.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(w.Button,{variant:"secondary",href:"plugin-install.php"},(0,N.__)("Learn more"))):React.createElement(w.__experimentalVStack,{spacing:3},React.createElement(Dt,null),e.map(r=>r.render?React.createElement(r.render,{key:r.slug,slug:r.slug,name:r.name,description:r.description,logo:r.logo,authentication:r.authentication,plugin:r.plugin}):null)),t&&React.createElement("p",null,(0,Ht.createInterpolateElement)((0,N.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function gn(){return React.createElement(pn,null)}var mn=gn;export{mn as stage}; +var Tt=Object.create;var qe=Object.defineProperty;var Vt=Object.getOwnPropertyDescriptor;var Nt=Object.getOwnPropertyNames;var Xt=Object.getPrototypeOf,Yt=Object.prototype.hasOwnProperty;var O=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var St=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of Nt(t))!Yt.call(e,r)&&r!==n&&qe(e,r,{get:()=>t[r],enumerable:!(o=Vt(t,r))||o.enumerable});return e};var s=(e,t,n)=>(n=e!=null?Tt(Xt(e)):{},St(t||!e||!e.__esModule?qe(n,"default",{value:e,enumerable:!0}):n,e));var I=O((Ln,Te)=>{Te.exports=window.wp.i18n});var k=O((yn,Ve)=>{Ve.exports=window.wp.components});var re=O((xn,Ne)=>{Ne.exports=window.ReactJSXRuntime});var H=O((zn,Ye)=>{Ye.exports=window.wp.element});var E=O((Dn,Ae)=>{Ae.exports=window.React});var st=O((lr,it)=>{it.exports=window.wp.privateApis});var ie=O((Gr,gt)=>{gt.exports=window.wp.data});var se=O((zr,mt)=>{mt.exports=window.wp.coreData});var ht=O((Mr,vt)=>{vt.exports=window.wp.url});function Xe(e){var t,n,o="";if(typeof e=="string"||typeof e=="number")o+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;t(0,Ze.jsx)(o,{ref:a,className:C("admin-ui-navigable-region",t),"aria-label":n,role:"region",tabIndex:"-1",...r,children:e}));Ce.displayName="NavigableRegion";var Ee=Ce;var Ke=s(E(),1),We={};function ge(e,t){let n=Ke.useRef(We);return n.current===We&&(n.current=e(t)),n}function me(e,...t){let n=new URL(`https://base-ui.com/production-error/${e}`);return t.forEach(o=>n.searchParams.append("args[]",o)),`Base UI error #${e}; visit ${n} for the full message.`}var oe=s(E(),1);function ve(e,t,n,o){let r=ge(ke).current;return Ct(r,e,t,n,o)&&Ue(r,[e,t,n,o]),r.callback}function Ie(e){let t=ge(ke).current;return Et(t,e)&&Ue(t,e),t.callback}function ke(){return{callback:null,cleanup:null,refs:[]}}function Ct(e,t,n,o,r){return e.refs[0]!==t||e.refs[1]!==n||e.refs[2]!==o||e.refs[3]!==r}function Et(e,t){return e.refs.length!==t.length||e.refs.some((n,o)=>n!==t[o])}function Ue(e,t){if(e.refs=t,t.every(n=>n==null)){e.callback=null;return}e.callback=n=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),n!=null){let o=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function he(e){if(!Fe.isValidElement(e))return null;let t=e,n=t.props;return(Je(19)?n?.ref:t.ref)??null}function U(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function _e(e,t){let n={};for(let o in e){let r=e[o];if(t?.hasOwnProperty(o)){let a=t[o](r);a!=null&&Object.assign(n,a);continue}r===!0?n[`data-${o.toLowerCase()}`]="":r&&(n[`data-${o.toLowerCase()}`]=r.toString())}return n}function $e(e,t){return typeof e=="function"?e(t):e}function et(e,t){return typeof e=="function"?e(t):e}var J={};function A(e,t,n,o,r){let a={...Pe(e,J)};return t&&(a=Q(a,t)),n&&(a=Q(a,n)),o&&(a=Q(a,o)),r&&(a=Q(a,r)),a}function tt(e){if(e.length===0)return J;if(e.length===1)return Pe(e[0],J);let t={...Pe(e[0],J)};for(let n=1;n=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function nt(e){return typeof e=="function"}function Pe(e,t){return nt(e)?e(t):e??J}function It(e,t){return t?e?n=>{if(Ut(n)){let r=n;kt(r);let a=t(r);return r.baseUIHandlerPrevented||e?.(r),a}let o=t(n);return e?.(n),o}:t:e}function kt(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function be(e,t){return t?e?t+" "+e:t:e}function Ut(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var Qt=Object.freeze([]),R=Object.freeze({});var we=s(E(),1);function rt(e,t,n={}){let o=t.render,r=Jt(t,n);if(n.enabled===!1)return null;let a=n.state??R;return Ft(e,o,r,a)}function Jt(e,t={}){let{className:n,style:o,render:r}=e,{state:a=R,ref:i,props:c,stateAttributesMapping:u,enabled:d=!0}=t,f=d?$e(n,a):void 0,g=d?et(o,a):void 0,D=d?_e(a,u):R,p=d?U(D,Array.isArray(c)?tt(c):c)??R:R;return typeof document<"u"&&(d?Array.isArray(i)?p.ref=Ie([p.ref,he(r),...i]):p.ref=ve(p.ref,he(r),i):ve(null,null)),d?(f!==void 0&&(p.className=be(p.className,f)),g!==void 0&&(p.style=U(p.style,g)),p):R}function Ft(e,t,n,o){if(t){if(typeof t=="function")return t(n,o);let r=A(n,t.props);return r.ref=n.ref,oe.cloneElement(t,r)}if(e&&typeof e=="string")return _t(e,n);throw new Error(me(8))}function _t(e,t){return e==="button"?(0,we.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,we.createElement)("img",{alt:"",...t,key:t.key}):oe.createElement(e,t)}function ae(e){return rt(e.defaultTagName??"div",e,e)}var at=s(H(),1);if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='244b5c59c0']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","244b5c59c0"),e.appendChild(document.createTextNode('@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;@layer wp-ui-components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);font-family:var(--wpds-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-font-size-sm,12px);font-weight:var(--wpds-font-weight-regular,400);line-height:var(--wpds-font-line-height-xs,16px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-bg-surface-error,#f6e6e3);color:var(--wpds-color-fg-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-bg-surface-warning,#fde6bd);color:var(--wpds-color-fg-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-bg-surface-caution,#fee994);color:var(--wpds-color-fg-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-bg-surface-success,#c5f7cc);color:var(--wpds-color-fg-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-bg-surface-info,#deebfa);color:var(--wpds-color-fg-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-bg-surface-neutral-weak,#f0f0f0);color:var(--wpds-color-fg-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-bg-surface-neutral,#f8f8f8);color:var(--wpds-color-fg-content-neutral-weak,#6d6d6d)}}')),document.head.appendChild(e)}var ot={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},Le=(0,at.forwardRef)(function({children:t,intent:n="none",render:o,className:r,...a},i){return ae({render:o,defaultTagName:"span",ref:i,props:A(a,{className:C(ot.badge,ot[`is-${n}-intent`],r),children:t})})});var lt=s(H(),1);if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='71d20935c2']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","71d20935c2"),e.appendChild(document.createTextNode("@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;@layer wp-ui-components{._19ce0419607e1896__stack{display:flex}}")),document.head.appendChild(e)}var $t={stack:"_19ce0419607e1896__stack"},en={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},W=(0,lt.forwardRef)(function({direction:t,gap:n,align:o,justify:r,wrap:a,render:i,...c},u){let d={gap:n&&en[n],alignItems:o,justifyContent:r,flexDirection:t,flexWrap:a};return ae({render:i,ref:u,props:A(c,{style:d,className:$t.stack})})});var ct=s(k(),1),{Fill:dt,Slot:ut}=(0,ct.createSlotFill)("SidebarToggle");var w=s(re(),1);function ft({headingLevel:e=2,breadcrumbs:t,badges:n,title:o,subTitle:r,actions:a,showSidebarToggle:i=!0}){let c=`h${e}`;return(0,w.jsxs)(W,{direction:"column",className:"admin-ui-page__header",render:(0,w.jsx)("header",{}),children:[(0,w.jsxs)(W,{direction:"row",justify:"space-between",gap:"sm",children:[(0,w.jsxs)(W,{direction:"row",gap:"sm",align:"center",justify:"start",children:[i&&(0,w.jsx)(ut,{bubblesVirtually:!0,className:"admin-ui-page__sidebar-toggle-slot"}),o&&(0,w.jsx)(c,{className:"admin-ui-page__header-title",children:o}),t,n]}),(0,w.jsx)(W,{direction:"row",gap:"sm",style:{width:"auto",flexShrink:0},className:"admin-ui-page__header-actions",align:"center",children:a})]}),r&&(0,w.jsx)("p",{className:"admin-ui-page__header-subtitle",children:r})]})}var F=s(re(),1);function pt({headingLevel:e,breadcrumbs:t,badges:n,title:o,subTitle:r,children:a,className:i,actions:c,hasPadding:u=!1,showSidebarToggle:d=!0}){let f=C("admin-ui-page",i);return(0,F.jsxs)(Ee,{className:f,ariaLabel:o,children:[(o||t||n)&&(0,F.jsx)(ft,{headingLevel:e,breadcrumbs:t,badges:n,title:o,subTitle:r,actions:c,showSidebarToggle:d}),u?(0,F.jsx)("div",{className:"admin-ui-page__content has-padding",children:a}):a]})}pt.SidebarToggleFill=dt;var ye=pt;var y=s(k()),Ht=s(ie()),Rt=s(H()),Z=s(I()),qt=s(se());import{privateApis as fn}from"@wordpress/connectors";if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='1b00f16b8d']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","1b00f16b8d"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background:linear-gradient(90deg,#fff9,#fff9),linear-gradient(90deg,#89dcdc,#c7eb5c 46.15%,#a920c1);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:220px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background:linear-gradient(270deg,#fff9,#fff9),linear-gradient(270deg,#89dcdc,#c7eb5c 46.15%,#a920c1)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:248px;inset-inline-end:8px;position:absolute;top:-15px;width:248px}.connectors-page>p{color:#949494;text-align:center}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:84px}.connectors-page .ai-plugin-callout__decoration{height:134px;inset-inline-end:4px;top:-8px;width:134px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var ee=s(k()),Oe=s(se()),ue=s(ie()),L=s(H()),m=s(I()),Ot=s(ht());import{speak as de}from"@wordpress/a11y";var ce=s(k()),$=s(H()),Ge=s(I());import{__experimentalRegisterConnector as tn,__experimentalConnectorItem as nn,__experimentalDefaultConnectorSettings as rn}from"@wordpress/connectors";var xe=s(se()),le=s(ie()),_=s(H()),l=s(I());import{speak as S}from"@wordpress/a11y";function Pt({file:e,settingName:t,connectorName:n,isInstalled:o,isActivated:r,keySource:a="none",initialIsConnected:i=!1}){let[c,u]=(0,_.useState)(!1),[d,f]=(0,_.useState)(!1),[g,D]=(0,_.useState)(i),[p,B]=(0,_.useState)(null),h=e?.replace(/\.php$/,""),x=h?.includes("/")?h.split("/")[0]:h,{derivedPluginStatus:P,canManagePlugins:G,currentApiKey:v,canInstallPlugins:z}=(0,le.useSelect)(N=>{let X=N(xe.store),K=X.getEntityRecord("root","site")?.[t]??"",Y=!!X.canUser("create",{kind:"root",name:"plugin"});if(!e)return{derivedPluginStatus:X.hasFinishedResolution("getEntityRecord",["root","site"])?"active":"checking",canManagePlugins:void 0,currentApiKey:K,canInstallPlugins:Y};let Re=X.getEntityRecord("root","plugin",h);if(!X.hasFinishedResolution("getEntityRecord",["root","plugin",h]))return{derivedPluginStatus:"checking",canManagePlugins:void 0,currentApiKey:K,canInstallPlugins:Y};if(Re)return{derivedPluginStatus:Re.status==="active"?"active":"inactive",canManagePlugins:!0,currentApiKey:K,canInstallPlugins:Y};let pe="not-installed";return r?pe="active":o&&(pe="inactive"),{derivedPluginStatus:pe,canManagePlugins:!1,currentApiKey:K,canInstallPlugins:Y}},[h,t,o,r]),b=p??P,j=G,q=b==="active"&&g||p==="active"&&!!v,{saveEntityRecord:M,invalidateResolution:T}=(0,le.useDispatch)(xe.store),fe=async()=>{if(x){f(!0);try{await M("root","plugin",{slug:x,status:"active"},{throwOnError:!0}),B("active"),T("getEntityRecord",["root","site"]),u(!0),S((0,l.sprintf)((0,l.__)("Plugin for %s installed and activated successfully."),n))}catch{S((0,l.sprintf)((0,l.__)("Failed to install plugin for %s."),n),"assertive")}finally{f(!1)}}},te=async()=>{if(e){f(!0);try{await M("root","plugin",{plugin:h,status:"active"},{throwOnError:!0}),B("active"),T("getEntityRecord",["root","site"]),u(!0),S((0,l.sprintf)((0,l.__)("Plugin for %s activated successfully."),n))}catch{S((0,l.sprintf)((0,l.__)("Failed to activate plugin for %s."),n),"assertive")}finally{f(!1)}}};return{pluginStatus:b,canInstallPlugins:z,canActivatePlugins:j,isExpanded:c,setIsExpanded:u,isBusy:d,isConnected:q,currentApiKey:v,keySource:a,handleButtonClick:()=>{if(b==="not-installed"){if(z===!1)return;fe()}else if(b==="inactive"){if(j===!1)return;te()}else u(!c)},getButtonLabel:()=>{if(d)return b==="not-installed"?(0,l.__)("Installing\u2026"):(0,l.__)("Activating\u2026");if(c)return(0,l.__)("Cancel");if(q)return(0,l.__)("Edit");switch(b){case"checking":return(0,l.__)("Checking\u2026");case"not-installed":return(0,l.__)("Install");case"inactive":return(0,l.__)("Activate");case"active":return(0,l.__)("Set up")}},saveApiKey:async N=>{let X=v;try{let Y=(await M("root","site",{[t]:N},{throwOnError:!0}))?.[t];if(N&&(Y===X||!Y))throw new Error("It was not possible to connect to the provider using this key.");D(!0),S((0,l.sprintf)((0,l.__)("%s connected successfully."),n))}catch(ne){throw console.error("Failed to save API key:",ne),ne}},removeApiKey:async()=>{try{await M("root","site",{[t]:""},{throwOnError:!0}),D(!1),S((0,l.sprintf)((0,l.__)("%s disconnected."),n))}catch(N){throw console.error("Failed to remove API key:",N),S((0,l.sprintf)((0,l.__)("Failed to disconnect %s."),n),"assertive"),N}}}}var bt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),wt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Lt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),yt=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 44 44",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("rect",{width:"44",height:"44",fill:"#357B49",rx:"6"}),React.createElement("path",{fill:"#fff",fillRule:"evenodd",d:"m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z",clipRule:"evenodd"})),xt=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));function ze(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"")?.connectors??{}}catch{return{}}}var on={google:xt,openai:bt,anthropic:wt,akismet:yt};function an(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let n=on[e];return React.createElement(n||Lt,null)}var sn=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:500,whiteSpace:"nowrap"}},(0,Ge.__)("Connected")),ln=()=>React.createElement(Le,null,(0,Ge.__)("Not available"));function cn({name:e,description:t,logo:n,authentication:o,plugin:r}){let a=o?.method==="api_key"?o:void 0,i=a?.settingName??"",c=a?.credentialsUrl??void 0,u=r?.file?.replace(/\.php$/,""),d=u?.includes("/")?u.split("/")[0]:u,f;try{c&&(f=new URL(c).hostname)}catch{}let{pluginStatus:g,canInstallPlugins:D,canActivatePlugins:p,isExpanded:B,setIsExpanded:h,isBusy:x,isConnected:P,currentApiKey:G,keySource:v,handleButtonClick:z,getButtonLabel:b,saveApiKey:j,removeApiKey:q}=Pt({file:r?.file,settingName:i,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:a?.keySource,initialIsConnected:a?.isConnected}),M=v==="env"||v==="constant",T=g==="not-installed"&&D===!1||g==="inactive"&&p===!1,fe=!T,te=(0,$.useRef)(null),V=(0,$.useRef)(!1);(0,$.useEffect)(()=>{V.current&&!x&&(V.current=!1,te.current?.focus())},[x,B,P]);let je=()=>{(g==="not-installed"||g==="inactive")&&(V.current=!0),z()};return React.createElement(nn,{className:d?`connector-item--${d}`:void 0,logo:n,name:e,description:t,actionArea:React.createElement(ce.__experimentalHStack,{spacing:3,expanded:!1},P&&React.createElement(sn,null),T&&React.createElement(ln,null),fe&&React.createElement(ce.Button,{ref:te,variant:B||P?"tertiary":"secondary",size:"compact",onClick:je,disabled:g==="checking"||x,isBusy:x},b()))},B&&g==="active"&&React.createElement(rn,{key:P?"connected":"setup",initialValue:M?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":G,helpUrl:c,helpLabel:f,readOnly:P||M,keySource:v,onRemove:M?void 0:async()=>{V.current=!0;try{await q()}catch{V.current=!1}},onSave:async He=>{await j(He),V.current=!0,h(!1)}}))}function Gt(){let e=ze(),t=n=>n.replace(/[^a-z0-9-_]/gi,"-");for(let[n,o]of Object.entries(e)){if(n==="akismet"&&!o.plugin?.isInstalled)continue;let{authentication:r}=o,a=t(n),i={name:o.name,description:o.description,type:o.type,logo:an(n,o.logoUrl),authentication:r,plugin:o.plugin};r.method==="api_key"&&(i.render=cn),tn(a,i)}}function zt(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPgAAAD4CAYAAADB0SsLAACAAElEQVR4XuzdB7hlRZEH8D73zRBniJLDzBAEVFQMKCaCWXENa1oTYM45hwXEtOa0ZgVzWnPOBHPWVcxgzjnrGvb/O91n5s5lZnjAe4Bw6vvqO3XPPed0rO6q6urqUkYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUYYYYQRRhhhhBFGGGGEEUaYD3RdtxY9/XuEEUb4F4aBoWdxhBFG+BeHxsxg0+BmwSXBrYPbBTfOI3Dz4NKZV0cYYYTFhMlkskGcm5tbC6f/m5mtlwavGLxacKt8+k7BY4N7B/cLHhbceTrtaRi+A2bTX7JkSY9Lly4tG220UY8jjDDCmcAsM8/iLHOfCYObtS8bPCi4ZfCIJPHw4J6lMvlVgzuslYEpmP7WbPoDc08z+MYbEwpGGGGEdcIUo24V3CG4WXB5o103R4fBtgpuFkRvm3ubBrcPXiTMuElw+66K4uhdgrt3VUx33Se4PMltF1wRREOMTmTfrNHL2/s7BLfMtzdq+dgmaW4UBt8+zH2RYHh7o+2C24fBl45MPsKFGsx8U4w8O+MO918U/FnwLsGH5f5Pcz0ueJT7YbAXB68f/HnwncGr5pmfBD/Z1Rn79OA3ggcEP5N3fpzrFYLvDqKvkaycEPxx8BbBxwd/Erxv8F6NflKeu0lL+1W5HtzS/lDKcLlcfxLm/lLw0mHq0zbZZJOf5rqPmVwZlWWEES5UQJwddNdp0RozTGPuvTb4z+B9MXbu/SPXpwbv6n7efV3wxo3+SPDQPPPP4Le6ysi/C/46ePk8813Phb5K8FPtuesnO28K/jNIbH9Wox8RfEijn5fnbtWef2fwmi1Pn096Vwz+I+X4Ucp0uTD1bzbddNN/ht5vGMC6kcFHuDCBma3prBuHCTYJg4QPJht1VQyeBKfp/YJmZeL1KnSe3TO4W967Wt6/WK475nrl4KVCbx28XJ7bv6u6tpkbbtHumdXd913PbVOqHn75UkX13RvN4LYjOs+sCBL56e/7JO1tglcJXiq4ZdK7StK+fHDLlOmg4JVCL8/9jfO/ciwp1VK/SamWela4gR5hhH9tMEPDaWNUcJsw+qeDX8l/FwsjvC2McGoQc704+OXQ1w4eG/x88NbBu6Pz7IOCN897X8w3n5ZvHZDvvCLX/8zvTlrDzDkfPDNoz+2bb94r12sEr9TyQbowaHw2ab4vaNY+JfhJ94Nme2U6KJ95WfDLwesGn9ToO86kMe88jTDC+QKGDovh4MDowR2iqxJ/idaXy3+no/Psobme0uj/yPWVjX5g8AnoPPtszIbOd94ZvFrE4q9E7315mHzO9+fL5GcGU8/K49OCBprrlZqnrwWv1OhfJs0Dg3/xO1ez/XfRQXr+xxp9y+DrG82KP8II/5owy0wzDM76fI3gdXOPZZoYfr2uOqBcIa/TjYnIl87vfwvuEbxYoy+e5/fMezcMQxONt8v1KpnFLxWcLOQ69FT+ifX7BnfuqjX9Bl1VF1juD09erhFkVb9O8Lru5//Duqrjs8QbCAwMuwUvU2r5LjqV1AgjnH9hihFW/27XywQ/GDw+nX6fMOP7g28JE+yR6+uC7wi9V/573qSKtEReDLRvXqc3n2GggNODxSD2L5aTyWzasygv68Lh/wZPDb6j1PX2Bzf634J3Dn6wXW/caA44PQzvD+mMMML5Da5Vqgj7veAV0WFCIuwBYc5eRE/HZeH+YXuOjnux0Ga/nWaZaejomHuawReLuQeYzcNsfoY8zTJ4A5z5pVLF8lsF39zoBwSf0eint9/o99TXRhjhfA6YNJfb5nqjdPrtgrcOI9wiuG3wdsE7BTmi3Cx4Z88Ht+qqKMx3fPaT/4qAwc3OdwnuFbx68K7BSwYvGzyyVMs9l1m0/0cY4XwFNwy+vNROzGqMfmhwn+DzgkeHWVeEwZ87qcaqLaZnu2HGm8WzA9Oz5/R31kcPvwfw/rruLxIwuj0/eJMgAyP69qUyvDq0Hq8O0U8pdbltLVhMyWWEEQZ4bKni5f+U6jCC/mTw2o3+QddEdBgm2nl9DA7OBcY6v8BLSq2TZwYf1GgiPFEe/flSZ3T0r4LLvDSoJwNSU0YYYTHhysFHBm8QvHipS0A6KSeSxwTvE6bdNddjgo/oquPJWoys016QYT2D1+Glusdep1TJB81llpFRHd62VOebhwXvHrRpZrXOf27ZIEa48IClK3qkjRjEcmvTDGkHBh9VqmWYkQx9ZH1l3TDfWXp9IvN63t++VAeSm5Wa12NKzQtbgBmSpIFhMMvjSl2qunWjDyiV4dCHBA8uleGIzxcpVRrxzFowSCDrgvXdnwdcotR8367U/KI5+rBNPC7fPSYMbpPL7cLYd2uOQz3DjzDCOQEd3Hout84TShUd6YZ0xVkR/Yv1lTUwzNrrYc6FgP2Dny1VzLXe3KsEwcsFf9doS1XfCP6j1AHJ0pT79ogTmf8ePK7UwcH915VqACNGW85aLyxg2ejhsyL6T7vq6deXKcy8f5j6pE033fSrYfCLmskHS/4II5xdGLZW8qUmSjIKEcuJmK8olUno2oOBqIeh029otlsgMIPfo1T1gD/5k4P/Fdyl1PVlTLqy1CWp/y6Vce8QfG6pUsi/l2oUNFsLAoE2w29bahkNGgsK6xkQBkMlMZ2R7YTgf3V1m+szU4dPCYPvFLxjGPu+uVqVWMueMcII8wE9Rae3rLOqVLdLsxjGMCuarV0XC8xe0iOyWlIygPB00+mJ4oeWyryYlHcYRqS33jCdfMtcbx68aegt3CuV8bcO8nO/TakDAi+62wQNXNJwn5pBD3bfrOm/I0stP3FfntSLAU+euLDauOKZO4R23wBoVcHz5xiadGD14XbB/5irnnP/Eea+Q64XsfyY+3fLM7u1Z2c/McIIZxAzWb6+XqpYiFne1ej7lDq7oc1+iwU8vqTB+8tMTHx+TqmztPtvK1XMJm5/IXi1dv/npTJrL86mPGbe37bf3Ea/1u7Tud/X7hOLbXRBm+3/s9FmUrow+pRSZ3fpnV6qtOL+H/MtA92QnkHoR+05g9Q5htYuBp4+jTD0AWHovzVaOKre9z3X6w9tODL5CGvBdMeY6hyDm6XObI80mrhqVqe/mqV6GN5xXSBRnGHs7aXq/WbEt5bKbGZutIGG3v2WUq31DID2dxt0VgRfXarqwAf8Bf5L3jxjR5fBgbOJMtnNZnAgCbjPsGbGRyufVQK0lQLSBPppeWfP4JuDx3d1nf9Vwf9xv9SBSB4x5VmGaVF7qk12Db43/705TL1H8JXBtwf3zr1nd3W/uu2x62rHES7MMHSorsYvu2ZXN3YQec1GGMymiUuXKgqvnHp1IQDD0uUFRJSeWVl6VAGDCesxhmWxX1mqVZuOjJHAwaUyHkeQQ4IHdjViKoOaAA8bdzXo4qHBTXOPjzy3WKK7TSzKy6Nuj/y+VlfFXGkS5cVss3nEzM1X3juet1dc+KerT+r+cOGiRHs5LLh5V7eV+haVgE6tTMR1hsobBQ0O82bA4VntBMPUS+fqJhcbXOxLt/nlepO60UVZb9xVnX3eaYxwAQVLLHPNvzsdxM6pX5Qq7mE8Vmli4U1LnbnQROaFAmrAD0r9rln0w6WKtjdP+sN20fsHe2eaXPlv03s9/4ZSBwD3P93Vvdfu/zC0wQj9f6EvGSS29+JzcPANx8CD2kEyMPN7hnhu7RnNsv4f7ZkPlDVWbdtFe7E8dfbb1N2l+dn7PVe3wH6/vW8g+kR7h5pjpQH98K4xrHqfdlxZH3h+aKfgdhtvvHGfXt65Qn6fjs73rpPnTkbnehfvDOmMcCEEwQEHRwmdKx3FTETU+2yudNeXlbrkpaNaEvvfUg1ZPeg8CwBvDH66VJH52UEhlQ4OPjJ5+EyuDGW37yoT37OreqZnHt3VKC1ooZVEakG/MbgyeFLwg/nGykkNKuG/iwZPaGl4l3X60/nfrHz/dp//PMOZ9B7c1YHuU8Gnhr6U+6FfkXeIxp9Knb07uGrp0qUnpQ4/HnqvoDzIuy2vVht865Cg0FPu3xbTDQw+MPlArw+mvNe2Tpt9IvjV/L543nlTvvXloNmbuP7ZIGPj6ll/ZPILGWDuhkvSUQ5Ip7l0OstmwUunMxwYXJYOQty0AYQ/pB6i982bq3WwBt4jRhO7fcsVQ6PttSY2C9dEdL3spEZS3SV4mUn1Xb9IkGHJdVlQHncKinluhqYDA9FULhrsgqLFQExz0eAlQ4sRtYf352pYpV2DB4QWqdWGGOltHbRP/TK5L4KqjS/S3j3oHXqu0FG+JVSUaC6CTeyfOrxUrsRn4aSUw7sGgsvP1b3vK3O9Yq7b57rLXA0UsWve2d5MHNx72AK7Phj+D05auiLZTFp+klx38eCVJrWuVoW+wmTKPXiECwFMMTfcWbDAXP8RRr9sOk5vkU2nob/qLFfv6tLPmcLQiQbs1uiAK0oVTX2bB9mPct9vrq1f9p+OGHw/Ou/eMOkfj04HvmfwGHTuPSn/sXh7nlGr35Ka6ylBa9nob+W5SwT/lPd+h9ly7UXmSWVgszH66vmvD7o4qUtP/fbNXB8SNJujbZCxJCa9t4em16OFbbpUe/dHZtHU3e9Fqgl9qdTht9r7dOOTGn2T4Ksafc/ctzKA/q+800epyXtvxbwkqpk2KtMwMPmSNqPPrb0OztovX7cJUjHQx3rGs6Nb6wUc0glncdugkL/fTuPrqJ9KRxAa2F5tgQb76CVTzLpOGDrb0OFcW4dzNUDQrz8QevdcPx78elcNW+/r6p5xMyzR93t59xrpwE9Mxxax9DbB+yRfGOlhwZtOapTUZ4QW9PAHwf9JmvsHvx8UG22vvPP14FeCewQ/EfTcfvn/rZ4LfcXg8/PdH+a/64d+VO5/L/Sdg3d0P/eOC17LfcyS62W9G/pdue4T/G5Qfe2Z508Nnp539wmenHveF1r5da1M1879ZylHrrcLPlTeXYNHpKw/zn8vnmXu6baaBv8tmWHw1kbE9Z/k3k1yNSCqk/svnRk4RrgAwtDIraH7WVnHCb1f8OIbV3F9WToNy2wv902q6LdB2c7frZP1hwFMqhjtN9GXWKsjEkUxt2fpyHu1DrnbpIrV7u8U3DfPTZbWAwX2TX42ynULdHB5/tskKELMRZZUERW9k7LlundwBTqI6aDyuuc/z+y8tDJhF9wGnf/ncl2WK3rTliYXUOl2eeeiwe2UsTH2zgOd+6vad/du70jboEJ0ByuCF8/vTYPySZQXhVX5hJraMbhVkKFuRXAT17TFTsFNguhdg5MNMab6a4OpqK4izCrDnsHLJf0dllafdfVxEd9RLyNcgGBgQh0wjXuJzTff/J9m7jT2PhHRf77ZZpv9tnWm1eJfY9rZT60Fw8zeGPrf8x0i4at0/txnpPO/JTdWbzMf77PPBL9vBs+zH096f8x9NoA3h/5T8ned5OM5y5Ytk8c7Bh8W/L/cPy7/39ozwRcHD8tzf87994W+TPAPob8Y3DfP/yz44/x/0eBXQ/vuJfPMibmir5r7r5RGrjfJ/0+QRvDewbu3tJ+a566f//+a914XPDB5/EOuH871Yrn/x/z/jSV1ADBL/8z93Pt8/pPHKwTfpXy5d3juvaR9987BY6WXe653bvePz2/qyZ/znfekXa6inVJHn8h12RZbbNFtvfXW6xWjWhtMt/NLg38J3j/4qKaKvSB0/7/nRrgAAUbUqDphGvivaejTghj81+lg/5frqnSyszW6N0bnIPL7XF+dNPbG7KEFecDg7w5y1LDTi+eZ00VW5ven2qDAQEUH/XPwOsnDfyc/f0+HxOAP1dFz7zHBW+e5v+SZ4zG4d3PvA43BlYmovE/K83sDV+i98v63vJ//L5HfH27PXTnffk1L40Z55om+FRqD38P9XJ+W567nft55w5IaA13+PpZ63BdThj5trsaWI8b/Iv/vl3tf8E7oA0O/W37znesHj2/fvVPwuDbIPjpp3jmI2V+c3zfwbr75pm233dZ6Ouv4u8PYy5YvXz4JkjzWqvtpwOD+14bBl+d76udBwUdKI9cX+n8YvOEIFxBIZ7EMVk2uc3MXx4StI+yajrAinW8uOPvafIA12/c5yoiCyvKuE7EWO99LpyO6C6roOYcZENPRLLx763Bz9ZwxIrrnic/EcqLm8iDG2WJJtfQTf1meged3XVqtymhLVsq0dzr0UL6VS9eIz7vkmYHe1v383wU3a7SDxojsewc395x6WrJmU4e8DiK6mXsQ0VnqIXrPuWZdD+yeq7omolMPpkV0EsX2GzURXRtsWeEyO+6448X33nvvXXbdddfL7rDDDpcIbp4ZfKuUact8S/vNtsFqaGWGxH9l3Trv7L60ShS84Bw6YbVgUKVmPzHCvwJgoCngcnla8I1pUC6bX83/p6ShN9IZzNzzZe6Z73Lx/Gap2yyl4bvWpVeG/mKuzvTipfaRUoMK8u5iWf7frsZh4275rUk16L1kUq3gTi55XDrjd4O3SP7uGSQCPyj//XueOT30U5dUI9u3g68JMrJ9K9cPYrA8/6XgF/P+qlw/vFE1gBkA3pDnTltSZ+Nn5t53gtcKPiTPSO+I3Bc/ThoPDx7imfx+TtK9RPL7zeAbQhvIHNIgWuyqXD8e/EJotgXl/HresZzHcCi9w4JPaWW6VfABrUwPDDoe6bQ894S0wWG5vi//PWqPPfa4Yhj6HsH7hcF53X0k+Ml81+DWq1Hrg4HJl9aBzGD08HxXOVjUbx36By1v40x+AYHBC+ujpe7I+nPwO2lY67Z9Jzib4LA+37UFUxo2hfDDlsZfSnUeMXN7hpcZ495PvdPVo4GGZTLM8F50rg4VfBE617sG+YGjn5j/jmjPvDp4zUZ/ODh4lp0+V2dPevMfl1T9+IftfZb6YZmMiym7wLCExZEHfb9gv2yV/5/Z1fjmaM4ydpcpx6fy23KfTSynRqTm0vqtMNQ/w6DW+DkH/WVSl+747Hv/8CDfeGncJSi4hPuPDfLtVwf0496RJ3k/ZtWqVZblMOq9d9llF8clSfunKQ9j5Jnq0NrUIOC5vMsHX3qW/npvwK76tq9mcDjCvy5QrK3d2nwBdMqViG4Dy1/zALHCbK8cwNKXGbunG3PrPOhBLEdzcEG7x88bzQ+cUwk1giOLdWuWeGeCcRzhjMLxhTsoxxMqh3PGOJMs9cxcXQNnXefMgil6m8Nc3YHV52NSHV04w+wYxPREXtZnNKu/b3Ga6QNE5vtm7kG96B1r0PnuJTF0ROdNw+QXD+6PjjRk7Z1PgeOGe0eXSRWHV0yqE5FDE5SPEwqVwYrAQXnvotG7t4u+fdUVK1Zc/sADD7xYmPzKwcuvXLlyh4juB3kuuJH8tzJMVf0Zwf8NV03qkicnHSsXh6YM6kddL2l4pt8b4TwGHW8KbBSx/nx0qQz9mlJFaWAP87xh5rtmbSK3/dP2SfPTFgrJrM1Z5dhS90vbjvncvOsc7/el81APeHdx5Xx/V0X3F3XVrdQSGtfRU7p6OCAf9A8Hr593uHYST4VXvm67/6iubvrw/LPyP1fVk7vqALMy+J7gu4J2YTlL7ORJ9ez67/YckfdRk+okc1Dwjo3m4umklZO66s9tgwiV4phSB68PBZ9tFu3qmvMTdtppJ3r4HYP32X777TGQ3WZcWA18T/N+VzeBiEkn7zbz3LGrIvedgjbbeOZhGTCunPffmm89EpNnkHgk9SHfNSC+Jfi2fJc9g5eeQcsgVzYE/p/GfOMWwY+F/q/gYcHPTep5awakM5UMRjj/AP2YWPfaUiOSfC5oEwfgXXZ2AxI8s9TvCo98VKkbRGyRPLjdJ2oTYYmBHFrsDvtns6qbrf7a/jNjswt4xwaRE9t9Hb73ZAt9j+Ax6EllnKFMNm70m02CzgS3E83zorhyi3X/r6ENOj9r//ElJz6jr1LqVlO0M8AdQIA2sNiKiqZ2GCR9y8YU+8ypIP+bvBgwPfMmBrHQGOOe2223HWmEDt5b0UvdR/73rpbJAOsdwRl6T7bg40MbHOX1FfmGlQhlfUukAd6EnIPevPnmm5O+nIVOxCcJOAPt0EmVcCS/QZhibni/UvNhABS3XXrfwOCYm2i/8QbW3Ec4/4CoJWZYMw/QSUQ4PaewMohBlpfqT07/ZTwDBhJr3QBD2OChUxFT+ZzraDZ6XBldKmMbGID901xjHb3LWGXLpeN7DQR2f60Kbh/E2NxoSQC2ctKNqQqeF6WFlMAecGhXfdylZYsoK78Z2ekp6D1DH9KesWXUu3zwWRsx1+CiSxde2Wi73pyRRkQ/eKuttrrirrvuuv0222xzYKN3yEB2UBjl6mESPvXqw2YWZ6/Jsx1fVhGkTRqxxEbKYHe4VN7hQ3696N1XiEi+XdK4dvCq+eZWuX+Dhr5LbfEs0bplbcPQ1XaADJo8Aq/SBot/z/VaSWcJ5macw+AMryOc/wCT2OLI2GNWMxPesVSR/DFTz50p6AxTIAADSWDYI/7CUju+2UtABfuczdTuC2uESRjKHtlVeGHwGa2DMV4dH9SDqAwvL1Wk5wNOwpD3I7tqgcf8RFoBHGwjxYRokUZtWEH/Z1cZhq83kdggJkKLvOyY/54efEVX93nbDopemSsRXNrEezPny7o6GCjjS4M36qo7LVrwB3vR0UR74Z9Z/Y+L+CxG2n3CJA/IDG4ftjK/IPekQcp5ZX4bXH1DHg8tdQuuvDvZ5TDPBO/aBgT5eGTe78JokzAazz4edWutXw84X+jq4Opqw8vjg06aoZ5YWXhivu845ncnvdfmuiUmPwfG1xEWCXQWFlOd6IhSLb3TkUZfPzyowc8CiK7iffuaHx0kZhNlndDhPnHWMhkRlh6OMdwngpMkiIH2Z1tPdh+amb/XaDM5ewFagASMh753njEIoA0QgwWYPjqIz1SPIYQSC/0gokN68K8bjclObbTB6R2NFiCh32xSqn3hno2mdgxqALXD7I+mdhhQerUjaBecaK2nRpQ2ONlS+tOUl2QxlImILkor+m7BJzaaqC4MlRUHzE9k/k2QjrxaHx6WuzD5wNRnhbl9Z4rBDZREcqsS/WaaXG091Xf+2ZyCth2W2UY4fwFmYg3eps0g9m/r/ERYoY/6pRdwFhkc8+oMgDRgABmsMVfrqp4NMMQQqggDCsQAHLtLPJWupSczGKBCkDj0VnkT7JDoTbwXiGFFVw1iZnQMujLXo4KeZUM4MnjdUoMuouVxy1IjovquLa83KfWctM1yvWapARjdl4Y6Qa9o70qb6uEdhj+VhCYtAPk+WDnaDHh46jnkhJRxo2XLlhGfGeluGeYQQIOqwBipfujNR5Qa0UVZlMmecX4Jt/fd4KpSDZb/Lg2I0RYCWp4hEd1aOBHdPoC7B29OXE9Z7h48Knnn+LPBtfYRziXQcFOgsxNTWYOJz2Y/nX1eMPOtB5b6LQYlIuwjShWlGdDMQnaZYZojg/ZGG+69Iw8APaT9kOC9G83I86hGE+dJBAYgDh+P7apF/fBSDyIws9N7Hx+kR5tFn5ArxpQvMyDR1/LfE0pVJTDTMaWuHmB8+fBd+SUBPKarujy1wnMs4gxi8oTBfEtZ3ROmyTq8QQmn+SbGnAseE8a4X5iAzeBBwYeEKTyPQY9OfcjHkaWmh6kNFMpEmjJb9mUq1YahHAaCRYXG4NNoKfLpwQckv/sGn57yHJfrMrP+IEGMcP4BDEn0E0nE7E3cIzKfHRjEWYYsVmtiOaagV7oPzOSnlLo01u/JLtVjbWWpZ2qJAmNQcJ8YiuGI8X5jpO+jG+N+tNEGk94ppFQ1gO0AzXpvQEC/tauRUdF820kK6J93dXZEQ4OcOkAbLL7aaBLAOxttMBxEdOKyAQMtwowZGE1E37/RvNkGFeSPYY6VpZVpUh1gqB2s4kR0W2P/VuoAQT3qreilMrTVB6L6oBJQGRYdZhjcQClPXw0z9yJ66D9N6lr97IA/wvkAdEgzIlGZWPnYUkXdHs5igzHOmckcJIDJ6afE85WlDh6ilJi1b1aqYQxgjkMafUSp+QB3KFUMBZjUt4DvPLjUI5EsVwn4L71rlTWRTDEvQ9ohpZaJocxAQ7Q9OnhE/jNYuM8LjZpCYvBdAwqmVQ4zuPSk4b68eYbxTDoGE0BMx3QGB7O2+4e0/xgC6bCcQh4UvEuQRf5ewfuFVg5lFe5JXbFRGBDlj2QjbQOFEM6McaQTA5K8q8cezmI7nWUYGLyrjkXDDE5cf0zwYZMaPHLR8zHCemCm4hlwdBy6Nr0S82D0swo6M2bAHJgME5ltzMCYDHNoeLqvNC1HyQidk1gN6LFmStAvYbWOQgc3Mw/P+BYwC2N8yp48c2bBfNK7a6lMjKmJvWZEM6Q80qUxDUYk+hKH0b7rfYMIcZw+TvS19oxmwDMIodkVjipV515Zms5eaoRWxsRhSRGNCUHv3KNMYYKjgjdvjMJ2QHQ34MnPHUJTO9QBpxZ1KD15Mvv7Hiu+pUNlUr5BtZlt30WBgcmnmF0Iqwd01flG3VJ39AX1OcK5CTMdgPhMxHtUqbP3/5VqkT2rQH8cxFnr3KKqEj110t5vu5zRio4p3Gdx9s6fgu9O/uiy7ltP3SUXQQn91lmIpmidiDiLNih8pNEGDyK65+jt7AjuP6tMieilDg5oIjrmQf+srLF2QzOzMvmWwe8r7f7BZY2IznL99EYbJAYR3T2iPNqz+6BTtm91TUS3xzr0yvYMml3gh42WHhFd2qQFji5oZaB/e8fVIGpvwFtKg3ODwcEMg/d1mOtvuqkjn0u1dYxwbsJMByASnlBqJzILHF+qcaeHs9BZrEvbdMF9dM9SR+8XdHWGOSL4nK46a9DVdH4xyHX6pwZtkiAae/+2G220kW8RZ2+y2Wabmel9ywAE6NR0XrMd8dUS29aldnS2A7O2gcP6OlGWNd6aOkY0iLy41FnRLIj2be94BsOQJujP1tvNwu69sN0fysQf3uz9vFIZ1yBmADHQWAVQPmlt2t4nfbA4yff98i5ruR1mNovwY39qrpbxOLQcXeqApz4MGGgDgjI9t1SjGhVE2sqGmZTbwNLDWWizcwzSaihIhzX7Z3V1oJJX9UZiG+HchJkOoMPQ3zCljo52bz5AjLRsBOmMOpylIDT9kJhufze90TIX0Zb4fmhQZ6Zzes4sDdDyAGymsMRlhhC8QEcGmNYgAYjdOjvAsAYoTGX92gytc2EODEbPN+sTgZWPWE7cP6RUyQBtkKNDe97SFakE8xoclNWM7D6a6E+94L0m/yza7lNVzGbyMYD6AMpnw4k24F1niRBNbOdJ5xnlmy4TT7Y+amypaVg+k961Uy+WAQ0EpBZ10UP7zrkCbfZ2pX5x+uFFuE1QP2BvUIfnap5GWBuIfkQps6HZA/3K4c8zaZhBlCY6YpoflSqWY4RPt/8wGjWAGGkmZwF2n95P3PxdqbMR5hrEWaIxUfUTpTKl+57DlKzpfptdv91ozHZSo6V3QqnWZ0azYxttVh3E5zeXqk/L62fKmrPJ5J9o7D5VxYz88/bfFUq16ruP0d7e7rMk+/YfSl3CM+u6Txrx3Z+W6qSiTO6fWuoAJI3fpn4FhHCfeMs1d1A7MPegdhjQXl5qPd+u1OU5ao6ykcA888bS4EzabEFhSky3Q4/ziyW/YcWgVzvkZ8ARzn0gCr+v1NlWZz2lVFG0B423ATAbv7ehGfK1pW76wHBEZ0zH4EW8lobZkFHM0huR04yPoTGiAeLdperoBg6DwtNKnV3tp35tV4/84SpqmcvsT+TFaBj/P0vd1CFthsIPlbq8hCFOLJW5zXTK51ki9MmliuMYWV7ZHpTDdyw7Eb+HMmFQ4rLvmi2PafQhpRq/5F0dKhP6iPaO5a0HlFqm95Sa3opS6+M1XZ2Nlcc7JA6DnbTV7TGllo/IK/9vK1UkNzjRuUkdBlP3V7fZuclIUwxul5pddQyHewTfFnxXV89FW83g52beLsyACQ4uVXQ0Ew7i7/kWdIzWkfadNNG9q9Z3TAQwBEZlUf+XgNmO3zq/2d0MCHYsdZABBjRi+gCCUWwa/V15Ob9QRc4TmGLyAW3xPSRoxxoVjYplI1Afjmtk8kUAFT8FJ5YqQtGdzRjoo6cfOD+BDjF4RrE85xanEBtBvoYulSnMjsTnYTntfA+zzN06/ndKLRNj3QcbTQoa2kn5qAK/L21dPfiLUo2j5xkMzK2NchX62dbUv01qUI1hZaDfBTgy+CLATKWyMH+5VHHSYXZiod11eOasNIBGnX5vQ/R80TftRtpss836WG/Q7xYr7KPBL3KHzLOvC34haKajB3+uqwcJzhtm056+tz76nKLyDT7itlUOO68wR/5/e1fLZDMNcf7zpRoeqR10clKKdvtoWSOif7hMDdDSOC9gGISDu0W6EGPui5Ma9ea9ydNXurpuf57l78IEq4I2KnDSoL9a++Vffb6qfIyNAYLLgg5WwBiCBoqgilEweZ/vUh1MhiUZS1KcQ87X4vqwbzrX3cLgO7fdXkJBDXor67nVhtlXZ4EeP1jqz1NoDA4uAic1jJWVA3v6+yi58yjPCOcQGGX+mIo2AzyTFberYYxWzzDnNgxpD2hGa8y91bJly07OldENM/wyKOKJ0L1OwvxbV9erxR5XDgYwFnqzmqWa8wXMlg8zY/Dg1inbH9o2S2GbvxzG+EfKJpjD21uZ2EweF/x2K5/3iesMlgyJPy7VqNnDedF+AzQGd4KM3WYOMjRgCdD596C95KvrYITFA8tg9CLW2Cemsi3XPHSo+POig0x3funrKJg8M/ZyzJ3ri8IMjub5etApIAIRfiDP/zR4kVIdVZxTRh/njspbTec/38B0GYfy2UGW8n03+KOUy0z+0fwnvJI1bo4jPwuy7Bu0hItmUKOHf6vU3XbXDH6j1COKpw1daxI+F2Fg8MbcfBkwOFH9l5MaNHKtehhhAaBVJM+vqvhVcY5ex0tsYwzS1SWo86zSk64DEIij/UaFdAR7ih0kgMmdibWs6eEOL9i8ibPDe7Of8x+/8NU/p2j3h3rgAUdNWRToasaoCa7O5+UAQq3o1YuUwYClTLsGd0KnyLZakk4GJrCddObLZwBqSv/d4MZ5v4+Pdh4yuait8iAQow3v+wVFYTUoa2NRZvW786y/XSBgpoE/UKooZ1mJRfbbpXp1nWcj6kz+eND9Knl4Q1fPpf51GOCjwZXp+L8Ic38jV4f8OfnTDE63s54uXjoPMiLqV0p18aR6KCOD2y1LjQxjLdo69Q9KXYs+tFQnFmv4PSwCQ5AseKZZwuL08stSI6CKy/bLMMCpKcuKzN6nB50QuuPSenaZGZwebs1fm5FKrN0rh+/cttTjlInovPe+k+eenG8K+ywC7RMGBjcQnhcg7YZ2DX4p+ItJndGdCPvrrm4gOtf73AUKZjrsJyc1+D14RanLLBxbejgfVLSADfRN0TqJd/TsLwb3oHOn8/88nd8xPc4MswRD4uAtZ2mMOyRxVvkYDQGnFc4smMH9h5XK+H8q1Qfduqz7LNM9LEIdGGwwpPXrPj15Doqb/reUwekkznRzvpvy7tJ0cMuAq/IcBx95ZDnns8+LjwWdY5D7LOvsDPaPvzDvXHKzzTb7bN5/xsBgGP28gCkGF/DRUcnKLhjlR1s92N67GHV+4YEZBmdhZowCNnPodNP+0uc18FNn2bc8ZL+0s7oEJCS27hymdhYYevPc78/Fap1jPtNu7w/dgHOMGR+YGVcMfyxCZ5M31n1Xde4QBIEVtc2eKccuGDBlNHM7iw1D2ojSW85bfqbzPk2TDgbwzUFER+8wVT+LCUO5BpVnzR9rGNzhCMptiUwebUqxDXi6jCOcHZipPP7QXyy1Uz+50fMyQg0NsdCNMTMAyQs/7edP6trpV4Jv1Pkzg38p6GRPZ4ifmPufbe++LPip9j4jFPdPHl82h/A3HzagmOm5rFoz/kKpPtx8zr9Uqo93Dwtdvhk4MGgt2Nr9ykk1PL0/DL1tyqRsnzZw5b/Xd3UdnH2Aq652Mhg/sNQAkcrAzVc5SCeMbF/M84JaOCCBTwNpZlHabAakTW1g0GTjWQ1T6Ysg+6GWL3X+6uDXQnt3sfN3wYaZysM8xNmDy5qIoEcNf84w27kCM/nDgP8I6gx2YxHDvxmkv/6TCBt6qyARm4hnZtTJlYMkgtmtDKws1Yfbfb7ndy11YwYdlojuPvH9sFLrA/OfG3D1Ust3alfj3SnPr1LvnEL6DTShxVf/JrrUTTZsDGiDwwsbTa0ymPnWI0qtN/dfWupg5v4w6C02DPXJ9rHWLD7F4GZqnnbajLpikPLO+WYJ818WZhjIbMbohBn4N9NPOYOcX2AwSBHhWMadJ2YtmNi6D2Nbo1lgual6x6i0PjVj+v6glwMbN8yOgIPPRaf+W2wgmgptZEAlpfTlm9Rzx3rRvatbageHHRaygQaYfgCrIIMFzT57bUnhvlxZEwJrsUGbHVKqNHSGGaKVR0gqvugOjKBiqHOTzLSKMcLZgSkGV/ncU80IGuNMYWZwMBh8vNRZcrFkKjPqB0s9jocawbrtbDJLRoxNg9h5QqnqBniQ5xvN+PSkUvN3eKm70TCvvL+91DV/xio7xe5d1vivP7FU24TtnWbEM+iTCwQGWPXvLDS2hfdmsHr5pJ40QmwlmnuO+vSG9o7yEX9tpLl9qTvvMDDJZNi9pnzqiuFNGtSUJ5TK5CeVKhIvVpmk/aFS+1Y/2Mz0m9Uz+RRY3fAOKU25rOtvO/3ACPMABhuzwzCKljWbGOald88Anda73y7rGKnPLsjfFFAXpMGqTc/s0+vaIQG5/qWrMc+J1f5j3MGg1I1hJnOfSI/Rib1ESMzs/nGlLsURYQ0Qh7b7ny2V2T9W6qCwPongnMK1Si2HkE29iM57LXWw86SubviP8Y9O+7dSZ7gT3S91LzoRHH2bskYFObZUxkdTOwxyaIMxaUhdUVsWrEwzbaY+padMvT+CfscYOg1TDO5lTjnagIhOCqG6cLUdYb7QrLFrMXmpBhGVOi2uzheIiRpTJ10sWFHq7raDS3U+MQsL9i//hwSv0MphFjbqA5tMVjYamBEGmBZTzXpDJ6cHSktnE0WF2Ey0pef6dg9TnXKhwCzlQAJlSbNMRGQZyid//UaMsrZITpUY8sQ5ybZeDxF11ZNvMipeo1TVQxlFmVEW93m8abMFL0wD56SJ2qIsfX9rqx4bOnABQ+uH4sl7yKC93odHmIGhonPdMtcXB1ltHR97bNC5VcS4+XRgnYaoSEw045hBHj38OY/3zwmYhRmVGMbMagI7PK79d0ypUWGA2cv6NtBxHtpo4p9ZHLPw4/a+GQ0TiA3HUEWffX5oMdJ2L9XV9Uk6nbItdPm6qovq1Dz1xA2XtkMN3H9sWaNq3L2sOQeOtZzqQC8nlj+rVGnDIEV9odZoz+eX6rZqRlRvnHqUT5l8d5BwFgSGusnVQZD61H+mPM4151L84lyXYfJ1ONl48ehS+5KBi3RFhVjMieOCBVMMbm11EP0cunc6urT90uvqwDP3BtH2Q6WKfsSq73rMn+t6fwHB7CRt6WFKFme/zWLyQYTlcsof232zgAgnPMUES8DQ7mMQe6bRBgiMjSaKYw60LaZmRrSIoMN6clkoaN8jldgPbbC111u7/KmrEV36diqVkb9TqmhtcDq51NBOBqdXtGdYzakbaNc7Nvo1papfaCsDDKpo3npWHBYE1iWipwxfy/1D0fbrp60cfbUuJxuVOrSZfsgeoqz3W/3AAtb7BRKmGJzxRmD9+3b1ZMxbB9Gr5tmBzdqYHHMTfRlxMEwP83j/nAAPsLuU6korIZ2aPg3MZAOtExPlgdnrOo1mUfacdzGNUzgxEiYTg5xIbIC4fWgi7Rapp7vnesSkOmbMduRzDF3VUfmM2zJpFhc//KatLZRzKNPBpdo8AIPocN+gwNvPILey1DoR78xAQJKxBu6/O5XaZspNGqCzLxawjzjg8Rapr53S7+4bxr7zXN1Nti4GB1yHvUNqunap/cqgPMJ8YOicweWTesrEcZPq9qixH9vV2WM+DErPNUOY9TTGsaWuKZ8bwKr9yFI7K2OaWXhIW2dWFsAybiAAmN1sBujfdytVvF8RvE8rN2Z4YFfDBxlEHpbrrVI/26UzOg/s/sElg+1ioaExM7Q2/IiuBdlwLTW/wKw4lA8DGGTp09QOore2IJY/pNQBAJMJ2mEN3ECs3jC1Qe5RpQaBZHchqhP9DXILBeqZ2mawstR3bPAhkxqyaTWDz/Q17XVcV0MrG7z0MTaDEeYDUwxurZgoOzgYsM4SjSytzFZ6DzP37lGq+GRph4HOu0TmcwPMYn8Onl7qUpe0B8vyIM4Sy4mzRHbGtg+2+wam5zaa0Y4HGFoHN3ug2RZ6ET1l5lHWi+icaUJzhV3NjAsJwze7JqJvsskm0jeYDWUyw7N6ozHuRxutPl7Z6KPKmvPW6O53bvRry5rDHIjoyqRuflzqgOC+39NGvHMK+pJ24byjj/VqR+pw62kGnxksv9nekVciunyNIvp8YYrBzeBmb/uEdRYd/Wmlit7zqcjLl2rMYrwh3j6xTDXEIsPKUhnynmaDUo1tZi9gg8VgWDNTmckAQ83wDMMamhWa6M7zi5Wa4enoru5k4jBDohEJVISYJ0esfNSkwmIzuBn8cUHShN/aZigHBn5Eo81wZmTitkFWPSiPZbNjuxqeyiyK4ZXJ7K7eSDlmcOvh6oG1He05Us1CwT6lHYCYtHdLvT0l9XhMroJBnoHBW1nl5+ldPRCDdCi/4ww+X1CZTcTcfFLPv7pb0AYOot89usrs8+m8OtIdSnVmoK8eWapuR88jNupEay94LhwQKY8MHr506VJWZstLh7cOQmwlmgOz1LCur8P4b4CBxuQ37upMib5F6AO66jF2m9TNNUPb8XT7oCUfsyhxWV2REuZTV/OCln9IB7/dVJnMZkM5zITsB4AITj9Xz2Z9ARYx7qpS7SHKbEY20GESbXNEqYMBUfyorkbtwdRHNqTyLBRoJ2lcP3UnPNOd0veOCG48q4NPld2pMLa1Wq1Q1iPLeLzR/GGKwW3S6EU/FRnsrZddO8RPZc/CzD16ofd5SbHievfrpXY69/9Q6syyGHBoqWmcmrJYFvqlFYHQ9GaiOxFPR/92e87MNYjoJI9BRNfxLfOhzWCYAv0/Kcs1Gs2KbkZUPtFTGOD6eguuap2yLARMfUuZfP93pUpHvSpVqnGR2sFyrtN/uN1XH4OIbmClt6JJIOwU6Gkr+ifKmuOYifyMWEOZqDM9LEC5BjXny12VJnrnnbTTVtMM3vrjgPdIv3QiqXK/vtT9+UcOH/TMCBuAQTRSyXNtHXxS44hbOnpLVx075tO4vMl0qsH4Y5nmmFINOa8q9RCABVt+mQFqhEMBjt5ss82sHT84ZXhIaNFNnhx8Ycs/8dW6r56ko1sPNuiYEblPEl8tTb2ktCORu+rqiklIMi/vqrFrZfBVQaLjitw/IdeXBm3+mE9dzQumvmNwUofPKnWN+smlLu0BZZJ3UhNJ6cWlDmAGq+ODwhCz/MufM91IMS8rVbQnMr+8VH8ADKT9iNBmWvd9ay0j2wKUTdrUnj31tTD2C3IVdWetGXyKwRk4SUrKJ8/PLvN0nx6hrMXgLJmHB28yqadOWJ4RMqh35JgHGOmJuYP4ZLRmwOJ1RFS/eqneYAsKA0PpIBnpl2y77bYGqkuFuS+lXJtssgld2pKQx3VoMzYw8AwdBZObiYGOxGNM2enzB3dVTBQwwplgwz5lZ6cdPKmB+q/bVbFzwdfEwfC94dtBA/DgeUfsHjz19ih1+csL1Cz+DLZfsh8c0tXlT2eVYRrtpEzEcyqIdnb+mQGOiI/muacODBACLpytKKfD8111rVWHIqaK3KLOrpN2sq13LQYHA5MPaQZXtb+00QjzgSkGt+mfGM0yLFSORqdvir82+1oPM/dZZ1ld31GaiF7q2Vw8kNz/dWmzwfq+d3YhnaMLQ0+22GKLTXLtO0Hy/+Ctttpqd5bnUsVCuieLrN869/sbTa8bRPQjgw9uNGMOqzqaxdkA5Tsi3Qwi+g9TbxhencGVQ6dcSGj1ZVcZBu2aQxL1h41AHfuN0U9q9KF5zAzclyn0o9G5EtXv0GjhnSyVeeYjXVM1cv1OVwcP9/nzo6kG/ttfXlp+5gXD8+0dhk3qhP35vYie+7+eayL6rE86GBi8Aeck+TrCD/cXuq4vcDDF4NsE3xn8SCpNDDAdykjbHx8zDzBLnxJ8tE4RPDnIvRLDnRx8U9cCAi5Go4S5J8uWLVtKRM/3bx285zbbbLNtV10jxfUyuDjKl+5t+Uyn/0CpjEGtcN9sbaY6satGrYNzD80/QPSYk3K1u4t32Sm5vjb15UigD6RzfiDXHdc1E50TUF8Nt5vUIITD/WcG/SC2s3vQxw1KIseKiPKAVg6zNaelk9rVoK1tWOQN4gaFp3RVSnFffRlIPpi03pkr//G3hjYIrOzOAoMPz04h9eCUXJ+fOhKG6iOpr7cGxc5bHfd9Gmb6yrGlthnpYjWDL0Z/usCAztg6pdC1YlMfGrQuaYdDj7PvTAFdz95de5OHM6V0FOIeURGjs2oTeXtxeFKdGkRA3dB3zzKEubt0ELO4b18sHWWfVrZ9kqb9zzqEfBJve4Zp+UMTY9HKu2nwEkHeagI1XnpSd3ChL5dv+q5lnSvk25cJLk/nvFLSvEpw0/X4VJ8jGDrxFJK2iN3yzn9BnQ+0iKSeMSBgIuUhDotSus2keiweMKll4oVHAhnet6f+Yt5P+S6Xcvgt4unlcr1ycBP/wTOD9j3PGiy0/96h+dT7zqXn6qx91dTXQblulbY7aNNNN71K2m3TWSaf+h5PPCoE9Ul7KEM/6M0nTxdKGESjXLfL9Ve5JZjfZdc3E6lIld2ApZmY9exgL87mf4Y5M5/7RDEiOvrHrYGvkO9eKUyx8QZ2EJ1lcKpJKws9lBj31XQaA9APuhq0j+fUqf4LrRO/u9FEbwZAtGVC68vK8bigNX30K4O9FT15N+v0Inqu3056l046f2MNTnlWmIXgQkPr4JAc+ykrHl31rvtSqXk3QL0PnbxePXg8Ovk9MvTR7f7RwSPb/eNDE5nd/1De7dspND/xvfK/gxV+nzLumSuffe/sp0+sq19Mg/8nrZ8Eecb1/SJIylNvnw7ytPtnyvGj4OVb/XHm2Ws4fgpo06Hspe7T/3tX3XYPzn9PzTevOZ88XSgBY0/hNqnozwe/m4ref7g/C0PDNeBwYW8vhw9r0N9JpT8v9OVT4afl+qagGdQzAvRvlWcuke9fMrhkIRnBYNHytqLUuGTEaYa0jwRPDb1j0n9H8ndarnvk9wtz/WauJA0dX7inm+T/OyWP3871fsEbyHuuT85/B+X+6cHX5jcD3mmhT0y6pIWvpUN+LeXZxewz4ELC0Mm7GnqKmPqJSZ2l39HKIdoLpv1G8mdWfELydnquNw7ewzO53jPXGyuH/0NfKfitvPPSSW0ndfDu/LdL3hV2+vNLawDLTwYNZntiOLghwGyTNdLGEUF1/ry8d1i+89185y25Xj74ndTZx1JXjKJfDZ6Welw5zeD64NS3XhQ8Pd+6ZZA09aD8Pkh6cIQp0EiNibeAqWxB9S+TCr9Crn6vd4/uwOBd1dXo6TunggXgPyC4a3BpEBPsMVcBvV/SWaKTJI29gwvK4FMMINM80+jJfu8V1Hl1AlFJ924dgqMFsVHH2aLdnyypR+zuvaQelsABY9+g882S9aUXD64Mkj4MUvumDETKSwcPCG4eXBlUvk0XsnwDtLpXplUt78TUla1MPOz2SF67oB1aewVZqB2awFbgCtCCN3qnj0bb3ldup6XoF3ulfHu2fnDR/L5UrnPtv9lsnQGmmHKX4IHSaWleTj3mW0JaX67V25Zh6MsGD9y0wmoGB/LW8kqyIAGyF4kqe8lW5pHBp0FlwUY/JBV9P42ZSv1+dNl/pMIx+joZXKMNzFTqOvfvU7lPyndu3sSs1y+p50z9KXhKkBfSH3PPEUL75du/zHN/zvdXSmMRmIBH3U+DXyt12ei0oGCFjqj9TAvGeInk5y1BftDE2ae3PB6RZx7YynFs6FsG/xx8SX4f5n7y+0EdM/iX0KemPPvnm79L3f3cTJTrt4Oeu/z66vAcAss5C7pjlwygn25lInW9I/in4NWT7vPb+WW3CT5cWXN9WO7ftpWvn1GDyk0KMHj9Kfc/l+suy5cv/2vK8ZP83jHX70nDwKw8Z1YmzDbF4HcNSuO1+e5123c+HLxy8K/Bb2DuzN6/Sr7+EHqPaQaXlr5qUAn9llam2+XeE5POn4OPHxh86NMXelARDY3oD0iF3TuVt8cm9cSMP2Fwz62rIWcYnK/zb1K5T8x3/j2NhRleHfrKuffb4Id0nPz+XfArG9UZ78dpvF8vIoNbAvtJqQyO2b+evDolgy75yaT3R8yQ/Lyx5fEawacF5bFn8JRfOY4O/kfu/T7XFwYPUzd5/70Y3HeC/4vBMTdmCH3JIFEdo1x2PswwX2j1DawAYPDv5p7Z91NJ4w/J56WCb1PXSyuDP7eV47YYu5XjYcHbtPvPyb1DPZ9vvCUoAMPv8t6n8t+Oeea3wR/k93atX/wxtFn9TGdwzDbD4Or2VcHrSjv1c3K+ww7zh9Bfyz2z90/D5L8+EwZ/kz6W37cNYmyTy2OlBz03QlmzNJYKyWVul1x3TgWyPhPD6EbzFdFZbS8zqfot0RbjONbWd1mid5+r505h8r3y/Um+2evgaailmHt9aZwD0Mr8yKkPk+C+ycfFWplXzK0xFDnkbt+WP8Y/1nHnYy2fq5byLZZUcX0Q0Ym61Izdk+eNcr1Y8n/R4EbpoBcPXiL0ZsqX/y4b7KOUnBkznE1wTJHlQAy0x6Qe+qAc2pIoDIjD6E2DVBC0MimH57dt9UB3H8TcVcEdllSG6l1UG0MT2/fVfsrj/zODgcHlaVJtMlSg1SJ6kD3GIMhIuWXq74Aw9eWCm2HuTaZsGC0/rr2InqujhqmCxHUbgkYRfRpaY/adPGhkfclcDTX82aDjcTDqOjunRpsCftvf6aqR6nDv5jtcD+nljoB961zV8b6b/z6R7+2RBmXdPj1Xhpx1prEQ0KQMy1+2dzL47Zl8vF8eJ3UZ7GUtj4dOqsUc/R/Buzf6wXPVq+87uT5jSVU7HKnz+uT5UsHvJ/8fCe6TzvgtM3dos+An8ox6oB8ueMczaOXCYg55op3Y8msZ7FUtv4cE7dRC3zx430bfJ3izRhNxlUn7vWJS/R6+HXxf7l0k5WCU+8JcXTJ1yIKyGwDmVaZW//DI4Hfz3RflvWvmm98Pvi31d0V0rh9PvV0q9ffNMPa3c3X22mrJTv+Q3qQOGIyB3w/eIvf0OfQj23/zyteFAoYKCdqy994gEU2n/1Eagz53WZWFyWdBo03BY0tdouGTbSuf5RAOLXaUuf+p/L4EujXsvmnMP6QB/d7d9+VjoUEeW/ms+/5qrp7dZRb/Sql55F//HnRXd2jxT0eLaMO3G/34SVtSmtRlsmFJ6eTku9+YsbRa1englnfomZj6e0Ma82WGswJdDfhIheJXYPfaN0pNz2pAv4Em1+vl90sbfYfQgyfbMaFv3+iXBPsy5co5pt8Dnv+tOOyWfDsL7deTuo5tj7jnhhjtNTNnAl1lcBFY+n4RtOtN//pkkAehfsCqzl7x54jo9PO9MPfA4EMfgflWvx8819vn99Pbd58y/L/Qdf0vC0OFTNY4dej8xDjnMRvVBWCcT4XZoMA7SSfwLUH1iI+Al9Rg6WVFHxxP6IqXCT3xX3t29rvnCIa8tzQ4a7DiOpbW+jfpAr1n0rURw7G08izvZnwOMO7bBspBxPvEQQ461JG9llTr+mWaqOnI4gNy4fRCpOcY4zBEjjLzZob5groKkl9xgIq7VMuvo505gsij7aV80dG2tiqT9nDlGILmocYJyfsr2ne5oqoXtLoaHIE4Cqmf+QaYNDPQvXyfc5Hdibwj7XG4qvTnqopwFYNlkEX9oOBVgpvPSnZDmvIUPLirquGqRrvOJ08XHhg63qT6Tn882BvDgu+eq+LYeh0aZiryLqXGB7eLSWV/vKuBCexFdv9FXe1I0nhzvseV84NBbp4a5m155vNlgWNsDeWbq3r0iUGSBG8tO84+KX9ddfX8RFc9o4RismXyRrkeWWqccGGQ+Gork5nPJhXums9J59PxnXr5+sw0LNknJQ11uEee49b5qW6Nl9xUzhYcMNEbS82jOvzvVg57AYRfUg6z5h3bM3fIlcSCFrbpQHTuqQv1g7ZLjpfch0O/B13q/gL/8TFYJ6jvKRDogy/Cw0uN4vKpUiPQYvRPT6qobfDR196u76VOnbf2sdTjrpjbzA1mvmvWVrf29wvL5bvCa/V/zjx74YWBAYJ9KKBUkCUts9xv/M51veLlUJkNeLLZm2wLn+ACvvX2rp0VlqtD7vq9zEnr55OqB9ts4D+i+w/QpcZHWzCYKt9FMhv4vvTNVKej5S94cqMFOHgZutSBqhdngyLbYAy0UNK2j/4peHJ0xWGzCecMjPVX6aRsjhf6WfsP88zW10KDnV7q0GYe23U/Wmp+MdVrGn235KH3OCw1Uuxd0V3d7tqHbMrVe8O+dnVkJQJtL72VCP1Cu+kv64SZcooE5H3bcfv0gvza5WvoF4eg1VeQ4axvp7k1fgr9h2aYdtjDf+dSz3dHP3P4c2TwBipPZeRKjLxerteeq6FrrxFkLOtjZA2j6AZgVanx0HtLZqk7s9zT4M52ZsGWFo+pfvvmpAbvPyjXuVzNNN5fyMB+0wy+tKubLWxOYJDiK48mwtJj0daV7ZiyNdLMtbLUCCcrSo15do18hwWZ2nJYmPvS22+//dZbbbXVlYOX22abbZYHrpq64jpJTDZ4+K5tmbMdf6EBFxxcat6lRy+nV/PVxow2lhBnlcU2TaK4Zbarl3qmm+2th5W6711j+1a/nbar7sZsKdIweKiTNd4nGwaDt1nWVX9As3tQ5UgQ+gZ1ga2AHwJbybVSv/qi0GGrmbVbu/7kzbe4IGszwUhcR5iGYYQM9tFB0zEfnqvln5enI789v/dcH4PPVDjDmigbfLb5Mr+21BC3lqmIw49OQ1lrf32+99+hbWg5IVeBE4QLErTA+3us/uICgDw2tH/5lcE3dlUXNNoTac1Q1vDdN3OZZQRWxBBm9P8pNeSwTi5/95rUM6sZ2x6w00477Zcy3S14ux133JGdgS/+CSmbYBPPa2ksaJnWA/RwW12lR1IifSgHRmfcUg7MTLpCCwd9aKnnmtlBR/pQPuI8pkGbfQWY0JYnlLpL7UWlfpfNZZ2gvqfgiFKPYzbTGhhs8xQXj3HSfeK6IB1vzpUax3agv8iXOlwfg2sz75sUqBu+e9Tw58yzF16YYnBul58M0lP5A/diUmjri7Ov9TBTiU8qVUzSyXorev63iaOPOppG+lpXZ8QhAinf8D4NjVpqBM//KzUo4IKBPDakP/bpBRmJBpWApMFGgNb5X43uaoA/Yqz7z8pvNgbi75snbaOETpiZm0Ryp/wumcEPaM//1SCQa79hp6wJILGYIIDCz0tND+PSSdE3LZXp5Zfa8ZR2X3v1mz9KPW/NDIj2Xt9mpYZs6lW3hgYOqgkRfb22kpl+YSD1rogwbBloW1pv3GibfuQX/atuzUES8nvRof3AzHdPac8ZnIj/aBuFeph59sILUwzOqeM6RPO5KqJbXzwyVzP7OnXwGdChzd5GY5E/b1ZqyF01/W9dtbwaja8zqZZljWBE7/fzlipOYrA+WOFCwdBBumptNsLfJfS2pRqc7PNGE92PKPXMKzaA23YtwB86yKK8Mld7qA/MIGhjxy0zCB6y5557rsjva2XQOni33XbbKWL7DfPfjVNfRFhBD32X6L/YwNR8y1KNaAYzM5u8E4Utm/VlKpVhlYM1fEWpgSyIu+pB3LmDuxoSySBtICMZaEvBK+n5yqSdtyjzAzO1PGFcA4TZliog7duXGiJLAA71RKogMciT/PZx+7p1M7jy+e5FS80/eojQM8IAUwwuprftdocFeQc9bq6K0tbH18ngMxXO8PTkUkP6qPQnlhqFVCNZI7+954OCQNyr0US1o0tdSrEbzayisc8NIIqaXeiiZhYqAobWsVlo2QQMQNb1r1+qqEtkvVXqwgYPPvd32nbbbYnlDwzeMTM4Zjom+Jj8tuRG18c4a4GyD2LnAgMRWh7U531LLROGxjDKRDq6dqnlMKCSLJ5a6sDKgIY2APJcQz+41MFJu2pDqtRxpX7XILJOUL4pIBkwvBok1CmaxKOu0dp9z0ZTK6gH+oH02QhWw8x3zdzeMSBTpdCkghGmYYrBrUta1jo6tN1Xf83fRGnr4evskDMVrlFYWolLZoJBFOsdQYJEdKMz2jleZgYir98alc/4X8oijcIzebWk1IuzXdVDe3E29H8Eiatonnn/hS5V9BvES0tfjDtEVb7fOq373+OFVWodeJ8KQppR/gV3sl8HaCCd3exICvpSqfnCvHRV9H1KZRw0UZ3Ijn5D8juIzJ8pa0R0baIMaGiwoEahL1nmB6LMeP74Ug/EQNviShJA2ycgz+jfljVpQxPF+uAjpT6jXXrnpFLPD+9hpr0v3NBmaLHQDwteLSi8kbPIHhF68HOefW0WLIeZkem0HCuMzMQonVvDsu6qeAargdb5btfeF/nTmqlZYrGBOGIG4KnGWGSGe2RXbQHEUjQHEFZwM70ymXFEUb1+6sqs/9DgLcLULMBCIh0VGmMxaEEiM1CX50Zvk4ayrCi1zonnymdmJoE8olSm1E7WpK0iYF5tZla3+vHQUgcEbWD2Jk4TxdkjDA5D+Ty31uy6AdAHjilVwrNagTabY16GMmK6PMurbxsYDa4PKRvuC9SRY0v9pvyTLEglI6wLBiaGXfWEIkY/pKteXPMZEc1kxEKziI6GqQ8ulZk04mHtO8Sp/ntdXQvFXEDHYvQhEg6dc5/230KD0YqYqBPLqw6nU/Hc0lkMTpiBSCtemYiklmB0dFFTrQwYjG6a+tLR0UeEpo4oN+yXHdZTb0eUxT9XS70q06quBlSUdzOwGfL+XV2bVyZ5Z4NQD9pPm2Bk9w2+jHeYW5kwu4HRd9dSPWbKybCnfJbW9AsDAnWHhIM2ext40NIYBhffph5IW343ZI/RZt65dKnfVafoEdYHjbmhHUW9mNRtIBb6zL1B9CMu6VzE72kR3VZNzNsfE9veHUQxnesXjcZUnytV1MX0iwFmOOnJI/GwF9FLzffr0cmfGYQdwX0rA4N4+Y6uOWkE/7ervvasyr/o6nq6+75rJloffKLU5+4/3JiHhHRW4dRS88WmMIjopDK6OFp79SJ67ln2Gso0LaL/tKwtopNiqG6+i6lWw0xfeGupzx9d1qg5J5Q1VnsiuoEefXZFdA45nlEGA8J3y+LW578+DIzcVecP65PP66of82wDrguIgf9dqvOBmYHuxdpqFKbv3bF957hujZGN2MiwAjDUM7q6nKXR6L3z1fPOKphdzTAvSHpmEjPUC0qdAUgP7rMmKxObgs6v46GP6urMR9+7X1fP9FJundnsg3GU12y3PpCe9eRDZu6fI5hpI9KJ/HJcOaLRZlMHHsg7kdlgqp7ZTKgnaAOZcjBaaR8zNaPak9p97frSMrMOPpM20V75iMys5Ohbljqjo/kaaFu0WVja8mdZckWp6UmHzWZ9QJz3/lVLXd9nBKQOjLA+aEyHZITSqTXKfEMlE/8872qmZrQx4gOdyUzg+8RdrqJo4rwGAhrfIXiWs9BmnrU60QKC4d0y2W1K7URmEGd/22ii06Mx/rCkpDOuzNWARSLZvtSyHtLVM8ictkEMXl5quYmgyrE+kB6m29AMdZZhpp2IxCQSkoQ8o9kODE7yTv2xN4CUpD2Uw6xqILMCIGQ0hmFLUCb9oVc75gH0fOVTf/s3msVenaLZNKg5BlMqkfqUP2kYUIjf2l99rg8OKfVblt5GOIug07MSE4GIneuEmQ5FFPtDqeKsxvLuu8sakYvIqFHRvym1IQdRzKhNHETrjER09GKK6EN6mO3jjZbv16JTNjPgait6fg8i+tu6NRbnL5RqZ0D7nk48XabVMFNXg3hJtOxh5v+zBTPfGKzoGOVNjbYZY1ClzMirHV26umlDuakrfTm6ehyxAcEz1A6MuE6YSXtQCRgoH9/oF5e1HV0GK7p+cWijf1XmL6IPji7sASOcRdis1POo3lbqEtY6YaZRjyh1p9GdS20knYqhxPt0PCLUpqW6SdrpxPj2slJ3LDHkcPEUZhljENWkbTZdDDATKd8HSxUV5U3ezTz3bbQZHsOjGQlZ/tEP7qpL69tLHQDMIGi2B+u59M+3lBlmmKmrxwTfWdacCrogMJUGgohL1yUN0U3lkVMRKUTdWhIk2srrPbq6PRR9TKlr6eheTA6NYV/bbeC4opl7mE75SATqEM2AZrZGP7LUgRz9tFKlO/QJpTK1vJocNmTHoNp5x8A0wtkA4jNRClNqDI0z36URyxs6FmYFGGKg6YQcRXQKPt3DEThoHleYj2hvVDfLnxswlE9n1tnQxNc9SmVs1nWdnthre6mlL+Jrf5RwVzdpWHaSd4Pj5sOH1wOkIiL9UCeLDeoTM7NvsKmoW+WhSvXt1NUY6wZmYjRgZdcmvSrV1SVDYa9mmXldYNBUvpWlfg9tIGQtp66xdRgA1bNBXH9RtweX9Rwr3dK1SqEPesY3zs06vEAB3YeVmbVURQ7ingqdD5jx/lyqqGag8O7nu3q++LBd1F7fXhSb1DjeP0DnGcz2iVJFwpsOHWqRraKfLjWPjE2vRHf1OJ9evMyVL/ogXpJMBoszcVanRP+kVCMUKYCdgRqwGmYY48RS32FMnP1vMcCMLT0i+aB2KBtjl/KtPpssV6JvbzkPfXpXBzl7ErST8NLrbIuZ/L++1DQeXqq0gmZE69MrVSoa6vDzperTaH3uDIN6qx+gbzDcYXRivndGy/nZACMk8QejGXWJ0pZP1isyz1Qu0ZNeS9Q2cvvOi7u6BfPTwbfNzc1tnU4joubH52o8a5s4Phdk/GEd/WyeO7Q17oIzwcz3Tih1ADKLHNtoDjn2T6OtGzM6of+rq8EK0Md31SEG/a6uHs5oQDwg2Ec8mU5rwFLtFDo249zsfwsCM9+ib3+2VJGZqIy+c1f17s/kyrHHsUUGOgEfzK6CKbwm7SHayqeCH56rkX7O4La8jjJod+U7otRAIGiOQWwXaPvrSYdouwlJNGhMi3nXgvZdYFurfJKSnlnqO1SNmTdGWCfMNNSq4V6p1mzrnhuyavbQ3qVT78/91W9M29W9yP5zwICwR31kzKVrTsjYc65GkhFbiUgsPJKwSSs16qSe3DE7kJxt8J2pshJFiaH2TlMf0PJOnL3ipPrjG5zQ8rb1pJ66KlILvZQ7L0a3r32os9XpBHOrj+piw42NOHZJEel9UxpCR1EJFqx8M0BMvnype8OVy+GJ1AwOTTad9KfGdlUVWb1qos4bQ4s4K358v214msHbe1AbCxclDVs+lY8aYGsuelVX+wBa+T2nnqlo0rxCV+vHvv216rD9BuqQX4Y6HNLot5ROPz/CemBgoK42/A+JZSo1f32yVJHZDLfOytxss8364HjtfcswxKeXdlWs+l3wpNYoInWI5CkW+h9sHQ2TXzQzuWin/hNN5sOeCx6ee724F/q+Q/50snMKOulUeT+cW0RQM+rx6Fzvnd/Wyvtgfl3dLuq+We2G8pfrh4MHy3d+i2Yqbtvq+nFtaei0X2zvKxPVBX1kaGvo6CcM+VmI8k3no1TDpvZQhr5M+e/YroZtUg7SFW829wXeNMgRy7+aeto97TScTSbC6ur8zdThye27JB8GR/SDgkc32kmsd2zpvTFoSdR9IaNIRP8Ifr+rA8/qvA/fn9SgIP3qSq7e7cuU+2IO9M9MDzwjrAPaaK0BnTr5+dBC0RqZiesqX0Osk8G32GKL1Yf+5R3WWiLrcekYDC5fDv5PVy3k35urkTT7M6822WST7+SZlUT14E+W1LO+3hT80Vzd3eZ4WfSdpvI3k/pZh+nOGdoGkh+1Tve0rp71fcf8flCuDko8Jmht/Ie5Pm+uRr358Vw9JMCsLn+fmdRjj/pvDmk0dHyTI3g91x8G2NK72aSGaUY/3HtwIcrnO1Nt9cJcHbxoC+aD81u7PmBSQw4r65NDO6DiB7m+KteVyYMBXmyA3dM+P871tCXtiKN1MfikMq1y3LirTlLq6m7B+7f7j8nzQlGjhea+ZqPfEezrsKsHVPYSxNDHpr5PsjP4eEdknRc2WqTY1XU+wgZgaDw4VyOfiqYp6uiqVDjrN5F5nQwOnMsdhp3bdtttt86MvtfOO++8Q0b/OSdU5JuOufXunrnu2tJxhNBw5hXawQJOSli1pMZjt0d9ZVDk1T4Iv061EDB8q6GyOhZX1NGVk3o0MPHU+WVoV+dfocUDdx6WU1f7QxHmav6ESZb31Xmcqk/nnIlW630hsPacq7HupCEsMbpXW4Z3FwK0U6tze9s5s1AtGMpENd0qaW08V+OeKY9nbX0dDkHQBn046+Bm2sI3/R5gyG/Dvg7navmGOhRrf6hD5VReYbj39NykRqkl/pN8qGHiuS+VZziThsMcPNun0fJKten71fD8CBsAESwbCl37zeCv52p44BPTOX4dFMtrnQy+1VZbdWHsuS233HLjvO/MZ0f93G7rrbc2g5+ed96Z7+xdqs+2IPo6kNNLf9xE9C9kMHDUjtMu3hP8bfC6wZdtVI/jufvAMBr8nMIU88H3LqnH9two+Jzgb1t6j5R28vD40Hdo908IHp77jv/x3lVz/ze5fnluJsT01PedVvixVo5rB82SvnXr4JOXVvH3WM961/Wcgu8MHT/4cunlqgwOBxDj/GGhb6McSe/ZoQ8Oam8Rby/W6v/zU2U4Q76GvDZ8l3LM1cMUiOOOsbp37jsDTR0+OfTtWrlfGdS2jjH6YO45J9yZcV+Z1Jhsqxl26vsOOxRtVX6vm3de1tIQv75/Xn5G2AAsXcPgjg/+sbBNqUyzCz3pT92abZ6zr2LwyTbbbDMXht4k73NP1Ci3yu9Lt80lJ06qhfwPuX59rp58+bM8+5vgPhvXc6noemJjfyj4f6GvH3y193PVWVaP6OcUlHPoPKFPSSdU1psFnbrxl1zvlfvHtLR1zju2+05/uYH7ecfBB1cL/j3oGF6z4hkYPO8vybOf805+Xye/39C+5VytZ7TDHx7jWe+6nlPwjaHTB1+vfGmDO+feMdoy10fm/hEtbWrQocG/hn5Xrpdo5aaDmznPkKfh20MZg+9v5btF8LnSyPV++cYj2reent9HtjQcSnm9lvZHcu8q+d5fgqevj8Hz/lzK8On2joCMbCHecfrMOIPPF3R8lRkkchGJ6OOcOzg+sF6uk8FBmHlu2bJlG+24447bZybfd7fddtt9+fLlm6SBBSbsI8P41qSGTNZBnO8FibCOS7pkcBN0/nN4AIZhZWd4cyzxJLjRpKoNs8mfZRg6T3A4p4xK4MyrK87VpTtipZh0RFXH6qKJtIxNjtthWV6+pB4r7LhdsK7vO8KXyuG7RNghDSKrM9uca+26IIPXAMP3JvVYYasXLP4s2ftjpPy3yaSeBT6I6J4Z9v+rj/70kvbf6u+2/913fpuDH5xTZta/QgbqbcOEjoVG7xSm3C14hfy3Ms8R2dXhcB6a1QeHbRC50UR27XsGBm91qL7VlfzynaC7O1duZPD5whSDq0w6Ef1IfHPbPQ9dH4MT0cPMvQ6e5z3HCPLg/Kb7fa6rsbct1xDDPhTU4J9NOl/Mde9cPxh0BpaTLozO35irp30+K/e+taSKd2K5PXRSLdfrzMdZhaED6cStI1sH/mauZpv7J0+n5/qI4K1z75u5PjN47dw/LejYYYPT6u9Mgzz65pIK71COuTpTPrd969/naiTb03Jl0OvfWYhyDTB8c/hu0O6tr+Z6z9y7Yf7/Wq7HTeoRTtrmJZPK6J5h1OqG98FA5z3tfJugWdrgLSKqE1b/LeV5TGgnq94pzM3KTWJ7VPAWee4bQaHArq4+cn1D0BKZ/uUwibUmkVZ/cC74VnUVZKjkfKSdbjedvxHOBDB4KpDV1xoljyzLFl8pdZnlerPPDxDmLptvvnn/fiqbvzOR8BWh7Rb7ffAToenj7rN+mvX+THScq2us3/PfXGXij6FzFcCQH7v7lq0Om9TzpwRuXDBG0IGGTtK15ZdcWX/5qMvHM4McRNA6ZL/ZJO99UUcfOuE0DJ3UN+eq8a2vw9wTs673LMt/R4V+TrvPgWbByjQNQ121bw+bTY7u6sktaMx5SKM/0NXdc5ZFT+uae6pvDN9qaNmKD/txTnTpqp/+LyLB3Sp10S/9RXJ7QP6z4USUWXp+v0zW1T0HfP3RHG0Obmn/pGvLZFPprB5QAr1HZa7/Nlmz1NifZtKe6fM4wplAGoje5SAEaOsgD7bDSvUf1ku09hmGTBXcOhPHCaMyi3l/yMCkWm6tB6MZ7nR6YipkzWUddVYVMZIjhlmaUwZx0qmfK1rjW6PlM77gzNC+yfGCMXH3SV0PtiRDjJW+mcOJJUReNHEzl7U715CvroL/PUQEFdCSw44yWXcmLqsjNCcTr6nXhS3Y2rCy1IMHOJrYFszRpZ81G23LLPqypW4j7V8arkB5g/qIo4T3zMC+LAP8ZSPFHbbffvvtue+++15uxYoV17xEYPfdd7/YFltscViecWAENeSwSbWWUwWUWz/RXw7uquNK7wEojVz7ypvU+sPgVDXtoQ6pjfpFn9/p/I0wD2gVDLmtvjR4Uql+5WYArqdmsTPAUNlTlW6jiV1NdpDpxMTz1wZXBN+RBnvPpJ5r/Wr/dZXBnp3nT+yqBHFc8OSuujkarYlxIpP035fPcwoz3zg2KD1OL3cKntLVABXSd//Y4CHuT6pY6lC91d+YGuSs51JtPjapXm7PD548qae5PKZUxx/+Asp0cqnBMexP5+L7n0NmFqF8dwq+q9RtpDaa2LXFR3xVqT7iHGHs4uI2qt17UEbQyuZqsObDflLE8stnUjgmZX9VGPqaYeZbZva+3W677Xbj7bff/lZ59v1h7vvkGa6xyv3YIMlOnzq+a3U4ja0eLcvankv6OyBIteFchLGn++iQzRHmC1OVZpQ/vVRxSHQOzEqcIrL2MNsJZyrdO57/cle3WRJNLbldYm5NWCgz9o8areH5vfsPU/Xhf7q6H9v2RTT9a8EYfKaDDOGGOIQMGzOInMPe6bd2Na7cUKYzMHjrnKQNIYiIlMpEvPxH7qsPDPb3/H/7UiPJ9CJzqdso/1bqWWI9zOTtbMFMHT251H37RGeba6RNTTBj/zX4obImTNP3S5MmhnxM1bt+8W3PRQy/Vn7/T/CzO+2000223HLLm3s2s/otwuh9vaUOnhcG50lnA5OzyW7Y0qB797P2kIb8tnokWfy6vX9I17wB8z9dvn9uIdr/wg6USzuNNJqNJzqr0Z/BbD5ArLe5nxfcFqWe2mm5TRTXG8Cubrm0dZAXFHHt0FID7fPTtjvrZqWen6UTyofrgsHQsRpcqdTyWdKji6JtHvEbLT8rS82HGbhXvodvNIOQzulophsEb5rfnGSI5zfNTLZzfjuP7ab5TYrhQCTgv33Q6lRZF7R8M2DzD5fjYeuvPQbDzjebPvZstPY4uNFnYPCuAo++m0bv3jni+aHbbLPNzQ466KADg9ddtWrVTa5xjWtce//997/q1ltvfYswvXO/rY7cPGV2HhmVZ4iEc4Y6bLYgqps6vPlcXdWgFt1y0mL1j8y9MGCkthvp1aV2ADMb+urTD20AdCBbMB14sDJXYp3Ya3QnLpT8oDU20f8VpTLSMfn9qlI3SNw3iLZN8IhGi8TZw9ApzgnMfEO8NOVjUMTQaOlJHy0/9k2jH1/W3zmX5WrWso5vuZGl+hURX3m9UTFelv90dGL5K7vqXy1NdXXn/mNT3z0nMPMNAwhf8cNKjYVGXQC4RfAF22KBwRiuBb7VkJHticFXRETfP2V9UMrzor322uuwHXfc8dZh+nvsscceN95hhx04vrx4o402OjLPsG1YTXlAUNtqyyeW2sdWw2wdBl+Xb+w3qdZ+Kyw2+owMvkBgdO8t3KVGOTmp0UTm+QAxm1j2ja6dO5Xr77u6rXIQ0c1YP2+0WUMoJDRRmF6IvlepouQgMi8YzDBAn16pwQANbOjnlTWHBPj/po0WEXQ1g+twc2uWdbYZzndLZ2dL+F90xFaMzLf/j5NqRSeiE8uPKVVs9g5L/oLBDCM8o9Q06PmkkG+VWk5BIdzXvsDAbLA9AyhrV20z+sXfwoDXye++X2yxxRa3DTNzDHp3GP8+ocWSt2rynOCwH/w9pe4HZ6mX/voYfGubkbyTeqV39ysRufJpny3XCGcTNOQdSo16SoxkKDHLEV/nA3uUGsLHZg2np1h/dSYYjyVGLPuStyv1LCqzh22TZhm6mxnG3nK0AQHzYzQi/GKBjoe5zTDEdWW1d5mYjjbLXqzU+jiyzDA4bB10k3TuO2T2vsfOO++8avvtt79hOutt995770tGZL1e/rt7GIDDB6Pb3bvqK47J2Dakt1jAsIbRMDD1SXuuaP8Rl5UbbNpwndBVEJDyHikrZ5YbBu+57bbbXiyi+jVD3yn6+EH57Tjle6Ssh+Y5thbr4uwQVAXSoL6lj62GKQbfNHiH4H1ST8RyBjubZHoHnJHBFwcYSohVB5YaPVVDnSEKxzTo/I0BbDx4YvBhk7pJ4JiunlXGamsQeHzoVaV2cuKvDk+EZezCCDojWtifa5RqdNNBepDGAoP0lNUVs6MZxs4ArYzCTdsBx4nDzquHp6M+OmLrxdPBbxZmv+VlLnOZq2y33XY3z3+PJtrmWbPfY1MXGI+EY+0dAwC6MkZcLGBMu1/Qcp+B6ohSRff1gWf8Tz8XmEHYaNtc9wgeFXxCpJYDUi4OPMdmQDs0ZWaAe2zq4UYpszZ8QqkHBa4XSEENbXLhUvukSd1cYoCghy9obIAR1oZPlypmmQWM/Eb9DRrcGnND6+n/JLqGps/34ldXAwX8sv0+JNhbS0sVhYmznjFzi4TiPpHyYY0mMvewCAxONJeGcENmb/SJ0w+AxtxQbDED1qPSMUkuvRU9M9nBZnPP7rrrrofnv170T6cnydCHWdQNdqzo0qCbrix1SfJI7y0SULOkR/XRHoJQPn2tJ9YGMzrRHpMyuH6/1LbB8KcE/x5mPCK/2RGI1Ta2GLDsY2Bv0Wf+L3hy+946Yaq/sKL/sdRvHTKpfuecjri3jgy+UDDDONZRzaKYVegcxrIzGGOmYWiMSY3k8l9tBucscmxX15V9Q7RSszbGILI+IWjTyuoZvFQbAI8vgwox1v3FNLhZysGM0jXDmsGPmn4AeKehQBmCE14xzLttyvfglPWYPfbY42KZyW5qBj/ggAOunBncppZj2wzOEm9dmJMHf//HlaqWbFJqmvutndqCAumAqmGFQ3q3nfl/FnjzkDIYBzkkDTM4/4bbo1MeOwnF0kPbiKN8nuGVqA0NDmcqdbnf8vSwrnr5SUNQTisvfRSXkcEXCGYagdGMwYtoTkx+YKlLR+uFKQang98reGRXl8Po33fpmg5e6oxCXGdco3djdsx8z64yuyU6aRP1zDho//ewvs5yVmDmGwYRkoMrZkAT19cHW5Wa77vO1dNaGZzusssuu+wepr7B5ptvfuvo4PtFB7+W+xtvvLG95bzK1AEvMsxMBcJEpCPfMsj0sBDlmwE2FQ42Qlb5ONVAva4PGFzvmketJFhmE7mHu6rQTIyH7Cv82A9r7cxjkaca+pDWhtpMmfrCrKNM1D5qA9QXqA0cjaQxPZiu9dII5wBmKvMTpVrF6aIvK1XEM7P1sK6KHxi8a1b0ICs6y/kgojPs9Fb0Uk8LmRbRB6u2zj5Y0aXHEMYKy6+7h3WlfVZh5huDSjBtRf/Q8Ofw7NQ7GMYzRFWMokx/CmMbkPqVgeXLlzu8UJn+mGeI6MR/77Cgi0KKPr6scUJR3z0sQvkw6m9KDTp5SKnpfWDq/1ng3tq3Wakeit9Gd2tEdPQRpS71oR/VVYnE8y8u1bai76wW0ddRJuvwQxrsPL9qtMF9ZO7FgJkKpYNZxjmkVHHVerDllvXC0Chdjcn2xuALgjYoiJ4KzdTWYF/e1cB6x5S6NozxMRadlLRAXEezspu50XTjHhai4We+obPyKiNd6MTKSmLpYXh26p0dSu3Iz8+AtjL3nxx8ie2T+f2o4AkR03nwUUFOyJVIru5eXur3+RagzaoGCAMo42MPC1G+GZAeGwA1x4qFtmDZXh9QyQx6L+vqmWyPy1U7WVXwnjVuYrh1fPThXV0R0U7qUhnfWOpg1sM6yjTUIZQn/cL7JMYRzgXYvNTzyihAZmGzLKYkvtkcQjdb6wXg3gzqLIIXQsYU1mTea0S/Q0r1fiOWmfWJ7KuC/NTRNhpofDRbwGKBpTkdlHg+eJnRi3sYyjlVJlb0fws64G9ZGJpjxw2W1NBTopagRX2x2eLwXIVPsqHF8+wQRFK09WcrE9QB5V8rvQWElaWmZ7UCiJ67oRURVnTLhJYStfNhpapsZnY2BO1nmdPgpG1ET8X8+ogZfz6wrNTvQ3kxkHt/8LwbYZFBZ9ew9E0zGpGLN5Q1VbuDuJSuszMO9xti2F4U6+pM/ZP2++CuRc8slcmJ4OjVvuilLqGZ2dBvKosH/11qGlQDuiP6/cOf1A4wVaZevJzUKKvq6Rel+p8r3+CLjumHlYFblaZ2hCaemwWlYSY1mLA4f1gaCwUz7cLAJr3XlXrE1KPLhg1t2py/uvyKT/6tRl+za44uuXqfFOK7jyi1b6CJ7fOBWRH9Z41ePbCOsLhAnNa4y0vtIHSvo/zu6k6w3hiyPvBfQ9sFBRR4T+h9g68JnthVxsBYJ3X17O1jStXZWLLpcOijSh3Vdaqj65cXBgambXD/Uk8voatKD/3k4c+hLIONISjqi91xdpEZwHRq+6vN/ganD7XyWQ5TVh5unH1OLHUwM2OjOfwQdenDVgoWDGbKx0j20VLrmGhMVLf2vj7YtNTTaj6YPOsHxGjlsD5NHbHzziD/kEb7vrYySK0W/dXZBoAU866GZv83lGqHUG8jnJvQ1QD2ZlziJdRhN+hlNMUMwgWJxUV0Jc7aL321Sd0iyKvLt4h+mB/tkICVQe/Yq22ppE/Pc6Va8i85m945BD1x6I3TdL1RmdtOqF0n1XFHuCHlEKGVr7YBj5Vc0AT2B4H90b0YG7TX3q4z20kNmPbOo+13591FBVpZqiun2YwV+twGDSltebFMJt+Wq5RbO1kWXBakNtnPb3lQm1h6M9hrs4PLhk8KtcxqpYK6hdaWjGrUOHCGuh/h3IHBWvrw4LHoNK4oLj0Tz60jwsZwf66GH/7bknrmlVhcvdNErgYMI7bv2nlmBEdbPuL4gT62q0s1f8n1JV01VBHjzBqrZ9XFhCGNrg5sjw59j6CgBpwy/jypAQl+VGp+zT6fabSObxaUX3oqIxL6/rnPYIh+dqkSC/rtpc7mROMvlwaLXb4p2LXUfPyuVPGZ2qEcBhv5QR+W/FBderUj9PHoXB1X1PeLXF86fHBoI9hgOL/uh6W6yv6pVPVkgw5UIywCtE49/HxK8LuT6mF093Tsb89VV9Rpd8Opt9dyQ9wtzP0VmHc5fAifK16X2Y+4bnOKjmPn2Te7uqWS3m0PtvRYmtGPznuss5ahjh86zmIzgO83NNveNVfbIFcmL0JCf3pSY8m/P3hqV2dvu6DsfSbOPqWrMciIs9QANL9u2yKV1YkgZrGvljoAEFVPLVP2hoUon3oacPr3DJCO1O1H89+OuZ48qfHaGAeFXvr6pEotL07exZa7fvBxadfTc8/hEQat73RTIamGuptKnzHTxh2edKQw6Rk8VrY8jHBeQBpm4zTU8kkNVk//FNReWB6HAdj7u85g/n6nA4jBs1lw07kas8y5ZKJ2iOLKWQIjEN2J40472WrjjTfecaONNrrk8uXLd7jIRS6y3bJlyy651VZb7bbNNtts6f6kelStq5MuOAydNKgO+pBO7be8WxaTD7O75UA0x56VjSbSOrMLLWCE54bOTqzt46CVelopUR3sFuw9uIb0z2Xoz07r6tZeIjiafzi3Ufu2SWS82LS9gy1si+Xso29YXSDSTzO4OmCQVQ8s5yzvpALqCNHcas25XsgRGgyNNTRYGvTxAijm+qw0rAio/wi+DzO3zr7W++5P4f+3dy/AmhTVHcB77iLC3c2yBhZQF3YvPsDoSpnSKJa6gEpekkppLOOrQFETX4SKEo1GBY2SxKI0vlIqiQjxDRrK4Ks0IBQqiRqVSolGixXEoAZ8RTSJ0ZzfnOm9w8e9y33iZe1/1ak533zz6Jnp033O6dOnpR7eOai3bOyPB/0kyDpe77A/zj8xiJf363HMs4OeMBz/grivoRs937vdq153NTFUUiSPu3LITirvnAT+355KIVcmCQfNB5e0n9rKJjcn/n+7HB4z0eR/YitC7GneYZfzrI9xbsnx5Ls7t6Sav+v+txKMjlDPv9Glr+Ga4Tmkv7Zc00/W5aIJ/2h/vIvHxDO8GR/04r2GhJbdqL4MJKOrZ6UZcKZ61p0lI9ka1gJ8KB+PMImtDrox6Mx1uQaVVS4u8H/9wJOoghi0Mc67MsiiCJIy8qx/L7aivtjY3w8y3ZT3WTrlp8SxvM7u+9Tp6enj4r8vBJ07uubE3VYW9dm7XAXVSiHW1dKTXxO0cyp79M/F/zdM5frnH44yeQ5ONIE+eMNLJmTgxXI/MY4R5XdakHhvUWYCUeCGMjtXe9UF3POhkt51vgSNlSmbX4oyet4jpjI/mhVTHhT7zvf9gh4VAv4GE4pC43rhMO1zV3nrdYN+e3huZoyZep6PWt4EfC2hCtNUJqCnYpsSKpE9/rCpXDpXgr4+d9kkfPy9Mrc4L7ShM55oqjaVm4qu17BowH7r168/ONTzIw899NCZ7du3HzEzM3PUEUcc8Stbt269Y6jr26NibVORamVaTYwqqmejskov5bdtjZvmIa/LJvOQGwnAa330jPUadfleNM4LzrNeoeJLbdX/qNvVwqg8yPfYNvAaLg2W94yntTBTmGZMqf3iW2mwLS65Ye9cd26u69bREtemklPPxeILpmlYCxgEu5K44/+MrVlgHE7XB1GZ2Vo8x3oumLzMTXrc4RgZP77dZYyzWG35sh8XJHCCI+dpUYF+z71i+8yNGzceF/u/FP9b82vVBdwzjIiH/Lqgy7q0tzkHOaEI+eVBVs0URMIh9a0uI7skjRTYY2iIQ83z9U62Yb+gF0NGris01Pl60Y/0BSi3qoB7jmu7fCZCfkXQdV0OX1ox1XeidcmK6/tbk65P2ECwoxef77rm9DvXeHd91s+V1oOvHVRhGj6YgAy22RuCFzDBvvpIlwEwx3Y55xsmL9NjdB3SWeecC/wQCy5a7qQukwW4x3Pj3ifG7w/FlmlQh5QEzqx65Z+A8WH35gnnLf9B0A1BhpcsHOA9zARdNhynQfj7gdcwvWzgTcN98sB7TsKPF2WmZ8OL8LtVMHwLtDXesXf+nS57W4LomcxA6yfQdOkv+NDAP9k3UDcI+W7g23qmT5aMUjNxyFApr33DWsGoIvRrXnXZ4lNNOZNUeKq537vUy/lQr1VyvFV8NO/qtiAedTHcB4Yw3ztU9QP3339/XvQjN23atCV68A2xnzd3ZjifOrC6Rvgs3NBEiOoJt+293V0G6rBhQRTYOJ56HPM9rtRj3vH1OcSJ12vdGujq9wiS8JB/Ac/z7V0L5GGa+Da85Lzhpryap7C7Rtbz+JP5cZ+SATBUdN9bDP6t9d0aFopRRagfVuCJcVuqqGgzvZt84irFYlXo1wRRCznVBLd8Pnjjq0JHBbY8PwR/R8neTYir4Rb3Fu99U/1w5fDQoC+WXBhA7/rZkmPUbGxluqjLJBj2/XNJoWVq8IIb7jIjT3mN/+q1nS/JgzBRvKAe6ZQcI5bbOc51v60lx4kvKhn5VlYByuX5Lozrs60/E3TZVPpDqOW+gXFwQUhXBIlsm6wDPSZ++2YcaQKWjPN7Pqp9E+q1jokPae52rzJ3mQqY6mahOUvQ7Eqqt0Cwx12rRrKJcDqtyyyr9p8VvJlH9guUqKrtzrKCAj6utCVnULnHp0qq6D+N/6+JLSefZ/3vMut9dhx1XRw7Xk/1roE3BfYVA+/5TnKtkvHuRw/7LxjO+UnJVFZ6PPu/U3JG12TZVgK82p5jZ5ez9njE/bayqiEzvJ77y/iS00An68Bc6E23klNGzRL7cclnWnBlaFgb0OP46Bxr1HMzqCz1WofUdjlhFgC9CUG4Y0l1XYwyj72ZTbKFCMBwITzHD6F2jJDH1YJ7E4IaX03V7MMqu1zA4LBhvzJX3jnbBp6nWE9cwYlWMd7PVGGmAE2hBsfwZ4gJ7wVqkRrRQkBl9nxUbvcQQ9/fL+5lDjunKZODc8y3XZBjLL654/T23pX3cUzJOPfdtgoNaxRD5ZDN5KNRMV4XdM/4yJcFWQZWUvte2BcBs6w4cx5T0kHFA6vnE954YdDpXc6lxhs/Xq2eQaUUI0591juLLWdKqPTnlszIAq8u6SSjghrDp9JS3fXUeMJMw5FAwzU1ZvbTSNjceNNkqfh6vZfHO9Ngnh909lRGia3GmL9GyjRdJo+IMkk6qNLm+5t/wPzx3HPCdx+hzgNn1jCfzgl6zuzfDbdJDMKN2M68sFT0XvXbd999/d6kYk5UhluC6YlUPIJVVXR2KfsVf0lJjyx1lsq8Wio6DzA1nH2tcXFv3mVqOR4R5OtLqtzGvNmbeA0QgXbM0WVWRX9KyamV+DNLPgez4/1Do2X/Z6enp2diewOVOUiYb79s8wrDdzJywasN7q3sBP/G4XdNEnFL0AA7/k9K5mJj2y90bnjDWsVIwKmVhrhk1dw/KuTjgx4ZfB9vvUgBpxI+oaS6SmiMpVaw6fSCajtnjt+rBR5wQ3Psb6i9EzAN6n7lrD0Ys6Xy+5WbJjek4lfYX9Uaz1MDZR4YDeM9Q6jXhZAfFZrP/aI372hAGzZULX7FoHGiRTB1wHtG+5RMRMGWdsxCQEvxfrbFMzBZCLkGpOG2jpGQV/KRzwrZNrPIpAVq9FllaWOfR5dUITm8VKI3lFRn8ZIknl5ysoae44/LrC27EjAsJoHDH5Yc7nllySygQBX/04HnbHzpwGuYXj7wvOV4Kr2e8GUlEx1sHngCz7xwzG/ts88+JmqcEu/t+EMOOeTgEOrfDOF+2KZNm9YLJNm4caGytmBsK/k+PYsG81UltQoCPicmGmqmFM2KYB9b8nzfqWFPw4SA64Gpaz8MXiWm9vk9djItFKLanCtopHq1P1FyeSM89VIPenlJ7/pSGpH50I8MlBy6Iuye45slG5GqzqrxNwy/NQKGtqi9HGfvKWlGGCoi0I6R6kgGFPv/sqQmYP/b9t57bwEm7w0BP3XLli3UdXb3joMOOugAfgwq+oSALRd1JMIogHdYvxOtaSGoJojG9UUls8saJmzYEzEScCrai0tmQiVwTx9oKW5gSQKkTqY66rX/qMsEEXptvekJJVVhPgA284rZ4yV7WpFnvPvwuBGvPHpoIMBUeaC2116MZ12ZQC9ejwfOw2pUU5MFmfBeHx8q+v03b958h+jBH0hFp65T0asvYwWFvL5DjY53+LySjjFlXQiYRxJYmI8gaeRzulwyevK4hj0YejuVhrBT/U4uqVbzHi8W4rv16NRg3l2qJfVZg4LXEKyEiv7ckgJIwJWbcw84yB4x8AS6+gaOKenxB3Yn2xX0+hoFMFxUGwHQCNgHBMxQI+G1iMKxoa7fLgTaVMvjqO4hQGbZ8XH06vMyhIhG9VclnWL8Bd4bk2deTNzL+3a+kQDP8+IuM63yIUjrNLPCjVDDWsPExyWIVZ2Fbw2/tfiLxYklz/1USbvPNanonFT2I8K+KOg5Rzix5HXeVdIMMH+Z+q83tv/a4bjvDb/hGyWDOQgfL7r9MyWz0QoYoYbzD3yuzDoFHcNu52xzrghA59j/+RBynvofhHALNqEFyYTymW4Yi16MAM3zfJ8t2TAxFb5d5rG75xDW+nw0J15y5sjphDu0jL/pMqXy5DkNezCofpwvotPYqKeWjODSeywW1HUOO8LCRsUTEtd6VZcTYZYb9WU4jL2sd9KDu/7jh//M/tLbQt3Co8rswgzUbaou6M3rWPDWksNj1XPOZCHQwIZ96FBmPare2jNYc/vpA/+0kmu59Scs4/k0rIYhfQeBKG8qOYR3s0CF+h4n7uU5OR6ZIoRchKGEFvwHZv6JfJs8p+EXCFRTKrYeimBQD4VJLhldzq2mvloiqI/bXkYFI3SGiEyo0auxoetQF0/x9oHXwFQBJbw1ok5PX4edNBCVV6A6rAaG0mohjy6zDZ7IQPOnPQN7VgARvtd46nNN9MorDtcf7muYU4NjvW9lpIHwS/ROQKjvu9Jql61h7YIDjBda8IiKT02k7vEqLxldZv4UVEOdrUkYJg+bFxMVUuX9bkn1k/NM+ajohBhPHQf8DwfePr/Z/zzuTAe9Iy+6/Q8qmZPc8Rq3x5ZU/f+iZI/qGBFzAkyovIJEmBr2f7fLVMt94AleeXnVFxHnf4uo74wjr9Lg9JPoon++Lld7vRRf0ha/2fmLee8Nex72LjkefkHJWVQi1Qy1LDggovYsMKpUxto/EHRel1M3l1PRDB29taQ6bVjvnJJON57lvytpb7p+bxIM/EtKjskDFdYzelYONuqwnnxHySEkw1EEmWrMqy6Y5i0lZ5uJDT+7pCORynx2l5lZ3cOMvbNC8G7Ho14zqEwmWlgIxu+n8oMw7xLu6rWfSrw+6B/iOOaLMfsPlWHiyRirEErbcBsHYeKh5lHnKKMOU3sFhdivEdgtagWdpGXA3GzeYM5BoErX8XvJD2R/VfGlMer5LueC67GBwPKeg2EwAl0x5o0IVImggdTxe+bAzPAc8sBJeuh+spc+gOAFHR7Cfd+g6clUSYvF6J0Jsjkq7vFrQesF2cT2d4P2i3sfHfQ7QWLUl/t+G35BoJbwyFJV2eXvK6n6cSYZhvlO0J+rTCr47tTRUSVdicp3Ysly6MVpFuLELynDWlpRlquDNgX9LATLbyu3XDecI0BE3DqeAPPEU9cNuXFk2W946qSSqjhn4zEDT6vRsDnGvPit+BAw2Vu38Kb7He/h7sG/a/369VfE/e9FuHf3bm4Jo/cmwu7SdZkh9/ANGzb09zMOH7+/io9jJMdciXfcsKdiVDkw55acP62S81xfVtJ7zb77WMm10Ppzhp6ynnszVNVyBUCVNmvslC4nf1xcMnzTOO/Hg86NsuwbFf/DQRfGPTmhpEeW/IGKbcTg4yVDUnnTL+4y4b+xcfuptob4XJdaTkuxn5rPicXGfWOXyxpdEtd/X9DmEOKPhDBfGttDYvvCUM3fGPzMcm3x+n67HIpjdpwR99vifkGfDl4q6PPiWa+I5z5q0CYmL9PQMCfYmYjkqujmEG+OCtWv6RXbg6KnkgKKjd2ngkKrAKo0gawgODUIhYe/TgS5S5TpThqSqPCHxDF3HmzVzVHpa7ZVHv2ZgTdkx94mFFTgnh+Ar2PPVPqqonsPdT64lWAOH9Tye8f97nO7BP6ooP2WIuCj9+j+Gh9RdMp3v9jeN+5JK6GqS5NsgYq9ggTeTDXhblgqONt4ik8o6Xyr6qx48NOi8j1c5VLZOZZQqKo9LQUTFfWUkvcDfgE96tjDbeVQKjeV+StRhjtHw/OzuLf84AfGvq/F/9R1DREthDrLln77wNNKziiprhsWpKG4Ls3gN0oGm5xf0t7H/0ucIx+5+32DkEev/X3Tb0OYjwy+V5nj3g9ZiIBrkMbk2QcBF9WmHJ+O3/wD1wddG//jr/XfVK5osssJ19CwVAha+feSIZ8CPwwVGdeWqfVFDlA5VeaxcKOo+KPLLAwTlZWq/E8lc8wJpmE7KwPHnwgySRDuEXRVCNXFBDzu+6Wgf4vyUJ8vjbJdFaQXf09c+6rY3jvIemtXTeVa4a5n/wldpk7eGb8FsjBPvlIybbJG4aogyxLLWnN1nEtNPjzucWUI9s649/bgPxHb/1g363Qr88H7qsdUqkIexDHoud8W+4zfG+KTAprW8umgq4P6deCbcDcsF8aQRb31avtUOq+6DRs2bJzKZPsbh97qgBDwO4Rw7RW0NWhLCHi3FCGfD50aPQvjwN0gEIdEOQ5UjhC2rVGOQzU28dvUTmmHHXPAVC4EgeeBtoYZO11GUsKyb5ceawLMWedeHHiG0oAdX9f3Wh/XnB4EU2acXwqyeIQloCTSsNbbbEkn4L9BoJ1roQrneRYLFljEwP/MESMC9v/yVC4LrLzmpjI36vppDQ0rh9prBJ0YvOWCXhKV8TiqcQjzB0OwHjw9Pf3ToCtDyPcZBG2XKokGIanq6JIxXIP6fUGU4fVxn21RhhuCriPkcfsrY3tj7L9rCNHHgn4Q939AbM+JfZZpMvuN2SFo5VlBT4/r/FfsPzNIdNuHS86+u8k963NUQR0auDl74/GzVhqde4r3Fr//OniLFwjA6RuskuP0r43y8xfIzbZ9KlMlL/u9NTTMi1HlfeaQ+um1se8R0XP+OCrjB0OoHmwoJ/77YvzeR8Wvlb3SZIWfD5PHTQrJQGKtec3fFPcyjPTDuPf1ce+7RVm+Yo2uKJ+VUy2o+KPY7ojf7xjKbgUYQ383xjVMrzw5zrH/NVM5zdKIwUsny+O+VbgnBXzy+ebC6Bonxzk/Cv71se3fW/CnB22Lw/42jjszrmuJoSrg/fJT8123oWHZqJV7KpctpqKbH03F5GS6a9D6oF8NYpOaL02VR2ArMb+eSM+En3PNtIVAWaYyFfQBcZ9Ncb8uBPvgENKDBj9An2plaJQsq2uJZeozNVrZmRtGAbZ1uVQydX1bbKnAyiXPevXaLwhVALtU+V2PaWPrPlT82w/3YmJI4MhZdtBeuT7cYVEma8T1Jk/wUmxJC8VT3id4bGhYdRDwCZLvjTPr3KiYDwjhtsTtR6NiWrTw6tj/hfifM0ygyNe6XIHF+PTXu5yrvOReSaVXBj2o4JLq6GP742vAySDku7ZoaCAmNYKb7J+rXHX/XDT8D0JH318ynbT0S9eUDNaRTuqa2Pe8Lp16X+1yjNscgH8N/o2T5ajP2NBwq2FCEJ5Ycqjq4iDBItRiywwbv+3X1Qo6Moit67c837zghqqOnbj0olEFFxFmgl0FnYCPhXpMk0JEQOt2TItFl+B9Fz0nh/m7Sz7rs6fSJMCLlzc0Zzjs7JIJK6SYslhFXw7lbmj4uWEkHAJKOInutS7VXymNBMRswAfdHx//3z/oQVO54ikhV/l56OmeuijSNBe/1nRT5amSX8tZ99f/DGmZXkrdp708ZCqXHpoJevCwZQIIIuJHYAqY2urYJTUsDVWRoScAAAP6SURBVA0rjtrbjGnUS1qw8ElBjw26Uwj9R2P7yTjGsNQ7u1zeV1CHGV/i383iOn3gzfh6xsCbxbXrfnVb+Xrfyrs31F568ph67lKo5Piz6bRmbgmZvahkskmRbu8tOdXWyieeyfNJGPHSoMvj/o+JMh0fWsY5sX208oyu29CwNjGPcCOrkZ4R9GfBc8D1EyXiOCufCtbwm4pOQPCE+p0DLwBFJpiqwv7cMQij4TNlYm4IgDGH3m/BN+xs/MPKMLkljjHHnOAzYU6Nd/GM6enpr4WQn1oboDkakYaGtY1BwG8fdI+gu69Ldf34oEdNpZdab22BBIEkZodJqSTunADhDys58wtvvvPPFSMB5A2XCumYIKbGw4N+3f6SGWDY0qa27oh9jw4SRcfRZkjOexDA8tAQ9LvxF3hPDQ23SdSefD7P9Qg8zjXBxLMG3rxzGWXwJ5SMff9AyZlgxqgvLDl91VRO+wWGmCgidlzACBv47OE4DcWrh+PYuUJs8XpaOdjwQnE1OI5Xhh0Dz2SQz0ziitd1ObHlvKA3x3PcKejsoLfH/pk4Tgis8vbzyz1jpardjJ2BqKHhNo0q2Lcg4FeUVG2lTSKc+OeXnJeNl3FFRhY8YSSIeOf1XvuSyf3rJBRkkYfvD/zRQV8eeBqBABa8Oe5vGXgRbacN/Lklh7RMQrm0ZL4z6vbOKLuY+Lq+25HD8f7TS39z+K1MNxPwsQnTPOQNezQmBNxkEon+9bqSMMjOarqkHh1vjFh2FceY5bUtSGipRRUOLjkppar75ndrKPB6Zf85Rlaap5bMAqPnNiNOj69RIOicfe6BJ8TK8uySmoPQUQs7/H4I6YFBzwxyH88hqeQfBLmf4cKTy9Jyyzc0NAzYu6S9zu7dVjL18wtKCqLppK8YeHHkEj1QnzUUeOr6SfgQSur6I2NrP6HX87oWxxjjWHooqZ56TPbG4x56osFqaGhYBgidnp7zbUdJtVgqqV59HoiKLltqVdH7udolVfSLBl4v/taB5wdgb/9fmV1HnNDr2XuMhXkuAW+C3tAwByZt8vn4CeiVqcVbS9rqbHbqN0F9Wcke3Pj5K4dj2fB6ag6wJw28HlzeObze+5iSyR6o9tKi3qWket9DOeu2er9rbz7+v6GhoaGhoaGhoeEXHGMVf2wHL4SHsWq9G5OgoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaFhDeL/AbL/6dpoj+OHAAAAAElFTkSuQmCC",width:"248",height:"248",style:{mixBlendMode:"multiply"}}),React.createElement("rect",{x:"184.055",y:"54.995",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"170.059",y:"44.06",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"200.238",y:"77.302",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"212.048",y:"87.8",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"206.799",y:"83.425",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"204.175",y:"85.612",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"219.046",y:"103.108",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"154.751",y:"30.064",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"188.866",y:"63.742",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"148.189",y:"34",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"134.051",y:"31.707",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"126.124",y:"24.771",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"115.385",y:"29.19",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"95.702",y:"31.376",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"91.766",y:"27.002",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"90.454",y:"32.688",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"184.389",y:"45.58",width:"2.187",height:"2.187"}),React.createElement("rect",{x:"162.185",y:"41.873",width:"2.187",height:"2.187"})))}var Mt="ai",Me="ai/ai",dn="https://wordpress.org/plugins/ai/",De=Object.values(ze()),un=De.some(e=>e.type==="ai_provider"),Dt=[];for(let e of De)e.type==="ai_provider"&&e.authentication.method==="api_key"&&Dt.push(e.authentication.settingName);function Bt(){let[e,t]=(0,L.useState)(!1),[n,o]=(0,L.useState)(!1),r=(0,L.useRef)(null);(0,L.useEffect)(()=>{n&&r.current?.focus()},[n]);let a=(0,L.useRef)(De.some(G=>G.type==="ai_provider"&&G.authentication.method==="api_key"&&G.authentication.isConnected)).current,{pluginStatus:i,canInstallPlugins:c,canManagePlugins:u,hasConnectedProvider:d}=(0,ue.useSelect)(G=>{let v=G(Oe.store),z=!!v.canUser("create",{kind:"root",name:"plugin"}),b=v.getEntityRecord("root","site"),j=a||Dt.some(T=>!!b?.[T]),q=v.getEntityRecord("root","plugin",Me);return v.hasFinishedResolution("getEntityRecord",["root","plugin",Me])?q?{pluginStatus:q.status==="active"?"active":"inactive",canInstallPlugins:z,canManagePlugins:!0,hasConnectedProvider:j}:{pluginStatus:"not-installed",canInstallPlugins:z,canManagePlugins:z,hasConnectedProvider:j}:{pluginStatus:"checking",canInstallPlugins:z,canManagePlugins:void 0,hasConnectedProvider:j}},[]),{saveEntityRecord:f}=(0,ue.useDispatch)(Oe.store),g=async()=>{t(!0);try{await f("root","plugin",{slug:Mt,status:"active"},{throwOnError:!0}),o(!0),de((0,m.__)("AI plugin installed and activated successfully."))}catch{de((0,m.__)("Failed to install the AI plugin."),"assertive")}finally{t(!1)}},D=async()=>{t(!0);try{await f("root","plugin",{plugin:Me,status:"active"},{throwOnError:!0}),o(!0),de((0,m.__)("AI plugin activated successfully."))}catch{de((0,m.__)("Failed to activate the AI plugin."),"assertive")}finally{t(!1)}};if(!un||i==="checking"||i==="active"&&a&&!n||i==="not-installed"&&c===!1||i==="inactive"&&u===!1)return null;let p=i==="active"&&!d,B=i==="active"&&d&&(!a||n),h=i==="not-installed"||i==="inactive",x=()=>B?(0,m.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):p?(0,m.__)("The AI plugin is installed. Connect a provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,m.__)("The AI plugin can use your connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),P=()=>i==="not-installed"?{label:e?(0,m.__)("Installing\u2026"):(0,m.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:g}:{label:e?(0,m.__)("Activating\u2026"):(0,m.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:D};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,L.createInterpolateElement)(x(),{strong:React.createElement("strong",null),a:React.createElement(ee.ExternalLink,{href:dn})})),h?React.createElement(ee.Button,{variant:"primary",size:"compact",isBusy:e,disabled:P().disabled,accessibleWhenDisabled:!0,onClick:P().onClick},P().label):React.createElement(ee.Button,{ref:r,variant:"secondary",size:"compact",href:(0,Ot.addQueryArgs)("options-general.php",{page:Mt})},(0,m.__)("Control features in the AI plugin"))),React.createElement(zt,null))}var jt=s(st()),{lock:Zr,unlock:Be}=(0,jt.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");var{store:pn}=Be(fn);Gt();function gn(){let{connectors:e,canInstallPlugins:t}=(0,Ht.useSelect)(r=>({connectors:Be(r(pn)).getConnectors(),canInstallPlugins:r(qt.store).canUser("create",{kind:"root",name:"plugin"})}),[]),o=e.filter(r=>r.render).length===0;return React.createElement(ye,{title:(0,Z.__)("Connectors"),headingLevel:1,subTitle:(0,Z.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${o?" connectors-page--empty":""}`},o?React.createElement(y.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(y.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(y.__experimentalHeading,{level:2,size:15,weight:600},(0,Z.__)("No connectors yet")),React.createElement(y.__experimentalText,{size:12},(0,Z.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(y.Button,{variant:"secondary",href:"plugin-install.php"},(0,Z.__)("Learn more"))):React.createElement(y.__experimentalVStack,{spacing:3},React.createElement(Bt,null),e.map(r=>r.render?React.createElement(r.render,{key:r.slug,slug:r.slug,name:r.name,description:r.description,type:r.type,logo:r.logo,authentication:r.authentication,plugin:r.plugin}):null)),t&&React.createElement("p",null,(0,Rt.createInterpolateElement)((0,Z.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function mn(){return React.createElement(gn,null)}var vn=mn;export{vn as stage}; From 32642e5359bed629010b3f52b929642eafd16e1a Mon Sep 17 00:00:00 2001 From: Aaron Jorbin Date: Mon, 6 Apr 2026 19:22:27 +0000 Subject: [PATCH 079/104] Revert Register Akismet Anti-Spam as a connector. This reverts [62193]. The connector for Akismet shouldn't show up when akismet is not installed. The plugin itself can register the connecter when it is activated. Follow-up to [62193]. See #65012. Props peterwilsoncc, jorgefilipecosta, johnbillion, eclev91, desrosj, davidbaumwald, jorbin. git-svn-id: https://develop.svn.wordpress.org/trunk@62210 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-connector-registry.php | 6 ++-- src/wp-includes/connectors.php | 29 ++++--------------- .../wpConnectorsGetConnectorSettings.php | 20 +++++-------- .../rest-api/rest-settings-controller.php | 1 - tests/qunit/fixtures/wp-api-generated.js | 7 ----- 5 files changed, 15 insertions(+), 48 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index d7643360efeeb..9fe51be96aa8e 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -71,8 +71,8 @@ final class WP_Connector_Registry { * For connectors with `api_key` authentication, a `setting_name` can be provided * explicitly. If omitted, one is automatically generated using the pattern * `connectors_{$type}_{$id}_api_key`, with hyphens in the type and ID normalized - * to underscores (e.g., connector type `spam_filtering` with ID `akismet` produces - * `connectors_spam_filtering_akismet_api_key`). This setting name is used for the + * to underscores (e.g., connector type `spam_filtering` with ID `my_plugin` produces + * `connectors_spam_filtering_my_plugin_api_key`). This setting name is used for the * Settings API registration and REST API exposure. * * Registering a connector with an ID that is already registered will trigger a @@ -110,7 +110,7 @@ final class WP_Connector_Registry { * Optional. Plugin data for install/activate UI. * * @type string $file The plugin's main file path relative to the plugins - * directory (e.g. 'akismet/akismet.php' or 'hello.php'). + * directory (e.g. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * @return array|null The registered connector data on success, null on failure. diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index a11faeb637623..63e018074fd58 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -59,7 +59,7 @@ function wp_is_connector_registered( string $id ): bool { * Optional. Plugin data for install/activate UI. * * @type string $file The plugin's main file path relative to the plugins - * directory (e.g. 'akismet/akismet.php' or 'hello.php'). + * directory (e.g. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * @phpstan-return ?array{ @@ -120,7 +120,7 @@ function wp_get_connector( string $id ): ?array { * Optional. Plugin data for install/activate UI. * * @type string $file The plugin's main file path relative to the plugins - * directory (e.g. 'akismet/akismet.php' or 'hello.php'). + * directory (e.g. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * } @@ -210,25 +210,6 @@ function _wp_connectors_init(): void { _wp_connectors_register_default_ai_providers( $registry ); } - // Non-AI default connectors. - $registry->register( - 'akismet', - array( - 'name' => __( 'Akismet Anti-spam' ), - 'description' => __( 'Protect your site from spam.' ), - 'type' => 'spam_filtering', - 'plugin' => array( - 'file' => 'akismet/akismet.php', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://akismet.com/get/', - 'setting_name' => 'wordpress_api_key', - 'constant_name' => 'WPCOM_API_KEY', - ), - ) - ); - /** * Fires when the connector registry is ready for plugins to register connectors. * @@ -417,9 +398,9 @@ function _wp_connectors_mask_api_key( string $key ): string { * @since 7.0.0 * @access private * - * @param string $setting_name The option name for the API key (e.g., 'connectors_spam_filtering_akismet_api_key'). - * @param string $env_var_name Optional. Environment variable name to check (e.g., 'AKISMET_API_KEY'). - * @param string $constant_name Optional. PHP constant name to check (e.g., 'AKISMET_API_KEY'). + * @param string $setting_name The option name for the API key (e.g., 'connectors_spam_filtering_my_plugin_api_key'). + * @param string $env_var_name Optional. Environment variable name to check (e.g., 'MY_PLUGIN_API_KEY'). + * @param string $constant_name Optional. PHP constant name to check (e.g., 'MY_PLUGIN_API_KEY'). * @return string The key source: 'env', 'constant', 'database', or 'none'. */ function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string { diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 9d6c4b8486d9c..cedac90111101 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -37,9 +37,8 @@ public function test_returns_expected_connector_keys(): void { $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'openai', $connectors ); $this->assertArrayHasKey( 'anthropic', $connectors ); - $this->assertArrayHasKey( 'akismet', $connectors ); $this->assertArrayHasKey( 'mock-connectors-test', $connectors ); - $this->assertCount( 5, $connectors ); + $this->assertCount( 4, $connectors ); } /** @@ -57,7 +56,7 @@ public function test_each_connector_has_required_fields(): void { $this->assertArrayHasKey( 'description', $connector_data, "Connector '{$connector_id}' is missing 'description'." ); $this->assertIsString( $connector_data['description'], "Connector '{$connector_id}' description should be a string." ); $this->assertArrayHasKey( 'type', $connector_data, "Connector '{$connector_id}' is missing 'type'." ); - $this->assertContains( $connector_data['type'], array( 'ai_provider', 'spam_filtering' ), "Connector '{$connector_id}' has unexpected type '{$connector_data['type']}'." ); + $this->assertContains( $connector_data['type'], array( 'ai_provider' ), "Connector '{$connector_id}' has unexpected type '{$connector_data['type']}'." ); $this->assertArrayHasKey( 'authentication', $connector_data, "Connector '{$connector_id}' is missing 'authentication'." ); $this->assertIsArray( $connector_data['authentication'], "Connector '{$connector_id}' authentication should be an array." ); $this->assertArrayHasKey( 'method', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'method'." ); @@ -80,16 +79,11 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url(): ++$api_key_count; $this->assertArrayHasKey( 'setting_name', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'setting_name'." ); - - // AI providers use the connectors_ai_{id}_api_key convention. - // Non-AI connectors may use custom setting names. - if ( 'ai_provider' === $connector_data['type'] ) { - $this->assertSame( - 'connectors_ai_' . str_replace( '-', '_', $connector_id ) . '_api_key', - $connector_data['authentication']['setting_name'] ?? null, - "Connector '{$connector_id}' setting_name does not match expected format." - ); - } + $this->assertSame( + 'connectors_ai_' . str_replace( '-', '_', $connector_id ) . '_api_key', + $connector_data['authentication']['setting_name'] ?? null, + "Connector '{$connector_id}' setting_name does not match expected format." + ); } $this->assertGreaterThan( 0, $api_key_count, 'At least one connector should use api_key authentication.' ); diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index 7f2ea9eba71f7..b83cef41d2cf3 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -119,7 +119,6 @@ public function test_get_items() { 'default_ping_status', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php - 'wordpress_api_key', // Registered by Akismet connector. 'wp_collaboration_enabled', ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index c3ca057691308..003dc397ae305 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11011,12 +11011,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "wordpress_api_key": { - "title": "Akismet Anti-spam API Key", - "description": "API key for the Akismet Anti-spam connector.", - "type": "string", - "required": false - }, "title": { "title": "Title", "description": "Site title.", @@ -14550,7 +14544,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "wordpress_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 74c99a3daea546320ffadc5ea37635a075fa4a1a Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Mon, 6 Apr 2026 23:51:43 +0000 Subject: [PATCH 080/104] Tests: Add missing `@covers` tags for some multisite tests. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62213 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/multisite/wpmuLogNewRegistrations.php | 2 ++ tests/phpunit/tests/multisite/wpmuValidateBlogSignup.php | 2 ++ tests/phpunit/tests/multisite/wpmuValidateUserSignup.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/phpunit/tests/multisite/wpmuLogNewRegistrations.php b/tests/phpunit/tests/multisite/wpmuLogNewRegistrations.php index 56a0915b93059..624b11f724f64 100644 --- a/tests/phpunit/tests/multisite/wpmuLogNewRegistrations.php +++ b/tests/phpunit/tests/multisite/wpmuLogNewRegistrations.php @@ -3,6 +3,8 @@ /** * @group ms-required * @group multisite + * + * @covers ::wpmu_log_new_registrations */ class Tests_Multisite_wpmuLogNewRegistrations extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/wpmuValidateBlogSignup.php b/tests/phpunit/tests/multisite/wpmuValidateBlogSignup.php index 47b1676dcf6fd..4a29026edeb11 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateBlogSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateBlogSignup.php @@ -3,6 +3,8 @@ /** * @group ms-required * @group multisite + * + * @covers ::wpmu_validate_blog_signup */ class Tests_Multisite_wpmuValidateBlogSignup extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php index 377e6f9118c1e..5c565aad5a016 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php @@ -3,6 +3,8 @@ /** * @group ms-required * @group multisite + * + * @covers ::wpmu_validate_user_signup */ class Tests_Multisite_wpmuValidateUserSignup extends WP_UnitTestCase { From ac186d761ba98d9516569c3325a7d3febca6b979 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 7 Apr 2026 18:37:54 +0000 Subject: [PATCH 081/104] Tests: Add missing `@covers` tags for some multisite tests. Follow-up to [62213]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62218 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/multisite/getIdFromBlogname.php | 2 ++ tests/phpunit/tests/multisite/getMainSiteId.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/phpunit/tests/multisite/getIdFromBlogname.php b/tests/phpunit/tests/multisite/getIdFromBlogname.php index d38da764a8fd5..9454e64703f84 100644 --- a/tests/phpunit/tests/multisite/getIdFromBlogname.php +++ b/tests/phpunit/tests/multisite/getIdFromBlogname.php @@ -7,6 +7,8 @@ * @group ms-required * @group ms-site * @group multisite + * + * @covers ::get_id_from_blogname */ class Tests_Multisite_GetIdFromBlogname extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/getMainSiteId.php b/tests/phpunit/tests/multisite/getMainSiteId.php index e5f44dce417ad..483e34d5ec79b 100644 --- a/tests/phpunit/tests/multisite/getMainSiteId.php +++ b/tests/phpunit/tests/multisite/getMainSiteId.php @@ -6,6 +6,8 @@ * @group ms-required * @group ms-site * @group multisite + * + * @covers ::get_main_site_id */ class Tests_Multisite_GetMainSiteId extends WP_UnitTestCase { From 502624fef086973c4bcfe00b077b9cba317cc0e5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Apr 2026 12:24:47 +0000 Subject: [PATCH 082/104] Block Hooks: Set ignored blocks meta in REST API response. Set `_wp_ignored_hooked_blocks` post meta in the REST API response sent from post-like endpoints that support Block Hooks (see `rest_block_hooks_post_types` filter). Previously, it was enough to set that post meta on write (i.e. save to DB). However, due to the way real-time collaboration syncs posts and reconciles them with content received from the server side, this information is now vital on the client side to ensure hooked blocks aren't duplicated. Developed in https://github.com/WordPress/wordpress-develop/pull/11410. Props bernhard-reiter, czarate, ingeniumed. Fixes #65008. git-svn-id: https://develop.svn.wordpress.org/trunk@62219 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/blocks.php | 30 +++++++- ...applyBlockHooksToContentFromPostObject.php | 71 +++++++++++++++++-- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 170d7c0fbf10a..cc1ac60667773 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1196,6 +1196,7 @@ function apply_block_hooks_to_content( $content, $context = null, $callback = 'i * of the block that corresponds to the post type are handled correctly. * * @since 6.8.0 + * @since 7.0.0 Added the `$ignored_hooked_blocks_at_root` parameter. * @access private * * @param string $content Serialized content. @@ -1205,9 +1206,17 @@ function apply_block_hooks_to_content( $content, $context = null, $callback = 'i * @param callable $callback A function that will be called for each block to generate * the markup for a given list of blocks that are hooked to it. * Default: 'insert_hooked_blocks'. + * @param array|null $ignored_hooked_blocks_at_root A reference to an array that will be populated + * with the ignored hooked blocks at the root level. + * Default: `null`. * @return string The serialized markup. */ -function apply_block_hooks_to_content_from_post_object( $content, $post = null, $callback = 'insert_hooked_blocks' ) { +function apply_block_hooks_to_content_from_post_object( + $content, + $post = null, + $callback = 'insert_hooked_blocks', + &$ignored_hooked_blocks_at_root = null +) { // Default to the current post if no context is provided. if ( null === $post ) { $post = get_post(); @@ -1287,6 +1296,16 @@ function apply_block_hooks_to_content_from_post_object( $content, $post = null, $content = apply_block_hooks_to_content( $content, $post, $callback ); remove_filter( 'hooked_block_types', $suppress_blocks_from_insertion_before_and_after_wrapper_block, PHP_INT_MAX ); + if ( null !== $ignored_hooked_blocks_at_root ) { + // Check wrapper block's metadata for ignored hooked blocks at the root level, and populate the reference parameter if needed. + $wrapper_block_markup = extract_serialized_parent_block( $content ); + $wrapper_block = parse_blocks( $wrapper_block_markup )[0]; + + if ( ! empty( $wrapper_block['attrs']['metadata']['ignoredHookedBlocks'] ) ) { + $ignored_hooked_blocks_at_root = $wrapper_block['attrs']['metadata']['ignoredHookedBlocks']; + } + } + // Finally, we need to remove the temporary wrapper block. $content = remove_serialized_parent_block( $content ); @@ -1449,6 +1468,7 @@ function insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata( &$parsed_a * * @since 6.6.0 * @since 6.8.0 Support non-`wp_navigation` post types. + * @since 7.0.0 Set `_wp_ignored_hooked_blocks` meta in the response for blocks hooked at the root level. * * @param WP_REST_Response $response The response object. * @param WP_Post $post Post object. @@ -1459,12 +1479,18 @@ function insert_hooked_blocks_into_rest_response( $response, $post ) { return $response; } + $ignored_hooked_blocks_at_root = array(); $response->data['content']['raw'] = apply_block_hooks_to_content_from_post_object( $response->data['content']['raw'], $post, - 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata' + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + $ignored_hooked_blocks_at_root ); + if ( ! empty( $ignored_hooked_blocks_at_root ) ) { + $response->data['meta']['_wp_ignored_hooked_blocks'] = wp_json_encode( $ignored_hooked_blocks_at_root ); + } + // If the rendered content was previously empty, we leave it like that. if ( empty( $response->data['content']['rendered'] ) ) { return $response; diff --git a/tests/phpunit/tests/blocks/applyBlockHooksToContentFromPostObject.php b/tests/phpunit/tests/blocks/applyBlockHooksToContentFromPostObject.php index 5ff9f7323e0f3..4f95727524c8c 100644 --- a/tests/phpunit/tests/blocks/applyBlockHooksToContentFromPostObject.php +++ b/tests/phpunit/tests/blocks/applyBlockHooksToContentFromPostObject.php @@ -130,21 +130,59 @@ public function test_apply_block_hooks_to_content_from_post_object_inserts_hooke $this->assertSame( $expected, $actual ); } + /** + * @ticket 65008 + */ + public function test_apply_block_hooks_to_content_from_post_object_sets_ignored_hooked_blocks() { + $ignored_hooked_blocks_at_root = array(); + + $expected = '' . + '' . + '

    Hello World!

    ' . + '' . + ''; + $actual = apply_block_hooks_to_content_from_post_object( + self::$post->post_content, + self::$post, + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + $ignored_hooked_blocks_at_root + ); + $this->assertSame( $expected, $actual, "Markup wasn't updated correctly." ); + $this->assertSame( + array( 'tests/hooked-block-first-child' ), + $ignored_hooked_blocks_at_root, + "Hooked block added at 'first_child' position wasn't added to ignoredHookedBlocks metadata." + ); + } + /** * @ticket 62716 + * @ticket 65008 */ public function test_apply_block_hooks_to_content_from_post_object_respects_ignored_hooked_blocks_post_meta() { - $expected = self::$post_with_ignored_hooked_block->post_content . ''; + $ignored_hooked_blocks_at_root = array(); + + $expected = '' . + '

    Hello World!

    ' . + '' . + ''; $actual = apply_block_hooks_to_content_from_post_object( self::$post_with_ignored_hooked_block->post_content, self::$post_with_ignored_hooked_block, - 'insert_hooked_blocks' + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + $ignored_hooked_blocks_at_root ); $this->assertSame( $expected, $actual ); + $this->assertSame( + array( 'tests/hooked-block-first-child' ), + $ignored_hooked_blocks_at_root, + "Pre-existing ignored hooked block at root level wasn't reflected in metadata." + ); } /** * @ticket 63287 + * @ticket 65008 */ public function test_apply_block_hooks_to_content_from_post_object_does_not_insert_hooked_block_before_container_block() { $filter = function ( $hooked_block_types, $relative_position, $anchor_block_type ) { @@ -155,31 +193,50 @@ public function test_apply_block_hooks_to_content_from_post_object_does_not_inse return $hooked_block_types; }; + $ignored_hooked_blocks_at_root = array(); + $expected = '' . - self::$post->post_content . + '' . + '

    Hello World!

    ' . + '' . ''; add_filter( 'hooked_block_types', $filter, 10, 3 ); $actual = apply_block_hooks_to_content_from_post_object( self::$post->post_content, self::$post, - 'insert_hooked_blocks' + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + $ignored_hooked_blocks_at_root ); remove_filter( 'hooked_block_types', $filter, 10 ); - $this->assertSame( $expected, $actual ); + $this->assertSame( $expected, $actual, "Hooked block added before 'core/post-content' block shouldn't be inserted." ); + $this->assertSame( + array( 'tests/hooked-block-first-child' ), + $ignored_hooked_blocks_at_root, + "ignoredHookedBlocks metadata wasn't set correctly." + ); } /** * @ticket 62716 + * @ticket 65008 */ public function test_apply_block_hooks_to_content_from_post_object_inserts_hooked_block_if_content_contains_no_blocks() { + $ignored_hooked_blocks_at_root = array(); + $expected = '' . self::$post_with_non_block_content->post_content; $actual = apply_block_hooks_to_content_from_post_object( self::$post_with_non_block_content->post_content, self::$post_with_non_block_content, - 'insert_hooked_blocks' + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + $ignored_hooked_blocks_at_root + ); + $this->assertSame( $expected, $actual, "Markup wasn't updated correctly." ); + $this->assertSame( + array( 'tests/hooked-block-first-child' ), + $ignored_hooked_blocks_at_root, + "Hooked block added at 'first_child' position wasn't added to ignoredHookedBlocks metadata." ); - $this->assertSame( $expected, $actual ); } } From c37a60aa62949ee90ff4705106ae525aaba54196 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 8 Apr 2026 15:47:24 +0000 Subject: [PATCH 083/104] Abilities: Strip internal schema keywords from abilities REST responses. Remove WordPress-internal properties (`sanitize_callback`, `validate_callback`, `arg_options`) from ability `input_schema` and `output_schema` fields in REST responses. These properties are used server-side but are not valid JSON Schema keywords and cause client-side validators to fail. Props jorgefilipecosta, ocean90, gziolo. Fixes #65035. git-svn-id: https://develop.svn.wordpress.org/trunk@62221 602fd350-edb4-49c9-b593-d223f7449a82 --- ...s-wp-rest-abilities-v1-list-controller.php | 83 +++++- .../wpRestAbilitiesV1ListController.php | 240 ++++++++++++++++++ 2 files changed, 321 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 6dfc54003863e..e3ce0c4f2e03e 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -215,6 +215,81 @@ private function normalize_schema_empty_object_defaults( array $schema ): array return $schema; } + /** + * WordPress-internal schema keywords to strip from REST responses. + * + * @since 7.0.0 + * @var array + */ + private const INTERNAL_SCHEMA_KEYWORDS = array( + 'sanitize_callback' => true, + 'validate_callback' => true, + 'arg_options' => true, + ); + + /** + * Recursively removes WordPress-internal keywords from a schema. + * + * Ability schemas may include WordPress-internal properties like + * `sanitize_callback`, `validate_callback`, and `arg_options` that are + * used server-side but are not valid JSON Schema keywords. This method + * removes those specific keys so they are not exposed in REST responses. + * + * @since 7.0.0 + * + * @param array $schema The schema array. + * @return array The schema without WordPress-internal keywords. + */ + private function strip_internal_schema_keywords( array $schema ): array { + $schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS ); + + // Sub-schema maps: keys are user-defined, values are sub-schemas. + // Note: 'dependencies' values can also be property-dependency arrays + // (numeric arrays of strings) which are skipped via wp_is_numeric_array(). + foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $key => $child_schema ) { + if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) { + $schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema ); + } + } + } + } + + // Single sub-schema keywords. + foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + $schema[ $keyword ] = $this->strip_internal_schema_keywords( $schema[ $keyword ] ); + } + } + + // Items: single schema or tuple array of schemas. + if ( isset( $schema['items'] ) ) { + if ( wp_is_numeric_array( $schema['items'] ) ) { + foreach ( $schema['items'] as $index => $item_schema ) { + if ( is_array( $item_schema ) ) { + $schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema ); + } + } + } elseif ( is_array( $schema['items'] ) ) { + $schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] ); + } + } + + // Array-of-schemas keywords. + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $index => $sub_schema ) { + if ( is_array( $sub_schema ) ) { + $schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema ); + } + } + } + } + + return $schema; + } + /** * Prepares an ability for response. * @@ -230,8 +305,12 @@ public function prepare_item_for_response( $ability, $request ) { 'label' => $ability->get_label(), 'description' => $ability->get_description(), 'category' => $ability->get_category(), - 'input_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ), - 'output_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ), + 'input_schema' => $this->strip_internal_schema_keywords( + $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ) + ), + 'output_schema' => $this->strip_internal_schema_keywords( + $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ) + ), 'meta' => $ability->get_meta(), ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 9ee564ef00069..d73a2c64177fc 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -776,4 +776,244 @@ public function test_filter_by_nonexistent_category(): void { $this->assertIsArray( $data ); $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); } + + /** + * Test that WordPress-internal schema keywords are stripped from ability schemas in REST response. + * + * @ticket 65035 + */ + public function test_internal_schema_keywords_stripped_from_response(): void { + $this->register_test_ability( + 'test/with-internal-keywords', + array( + 'label' => 'Test Internal Keywords', + 'description' => 'Tests stripping of internal schema keywords', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'description' => 'The content value.', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'is_string', + 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'execute_callback' => static function ( $input ) { + return $input['content']; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'input_schema', $data ); + $this->assertArrayHasKey( 'properties', $data['input_schema'] ); + $this->assertArrayHasKey( 'content', $data['input_schema']['properties'] ); + $this->assertArrayHasKey( 'output_schema', $data ); + + // Verify internal keywords are stripped from input_schema properties. + $content_schema = $data['input_schema']['properties']['content']; + $this->assertArrayNotHasKey( 'sanitize_callback', $content_schema ); + $this->assertArrayNotHasKey( 'validate_callback', $content_schema ); + $this->assertArrayNotHasKey( 'arg_options', $content_schema ); + + // Verify valid JSON Schema keywords are preserved. + $this->assertSame( 'string', $content_schema['type'] ); + $this->assertSame( 'The content value.', $content_schema['description'] ); + + // Verify internal keywords are stripped from output_schema. + $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] ); + $this->assertSame( 'string', $data['output_schema']['type'] ); + } + + /** + * Test that internal schema keywords are stripped from nested sub-schema locations. + * + * @ticket 64098 + */ + public function test_internal_schema_keywords_stripped_from_nested_sub_schemas(): void { + $this->register_test_ability( + 'test/nested-internal-keywords', + array( + 'label' => 'Test Nested Keywords', + 'description' => 'Tests stripping from all sub-schema locations', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'anyOf' => array( + array( + 'type' => 'object', + 'sanitize_callback' => 'sanitize_text_field', + 'properties' => array( + 'value' => array( + 'type' => 'string', + 'validate_callback' => 'is_string', + ), + ), + ), + array( + 'type' => 'number', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + ), + 'oneOf' => array( + array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'allOf' => array( + array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + ), + ), + 'not' => array( + 'type' => 'null', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + 'patternProperties' => array( + '^S_' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'definitions' => array( + 'address' => array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + 'properties' => array( + 'street' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + ), + 'dependencies' => array( + 'bar' => array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + 'properties' => array( + 'baz' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + 'qux' => array( 'bar' ), + ), + 'additionalProperties' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'output_schema' => array( + 'type' => 'array', + 'items' => array( + array( + 'type' => 'string', + 'validate_callback' => 'is_string', + ), + array( + 'type' => 'number', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + ), + 'additionalItems' => array( + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', + ), + ), + 'execute_callback' => static function ( $input ) { + return array(); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-internal-keywords' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // Verify internal keywords are stripped from anyOf sub-schemas. + $this->assertArrayHasKey( 'anyOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['anyOf'][0] ); + $this->assertSame( 'object', $data['input_schema']['anyOf'][0]['type'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['anyOf'][0]['properties']['value'] ); + $this->assertSame( 'string', $data['input_schema']['anyOf'][0]['properties']['value']['type'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['anyOf'][1] ); + $this->assertSame( 'number', $data['input_schema']['anyOf'][1]['type'] ); + + // Verify internal keywords are stripped from oneOf sub-schemas. + $this->assertArrayHasKey( 'oneOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['oneOf'][0] ); + $this->assertSame( 'string', $data['input_schema']['oneOf'][0]['type'] ); + + // Verify internal keywords are stripped from allOf sub-schemas. + $this->assertArrayHasKey( 'allOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['allOf'][0] ); + $this->assertSame( 'object', $data['input_schema']['allOf'][0]['type'] ); + + // Verify internal keywords are stripped from not sub-schema. + $this->assertArrayHasKey( 'not', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['not'] ); + $this->assertSame( 'null', $data['input_schema']['not']['type'] ); + + // Verify internal keywords are stripped from patternProperties sub-schemas. + $this->assertArrayHasKey( 'patternProperties', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['patternProperties']['^S_'] ); + $this->assertSame( 'string', $data['input_schema']['patternProperties']['^S_']['type'] ); + + // Verify internal keywords are stripped from dependencies schema values. + $this->assertArrayHasKey( 'dependencies', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['dependencies']['bar'] ); + $this->assertSame( 'object', $data['input_schema']['dependencies']['bar']['type'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['dependencies']['bar']['properties']['baz'] ); + $this->assertSame( 'string', $data['input_schema']['dependencies']['bar']['properties']['baz']['type'] ); + // Property dependencies (numeric arrays) should pass through unchanged. + $this->assertSame( array( 'bar' ), $data['input_schema']['dependencies']['qux'] ); + + // Verify internal keywords are stripped from definitions sub-schemas. + $this->assertArrayHasKey( 'definitions', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['definitions']['address'] ); + $this->assertSame( 'object', $data['input_schema']['definitions']['address']['type'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['definitions']['address']['properties']['street'] ); + $this->assertSame( 'string', $data['input_schema']['definitions']['address']['properties']['street']['type'] ); + + // Verify internal keywords are stripped from additionalProperties sub-schema. + $this->assertArrayHasKey( 'additionalProperties', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['additionalProperties'] ); + $this->assertSame( 'string', $data['input_schema']['additionalProperties']['type'] ); + + // Verify internal keywords are stripped from tuple-style items sub-schemas. + $this->assertArrayHasKey( 'items', $data['output_schema'] ); + $this->assertCount( 2, $data['output_schema']['items'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema']['items'][0] ); + $this->assertSame( 'string', $data['output_schema']['items'][0]['type'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['output_schema']['items'][1] ); + $this->assertSame( 'number', $data['output_schema']['items'][1]['type'] ); + + // Verify internal keywords are stripped from additionalItems sub-schema. + $this->assertArrayHasKey( 'additionalItems', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema']['additionalItems'] ); + $this->assertSame( 'boolean', $data['output_schema']['additionalItems']['type'] ); + } } From e12ddb3c76e73ef32510e5464472e04f0cd1483a Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 8 Apr 2026 18:41:15 +0000 Subject: [PATCH 084/104] Tests: Add missing `@covers` tags for some multisite tests. Follow-up to [62213], [62218]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62222 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/multisite/isEmailAddressUnsafe.php | 2 ++ tests/phpunit/tests/multisite/isUploadSpaceAvailable.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/phpunit/tests/multisite/isEmailAddressUnsafe.php b/tests/phpunit/tests/multisite/isEmailAddressUnsafe.php index 38d17d9ed0719..862086f0ebb85 100644 --- a/tests/phpunit/tests/multisite/isEmailAddressUnsafe.php +++ b/tests/phpunit/tests/multisite/isEmailAddressUnsafe.php @@ -3,6 +3,8 @@ /** * @group ms-required * @group multisite + * + * @covers ::is_email_address_unsafe */ class Tests_Multisite_IsEmailAddressUnsafe extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/isUploadSpaceAvailable.php b/tests/phpunit/tests/multisite/isUploadSpaceAvailable.php index 62c9dc6c97e31..57fee4056334c 100644 --- a/tests/phpunit/tests/multisite/isUploadSpaceAvailable.php +++ b/tests/phpunit/tests/multisite/isUploadSpaceAvailable.php @@ -8,6 +8,8 @@ * * @group ms-required * @group multisite + * + * @covers ::is_upload_space_available */ class Tests_Multisite_IsUploadSpaceAvailable extends WP_UnitTestCase { From a1c062cbab200fb95a4bab29b8b34869435c7276 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 9 Apr 2026 20:07:35 +0000 Subject: [PATCH 085/104] Tests: Add missing `@covers` tags for some oEmbed tests. Props sagardeshmukh. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62223 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/oembed/WpEmbed.php | 2 ++ tests/phpunit/tests/oembed/postEmbedUrl.php | 2 ++ tests/phpunit/tests/oembed/wpOembed.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/phpunit/tests/oembed/WpEmbed.php b/tests/phpunit/tests/oembed/WpEmbed.php index c7b0649867d97..59a1b37dd9e81 100644 --- a/tests/phpunit/tests/oembed/WpEmbed.php +++ b/tests/phpunit/tests/oembed/WpEmbed.php @@ -2,6 +2,8 @@ /** * @group oembed + * + * @covers WP_Embed */ class Tests_WP_Embed extends WP_UnitTestCase { /** diff --git a/tests/phpunit/tests/oembed/postEmbedUrl.php b/tests/phpunit/tests/oembed/postEmbedUrl.php index ed674b8429c38..225bf05e16839 100644 --- a/tests/phpunit/tests/oembed/postEmbedUrl.php +++ b/tests/phpunit/tests/oembed/postEmbedUrl.php @@ -2,6 +2,8 @@ /** * @group oembed + * + * @covers ::get_post_embed_url */ class Tests_Post_Embed_URL extends WP_UnitTestCase { public function test_non_existent_post() { diff --git a/tests/phpunit/tests/oembed/wpOembed.php b/tests/phpunit/tests/oembed/wpOembed.php index 76d733dbce2e0..43324870350e6 100644 --- a/tests/phpunit/tests/oembed/wpOembed.php +++ b/tests/phpunit/tests/oembed/wpOembed.php @@ -2,6 +2,8 @@ /** * @group oembed + * + * @covers WP_oEmbed */ class Tests_WP_oEmbed extends WP_UnitTestCase { /** From f2730e718347e86cbfd9ddf8c94c2f2e1b80b609 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 10 Apr 2026 10:18:08 -0400 Subject: [PATCH 086/104] Update src/wp-includes/collaboration/class-wp-collaboration-table-storage.php Co-authored-by: Mukesh Panchal --- .../collaboration/class-wp-collaboration-table-storage.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 2fdf56d820057..2781af4716bf4 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -291,10 +291,10 @@ public function remove_updates_through_cursor( string $room, int $cursor ): bool * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $room Room identifier. + * @param string $room Room identifier. * @param string $client_id Client identifier. - * @param array $state Serializable awareness state for this client. - * @param int $user_id WordPress user ID that owns this client. + * @param array $state Serializable awareness state for this client. + * @param int $user_id WordPress user ID that owns this client. * @return bool True on success, false on failure. */ public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { From 8371c5c10eeca067317e758b98444251147941fe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 10 Apr 2026 11:02:12 -0400 Subject: [PATCH 087/104] Collaboration: Cap client_id at the storage column width. Add a maxLength constraint to the client_id argument schema so overlong values are rejected at the REST layer rather than being silently truncated (or erroring) at the database. The 32-character limit matches the client_id varchar(32) column in schema.php and mirrors the approach already used for the room argument. Also document why both minimum and minLength are present: client_id has a union type (string|integer), and WordPress REST API validation dispatches string values to minLength/maxLength and integer values to minimum, so both keywords are required to bound each branch of the union. --- ...s-wp-http-polling-collaboration-server.php | 6 ++++ .../rest-api/rest-collaboration-server.php | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index b6c36dadae542..b3a7907cb021c 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -151,9 +151,15 @@ public function register_routes(): void { 'required' => true, 'type' => array( 'object', 'null' ), ), + /* + * client_id accepts both string and integer values: + * - 'minimum' bounds the integer form. + * - 'minLength' / 'maxLength' bound the string form. + */ 'client_id' => array( 'minimum' => 1, 'minLength' => 1, + 'maxLength' => 32, // Matches the client_id column width in wp-admin/includes/schema.php. 'required' => true, 'type' => array( 'string', 'integer' ), 'sanitize_callback' => function ( $value ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 640c214ead4a8..f52dec3388cb0 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -512,6 +512,40 @@ public function test_collaboration_client_id_integer_coercion(): void { $this->assertArrayHasKey( '42', $data['rooms'][0]['awareness'], 'Numeric client_id should be coerced to string key in awareness.' ); } + /** + * Validates that REST accepts client IDs at the column width boundary (32 chars). + * + * @ticket 64696 + */ + public function test_collaboration_client_id_accepts_string_at_max_length(): void { + wp_set_current_user( self::$editor_id ); + + $client_id = str_repeat( 'a', 32 ); + $this->assertSame( 32, strlen( $client_id ), 'Client ID should be 32 characters.' ); + + $rooms = array( $this->build_room( $this->get_post_room(), $client_id ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status(), 'REST should accept client IDs at 32 characters.' ); + } + + /** + * Validates that REST rejects client IDs exceeding the column width (32 chars). + * + * @ticket 64696 + */ + public function test_collaboration_client_id_rejects_string_over_max_length(): void { + wp_set_current_user( self::$editor_id ); + + $client_id = str_repeat( 'a', 33 ); + $this->assertSame( 33, strlen( $client_id ), 'Client ID should be 33 characters.' ); + + $rooms = array( $this->build_room( $this->get_post_room(), $client_id ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'REST should reject client IDs exceeding 32 characters.' ); + } + /** * Verifies that dispatching with an empty rooms array returns HTTP 200. * From 8ee522034cf552667ef44a04bc59e50e785eb8a5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 10 Apr 2026 12:19:21 -0400 Subject: [PATCH 088/104] Collaboration: Add (room, type, date_gmt) index to the collaboration table. Add a composite KEY room_type_date (room, type, date_gmt) so the awareness read path in get_awareness_state() can seek directly to a room's live awareness rows instead of relying on the KEY room (room, id) prefix and post-filtering by type and date_gmt. In the single-table design where awareness and update rows share $wpdb->collaboration and are distinguished only by the type column, the previous indexes left no covering path for the hot-path query WHERE room = %s AND type = 'awareness' AND date_gmt >= %s, which runs on every cache-miss awareness poll (~0.5-1s per active editor). EXPLAIN on a populated table (11k rows) confirms MySQL switches from KEY room (rows=55, Using where) to KEY room_type_date (rows=3, Using index condition), with the full WHERE clause pushed down into the index lookup. The set_awareness_state() existence check (WHERE room = %s AND type = 'awareness' AND client_id = %s) also picks up the new index as a side benefit: its plan switches from an index_merge intersection of type_client_id and room to a single-index seek on room_type_date. The old intersection plan wasn't pathological, but a single composite seek is simpler and more predictable. Bump $wp_db_version to 61842 so dbDelta picks up the new index on upgrade. --- src/wp-admin/includes/schema.php | 1 + src/wp-includes/version.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 7f797eb0c705a..1c7b40b71c498 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -198,6 +198,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { PRIMARY KEY (id), KEY type_client_id (type,client_id), KEY room (room,id), + KEY room_type_date (room,type,date_gmt), KEY date_gmt (date_gmt) ) $charset_collate;\n"; diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 61237429dd9d5..59b4142166a96 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61841; +$wp_db_version = 61842; /** * Holds the TinyMCE version. From 80433208a67206b6413f7fa4f7d5f68c72f2a15c Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 10 Apr 2026 19:46:03 +0000 Subject: [PATCH 089/104] Tests: Rename some oEmbed test classes as per the naming conventions. Follow-up to [34903], [62223]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62224 602fd350-edb4-49c9-b593-d223f7449a82 --- .../oembed/{getResponseData.php => getOembedResponseData.php} | 3 ++- .../tests/oembed/{postEmbedUrl.php => getPostEmbedUrl.php} | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename tests/phpunit/tests/oembed/{getResponseData.php => getOembedResponseData.php} (99%) rename tests/phpunit/tests/oembed/{postEmbedUrl.php => getPostEmbedUrl.php} (98%) diff --git a/tests/phpunit/tests/oembed/getResponseData.php b/tests/phpunit/tests/oembed/getOembedResponseData.php similarity index 99% rename from tests/phpunit/tests/oembed/getResponseData.php rename to tests/phpunit/tests/oembed/getOembedResponseData.php index 09a0f3142b319..695c4f6c5f889 100644 --- a/tests/phpunit/tests/oembed/getResponseData.php +++ b/tests/phpunit/tests/oembed/getOembedResponseData.php @@ -2,9 +2,10 @@ /** * @group oembed + * * @covers ::get_oembed_response_data */ -class Tests_oEmbed_Response_Data extends WP_UnitTestCase { +class Tests_oEmbed_GetOembedResponseData extends WP_UnitTestCase { public function set_up() { parent::set_up(); diff --git a/tests/phpunit/tests/oembed/postEmbedUrl.php b/tests/phpunit/tests/oembed/getPostEmbedUrl.php similarity index 98% rename from tests/phpunit/tests/oembed/postEmbedUrl.php rename to tests/phpunit/tests/oembed/getPostEmbedUrl.php index 225bf05e16839..3f2cd23399126 100644 --- a/tests/phpunit/tests/oembed/postEmbedUrl.php +++ b/tests/phpunit/tests/oembed/getPostEmbedUrl.php @@ -5,7 +5,7 @@ * * @covers ::get_post_embed_url */ -class Tests_Post_Embed_URL extends WP_UnitTestCase { +class Tests_oEmbed_GetPostEmbedUrl extends WP_UnitTestCase { public function test_non_existent_post() { $embed_url = get_post_embed_url( 0 ); $this->assertFalse( $embed_url ); From a34cf16ab66c3534154a6728a536fe7cc9e91af8 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sat, 11 Apr 2026 14:22:15 +0000 Subject: [PATCH 090/104] Email: Add unit tests covering email validation and sanitization. In preparation for later work to allow non-US-ASCII email addresses, this change extends the unit test suite for `is_email()` and adds new tests covering `antispambot()` and `sanitize_email()`. This work was done collaboratively during WordCamp Vienna, 2026 as a Contributor Challenge in cooperation with and support from ICANN and also GeoTLDs Universal Acceptance Local Initiative. Developed in: https://github.com/WordPress/wordpress-develop/pull/11552 Discussed in: https://core.trac.wordpress.org/ticket/31992 Props agulbra, akirk, benniledl, dmsnell. See #31992. git-svn-id: https://develop.svn.wordpress.org/trunk@62225 602fd350-edb4-49c9-b593-d223f7449a82 --- .../phpunit/tests/formatting/antispambot.php | 73 +++++++++++++++ tests/phpunit/tests/formatting/isEmail.php | 89 ++++++++++++++++--- .../tests/formatting/sanitizeEmail.php | 43 +++++++++ 3 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 tests/phpunit/tests/formatting/antispambot.php create mode 100644 tests/phpunit/tests/formatting/sanitizeEmail.php diff --git a/tests/phpunit/tests/formatting/antispambot.php b/tests/phpunit/tests/formatting/antispambot.php new file mode 100644 index 0000000000000..159d907ada9b0 --- /dev/null +++ b/tests/phpunit/tests/formatting/antispambot.php @@ -0,0 +1,73 @@ +assertTrue( wp_is_valid_utf8( antispambot( $email ) ) ); + } + + /** + * Data provider. + * + * return array[] + */ + public function data_returns_valid_utf8() { + return array( + 'plain' => array( 'bob@example.com' ), + 'plain with ip' => array( 'ace@204.32.222.14' ), + 'deep subdomain' => array( 'kevin@many.subdomains.make.a.happy.man.edu' ), + 'short address' => array( 'a@b.co' ), + 'weird but legal dots' => array( '..@example.com' ), + ); + } + + /** + * This tests that antispambot performs some sort of obfuscation + * and that the obfuscation maps back to the original value. + * + * @ticket 31992 + * + * @dataProvider data_antispambot_obfuscates + * + * @param string $provided The email address to obfuscate. + */ + public function test_antispambot_obfuscates( $provided ) { + // The only token should be the email address, so advance once and treat as a text node. + $obfuscated = antispambot( $provided ); + $p = new WP_HTML_Tag_Processor( $obfuscated ); + $p->next_token(); + $decoded = rawurldecode( $p->get_modifiable_text() ); + + $this->assertNotSame( $provided, $obfuscated, 'Should have produced an obfuscated representation.' ); + $this->assertSame( $provided, $decoded, 'Should have decoded to the original email after restoring.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_antispambot_obfuscates() { + return array( + array( 'example@example.com' ), + array( '#@example.com' ), + ); + } +} diff --git a/tests/phpunit/tests/formatting/isEmail.php b/tests/phpunit/tests/formatting/isEmail.php index eb5a0379b8515..d79647885ceba 100644 --- a/tests/phpunit/tests/formatting/isEmail.php +++ b/tests/phpunit/tests/formatting/isEmail.php @@ -1,32 +1,44 @@ assertSame( $email, is_email( $email ), "is_email() should return the email address for $email." ); + $this->assertSame( + $email, + is_email( $email ), + 'Should return the given email address unchanged when valid.' + ); } /** - * Data provider for valid email addresses. + * Data provider. * - * @return array + * @return Generator */ - public static function valid_email_provider() { + public static function data_valid_email_provider() { $valid_emails = array( 'bob@example.com', 'phil@example.info', + 'phil@TLA.example', 'ace@204.32.222.14', 'kevin@many.subdomains.make.a.happy.man.edu', 'a@b.co', 'bill+ted@example.com', + '..@example.com', ); foreach ( $valid_emails as $email ) { @@ -35,18 +47,27 @@ public static function valid_email_provider() { } /** - * @dataProvider invalid_email_provider + * Ensures that unrecognized email addresses are rejected. + * + * @ticket 31992 + * + * @dataProvider data_invalid_email_provider + * + * @param string $email Invalid or unrecognized-to-WordPress email address. */ public function test_returns_false_if_given_an_invalid_email_address( $email ) { - $this->assertFalse( is_email( $email ), "is_email() should return false for $email." ); + $this->assertFalse( + is_email( $email ), + 'Should have rejected the email as invalid.' + ); } /** - * Data provider for invalid email addresses. + * Data provider. * - * @return array + * @return Generator */ - public static function invalid_email_provider() { + public static function data_invalid_email_provider() { $invalid_emails = array( 'khaaaaaaaaaaaaaaan!', 'http://bob.example.com/', @@ -54,6 +75,50 @@ public static function invalid_email_provider() { 'com.exampleNOSPAMbob', 'bob@your mom', 'a@b.c', + '" "@b.c', + '"@"@b.c', + 'a@route.org@b.c', + 'h(aj@couc.ou', // bad comment. + 'hi@', + 'hi@hi@couc.ou', // double @. + + /* + * The next address is not deliverable as described, + * SMTP servers should strip the (ab), so it is very + * likely a source of confusion or a typo. + * Best rejected. + */ + '(ab)cd@couc.ou', + + /* + * The next address is not globally deliverable, + * so it may work with PHPMailer and break with + * mail sending services. Best not allow users + * to paint themselves into that corner. This also + * avoids security problems like those that were + * used to probe the WordPress server's local + * network. + */ + 'toto@to', + + /* + * Several addresses are best rejected because + * we don't want to allow sending to fe80::, 192.168 + * and other special addresses; that too might + * be used to probe the WordPress server's local + * network. + */ + 'to@[2001:db8::1]', + 'to@[IPv6:2001:db8::1]', + 'to@[192.168.1.1]', + + /* + * Ill-formed UTF-8 byte sequences must be rejected. + * A lone continuation byte (0x80) is not valid UTF-8 + * whether it appears in the local part or the domain. + */ + "a\x80b@example.com", // invalid UTF-8 in local part. + "abc@\x80.org", // invalid UTF-8 in domain subdomain. ); foreach ( $invalid_emails as $email ) { diff --git a/tests/phpunit/tests/formatting/sanitizeEmail.php b/tests/phpunit/tests/formatting/sanitizeEmail.php new file mode 100644 index 0000000000000..110375bd21cf2 --- /dev/null +++ b/tests/phpunit/tests/formatting/sanitizeEmail.php @@ -0,0 +1,43 @@ +assertSame( + $expected, + sanitize_email( $address ), + 'Should have produced the known sanitized form of the email.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_sanitized_email_pairs() { + return array( + 'shorter than 6 characters' => array( 'a@b', '' ), + 'contains no @' => array( 'ab', '' ), + 'just a TLD' => array( 'abc@com', '' ), + 'plain' => array( 'abc@example.com', 'abc@example.com' ), + 'invalid utf8 in local' => array( "a\x80b@example.com", '' ), + 'invalid utf8 subdomain dropped' => array( "abc@sub.\x80.org", 'abc@sub.org' ), + 'all subdomains invalid utf8' => array( "abc@\x80.org", '' ), + ); + } +} From bbb00c582738989cc57a4fc84a564efb8ace0b55 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sat, 11 Apr 2026 18:45:38 +0000 Subject: [PATCH 091/104] Email: Add unit tests covering email validation and sanitization. (Take 2) When the original patch from PR#11552 was merged, it did not include the latest version of the PR code, which had removed a failing test. This patch removes the failing test to match what ran in the tests on the PR. Developed in: https://github.com/WordPress/wordpress-develop/pull/11552 Discussed in: https://core.trac.wordpress.org/ticket/31992 Follow-up to: [62225]. Props agulbra, akirk, benniledl, dmsnell. See #31992. git-svn-id: https://develop.svn.wordpress.org/trunk@62226 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/formatting/sanitizeEmail.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/formatting/sanitizeEmail.php b/tests/phpunit/tests/formatting/sanitizeEmail.php index 110375bd21cf2..6ca396f42dc26 100644 --- a/tests/phpunit/tests/formatting/sanitizeEmail.php +++ b/tests/phpunit/tests/formatting/sanitizeEmail.php @@ -35,7 +35,6 @@ public function data_sanitized_email_pairs() { 'contains no @' => array( 'ab', '' ), 'just a TLD' => array( 'abc@com', '' ), 'plain' => array( 'abc@example.com', 'abc@example.com' ), - 'invalid utf8 in local' => array( "a\x80b@example.com", '' ), 'invalid utf8 subdomain dropped' => array( "abc@sub.\x80.org", 'abc@sub.org' ), 'all subdomains invalid utf8' => array( "abc@\x80.org", '' ), ); From e28a4a4657f8768389855abf36a8dbb88a9bb89f Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 11 Apr 2026 23:53:56 +0000 Subject: [PATCH 092/104] Tests: Rename some oEmbed test classes as per the naming conventions. Includes moving the data provider after the corresponding test for consistency with the rest of the test suite. Follow-up to [34903], [62223], [62224]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62227 602fd350-edb4-49c9-b593-d223f7449a82 --- ...> wpFilterOembedIframeTitleAttributes.php} | 25 ++++++++------- ...terResult.php => wpFilterOembedResult.php} | 31 ++++++++++--------- 2 files changed, 31 insertions(+), 25 deletions(-) rename tests/phpunit/tests/oembed/{filterTitleAttributes.php => wpFilterOembedIframeTitleAttributes.php} (92%) rename tests/phpunit/tests/oembed/{filterResult.php => wpFilterOembedResult.php} (98%) diff --git a/tests/phpunit/tests/oembed/filterTitleAttributes.php b/tests/phpunit/tests/oembed/wpFilterOembedIframeTitleAttributes.php similarity index 92% rename from tests/phpunit/tests/oembed/filterTitleAttributes.php rename to tests/phpunit/tests/oembed/wpFilterOembedIframeTitleAttributes.php index 29d22f838af79..83cc4b5f3ca38 100644 --- a/tests/phpunit/tests/oembed/filterTitleAttributes.php +++ b/tests/phpunit/tests/oembed/wpFilterOembedIframeTitleAttributes.php @@ -2,9 +2,21 @@ /** * @group oembed + * + * @covers ::wp_filter_oembed_iframe_title_attribute */ -class Tests_Filter_oEmbed_Iframe_Title_Attribute extends WP_UnitTestCase { - public function data_filter_oembed_iframe_title_attribute() { +class Tests_oEmbed_wpFilterOembedIframeTitleAttribute extends WP_UnitTestCase { + + /** + * @dataProvider data_oembed_iframe_title_attribute + */ + public function test_oembed_iframe_title_attribute( $html, $oembed_data, $url, $expected ) { + $actual = wp_filter_oembed_iframe_title_attribute( $html, (object) $oembed_data, $url ); + + $this->assertEqualHTML( $expected, $actual ); + } + + public function data_oembed_iframe_title_attribute() { return array( array( '

    Foo

    Bar', @@ -61,15 +73,6 @@ public function data_filter_oembed_iframe_title_attribute() { ); } - /** - * @dataProvider data_filter_oembed_iframe_title_attribute - */ - public function test_oembed_iframe_title_attribute( $html, $oembed_data, $url, $expected ) { - $actual = wp_filter_oembed_iframe_title_attribute( $html, (object) $oembed_data, $url ); - - $this->assertEqualHTML( $expected, $actual ); - } - public function test_filter_oembed_iframe_title_attribute() { add_filter( 'oembed_iframe_title_attribute', array( $this, '_filter_oembed_iframe_title_attribute' ) ); diff --git a/tests/phpunit/tests/oembed/filterResult.php b/tests/phpunit/tests/oembed/wpFilterOembedResult.php similarity index 98% rename from tests/phpunit/tests/oembed/filterResult.php rename to tests/phpunit/tests/oembed/wpFilterOembedResult.php index 10dbe0e4ea017..3649d7210b58a 100644 --- a/tests/phpunit/tests/oembed/filterResult.php +++ b/tests/phpunit/tests/oembed/wpFilterOembedResult.php @@ -2,8 +2,11 @@ /** * @group oembed + * + * @covers ::wp_filter_oembed_result */ -class Tests_Filter_oEmbed_Result extends WP_UnitTestCase { +class Tests_oEmbed_wpFilterOembedResult extends WP_UnitTestCase { + public function test_filter_oembed_result_trusted_malicious_iframe() { $html = '

    '; @@ -154,6 +157,19 @@ public function test_filter_oembed_result_allowed_html() { $this->assertEqualHTML( '
    ', $actual ); } + /** + * @dataProvider data_wp_filter_pre_oembed_custom_result + */ + public function test_wp_filter_pre_oembed_custom_result( $html, $expected ) { + $data = (object) array( + 'type' => 'rich', + 'title' => 'Hola', + 'html' => $html, + ); + $actual = _wp_oembed_get_object()->data2html( $data, 'https://untrusted.localhost' ); + $this->assertEqualHTML( $expected, $actual ); + } + public function data_wp_filter_pre_oembed_custom_result() { return array( array( @@ -175,19 +191,6 @@ public function data_wp_filter_pre_oembed_custom_result() { ); } - /** - * @dataProvider data_wp_filter_pre_oembed_custom_result - */ - public function test_wp_filter_pre_oembed_custom_result( $html, $expected ) { - $data = (object) array( - 'type' => 'rich', - 'title' => 'Hola', - 'html' => $html, - ); - $actual = _wp_oembed_get_object()->data2html( $data, 'https://untrusted.localhost' ); - $this->assertEqualHTML( $expected, $actual ); - } - /** * @group feed */ From 4bdcb352d932ca57c98f9c96edb580cbfa17a7b8 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sun, 12 Apr 2026 14:25:32 +0000 Subject: [PATCH 093/104] Tests: Expand `@covers` tags for `WP_oEmbed` tests. Follow-up to [62223]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62228 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/oembed/wpOembed.php | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/oembed/wpOembed.php b/tests/phpunit/tests/oembed/wpOembed.php index 43324870350e6..288d1742d373e 100644 --- a/tests/phpunit/tests/oembed/wpOembed.php +++ b/tests/phpunit/tests/oembed/wpOembed.php @@ -3,7 +3,7 @@ /** * @group oembed * - * @covers WP_oEmbed + * @coversDefaultClass WP_oEmbed */ class Tests_WP_oEmbed extends WP_UnitTestCase { /** @@ -49,6 +49,9 @@ public function _filter_pre_oembed_result( $result ) { return $result ? $result : false; } + /** + * @covers ::get_html + */ public function test_wp_filter_pre_oembed_result_prevents_http_request_for_internal_permalinks() { $post_id = self::factory()->post->create(); $permalink = get_permalink( $post_id ); @@ -61,6 +64,9 @@ public function test_wp_filter_pre_oembed_result_prevents_http_request_for_inter $this->assertSame( $this->pre_oembed_result_filtered, $actual ); } + /** + * @covers ::get_html + */ public function test_wp_filter_pre_oembed_result_prevents_http_request_when_viewing_the_post() { $post_id = self::factory()->post->create(); $permalink = get_permalink( $post_id ); @@ -76,6 +82,9 @@ public function test_wp_filter_pre_oembed_result_prevents_http_request_when_view $this->assertSame( $this->pre_oembed_result_filtered, $actual ); } + /** + * @covers ::get_html + */ public function test_wp_filter_pre_oembed_result_non_existent_post() { $post_id = self::factory()->post->create(); $permalink = get_permalink( $post_id ); @@ -95,6 +104,8 @@ public function test_wp_filter_pre_oembed_result_non_existent_post() { * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_root_root() { $post_id = self::factory()->post->create(); @@ -112,6 +123,8 @@ public function test_wp_filter_pre_oembed_result_multisite_root_root() { * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_sub_samesub() { $user_id = self::$user_id; @@ -141,6 +154,8 @@ public function test_wp_filter_pre_oembed_result_multisite_sub_samesub() { * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_sub_othersub() { $user_id = self::$user_id; @@ -178,6 +193,8 @@ public function test_wp_filter_pre_oembed_result_multisite_sub_othersub() { * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_sub_main() { $post_id = self::factory()->post->create(); @@ -205,6 +222,8 @@ public function test_wp_filter_pre_oembed_result_multisite_sub_main() { * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_preserves_switched_state() { $user_id = self::$user_id; @@ -234,6 +253,8 @@ public function test_wp_filter_pre_oembed_result_multisite_preserves_switched_st * @ticket 40673 * @group multisite * @group ms-required + * + * @covers ::get_html */ public function test_wp_filter_pre_oembed_result_multisite_restores_state_if_no_post_is_found() { $current_blog_id = get_current_blog_id(); From aa72dfed4432cfd875f77d2c475f772c6623cae5 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Mon, 13 Apr 2026 07:29:55 +0000 Subject: [PATCH 094/104] Tests: Expand `@covers` tags for `WP_Embed` tests. Includes removing the `external-http` group for a `WP_Embed::run_shortcode()` test which does not perform any HTTP requests. Follow-up to [50448], [62223], [62228]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62229 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/oembed/WpEmbed.php | 65 +++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/oembed/WpEmbed.php b/tests/phpunit/tests/oembed/WpEmbed.php index 59a1b37dd9e81..4c77e904399db 100644 --- a/tests/phpunit/tests/oembed/WpEmbed.php +++ b/tests/phpunit/tests/oembed/WpEmbed.php @@ -3,7 +3,7 @@ /** * @group oembed * - * @covers WP_Embed + * @coversDefaultClass WP_Embed */ class Tests_WP_Embed extends WP_UnitTestCase { /** @@ -24,11 +24,17 @@ public function _pre_oembed_result_callback() { return 'Embedded content'; } + /** + * @covers ::maybe_run_ajax_cache + */ public function test_maybe_run_ajax_cache_should_return_nothing_if_there_is_no_post() { $this->expectOutputString( '' ); $this->wp_embed->maybe_run_ajax_cache(); } + /** + * @covers ::maybe_run_ajax_cache + */ public function test_maybe_run_ajax_cache_should_return_nothing_if_there_is_no_message() { $GLOBALS['post'] = self::factory()->post->create_and_get( array( @@ -42,6 +48,9 @@ public function test_maybe_run_ajax_cache_should_return_nothing_if_there_is_no_m unset( $GLOBALS['post'] ); } + /** + * @covers ::maybe_run_ajax_cache + */ public function test_maybe_run_ajax_cache_should_return_javascript() { $GLOBALS['post'] = self::factory()->post->create_and_get( array( @@ -59,6 +68,9 @@ public function test_maybe_run_ajax_cache_should_return_javascript() { $this->assertStringContainsString( $url, $actual ); } + /** + * @covers ::wp_maybe_load_embeds + */ public function test_wp_maybe_load_embeds() { $this->assertSameSets( array( 10, 9999 ), array_keys( $GLOBALS['wp_embed']->handlers ) ); $this->assertSameSets( @@ -76,6 +88,9 @@ public function test_wp_maybe_load_embeds() { ); } + /** + * @covers ::wp_embed_register_handler + */ public function test_wp_embed_register_handler() { $handle = __FUNCTION__; $regex = '#https?://example\.com/embed/([^/]+)#i'; @@ -94,6 +109,9 @@ public function test_wp_embed_register_handler() { $this->assertContains( $expected, $actual ); } + /** + * @covers ::wp_embed_unregister_handler + */ public function test_wp_embed_unregister_handler() { $this->assertArrayHasKey( 'youtube_embed_url', $GLOBALS['wp_embed']->handlers[10] ); @@ -109,6 +127,8 @@ public function test_wp_embed_unregister_handler() { /** * @group external-http + * + * @covers ::autoembed */ public function test_autoembed_should_do_nothing_without_matching_handler() { $content = "\nhttp://example.com/embed/foo\n"; @@ -119,6 +139,8 @@ public function test_autoembed_should_do_nothing_without_matching_handler() { /** * @group external-http + * + * @covers ::autoembed */ public function test_autoembed_should_return_modified_content() { $handle = __FUNCTION__; @@ -135,6 +157,9 @@ public function test_autoembed_should_return_modified_content() { $this->assertSame( "\nEmbedded http://example.com/embed/foo\n", $actual ); } + /** + * @covers ::delete_oembed_caches + */ public function test_delete_oembed_caches() { $post_id = self::factory()->post->create(); @@ -148,6 +173,9 @@ public function test_delete_oembed_caches() { $this->assertSame( array(), get_post_meta( $post_id, '_oembed_baz' ) ); } + /** + * @covers ::cache_oembed + */ public function test_cache_oembed_invalid_post_type() { $post_id = self::factory()->post->create( array( 'post_type' => 'nav_menu_item' ) ); @@ -155,6 +183,9 @@ public function test_cache_oembed_invalid_post_type() { $this->assertNotSame( $post_id, $this->wp_embed->post_ID ); } + /** + * @covers ::cache_oembed + */ public function test_cache_oembed_empty_content() { $post_id = self::factory()->post->create( array( 'post_content' => '' ) ); @@ -162,6 +193,9 @@ public function test_cache_oembed_empty_content() { $this->assertNotSame( $post_id, $this->wp_embed->post_ID ); } + /** + * @covers ::cache_oembed + */ public function test_cache_oembed_for_post() { $url = 'https://example.com/'; $expected = 'Embedded content'; @@ -180,6 +214,9 @@ public function test_cache_oembed_for_post() { $this->assertNotEmpty( get_post_meta( $post_id, $cachekey_time, true ) ); } + /** + * @covers ::shortcode + */ public function test_shortcode_should_get_cached_data_from_post_meta_for_known_post() { global $post; @@ -207,6 +244,9 @@ public function test_shortcode_should_get_cached_data_from_post_meta_for_known_p $this->assertSame( $expected, $cached ); } + /** + * @covers ::shortcode + */ public function test_shortcode_should_get_cached_failure_from_post_meta_for_known_post() { global $post; @@ -241,6 +281,8 @@ public function test_shortcode_should_get_cached_failure_from_post_meta_for_know /** * @ticket 34115 + * + * @covers ::shortcode */ public function test_shortcode_should_cache_data_in_custom_post() { $url = 'https://example.com/'; @@ -267,6 +309,8 @@ public function test_shortcode_should_cache_data_in_custom_post() { /** * @ticket 34115 + * + * @covers ::shortcode */ public function test_shortcode_should_cache_failure_in_custom_post() { $url = 'https://example.com/'; @@ -295,6 +339,8 @@ public function test_shortcode_should_cache_failure_in_custom_post() { * Test that parsing an embed shortcode should cause oembed_cache to be updated. * * @ticket 42310 + * + * @covers ::shortcode */ public function test_shortcode_should_update_custom_post() { add_filter( 'oembed_ttl', '__return_zero' ); @@ -327,6 +373,8 @@ public function test_shortcode_should_update_custom_post() { /** * @group external-http + * + * @covers ::shortcode */ public function test_shortcode_should_get_url_from_src_attribute() { $url = 'http://example.com/embed/foo'; @@ -337,6 +385,8 @@ public function test_shortcode_should_get_url_from_src_attribute() { /** * @group external-http + * + * @covers ::shortcode */ public function test_shortcode_should_return_empty_string_for_missing_url() { $this->assertEmpty( $this->wp_embed->shortcode( array() ) ); @@ -344,6 +394,8 @@ public function test_shortcode_should_return_empty_string_for_missing_url() { /** * @group external-http + * + * @covers ::shortcode */ public function test_shortcode_should_make_link_for_unknown_url() { $url = 'http://example.com/embed/foo'; @@ -353,7 +405,7 @@ public function test_shortcode_should_make_link_for_unknown_url() { } /** - * @group external-http + * @covers ::run_shortcode */ public function test_run_shortcode_url_only() { $url = 'http://example.com/embed/foo'; @@ -361,6 +413,9 @@ public function test_run_shortcode_url_only() { $this->assertSame( '' . esc_html( $url ) . '', $actual ); } + /** + * @covers ::maybe_make_link + */ public function test_maybe_make_link() { $url = 'http://example.com/embed/foo'; $actual = $this->wp_embed->maybe_make_link( $url ); @@ -368,11 +423,17 @@ public function test_maybe_make_link() { $this->assertSame( '' . esc_html( $url ) . '', $actual ); } + /** + * @covers ::maybe_make_link + */ public function test_maybe_make_link_return_false_on_fail() { $this->wp_embed->return_false_on_fail = true; $this->assertFalse( $this->wp_embed->maybe_make_link( 'http://example.com/' ) ); } + /** + * @covers ::maybe_make_link + */ public function test_maybe_make_link_do_not_link_if_unknown() { $url = 'http://example.com/'; From 281692378f2d68216ea68c82cc62302e7e20c216 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 14 Apr 2026 10:12:12 +0000 Subject: [PATCH 095/104] Upgrade/Install: Use new default admin color scheme for links on the setup screen. This changeset updates the link colors on the setup screen and the default `wp_die()` fallback styles to use the new default admin color scheme. Props audrasjb, darshitrajyaguru97, dhrumilk, hbhalodia, huzaifaalmesbah, ismail0071, mikinc860, pooja-n, shailu25, sumitsingh, vishitshah, wildworks Fixes #64962. See #64308. git-svn-id: https://develop.svn.wordpress.org/trunk@62230 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/install.css | 6 +++--- src/wp-includes/functions.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/css/install.css b/src/wp-admin/css/install.css index 71ea71c7d2863..9476749dd7cf2 100644 --- a/src/wp-admin/css/install.css +++ b/src/wp-admin/css/install.css @@ -16,16 +16,16 @@ body { } a { - color: #2271b1; + color: var(--wp-admin-theme-color); } a:hover, a:active { - color: #135e96; + color: var(--wp-admin-theme-color-darker-20); } a:focus { - color: #043959; + color: var(--wp-admin-theme-color-darker-20); border-radius: 2px; box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); /* Only visible in Windows High Contrast mode */ diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 85b6043b0b5c8..7d71c8c56963d 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -3975,14 +3975,14 @@ function _default_wp_die_handler( $message, $title = '', $args = array() ) { font-size: 14px ; } a { - color: #2271b1; + color: #3858e9; } a:hover, a:active { - color: #135e96; + color: #183ad6; } a:focus { - color: #043959; + color: #183ad6; box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); outline: 2px solid transparent; } From 5accf7de0ce5078b47e5941aa32b2e560eae80af Mon Sep 17 00:00:00 2001 From: ramonopoly Date: Tue, 14 Apr 2026 10:25:31 +0000 Subject: [PATCH 096/104] Disable pings/trackbacks for local, development, and staging environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When WP_ENVIRONMENT_TYPE is not `production`, disable pingbacks and trackbacks. Otherwise, when `WP_ENVIRONMENT_TYPE` is `local`, `development`, or `staging`, pingbacks and trackbacks are sent when posts are published. This creates confusion on the receiving end and is unnecessary for testing workflows. Props arcangelini, cagrimmett, ramonopoly, tyxla. Fixes #64837. git-svn-id: https://develop.svn.wordpress.org/trunk@62231 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/comment.php | 78 ++++++ src/wp-includes/default-filters.php | 6 + .../comment/disablePingsForEnvironment.php | 254 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 tests/phpunit/tests/comment/disablePingsForEnvironment.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 5395997ecd0ef..70d5c03b378f4 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3163,6 +3163,84 @@ function generic_ping( $post_id = 0 ) { return $post_id; } +/** + * Determines whether pings should be disabled for the current environment. + * + * By default, all pings (outgoing pingbacks, trackbacks, and ping service + * notifications, as well as incoming pingbacks and trackbacks) are disabled + * for non-production environments ('local', 'development', 'staging'). + * + * @since 7.1.0 + * + * @return bool True if pings should be disabled, false otherwise. + */ +function wp_should_disable_pings_for_environment() { + $environment_type = wp_get_environment_type(); + $should_disable = 'production' !== $environment_type; + + /** + * Filters whether pings should be disabled for the current environment. + * + * Returning false re-enables pings in non-production environments. + * Returning true disables pings even in production. + * + * @since 7.1.0 + * + * @param bool $should_disable Whether pings should be disabled. Default true + * for non-production environments, false for production. + * @param string $environment_type The current environment type as returned by + * wp_get_environment_type(). + */ + return apply_filters( 'wp_should_disable_pings_for_environment', $should_disable, $environment_type ); +} + +/** + * Removes outgoing ping callbacks in non-production environments. + * + * Hooked to `do_all_pings` at priority 1 so it runs before the default + * priority 10 callbacks. Does not remove `do_all_enclosures`. + * + * @since 7.1.0 + */ +function wp_maybe_disable_outgoing_pings_for_environment() { + if ( wp_should_disable_pings_for_environment() ) { + remove_action( 'do_all_pings', 'do_all_pingbacks' ); + remove_action( 'do_all_pings', 'do_all_trackbacks' ); + remove_action( 'do_all_pings', 'generic_ping' ); + } +} + +/** + * Rejects incoming trackbacks in non-production environments. + * + * Hooked to `pre_trackback_post` which fires in `wp-trackback.php` before the + * trackback is processed. Calls `trackback_response()` which sends an XML error + * response and terminates the request. + * + * @since 7.1.0 + */ +function wp_maybe_disable_trackback_for_environment() { + if ( wp_should_disable_pings_for_environment() ) { + trackback_response( 1, __( 'Trackbacks are disabled in non-production environments.' ) ); + } +} + +/** + * Removes the pingback XML-RPC method in non-production environments. + * + * @since 7.1.0 + * + * @param string[] $methods An array of XML-RPC methods, keyed by their methodName. + * @return string[] Modified array of XML-RPC methods. + */ +function wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ) { + if ( wp_should_disable_pings_for_environment() ) { + unset( $methods['pingback.ping'] ); + } + + return $methods; +} + /** * Pings back the links found in a post. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4b6d9de25fa11..8beb3718f52f5 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -421,6 +421,12 @@ add_action( 'do_all_pings', 'do_all_enclosures', 10, 0 ); add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); add_action( 'do_all_pings', 'generic_ping', 10, 0 ); + +// Disable pings (pingbacks, trackbacks, and ping service notifications) in non-production environments. +add_action( 'do_all_pings', 'wp_maybe_disable_outgoing_pings_for_environment', 1, 0 ); +add_action( 'pre_trackback_post', 'wp_maybe_disable_trackback_for_environment', 10, 0 ); +add_filter( 'xmlrpc_methods', 'wp_maybe_disable_xmlrpc_pingback_for_environment' ); + add_action( 'do_robots', 'do_robots' ); add_action( 'do_favicon', 'do_favicon' ); add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer', 1000 ); // Late priority to let `wp_template_enhancement_output_buffer` filters and `wp_finalized_template_enhancement_output_buffer` actions be registered. diff --git a/tests/phpunit/tests/comment/disablePingsForEnvironment.php b/tests/phpunit/tests/comment/disablePingsForEnvironment.php new file mode 100644 index 0000000000000..88fbd3e55f434 --- /dev/null +++ b/tests/phpunit/tests/comment/disablePingsForEnvironment.php @@ -0,0 +1,254 @@ +original_env = getenv( 'WP_ENVIRONMENT_TYPE' ); + } + + public function tear_down() { + if ( false === $this->original_env ) { + putenv( 'WP_ENVIRONMENT_TYPE' ); + } else { + putenv( 'WP_ENVIRONMENT_TYPE=' . $this->original_env ); + } + parent::tear_down(); + } + + /** + * @ticket 64837 + */ + public function test_should_disable_returns_true_for_local() { + putenv( 'WP_ENVIRONMENT_TYPE=local' ); + $this->assertTrue( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_should_disable_returns_true_for_development() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + $this->assertTrue( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_should_disable_returns_true_for_staging() { + putenv( 'WP_ENVIRONMENT_TYPE=staging' ); + $this->assertTrue( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_should_disable_returns_false_for_production() { + putenv( 'WP_ENVIRONMENT_TYPE=production' ); + $this->assertFalse( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_filter_can_enable_pings_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=local' ); + add_filter( 'wp_should_disable_pings_for_environment', '__return_false' ); + + $this->assertFalse( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_filter_can_disable_pings_in_production() { + putenv( 'WP_ENVIRONMENT_TYPE=production' ); + add_filter( 'wp_should_disable_pings_for_environment', '__return_true' ); + + $this->assertTrue( wp_should_disable_pings_for_environment() ); + } + + /** + * @ticket 64837 + */ + public function test_filter_receives_environment_type() { + putenv( 'WP_ENVIRONMENT_TYPE=staging' ); + + $received_type = null; + add_filter( + 'wp_should_disable_pings_for_environment', + function ( $should_disable, $environment_type ) use ( &$received_type ) { + $received_type = $environment_type; + return $should_disable; + }, + 10, + 2 + ); + + wp_should_disable_pings_for_environment(); + + $this->assertSame( 'staging', $received_type ); + } + + /** + * @ticket 64837 + */ + public function test_outgoing_pingbacks_removed_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + // Re-register the defaults to ensure a clean state. + add_action( 'do_all_pings', 'do_all_pingbacks', 10, 0 ); + + // Fire the priority-1 callback. + wp_maybe_disable_outgoing_pings_for_environment(); + + $this->assertFalse( has_action( 'do_all_pings', 'do_all_pingbacks' ) ); + } + + /** + * @ticket 64837 + */ + public function test_outgoing_trackbacks_removed_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); + + wp_maybe_disable_outgoing_pings_for_environment(); + + $this->assertFalse( has_action( 'do_all_pings', 'do_all_trackbacks' ) ); + } + + /** + * @ticket 64837 + */ + public function test_outgoing_generic_ping_removed_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + add_action( 'do_all_pings', 'generic_ping', 10, 0 ); + + wp_maybe_disable_outgoing_pings_for_environment(); + + $this->assertFalse( has_action( 'do_all_pings', 'generic_ping' ) ); + } + + /** + * @ticket 64837 + */ + public function test_enclosures_not_removed_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + add_action( 'do_all_pings', 'do_all_enclosures', 10, 0 ); + + wp_maybe_disable_outgoing_pings_for_environment(); + + $this->assertTrue( has_action( 'do_all_pings', 'do_all_enclosures', 10 ) ); + } + + /** + * @ticket 64837 + */ + public function test_outgoing_pings_preserved_in_production() { + putenv( 'WP_ENVIRONMENT_TYPE=production' ); + + add_action( 'do_all_pings', 'do_all_pingbacks', 10, 0 ); + add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); + add_action( 'do_all_pings', 'generic_ping', 10, 0 ); + + wp_maybe_disable_outgoing_pings_for_environment(); + + $this->assertTrue( has_action( 'do_all_pings', 'do_all_pingbacks', 10 ), 'do_all_pingbacks should still be hooked at priority 10.' ); + $this->assertTrue( has_action( 'do_all_pings', 'do_all_trackbacks', 10 ), 'do_all_trackbacks should still be hooked at priority 10.' ); + $this->assertTrue( has_action( 'do_all_pings', 'generic_ping', 10 ), 'generic_ping should still be hooked at priority 10.' ); + } + + /** + * @ticket 64837 + */ + public function test_trackback_hook_is_registered() { + $this->assertTrue( has_action( 'pre_trackback_post', 'wp_maybe_disable_trackback_for_environment', 10 ) ); + } + + /** + * @ticket 64837 + */ + public function test_pings_open_unaffected_by_environment() { + putenv( 'WP_ENVIRONMENT_TYPE=local' ); + + $post = self::factory()->post->create_and_get( + array( 'ping_status' => 'open' ) + ); + + $this->assertTrue( pings_open( $post ) ); + } + + /** + * @ticket 64837 + */ + public function test_xmlrpc_pingback_removed_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + $methods = array( + 'pingback.ping' => 'this:pingback_ping', + 'pingback.extensions.getPingbacks' => 'this:pingback_extensions_getPingbacks', + 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', + ); + + $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); + + $this->assertArrayNotHasKey( 'pingback.ping', $filtered ); + } + + /** + * @ticket 64837 + */ + public function test_xmlrpc_pingback_preserved_in_production() { + putenv( 'WP_ENVIRONMENT_TYPE=production' ); + + $methods = array( + 'pingback.ping' => 'this:pingback_ping', + 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', + ); + + $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); + + $this->assertArrayHasKey( 'pingback.ping', $filtered ); + } + + /** + * @ticket 64837 + */ + public function test_xmlrpc_other_methods_preserved_in_non_production() { + putenv( 'WP_ENVIRONMENT_TYPE=development' ); + + $methods = array( + 'pingback.ping' => 'this:pingback_ping', + 'pingback.extensions.getPingbacks' => 'this:pingback_extensions_getPingbacks', + 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', + 'wp.getPost' => 'this:wp_getPost', + ); + + $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); + + $this->assertArrayHasKey( 'pingback.extensions.getPingbacks', $filtered ); + $this->assertArrayHasKey( 'wp.getUsersBlogs', $filtered ); + $this->assertArrayHasKey( 'wp.getPost', $filtered ); + } +} From 8e3998b60e8a93a7626da1825aa919664275234c Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 14 Apr 2026 10:45:04 +0000 Subject: [PATCH 097/104] Administration: Fix focus outline being cut off for the metabox collapse and move buttons. Fixes an issue where the focus outline on metabox collapse buttons and move handles was being clipped. Props abcd95, audrasjb, brianhogg, darshitrajyaguru97, poena, wildworks. Fixes #65060. git-svn-id: https://develop.svn.wordpress.org/trunk@62232 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index b317af45e023e..4c18ab586c359 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -3340,7 +3340,7 @@ img { .postbox .handle-order-higher:focus, .postbox .handle-order-lower:focus, .postbox .handlediv:focus { - box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color); + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color); border-radius: 50%; /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; From d7a80fc1887fc2a94560ee54e6a74e6fdce800d7 Mon Sep 17 00:00:00 2001 From: ramonopoly Date: Tue, 14 Apr 2026 11:37:02 +0000 Subject: [PATCH 098/104] REVERT r62231: Disable pings/trackbacks for local, development, and staging environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits to trunk have been paused for 7.0. See: https://make.wordpress.org/core/2026/04/02/the-path-forward-for-wordpress-7-0/ Props arcangelini, cagrimmett, ramonopoly, tyxla, ocean90, khushipatel15. Follow-up to [64837]. See #64837. git-svn-id: https://develop.svn.wordpress.org/trunk@62233 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/comment.php | 78 ------ src/wp-includes/default-filters.php | 6 - .../comment/disablePingsForEnvironment.php | 254 ------------------ 3 files changed, 338 deletions(-) delete mode 100644 tests/phpunit/tests/comment/disablePingsForEnvironment.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 70d5c03b378f4..5395997ecd0ef 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3163,84 +3163,6 @@ function generic_ping( $post_id = 0 ) { return $post_id; } -/** - * Determines whether pings should be disabled for the current environment. - * - * By default, all pings (outgoing pingbacks, trackbacks, and ping service - * notifications, as well as incoming pingbacks and trackbacks) are disabled - * for non-production environments ('local', 'development', 'staging'). - * - * @since 7.1.0 - * - * @return bool True if pings should be disabled, false otherwise. - */ -function wp_should_disable_pings_for_environment() { - $environment_type = wp_get_environment_type(); - $should_disable = 'production' !== $environment_type; - - /** - * Filters whether pings should be disabled for the current environment. - * - * Returning false re-enables pings in non-production environments. - * Returning true disables pings even in production. - * - * @since 7.1.0 - * - * @param bool $should_disable Whether pings should be disabled. Default true - * for non-production environments, false for production. - * @param string $environment_type The current environment type as returned by - * wp_get_environment_type(). - */ - return apply_filters( 'wp_should_disable_pings_for_environment', $should_disable, $environment_type ); -} - -/** - * Removes outgoing ping callbacks in non-production environments. - * - * Hooked to `do_all_pings` at priority 1 so it runs before the default - * priority 10 callbacks. Does not remove `do_all_enclosures`. - * - * @since 7.1.0 - */ -function wp_maybe_disable_outgoing_pings_for_environment() { - if ( wp_should_disable_pings_for_environment() ) { - remove_action( 'do_all_pings', 'do_all_pingbacks' ); - remove_action( 'do_all_pings', 'do_all_trackbacks' ); - remove_action( 'do_all_pings', 'generic_ping' ); - } -} - -/** - * Rejects incoming trackbacks in non-production environments. - * - * Hooked to `pre_trackback_post` which fires in `wp-trackback.php` before the - * trackback is processed. Calls `trackback_response()` which sends an XML error - * response and terminates the request. - * - * @since 7.1.0 - */ -function wp_maybe_disable_trackback_for_environment() { - if ( wp_should_disable_pings_for_environment() ) { - trackback_response( 1, __( 'Trackbacks are disabled in non-production environments.' ) ); - } -} - -/** - * Removes the pingback XML-RPC method in non-production environments. - * - * @since 7.1.0 - * - * @param string[] $methods An array of XML-RPC methods, keyed by their methodName. - * @return string[] Modified array of XML-RPC methods. - */ -function wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ) { - if ( wp_should_disable_pings_for_environment() ) { - unset( $methods['pingback.ping'] ); - } - - return $methods; -} - /** * Pings back the links found in a post. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 8beb3718f52f5..4b6d9de25fa11 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -421,12 +421,6 @@ add_action( 'do_all_pings', 'do_all_enclosures', 10, 0 ); add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); add_action( 'do_all_pings', 'generic_ping', 10, 0 ); - -// Disable pings (pingbacks, trackbacks, and ping service notifications) in non-production environments. -add_action( 'do_all_pings', 'wp_maybe_disable_outgoing_pings_for_environment', 1, 0 ); -add_action( 'pre_trackback_post', 'wp_maybe_disable_trackback_for_environment', 10, 0 ); -add_filter( 'xmlrpc_methods', 'wp_maybe_disable_xmlrpc_pingback_for_environment' ); - add_action( 'do_robots', 'do_robots' ); add_action( 'do_favicon', 'do_favicon' ); add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer', 1000 ); // Late priority to let `wp_template_enhancement_output_buffer` filters and `wp_finalized_template_enhancement_output_buffer` actions be registered. diff --git a/tests/phpunit/tests/comment/disablePingsForEnvironment.php b/tests/phpunit/tests/comment/disablePingsForEnvironment.php deleted file mode 100644 index 88fbd3e55f434..0000000000000 --- a/tests/phpunit/tests/comment/disablePingsForEnvironment.php +++ /dev/null @@ -1,254 +0,0 @@ -original_env = getenv( 'WP_ENVIRONMENT_TYPE' ); - } - - public function tear_down() { - if ( false === $this->original_env ) { - putenv( 'WP_ENVIRONMENT_TYPE' ); - } else { - putenv( 'WP_ENVIRONMENT_TYPE=' . $this->original_env ); - } - parent::tear_down(); - } - - /** - * @ticket 64837 - */ - public function test_should_disable_returns_true_for_local() { - putenv( 'WP_ENVIRONMENT_TYPE=local' ); - $this->assertTrue( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_should_disable_returns_true_for_development() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - $this->assertTrue( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_should_disable_returns_true_for_staging() { - putenv( 'WP_ENVIRONMENT_TYPE=staging' ); - $this->assertTrue( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_should_disable_returns_false_for_production() { - putenv( 'WP_ENVIRONMENT_TYPE=production' ); - $this->assertFalse( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_filter_can_enable_pings_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=local' ); - add_filter( 'wp_should_disable_pings_for_environment', '__return_false' ); - - $this->assertFalse( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_filter_can_disable_pings_in_production() { - putenv( 'WP_ENVIRONMENT_TYPE=production' ); - add_filter( 'wp_should_disable_pings_for_environment', '__return_true' ); - - $this->assertTrue( wp_should_disable_pings_for_environment() ); - } - - /** - * @ticket 64837 - */ - public function test_filter_receives_environment_type() { - putenv( 'WP_ENVIRONMENT_TYPE=staging' ); - - $received_type = null; - add_filter( - 'wp_should_disable_pings_for_environment', - function ( $should_disable, $environment_type ) use ( &$received_type ) { - $received_type = $environment_type; - return $should_disable; - }, - 10, - 2 - ); - - wp_should_disable_pings_for_environment(); - - $this->assertSame( 'staging', $received_type ); - } - - /** - * @ticket 64837 - */ - public function test_outgoing_pingbacks_removed_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - // Re-register the defaults to ensure a clean state. - add_action( 'do_all_pings', 'do_all_pingbacks', 10, 0 ); - - // Fire the priority-1 callback. - wp_maybe_disable_outgoing_pings_for_environment(); - - $this->assertFalse( has_action( 'do_all_pings', 'do_all_pingbacks' ) ); - } - - /** - * @ticket 64837 - */ - public function test_outgoing_trackbacks_removed_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); - - wp_maybe_disable_outgoing_pings_for_environment(); - - $this->assertFalse( has_action( 'do_all_pings', 'do_all_trackbacks' ) ); - } - - /** - * @ticket 64837 - */ - public function test_outgoing_generic_ping_removed_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - add_action( 'do_all_pings', 'generic_ping', 10, 0 ); - - wp_maybe_disable_outgoing_pings_for_environment(); - - $this->assertFalse( has_action( 'do_all_pings', 'generic_ping' ) ); - } - - /** - * @ticket 64837 - */ - public function test_enclosures_not_removed_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - add_action( 'do_all_pings', 'do_all_enclosures', 10, 0 ); - - wp_maybe_disable_outgoing_pings_for_environment(); - - $this->assertTrue( has_action( 'do_all_pings', 'do_all_enclosures', 10 ) ); - } - - /** - * @ticket 64837 - */ - public function test_outgoing_pings_preserved_in_production() { - putenv( 'WP_ENVIRONMENT_TYPE=production' ); - - add_action( 'do_all_pings', 'do_all_pingbacks', 10, 0 ); - add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); - add_action( 'do_all_pings', 'generic_ping', 10, 0 ); - - wp_maybe_disable_outgoing_pings_for_environment(); - - $this->assertTrue( has_action( 'do_all_pings', 'do_all_pingbacks', 10 ), 'do_all_pingbacks should still be hooked at priority 10.' ); - $this->assertTrue( has_action( 'do_all_pings', 'do_all_trackbacks', 10 ), 'do_all_trackbacks should still be hooked at priority 10.' ); - $this->assertTrue( has_action( 'do_all_pings', 'generic_ping', 10 ), 'generic_ping should still be hooked at priority 10.' ); - } - - /** - * @ticket 64837 - */ - public function test_trackback_hook_is_registered() { - $this->assertTrue( has_action( 'pre_trackback_post', 'wp_maybe_disable_trackback_for_environment', 10 ) ); - } - - /** - * @ticket 64837 - */ - public function test_pings_open_unaffected_by_environment() { - putenv( 'WP_ENVIRONMENT_TYPE=local' ); - - $post = self::factory()->post->create_and_get( - array( 'ping_status' => 'open' ) - ); - - $this->assertTrue( pings_open( $post ) ); - } - - /** - * @ticket 64837 - */ - public function test_xmlrpc_pingback_removed_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - $methods = array( - 'pingback.ping' => 'this:pingback_ping', - 'pingback.extensions.getPingbacks' => 'this:pingback_extensions_getPingbacks', - 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', - ); - - $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); - - $this->assertArrayNotHasKey( 'pingback.ping', $filtered ); - } - - /** - * @ticket 64837 - */ - public function test_xmlrpc_pingback_preserved_in_production() { - putenv( 'WP_ENVIRONMENT_TYPE=production' ); - - $methods = array( - 'pingback.ping' => 'this:pingback_ping', - 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', - ); - - $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); - - $this->assertArrayHasKey( 'pingback.ping', $filtered ); - } - - /** - * @ticket 64837 - */ - public function test_xmlrpc_other_methods_preserved_in_non_production() { - putenv( 'WP_ENVIRONMENT_TYPE=development' ); - - $methods = array( - 'pingback.ping' => 'this:pingback_ping', - 'pingback.extensions.getPingbacks' => 'this:pingback_extensions_getPingbacks', - 'wp.getUsersBlogs' => 'this:wp_getUsersBlogs', - 'wp.getPost' => 'this:wp_getPost', - ); - - $filtered = wp_maybe_disable_xmlrpc_pingback_for_environment( $methods ); - - $this->assertArrayHasKey( 'pingback.extensions.getPingbacks', $filtered ); - $this->assertArrayHasKey( 'wp.getUsersBlogs', $filtered ); - $this->assertArrayHasKey( 'wp.getPost', $filtered ); - } -} From 5044e953df6e6bc058f8c6bf2eb9e81e4889a9b1 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 14 Apr 2026 22:29:29 +0000 Subject: [PATCH 099/104] Tests: Rename some oEmbed test classes as per the naming conventions. Follow-up to [37708], [37892], [62224], [62227]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62234 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/oembed/WpEmbed.php | 2 +- tests/phpunit/tests/oembed/wpOembed.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/oembed/WpEmbed.php b/tests/phpunit/tests/oembed/WpEmbed.php index 4c77e904399db..42d9c9e0f4ed9 100644 --- a/tests/phpunit/tests/oembed/WpEmbed.php +++ b/tests/phpunit/tests/oembed/WpEmbed.php @@ -5,7 +5,7 @@ * * @coversDefaultClass WP_Embed */ -class Tests_WP_Embed extends WP_UnitTestCase { +class Tests_oEmbed_WpEmbed extends WP_UnitTestCase { /** * @var WP_Embed */ diff --git a/tests/phpunit/tests/oembed/wpOembed.php b/tests/phpunit/tests/oembed/wpOembed.php index 288d1742d373e..bc10c2a10a7eb 100644 --- a/tests/phpunit/tests/oembed/wpOembed.php +++ b/tests/phpunit/tests/oembed/wpOembed.php @@ -5,7 +5,7 @@ * * @coversDefaultClass WP_oEmbed */ -class Tests_WP_oEmbed extends WP_UnitTestCase { +class Tests_oEmbed_wpOembed extends WP_UnitTestCase { /** * @var WP_oEmbed */ From bf4c17409433cc14a37aa0ad7bf10982a4b8ebe6 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 15 Apr 2026 22:27:45 +0000 Subject: [PATCH 100/104] Tests: Add missing `@covers` tags for some multisite tests. Follow-up to [62213], [62218], [62222]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62237 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/multisite/getBlogDetails.php | 2 ++ tests/phpunit/tests/multisite/updateBlogDetails.php | 2 ++ tests/phpunit/tests/multisite/updateBlogStatus.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/phpunit/tests/multisite/getBlogDetails.php b/tests/phpunit/tests/multisite/getBlogDetails.php index 5a374d43dc69a..19a8520c2c887 100644 --- a/tests/phpunit/tests/multisite/getBlogDetails.php +++ b/tests/phpunit/tests/multisite/getBlogDetails.php @@ -5,6 +5,8 @@ * @group ms-required * @group ms-site * @group multisite + * + * @covers ::get_blog_details */ class Tests_Multisite_GetBlogDetails extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/updateBlogDetails.php b/tests/phpunit/tests/multisite/updateBlogDetails.php index 8800e66818684..62c0f7b355cd7 100644 --- a/tests/phpunit/tests/multisite/updateBlogDetails.php +++ b/tests/phpunit/tests/multisite/updateBlogDetails.php @@ -4,6 +4,8 @@ * @group ms-required * @group ms-site * @group multisite + * + * @covers ::update_blog_details */ class Tests_Multisite_UpdateBlogDetails extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/multisite/updateBlogStatus.php b/tests/phpunit/tests/multisite/updateBlogStatus.php index 069eddd984abb..20cd90307fd34 100644 --- a/tests/phpunit/tests/multisite/updateBlogStatus.php +++ b/tests/phpunit/tests/multisite/updateBlogStatus.php @@ -4,6 +4,8 @@ * @group ms-required * @group ms-site * @group multisite + * + * @covers ::update_blog_status */ class Tests_Multisite_UpdateBlogStatus extends WP_UnitTestCase { From 42388b5720be7aaa3e79b653cc46c92a3d5c75b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Thu, 16 Apr 2026 07:22:23 +0000 Subject: [PATCH 101/104] Abilities API: Catch exceptions thrown by ability callbacks and return WP_Error. Wraps `invoke_callback()` in a try/catch so that exceptions thrown by execute or permission callbacks are converted to a `WP_Error` with the `ability_callback_exception` code instead of propagating as uncaught throwables. Developed in: https://github.com/WordPress/wordpress-develop/pull/11544 Props priyankagusani, jamesgiroux, jeffpaul, dkotter, adamsilverstein, justlevine, jorbin, pavanpatil1. Fixes #65058. git-svn-id: https://develop.svn.wordpress.org/trunk@62238 602fd350-edb4-49c9-b593-d223f7449a82 --- .../abilities-api/class-wp-ability.php | 16 ++++++- .../phpunit/tests/abilities-api/wpAbility.php | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 967f1641156b0..cc01cc274c143 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -502,7 +502,7 @@ public function validate_input( $input = null ) { * * @param callable $callback The callable to invoke. * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed The result of the callable execution. + * @return mixed The result of the callable execution, or a `WP_Error` if the callback threw. */ protected function invoke_callback( callable $callback, $input = null ) { $args = array(); @@ -510,7 +510,19 @@ protected function invoke_callback( callable $callback, $input = null ) { $args[] = $input; } - return $callback( ...$args ); + try { + return $callback( ...$args ); + } catch ( Throwable $e ) { + return new WP_Error( + 'ability_callback_exception', + sprintf( + /* translators: 1: Ability name, 2: Exception message. */ + __( 'Ability "%1$s" callback threw an exception: %2$s' ), + esc_html( $this->name ), + esc_html( $e->getMessage() ) + ) + ); + } } /** diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 73a5fbf17a9ef..aea2c09624929 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -497,6 +497,54 @@ public function test_execute_no_input() { $this->assertSame( 42, $ability->execute() ); } + /** + * Tests that an exception thrown by the execute callback is converted to a WP_Error + * instead of being propagated as an uncaught throwable. + * + * @ticket 65058 + */ + public function test_execute_catches_callback_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + throw new RuntimeException( 'boom' ); + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + $this->assertWPError( $result, 'Ability::execute() should return WP_Error when the callback throws.' ); + $this->assertSame( 'ability_callback_exception', $result->get_error_code() ); + $this->assertStringContainsString( 'boom', $result->get_error_message() ); + } + + /** + * Tests that an exception thrown by the permission callback is converted to a WP_Error + * instead of being propagated as an uncaught throwable. + * + * @ticket 65058 + */ + public function test_check_permissions_catches_callback_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'permission_callback' => static function (): bool { + throw new RuntimeException( 'permission exploded' ); + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->check_permissions(); + + $this->assertWPError( $result, 'Ability::check_permissions() should return WP_Error when the callback throws.' ); + $this->assertSame( 'ability_callback_exception', $result->get_error_code() ); + $this->assertStringContainsString( 'permission exploded', $result->get_error_message() ); + } + /** * Tests that before_execute_ability action is fired with correct parameters. * From 9f8a3b1e1893f85fabc17b0d2b6d96e6bdb92208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Thu, 16 Apr 2026 07:37:31 +0000 Subject: [PATCH 102/104] AI: Prevent `wp_supports_ai` filter from overriding the `WP_AI_SUPPORT` constant. When `WP_AI_SUPPORT` is explicitly set to `false`, `wp_supports_ai()` now returns early before the filter runs. This ensures the site owner's explicit preference to disable AI cannot be overridden by a plugin via the `wp_supports_ai` filter. The filter default is now always `true`, since the constant check happens beforehand. Developed in: https://github.com/WordPress/wordpress-develop/pull/11295 Follow-up to [62067]. Props justlevine, westonruter, gziolo, mindctrl, adamsilverstein, johnjamesjacoby, ahortin, nilambar, ozgursar, audrasjb, jeffpaul. Fixes #64706. git-svn-id: https://develop.svn.wordpress.org/trunk@62239 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/ai-client.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 818e1dbaedcde..4fc20166fb8bb 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -17,20 +17,22 @@ * @return bool Whether AI features are supported. */ function wp_supports_ai(): bool { - $is_enabled = defined( 'WP_AI_SUPPORT' ) ? WP_AI_SUPPORT : true; + // Return early if AI is disabled by the current environment. + if ( defined( 'WP_AI_SUPPORT' ) && ! WP_AI_SUPPORT ) { + return false; + } /** - * Filters whether the current request should use AI. + * Filters whether the current request can use AI. * * This allows plugins and 3rd-party code to disable AI features on a per-request basis, or to even override explicit * preferences defined by the site owner. * * @since 7.0.0 * - * @param bool $is_enabled Whether the current request should use AI. Default to WP_AI_SUPPORT constant, or true if - * the constant is not defined. + * @param bool $is_enabled Whether AI is available. Default to true. */ - return (bool) apply_filters( 'wp_supports_ai', $is_enabled ); + return (bool) apply_filters( 'wp_supports_ai', true ); } /** From bccb9c1143d15fd00da54059269aa2cc3dbd1665 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 16 Apr 2026 18:42:53 +0000 Subject: [PATCH 103/104] Tests: Remove `external-http` group from a `get_theme_feature_list()` test. This particular test checks the list of theme features hardcoded into Core and does not perform an external API request. Follow-up to [39906]. See #64225. git-svn-id: https://develop.svn.wordpress.org/trunk@62243 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/admin/includesTheme.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/admin/includesTheme.php b/tests/phpunit/tests/admin/includesTheme.php index ed90cf9514ae5..446c048bcf18e 100644 --- a/tests/phpunit/tests/admin/includesTheme.php +++ b/tests/phpunit/tests/admin/includesTheme.php @@ -241,7 +241,6 @@ public function test_get_theme_featured_list_api() { * * Differences in the structure can also trigger failure by causing PHP notices/warnings. * - * @group external-http * @ticket 28121 */ public function test_get_theme_featured_list_hardcoded() { From d95a339da14ac6cbb1902257c1a3b537d9103a97 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Apr 2026 08:13:43 +1000 Subject: [PATCH 104/104] Update tests/qunit/fixtures/wp-api-generated.js. --- tests/qunit/fixtures/wp-api-generated.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 79d977f1b17b6..98426b717f3b6 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12760,6 +12760,7 @@ mockedApiResponse.Schema = { "client_id": { "minimum": 1, "minLength": 1, + "maxLength": 32, "required": true, "type": [ "string", @@ -12876,6 +12877,7 @@ mockedApiResponse.Schema = { "client_id": { "minimum": 1, "minLength": 1, + "maxLength": 32, "required": true, "type": [ "string",