diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index 1186f9bedce21..aa58ee42911b7 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 sync updates cleanup. +if ( wp_is_collaboration_enabled() + && ! wp_next_scheduled( 'wp_delete_old_sync_updates' ) + && ! wp_installing() +) { + wp_schedule_event( time(), 'daily', 'wp_delete_old_sync_updates' ); +} + set_screen_options(); $date_format = __( 'F j, Y' ); diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 7a95f65ad80cc..92dc70bf040ad 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,15 @@ 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->sync_updates ( + id bigint(20) unsigned NOT NULL auto_increment, + room varchar(255) NOT NULL, + update_value longtext NOT NULL, + created_at datetime NOT NULL default '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY room (room,id), + KEY created_at (created_at) ) $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..e49c3b7d1e6c0 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 < 61698 ) { upgrade_700(); } diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..b66be95487181 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', + 'sync_updates', ); /** @@ -404,6 +405,15 @@ class wpdb { */ public $posts; + /** + * WordPress Sync Updates table. + * + * @since 7.0.0 + * + * @var string + */ + public $sync_updates; + /** * WordPress Terms table. * diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 6fdbe2889ba7a..6d2415ccde925 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 61698. + * + * @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' ) >= 61698; +} + /** * Injects the real-time collaboration setting into a global variable. * @@ -14,7 +29,7 @@ * @access private */ function wp_collaboration_inject_setting() { - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { + if ( wp_is_collaboration_enabled() ) { wp_add_inline_script( 'wp-core-data', 'window._wpCollaborationEnabled = true;', @@ -22,3 +37,26 @@ function wp_collaboration_inject_setting() { ); } } + +/** + * Deletes sync updates older than 7 days from the wp_sync_updates table. + * + * Rows left behind by abandoned collaborative editing sessions are cleaned up + * to prevent unbounded table growth. + * + * @since 7.0.0 + */ +function wp_delete_old_sync_updates() { + if ( ! wp_is_collaboration_enabled() ) { + return; + } + + global $wpdb; + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->sync_updates} WHERE created_at < %s", + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) + ) + ); +} 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 533b23fdb4e34..d3ca6c1ff5440 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 @@ -3,6 +3,7 @@ * WP_HTTP_Polling_Sync_Server class * * @package WordPress + * @since 7.0.0 */ /** @@ -73,6 +74,7 @@ class WP_HTTP_Polling_Sync_Server { * Storage backend for sync updates. * * @since 7.0.0 + * @var WP_Sync_Storage */ private WP_Sync_Storage $storage; @@ -130,9 +132,10 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'maxLength' => 255, // The size of the wp_sync_updates.room column. ), 'updates' => array( 'items' => $typed_update_args, @@ -198,7 +201,7 @@ public function check_permissions( WP_REST_Request $request ) { sprintf( /* translators: %s: The room name encodes the current entity being synced. */ __( 'You do not have permission to sync this entity: %s.' ), - $room + esc_html( $room ) ), array( 'status' => rest_authorization_required_code() ) ); 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/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php new file mode 100644 index 0000000000000..4b6dd074d5d3a --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -0,0 +1,236 @@ + + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @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 { + global $wpdb; + + $result = $wpdb->insert( + $wpdb->sync_updates, + array( + 'room' => $room, + 'update_value' => wp_json_encode( $update ), + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * Awareness is ephemeral and stored as a transient rather than + * in the sync_updates table. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Awareness state. + */ + public function get_awareness_state( string $room ): array { + $awareness = get_transient( $this->get_awareness_transient_key( $room ) ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return array_values( $awareness ); + } + + /** + * 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 sync updates from a room after a given cursor. + * + * Uses a snapshot approach: captures MAX(id) first, then fetches rows + * WHERE id > cursor AND id <= max_id. Updates arriving after the snapshot + * are deferred to the next poll, never lost. + * + * @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 Sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + global $wpdb; + + // Snapshot the current max ID for this room to define a stable upper bound. + $max_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room = %s", + $room + ) + ); + + $this->room_cursors[ $room ] = $max_id; + + if ( 0 === $max_id || $max_id <= $cursor ) { + $this->room_update_counts[ $room ] = 0; + return array(); + } + + // Count total updates for this room (used by compaction threshold logic). + // Bounded by max_id to stay consistent with the snapshot window above. + $this->room_update_counts[ $room ] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room = %s AND id <= %d", + $room, + $max_id + ) + ); + + // Fetch updates after the cursor up to the snapshot boundary. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s 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 ) { + $updates[] = json_decode( $row->update_value, true ); + } + + 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->sync_updates} WHERE room = %s AND id < %d", + $room, + $cursor + ) + ); + + return false !== $result; + } + + /** + * Returns the transient key used to store awareness state for a room. + * + * The room name is hashed with md5 to guarantee the key stays within + * the 172-character limit imposed by the wp_options option_name column + * (varchar 191 minus the 19-character `_transient_timeout_` prefix). + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return string Transient key. + */ + private function get_awareness_transient_key( string $room ): string { + return 'sync_awareness_' . md5( $room ); + } + + /** + * Sets awareness state for a given room. + * + * Awareness is ephemeral and stored as a transient with a short timeout. + * + * @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 { + // Awareness is high-frequency, short-lived data (cursor positions, selections) + // that doesn't need cursor-based history. Transients avoid row churn in the table. + return set_transient( $this->get_awareness_transient_key( $room ), $awareness, MINUTE_IN_SECONDS ); + } +} diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index d84dbeb1e4aae..62dbf170c3edd 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -3,8 +3,14 @@ * WP_Sync_Storage interface * * @package WordPress + * @since 7.0.0 */ +/** + * Interface for sync storage backends used by the collaborative editing server. + * + * @since 7.0.0 + */ interface WP_Sync_Storage { /** * Adds a sync update to a given room. @@ -51,8 +57,7 @@ public function get_cursor( string $room ): int; 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. + * Retrieves sync updates from a room after the given cursor. * * @since 7.0.0 * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 8ce256e046f84..904d2bf4f9006 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_sync_updates', 'wp_delete_old_sync_updates' ); // Navigation menu actions. add_action( 'delete_post', '_wp_delete_post_menu_item' ); 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( diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index df7f262d3aa58..9c3a939970638 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,9 +430,19 @@ 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 ); + if ( wp_is_collaboration_enabled() ) { + $sync_storage = new WP_Sync_Table_Storage(); + + /** + * Filters the sync storage backend used for real-time collaboration. + * + * @since 7.0.0 + * + * @param WP_Sync_Storage $sync_storage Storage backend instance. + */ + $sync_storage = apply_filters( 'wp_sync_storage', $sync_storage ); + + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); $sync_server->register_routes(); } } diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 6e6f87cc193fc..0159650bc8ed8 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 = 61696; +$wp_db_version = 61698; /** * Holds the TinyMCE version. diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..f4db57392965c 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,7 +310,7 @@ 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-sync-table-storage.php'; require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.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..820a84065d543 --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -0,0 +1,106 @@ +/** + * 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. + await expect( + page.getByText( 'Test Collaborator' ) + ).toBeVisible(); + + await expect( + page.getByText( '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 } ); + + // Close User C's context to simulate leaving. + await collaborationUtils.page3.close(); + + // After the awareness timeout (30s), User A and B should see + // the collaborators list update. The button may still be visible + // but should reflect only 1 remaining collaborator. + // We verify by opening the popover and checking that User C's + // name is no longer listed. + await expect( async () => { + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await presenceButton.click(); + + // "Another Collaborator" (User C) should no longer appear. + await expect( + page.getByText( 'Another Collaborator' ) + ).not.toBeVisible(); + + // "Test Collaborator" (User B) should still be listed. + await expect( + page.getByText( 'Test Collaborator' ) + ).toBeVisible(); + } ).toPass( { timeout: 45000 } ); + } ); +} ); 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..4d42507afca63 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -0,0 +1,451 @@ +/** + * 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. + * + * Uses the form-based approach because this setting is registered + * on admin_init in the "writing" group and is not exposed via + * /wp/v2/settings. + * + * @param {boolean} enabled Whether to enable or disable collaboration. + */ + async setCollaboration( enabled ) { + const response = await this.requestUtils.request.get( + '/wp-admin/options-writing.php' + ); + const html = await response.text(); + const nonce = html.match( /name="_wpnonce" value="([^"]+)"/ )[ 1 ]; + + const formData = { + option_page: 'writing', + action: 'update', + _wpnonce: nonce, + _wp_http_referer: '/wp-admin/options-writing.php', + submit: 'Save Changes', + default_category: 1, + default_post_format: 0, + }; + + if ( enabled ) { + formData.wp_enable_real_time_collaboration = 1; + } + + await this.requestUtils.request.post( '/wp-admin/options.php', { + form: formData, + failOnStatusCode: true, + } ); + } + + /** + * 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-sync' ) && + 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..cf8b4aad2987e --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,40 @@ +/** + * 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 ); + await requestUtils.createUser( THIRD_USER ); + await use( utils ); + await utils.teardown(); + }, +} ); diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index a09b256115f48..cecc26e016bae 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -31,12 +31,10 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); - // 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() ); + // 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->sync_updates}" ); } /** @@ -301,14 +299,18 @@ public function test_sync_response_room_matches_request() { $this->assertSame( $room, $data['rooms'][0]['room'] ); } - public function test_sync_end_cursor_is_positive_integer() { + /** + * @ticket 64696 + */ + public function test_sync_end_cursor_is_non_negative_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'] ); + // Cursor is 0 for an empty room (no rows in the table yet). + $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); } public function test_sync_empty_updates_returns_zero_total() { @@ -757,4 +759,404 @@ public function test_sync_rooms_are_isolated() { // Room 2 should have no updates. $this->assertEmpty( $data['rooms'][1]['updates'] ); } + + /* + * Cursor tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_empty_room_cursor_is_zero(): void { + 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]['end_cursor'] ); + } + + /** + * @ticket 64696 + */ + public function test_sync_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_sync( + 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_sync( + 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_sync_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_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + // Client 2 fetches updates and gets a cursor. + $response1 = $this->dispatch_sync( + 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_sync( + 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_sync_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 sync operations. + $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 ) ), + ) + ); + + $last_changed_after = wp_cache_get_last_changed( 'posts' ); + + $this->assertSame( $last_changed_before, $last_changed_after, 'Sync operations should not invalidate the posts last changed cache.' ); + } + + /* + * Race condition tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_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_sync( + 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_sync( + 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_sync( + array( + $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction_update ) ), + ) + ); + + // Client 3 requests all updates from the beginning. + $response = $this->dispatch_sync( + 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_sync_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_sync( + 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_sync( + array( + $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + // Client 2 checks the state. + $response = $this->dispatch_sync( + 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.' ); + } + + /* + * Storage filter tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_storage_filter_is_applied(): void { + $filtered_storage = null; + + add_filter( + 'wp_sync_storage', + static function ( WP_Sync_Storage $storage ) use ( &$filtered_storage ): WP_Sync_Storage { + $filtered_storage = $storage; + return $storage; + } + ); + + // Re-trigger route registration to invoke the filter. + $server = rest_get_server(); + do_action( 'rest_api_init', $server ); + + $this->assertInstanceOf( WP_Sync_Storage::class, $filtered_storage, 'The wp_sync_storage filter should be applied during route registration.' ); + } + + /* + * Cron cleanup tests. + */ + + /** + * Inserts a row directly into the sync_updates 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_sync_row( int $age_in_seconds, string $label = 'test' ): void { + global $wpdb; + + $wpdb->insert( + $wpdb->sync_updates, + array( + 'room' => $this->get_post_room(), + 'update_value' => wp_json_encode( + array( + 'type' => 'update', + 'data' => $label, + ) + ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + ), + array( '%s', '%s', '%s' ) + ); + } + + /** + * Returns the number of rows in the sync_updates table. + * + * @return positive-int Row count. + */ + private function get_sync_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->sync_updates}" ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_old_rows(): void { + $this->insert_sync_row( 8 * DAY_IN_SECONDS ); + + $this->assertSame( 1, $this->get_sync_row_count() ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 0, $this->get_sync_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_recent_rows(): void { + $this->insert_sync_row( DAY_IN_SECONDS ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 1, $this->get_sync_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { + $this->insert_sync_row( WEEK_IN_SECONDS + 1, 'expired' ); + $this->insert_sync_row( WEEK_IN_SECONDS - 1, 'just-inside' ); + + wp_delete_old_sync_updates(); + + global $wpdb; + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->sync_updates}" ); + + $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_sync_row( 10 * DAY_IN_SECONDS ); + $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + + // 2 recent rows. + $this->insert_sync_row( HOUR_IN_SECONDS ); + $this->insert_sync_row( HOUR_IN_SECONDS ); + + $this->assertSame( 5, $this->get_sync_row_count() ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 2, $this->get_sync_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_sync_updates', 'wp_delete_old_sync_updates' ), + 'The wp_delete_old_sync_updates action should be hooked in default-filters.php.' + ); + } + + /* + * Route registration guard tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_routes_not_registered_when_db_version_is_old(): void { + update_option( 'db_version', 61697 ); + + // 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-sync/v1/updates', $routes, 'Sync routes should not be registered when db_version is below 61698.' ); + + // Reset again so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } }