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/.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 }} 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/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' ] ); 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 | 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..4d0b8110e0a9f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "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", "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-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-admin/css/color-picker.css b/src/wp-admin/css/color-picker.css index 2e038353b7cca..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 { @@ -94,11 +94,10 @@ width: 4rem; font-size: 12px; font-family: monospace; - line-height: 2.33333333; /* 28px */ margin: 0; padding: 0 5px; vertical-align: top; - min-height: 30px; + min-height: 32px; } .wp-color-picker::-webkit-input-placeholder { diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index f3128b9e657ca..4c18ab586c359 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; } @@ -1474,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; @@ -2078,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; @@ -2293,7 +2281,7 @@ html.wp-toolbar { line-height: 1; } -.postbox.closed { +.postbox.closed .postbox-header { border-bottom: 0; } @@ -3352,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; 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/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 { 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/install.css b/src/wp-admin/css/install.css index 4173e9a228fda..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 */ @@ -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 { diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index e2b7e30f1dd63..c659079bea2e9 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -682,11 +682,11 @@ th.sorted a span { } .tablenav-pages .current-page { + vertical-align: top; margin: 0 2px 0 0; 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 +1099,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; } 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-admin/includes/class-wp-importer.php b/src/wp-admin/includes/class-wp-importer.php index 085a5adc0e71a..e1531eb260770 100644 --- a/src/wp-admin/includes/class-wp-importer.php +++ b/src/wp-admin/includes/class-wp-importer.php @@ -135,8 +135,14 @@ public function get_imported_comments( $blog_id ) { } /** - * @param int $blog_id - * @return int|void + * Sets the blog to import to. + * + * Accepts a numeric blog ID or a URL string. When given a URL, + * the blog is looked up by domain and path. On multisite, switches + * to the resolved blog. Exits with an error if the blog cannot be found. + * + * @param int|string $blog_id Blog ID or URL. + * @return int Blog ID on success. Exits on failure. */ public function set_blog( $blog_id ) { if ( is_numeric( $blog_id ) ) { @@ -177,7 +183,7 @@ public function set_blog( $blog_id ) { /** * @param int $user_id - * @return int|void + * @return int */ public function set_user( $user_id ) { if ( is_numeric( $user_id ) ) { 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/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' ); } } diff --git a/src/wp-admin/includes/class-wp-privacy-requests-table.php b/src/wp-admin/includes/class-wp-privacy-requests-table.php index cffd2218f11f7..854419069511a 100644 --- a/src/wp-admin/includes/class-wp-privacy-requests-table.php +++ b/src/wp-admin/includes/class-wp-privacy-requests-table.php @@ -435,7 +435,7 @@ public function column_cb( $item ) { * @since 4.9.6 * * @param WP_User_Request $item Item being shown. - * @return string|void Status column markup. Returns a string if no status is found, + * @return string|null Status column markup. Returns a string if no status is found, * otherwise it displays the markup. */ public function column_status( $item ) { @@ -465,6 +465,7 @@ public function column_status( $item ) { } echo ''; + return null; } /** diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 44c04175abaf2..75e046ef8ffa7 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -167,7 +167,17 @@ public function enqueue_scripts() { * @since 5.4.0 * * @param callable $callback - * @return mixed|void + * @return array{ + * label: string, + * status: 'good'|'recommended'|'critical', + * badge: array{ + * label: string, + * color: string, + * }, + * description: string, + * actions: string, + * test: string, + * } */ private function perform_test( $callback ) { /** diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 94d95f749f84b..5415e5961bf7b 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1648,7 +1648,7 @@ function wp_dashboard_primary_output( $widget_id, $feeds ) { * * @since 3.0.0 * - * @return true|void True if not multisite, user can't upload files, or the space check option is disabled. + * @return true|null True if not multisite, user can't upload files, or the space check option is disabled. */ function wp_dashboard_quota() { if ( ! is_multisite() || ! current_user_can( 'upload_files' ) @@ -1709,6 +1709,7 @@ function wp_dashboard_quota() { @@ -753,7 +754,7 @@ function get_upload_iframe_src( $type = null, $post_id = null, $tab = null ) { * * @since 2.5.0 * - * @return null|array|void Array of error messages keyed by attachment ID, null or void on success. + * @return null|array Array of error messages keyed by attachment ID, null on success, or exit. */ function media_upload_form_handler() { check_admin_referer( 'media-form' ); @@ -874,7 +875,7 @@ function media_upload_form_handler() { */ $html = apply_filters( 'media_send_to_editor', $html, $send_id, $attachment ); - return media_send_to_editor( $html ); + media_send_to_editor( $html ); } return $errors; @@ -976,7 +977,7 @@ function wp_media_upload_handler() { $html = apply_filters( 'image_send_to_editor_url', $html, sanitize_url( $src ), $alt, $align ); } - return media_send_to_editor( $html ); + media_send_to_editor( $html ); } if ( isset( $_POST['save'] ) ) { 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 ) { - + + + diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index fae10f1a679a4..94d4a585a80f1 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -1296,8 +1296,8 @@ function is_uninstallable_plugin( $plugin ) { * @since 2.7.0 * * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return true|void True if a plugin's uninstall.php file has been found and included. - * Void otherwise. + * @return true|null True if a plugin's uninstall.php file has been found and included. + * Null otherwise. */ function uninstall_plugin( $plugin ) { $file = plugin_basename( $plugin ); @@ -1350,6 +1350,7 @@ function uninstall_plugin( $plugin ) { */ do_action( "uninstall_{$file}" ); } + return null; } // diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index a087e6b560f05..8d52c1487a7a5 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -982,15 +982,15 @@ function wp_write_post() { * * @since 2.0.0 * - * @return int|void Post ID on success, void on failure. + * @return int Post ID on success. Dies on failure. */ function write_post() { $result = wp_write_post(); if ( is_wp_error( $result ) ) { wp_die( $result->get_error_message() ); - } else { - return $result; } + + return $result; } // diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 340bdebac71eb..1c7b40b71c498 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,20 @@ 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', + 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 room_type_date (room,type,date_gmt), + 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..dde5b44f0abbf 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 < 61841 ) { upgrade_700(); } @@ -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' ); 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/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/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/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 ); } /** 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/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/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,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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 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}; 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-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 ); 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-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index a4bd8fb48f29b..9fe51be96aa8e 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -31,14 +31,16 @@ * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * } */ @@ -66,12 +68,12 @@ final class WP_Connector_Registry { * Registers a new connector. * * Validates the provided arguments and stores the connector in the registry. - * For connectors with `api_key` authentication, a `setting_name` is automatically - * generated using the pattern `connectors_ai_{$id}_api_key`, with hyphens in the ID - * normalized to underscores (e.g., connector ID `openai` produces - * `connectors_ai_openai_api_key`, and `azure-openai` produces - * `connectors_ai_azure_openai_api_key`). This setting name is used for the Settings - * API registration and REST API exposure. + * 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 `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 * `_doing_it_wrong()` notice and return `null`. To override an existing connector, @@ -89,17 +91,26 @@ final class WP_Connector_Registry { * @type string $name Required. The connector's display name. * @type string $description Optional. The connector's description. Default empty string. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type string $type Required. The connector type, e.g. 'ai_provider'. * @type array $authentication { * Required. Authentication configuration. * * @type string $method Required. The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * When omitted, auto-generated as + * `connectors_{$type}_{$id}_api_key`. + * Must be a non-empty string when provided. + * @type string $constant_name Optional. PHP constant name for the API key + * (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided. + * @type string $env_var_name Optional. Environment variable name for the API key + * (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided. * } * @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. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * @return array|null The registered connector data on success, null on failure. @@ -192,15 +203,48 @@ public function register( string $id, array $args ): ?array { if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) { $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url']; } - if ( ! empty( $args['authentication']['setting_name'] ) && is_string( $args['authentication']['setting_name'] ) ) { + if ( isset( $args['authentication']['setting_name'] ) ) { + if ( ! is_string( $args['authentication']['setting_name'] ) || '' === $args['authentication']['setting_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication setting_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } $connector['authentication']['setting_name'] = $args['authentication']['setting_name']; } else { - $connector['authentication']['setting_name'] = 'connectors_ai_' . str_replace( '-', '_', $id ) . '_api_key'; + $connector['authentication']['setting_name'] = str_replace( '-', '_', "connectors_{$connector['type']}_{$id}_api_key" ); + } + if ( isset( $args['authentication']['constant_name'] ) ) { + if ( ! is_string( $args['authentication']['constant_name'] ) || '' === $args['authentication']['constant_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication constant_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + $connector['authentication']['constant_name'] = $args['authentication']['constant_name']; + } + if ( isset( $args['authentication']['env_var_name'] ) ) { + if ( ! is_string( $args['authentication']['env_var_name'] ) || '' === $args['authentication']['env_var_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication env_var_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + $connector['authentication']['env_var_name'] = $args['authentication']['env_var_name']; } } - 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/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-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 995ade9508a9a..8cbf6d977f5a2 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; } @@ -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/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..f22ac010cc975 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. * @@ -1453,11 +1463,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 +1676,7 @@ public function prepare( $query, ...$args ) { '6.2.0' ); - return; + return null; } $args_count = count( $args ); @@ -1684,7 +1694,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 +1804,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 +1865,8 @@ public function print_error( $str = '' ) { $query ); } + + return null; } /** @@ -2117,7 +2129,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 +3068,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 +3099,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 +3915,8 @@ public function get_col_info( $info_type = 'name', $col_offset = -1 ) { return $this->col_info[ $col_offset ]->{$info_type}; } } + + return null; } /** @@ -3937,7 +3952,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 +4010,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 +4021,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/collaboration.php b/src/wp-includes/collaboration.php index 11698a2ac78f4..350b6cf1b21b0 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -11,7 +11,8 @@ * * If the WP_ALLOW_COLLABORATION constant is false, * collaboration is always disabled regardless of the database option. - * Otherwise, falls back to the 'wp_collaboration_enabled' option. + * Otherwise, the feature requires both the 'wp_collaboration_enabled' + * option and the database schema introduced in db_version 61841. * * @since 7.0.0 * @@ -20,7 +21,8 @@ function wp_is_collaboration_enabled() { return ( wp_is_collaboration_allowed() && - (bool) get_option( 'wp_collaboration_enabled' ) + get_option( 'wp_collaboration_enabled' ) && + get_option( 'db_version' ) >= 61841 ); } @@ -34,7 +36,7 @@ function wp_is_collaboration_enabled() { * * @since 7.0.0 * - * @return bool Whether real-time collaboration is enabled. + * @return bool Whether real-time collaboration is allowed. */ function wp_is_collaboration_allowed() { if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) { @@ -83,3 +85,41 @@ 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() { + global $wpdb; + + if ( ! wp_is_collaboration_enabled() ) { + /* + * Collaboration was enabled in the past but has since been disabled. + * Unschedule the cron job prior to clean up so this callback does not + * continue to run. + */ + wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); + } + + /* Clean up rows older than 7 days. */ + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE 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/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php new file mode 100644 index 0000000000000..2781af4716bf4 --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -0,0 +1,392 @@ +, 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; + + if ( '' === $room || empty( $update['type'] ) || empty( $update['client_id'] ) ) { + return false; + } + + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => $update['type'] ?? '', + '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', '%d' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * 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 + * + * @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; + + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + return $cached; + } + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT client_id, user_id, data 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->data, true ); + if ( is_array( $decoded ) ) { + $entries[] = array( + 'client_id' => $row->client_id, + 'state' => $decoded, + 'user_id' => (int) $row->user_id, + ); + } + } + + wp_cache_set( $cache_key, $entries, 'collaboration', $timeout ); + + 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. + * + * @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; + + /* + * 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'", + $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 data 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->data, true ); + if ( is_array( $decoded ) ) { + $updates[] = $decoded; + } + } + + return $updates; + } + + /** + * Removes updates from a room up to and including the given cursor. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates up to and including this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_through_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", + $room, + $cursor + ) + ); + + return false !== $result; + } + + /** + * Sets awareness state for a given client in a room. + * + * 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 + * 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. + * + * @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; + + if ( '' === $room || '' === $client_id ) { + return false; + } + + $data = wp_json_encode( $state ); + + /* + * 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( + $wpdb->prepare( + "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, + array( + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ), + array( 'id' => $exists->id ) + ); + } else { + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ) + ); + } + + if ( false === $result ) { + 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:' . str_replace( '/', ':', $room ); + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + $normalized_state = json_decode( $data, 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 true; + } +} 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 53% 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..b3a7907cb021c 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 +122,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', @@ -124,15 +151,26 @@ 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, - 'required' => true, - 'type' => 'integer', + '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 ) { + 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 +180,53 @@ 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' ), + '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', + ), + ), + ); + 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 +237,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 +254,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 +271,29 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Handles request: stores sync updates and awareness data, and returns + * 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. * * @since 7.0.0 @@ -244,18 +313,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 ) ) === (string) $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 +345,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,16 +354,26 @@ 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 { + // 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. @@ -298,8 +381,10 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ 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; } @@ -314,9 +399,11 @@ 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 - // 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', @@ -329,66 +416,68 @@ 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 +486,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,19 +502,39 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { - if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + /* + * 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_through_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 - // 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: @@ -445,7 +554,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 +565,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 +578,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 +594,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 +602,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 +613,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 ); 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 658a9b65539dd..0000000000000 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ /dev/null @@ -1,378 +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 - * - * @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; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Use direct database operation to avoid cache invalidation performed by - // post meta functions (`wp_cache_set_posts_last_changed()` and direct - // `wp_cache_delete()` calls). - return (bool) $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $post_id, - 'meta_key' => self::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - /** - * Gets awareness state for a given room. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @return array Awareness state. - */ - public function get_awareness_state( string $room ): array { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - // Use direct database operation to avoid updating the post meta cache. - // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist - // from a past race condition in set_awareness_state(). - $meta_value = $wpdb->get_var( - $wpdb->prepare( - "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", - $post_id, - self::AWARENESS_META_KEY - ) - ); - - if ( null === $meta_value ) { - return array(); - } - - $awareness = json_decode( $meta_value, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return array_values( $awareness ); - } - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @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 { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Use direct database operation to avoid cache invalidation performed by - // post meta functions (`wp_cache_set_posts_last_changed()` and direct - // `wp_cache_delete()` calls). - // - // If two concurrent requests both see no row and both INSERT, the - // duplicate is harmless: get_awareness_state() reads the latest row - // (ORDER BY meta_id DESC). - $meta_id = $wpdb->get_var( - $wpdb->prepare( - "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", - $post_id, - self::AWARENESS_META_KEY - ) - ); - - if ( $meta_id ) { - return (bool) $wpdb->update( - $wpdb->postmeta, - array( 'meta_value' => wp_json_encode( $awareness ) ), - array( 'meta_id' => $meta_id ), - array( '%s' ), - array( '%d' ) - ); - } - - return (bool) $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $post_id, - 'meta_key' => self::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( $awareness ), - ), - array( '%d', '%s', '%s' ) - ); - } - - /** - * Gets the current cursor for a given room. - * - * The cursor is set during get_updates_after_cursor() and represents the - * highest meta_id seen for the room's sync updates. - * - * @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', - 'orderby' => 'ID', - 'order' => 'ASC', - ) - ); - - $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 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 the given cursor. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor (meta_id). - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - $this->room_cursors[ $room ] = 0; - $this->room_update_counts[ $room ] = 0; - return array(); - } - - // Capture the current room state first so the returned cursor is race-safe. - $stats = $wpdb->get_row( - $wpdb->prepare( - "SELECT COUNT(*) AS total_updates, COALESCE( MAX(meta_id), 0 ) AS max_meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", - $post_id, - self::SYNC_UPDATE_META_KEY - ) - ); - - $total_updates = $stats ? (int) $stats->total_updates : 0; - $max_meta_id = $stats ? (int) $stats->max_meta_id : 0; - - $this->room_update_counts[ $room ] = $total_updates; - $this->room_cursors[ $room ] = $max_meta_id; - - if ( $max_meta_id <= $cursor ) { - return array(); - } - - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC", - $post_id, - self::SYNC_UPDATE_META_KEY, - $cursor, - $max_meta_id - ) - ); - - if ( ! $rows ) { - return array(); - } - - $updates = array(); - foreach ( $rows as $row ) { - $decoded = json_decode( $row->meta_value, true ); - if ( null !== $decoded ) { - $updates[] = $decoded; - } - } - - return $updates; - } - - /** - * Removes updates from a room that are older than the given cursor. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with meta_id < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - $deleted_rows = $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d", - $post_id, - self::SYNC_UPDATE_META_KEY, - $cursor - ) - ); - - if ( false === $deleted_rows ) { - return false; - } - - return true; - } -} 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/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/connectors.php b/src/wp-includes/connectors.php index bdc585723aaf1..63e018074fd58 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -43,33 +43,39 @@ function wp_is_connector_registered( string $id ): bool { * @type string $name The connector's display name. * @type string $description The connector's description. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type string $type The connector type, e.g. 'ai_provider'. * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. + * credentials_url, setting_name, and optionally constant_name and + * env_var_name. When 'none', only method is present. * * @type string $method The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. * @type string $setting_name Optional. The setting name for the API key. + * @type string $constant_name Optional. PHP constant name for the API key. + * @type string $env_var_name Optional. Environment variable name for the API key. * } * @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. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * @phpstan-return ?array{ * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * } */ @@ -98,19 +104,23 @@ function wp_get_connector( string $id ): ?array { * @type string $name The connector's display name. * @type string $description The connector's description. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type string $type The connector type, e.g. 'ai_provider'. * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. + * credentials_url, setting_name, and optionally constant_name and + * env_var_name. When 'none', only method is present. * * @type string $method The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. * @type string $setting_name Optional. The setting name for the API key. + * @type string $constant_name Optional. PHP constant name for the API key. + * @type string $env_var_name Optional. Environment variable name for the API key. * } * @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. 'my-plugin/my-plugin.php' or 'hello.php'). * } * } * } @@ -118,14 +128,16 @@ function wp_get_connector( string $id ): ?array { * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ - * slug: non-empty-string + * file: non-empty-string * } * }> */ @@ -216,10 +228,10 @@ function _wp_connectors_init(): void { * Example — overriding metadata on an auto-discovered connector: * * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { - * if ( $registry->is_registered( 'openai' ) ) { - * $connector = $registry->unregister( 'openai' ); - * $connector['description'] = __( 'Custom description for OpenAI.', 'my-plugin' ); - * $registry->register( 'openai', $connector ); + * if ( $registry->is_registered( 'anthropic' ) ) { + * $connector = $registry->unregister( 'anthropic' ); + * $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' ); + * $registry->register( 'anthropic', $connector ); * } * } ); * @@ -246,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', @@ -258,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', @@ -270,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', @@ -335,6 +347,26 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re // Register all default connectors directly on the registry. foreach ( $defaults as $id => $args ) { + if ( 'api_key' === $args['authentication']['method'] ) { + $sanitized_id = str_replace( '-', '_', $id ); + + if ( ! isset( $args['authentication']['setting_name'] ) ) { + $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key"; + } + + // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention. + if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) { + $constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY'; + + if ( ! isset( $args['authentication']['constant_name'] ) ) { + $args['authentication']['constant_name'] = $constant_case_key; + } + + if ( ! isset( $args['authentication']['env_var_name'] ) ) { + $args['authentication']['env_var_name'] = $constant_case_key; + } + } + } $registry->register( $id, $args ); } } @@ -357,35 +389,32 @@ function _wp_connectors_mask_api_key( string $key ): string { } /** - * Determines the source of an API key for a given provider. + * Determines the source of an API key for a given connector. * * Checks in order: environment variable, PHP constant, database. - * Uses the same naming convention as the WP AI Client ProviderRegistry. + * Environment variable and constant are only checked when their + * respective names are provided. * * @since 7.0.0 * @access private * - * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). - * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_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 $provider_id, string $setting_name ): string { - // Convert provider ID to CONSTANT_CASE for env var name. - // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. - $constant_case_id = strtoupper( - preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) - ); - $env_var_name = "{$constant_case_id}_API_KEY"; - +function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string { // Check environment variable first. - $env_value = getenv( $env_var_name ); - if ( false !== $env_value && '' !== $env_value ) { - return 'env'; + if ( '' !== $env_var_name ) { + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } } // Check PHP constant. - if ( defined( $env_var_name ) ) { - $const_value = constant( $env_var_name ); + if ( '' !== $constant_name && defined( $constant_name ) ) { + $const_value = constant( $constant_name ); if ( is_string( $const_value ) && '' !== $const_value ) { return 'constant'; } @@ -470,7 +499,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } @@ -481,8 +510,9 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R $value = $data[ $setting_name ]; - // On update, validate the key before masking. - if ( $is_update && is_string( $value ) && '' !== $value ) { + // On update, validate AI provider keys before masking. + // Non-AI connectors accept keys as-is; the service plugin handles its own validation. + if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) { if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { update_option( $setting_name, '' ); $data[ $setting_name ] = ''; @@ -508,16 +538,22 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R * @access private */ function _wp_register_default_connector_settings(): void { - $ai_registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); + $registered_settings = get_registered_settings(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + continue; + } + + // Skip if the setting is already registered (e.g. by an owning plugin). + if ( isset( $registered_settings[ $auth['setting_name'] ] ) ) { continue; } - // Skip registering the setting if the provider is not in the registry. - if ( ! $ai_registry->hasProvider( $connector_id ) ) { + // For AI providers, skip if the provider is not in the AI Client registry. + if ( 'ai_provider' === $connector_data['type'] && ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -527,13 +563,13 @@ function _wp_register_default_connector_settings(): void { array( 'type' => 'string', 'label' => sprintf( - /* translators: %s: AI provider name. */ + /* translators: %s: Connector name. */ __( '%s API Key' ), $connector_data['name'] ), 'description' => sprintf( - /* translators: %s: AI provider name. */ - __( 'API key for the %s AI provider.' ), + /* translators: %s: Connector name. */ + __( 'API key for the %s connector.' ), $connector_data['name'] ), 'default' => '', @@ -569,7 +605,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { } // Skip if the key is already provided via env var or constant. - $key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ); + $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' ); if ( 'env' === $key_source || 'constant' === $key_source ) { continue; } @@ -602,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 ) { @@ -620,11 +650,17 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; - $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' ); - try { - $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); - } catch ( Exception $e ) { - $auth_out['isConnected'] = false; + $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' ); + $auth_out['keySource'] = $key_source; + + if ( 'ai_provider' === $connector_data['type'] ) { + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } + } else { + $auth_out['isConnected'] = 'none' !== $key_source; } } @@ -636,15 +672,13 @@ 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, + 'file' => $file, 'isInstalled' => $is_installed, 'isActivated' => $is_activated, ); 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; } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4b6d9de25fa11..17c1695e6d72d 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/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/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 262b069e6da22..7d71c8c56963d 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,9 +3775,9 @@ 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['exit'] is false ? void : never ) + * @phpstan-return ( $args is array{exit: false} ? void : never ) */ function wp_die( $message = '', $title = '', $args = array() ) { global $wp_query; @@ -3974,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; } @@ -5105,7 +5106,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 +5183,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; @@ -5511,6 +5512,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; @@ -8578,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(); @@ -8590,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 7ff250413ad2b..8f6ec1cef4e26 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; @@ -3389,7 +3422,7 @@ function wp_get_attachment_id3_keys( $attachment, $context = 'display' ) { * @type string $style The 'style' attribute for the `

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..befcd14da5730 --- /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_collaboration_enabled: 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..5c34a5d88d901 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,69 @@ +/** + * 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, + testInfo + ) => { + const utils = new CollaborationUtils( { + admin, + editor, + 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; + } + } ); + 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/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php index e7b88025aa592..42f85c212d092 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -172,6 +172,7 @@ private static function register_mock_connectors_provider(): void { 'authentication' => array( 'method' => 'api_key', 'credentials_url' => null, + 'setting_name' => 'connectors_ai_mock_connectors_test_api_key', ), ) ); 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. * 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' ); + } } 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() { 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 ); } } 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/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index 522c9f9299ddb..d1a46dc0981fe 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -32,9 +32,9 @@ public function set_up(): void { $this->registry = new WP_Connector_Registry(); self::$default_args = array( - 'name' => 'Test Provider', - 'description' => 'A test AI provider.', - 'type' => 'ai_provider', + 'name' => 'Test Connector', + 'description' => 'A test connector.', + 'type' => 'test_type', 'authentication' => array( 'method' => 'api_key', 'credentials_url' => 'https://example.com/keys', @@ -49,12 +49,12 @@ public function test_register_returns_connector_data() { $result = $this->registry->register( 'test-provider', self::$default_args ); $this->assertIsArray( $result ); - $this->assertSame( 'Test Provider', $result['name'] ); - $this->assertSame( 'A test AI provider.', $result['description'] ); - $this->assertSame( 'ai_provider', $result['type'] ); + $this->assertSame( 'Test Connector', $result['name'] ); + $this->assertSame( 'A test connector.', $result['description'] ); + $this->assertSame( 'test_type', $result['type'] ); $this->assertSame( 'api_key', $result['authentication']['method'] ); $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] ); - $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] ); + $this->assertSame( 'connectors_test_type_test_provider_api_key', $result['authentication']['setting_name'] ); } /** @@ -63,7 +63,7 @@ public function test_register_returns_connector_data() { public function test_register_generates_setting_name_for_api_key() { $result = $this->registry->register( 'myai', self::$default_args ); - $this->assertSame( 'connectors_ai_myai_api_key', $result['authentication']['setting_name'] ); + $this->assertSame( 'connectors_test_type_myai_api_key', $result['authentication']['setting_name'] ); } /** @@ -72,7 +72,157 @@ public function test_register_generates_setting_name_for_api_key() { public function test_register_generates_setting_name_normalizes_hyphens() { $result = $this->registry->register( 'my-ai', self::$default_args ); - $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] ); + $this->assertSame( 'connectors_test_type_my_ai_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_generates_setting_name_using_type_and_id() { + $args = self::$default_args; + $args['type'] = 'email_delivery'; + + $result = $this->registry->register( 'sendgrid', $args ); + + $this->assertSame( 'connectors_email_delivery_sendgrid_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_uses_custom_setting_name_when_provided() { + $args = self::$default_args; + $args['authentication']['setting_name'] = 'wordpress_api_key'; + + $result = $this->registry->register( 'custom-setting', $args ); + + $this->assertSame( 'wordpress_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_empty_setting_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['setting_name'] = ''; + + $result = $this->registry->register( 'empty-setting', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_non_string_setting_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['setting_name'] = 123; + + $result = $this->registry->register( 'non-string-setting', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64957 + */ + public function test_register_stores_constant_name_when_provided() { + $args = self::$default_args; + $args['authentication']['constant_name'] = 'MY_PROVIDER_API_KEY'; + + $result = $this->registry->register( 'my-provider', $args ); + + $this->assertSame( 'MY_PROVIDER_API_KEY', $result['authentication']['constant_name'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_omits_constant_name_when_not_provided() { + $result = $this->registry->register( 'no-const', self::$default_args ); + + $this->assertArrayNotHasKey( 'constant_name', $result['authentication'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_empty_constant_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['constant_name'] = ''; + + $result = $this->registry->register( 'empty-const', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_non_string_constant_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['constant_name'] = 123; + + $result = $this->registry->register( 'bad-const', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64957 + */ + public function test_register_stores_env_var_name_when_provided() { + $args = self::$default_args; + $args['authentication']['env_var_name'] = 'MY_PROVIDER_API_KEY'; + + $result = $this->registry->register( 'my-provider', $args ); + + $this->assertSame( 'MY_PROVIDER_API_KEY', $result['authentication']['env_var_name'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_omits_env_var_name_when_not_provided() { + $result = $this->registry->register( 'no-env', self::$default_args ); + + $this->assertArrayNotHasKey( 'env_var_name', $result['authentication'] ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_empty_env_var_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['env_var_name'] = ''; + + $result = $this->registry->register( 'empty-env', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64957 + */ + public function test_register_rejects_non_string_env_var_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['env_var_name'] = 123; + + $result = $this->registry->register( 'bad-env', $args ); + + $this->assertNull( $result ); } /** @@ -80,8 +230,8 @@ public function test_register_generates_setting_name_normalizes_hyphens() { */ public function test_register_no_setting_name_for_none_auth() { $args = array( - 'name' => 'No Auth Provider', - 'type' => 'ai_provider', + 'name' => 'No Auth Connector', + 'type' => 'test_type', 'authentication' => array( 'method' => 'none' ), ); $result = $this->registry->register( 'no-auth', $args ); @@ -96,7 +246,7 @@ public function test_register_no_setting_name_for_none_auth() { public function test_register_defaults_description_to_empty_string() { $args = array( 'name' => 'Minimal', - 'type' => 'ai_provider', + 'type' => 'test_type', 'authentication' => array( 'method' => 'none' ), ); @@ -144,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'] ); } /** @@ -308,7 +458,7 @@ public function test_get_registered_returns_connector_data() { $result = $this->registry->get_registered( 'my-connector' ); $this->assertIsArray( $result ); - $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'Test Connector', $result['name'] ); } /** @@ -355,7 +505,7 @@ public function test_unregister_removes_connector() { $result = $this->registry->unregister( 'to-remove' ); $this->assertIsArray( $result ); - $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'Test Connector', $result['name'] ); $this->assertFalse( $this->registry->is_registered( 'to-remove' ) ); } @@ -404,7 +554,9 @@ public function test_get_instance_returns_same_instance() { public function test_register_skips_when_ai_not_supported() { add_filter( 'wp_supports_ai', '__return_false' ); - $this->registry->register( 'first', self::$default_args ); + $args = self::$default_args; + $args['type'] = 'ai_provider'; + $this->registry->register( 'first', $args ); $all = $this->registry->get_all_registered(); $this->assertCount( 0, $all ); diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetApiKeySource.php b/tests/phpunit/tests/connectors/wpConnectorsGetApiKeySource.php new file mode 100644 index 0000000000000..cdbb53b90ab09 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsGetApiKeySource.php @@ -0,0 +1,141 @@ +assertSame( 'none', $result ); + } + + /** + * @ticket 64957 + */ + public function test_returns_database_when_option_set() { + $setting_name = 'connectors_ai_test_source_api_key'; + update_option( $setting_name, 'sk-test-key-123' ); + + $result = _wp_connectors_get_api_key_source( $setting_name ); + + delete_option( $setting_name ); + + $this->assertSame( 'database', $result ); + } + + /** + * @ticket 64957 + */ + public function test_returns_env_when_env_var_set() { + $env_var = 'WP_TEST_CONNECTOR_API_KEY'; + putenv( "{$env_var}=sk-from-env" ); + + $result = _wp_connectors_get_api_key_source( 'connectors_ai_test_api_key', $env_var ); + + putenv( $env_var ); + + $this->assertSame( 'env', $result ); + } + + /** + * @ticket 64957 + */ + public function test_returns_constant_when_constant_defined() { + $constant_name = 'WP_TEST_CONNECTOR_CONST_KEY'; + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'sk-from-constant' ); + } + + $result = _wp_connectors_get_api_key_source( 'connectors_ai_test_api_key', '', $constant_name ); + + $this->assertSame( 'constant', $result ); + } + + /** + * @ticket 64957 + */ + public function test_env_takes_priority_over_constant_and_database() { + $setting_name = 'connectors_ai_priority_test_api_key'; + $env_var = 'WP_TEST_PRIORITY_ENV_KEY'; + $constant_name = 'WP_TEST_PRIORITY_CONST_KEY'; + + update_option( $setting_name, 'sk-from-db' ); + putenv( "{$env_var}=sk-from-env" ); + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'sk-from-constant' ); + } + + $result = _wp_connectors_get_api_key_source( $setting_name, $env_var, $constant_name ); + + putenv( $env_var ); + delete_option( $setting_name ); + + $this->assertSame( 'env', $result ); + } + + /** + * @ticket 64957 + */ + public function test_constant_takes_priority_over_database() { + $setting_name = 'connectors_ai_const_priority_api_key'; + $constant_name = 'WP_TEST_CONST_PRIORITY_KEY'; + + update_option( $setting_name, 'sk-from-db' ); + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'sk-from-constant' ); + } + + $result = _wp_connectors_get_api_key_source( $setting_name, '', $constant_name ); + + delete_option( $setting_name ); + + $this->assertSame( 'constant', $result ); + } + + /** + * @ticket 64957 + */ + public function test_skips_env_check_when_env_var_name_empty() { + $env_var = 'WP_TEST_SKIP_ENV_KEY'; + $setting_name = 'connectors_ai_skip_env_api_key'; + + putenv( "{$env_var}=sk-from-env" ); + update_option( $setting_name, 'sk-from-db' ); + + // Empty env_var_name means env is not checked, falls through to database. + $result = _wp_connectors_get_api_key_source( $setting_name, '', '' ); + + putenv( $env_var ); + delete_option( $setting_name ); + + $this->assertSame( 'database', $result ); + } + + /** + * @ticket 64957 + */ + public function test_skips_constant_check_when_constant_name_empty() { + $constant_name = 'WP_TEST_SKIP_CONST_KEY'; + $setting_name = 'connectors_ai_skip_const_api_key'; + + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'sk-from-constant' ); + } + update_option( $setting_name, 'sk-from-db' ); + + // Empty constant_name means constant is not checked, falls through to database. + $result = _wp_connectors_get_api_key_source( $setting_name, '' ); + + delete_option( $setting_name ); + + $this->assertSame( 'database', $result ); + } +} 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..6ca396f42dc26 --- /dev/null +++ b/tests/phpunit/tests/formatting/sanitizeEmail.php @@ -0,0 +1,42 @@ +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 subdomain dropped' => array( "abc@sub.\x80.org", 'abc@sub.org' ), + 'all subdomains invalid utf8' => array( "abc@\x80.org", '' ), + ); + } +} 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' ), + ), + ); + } +} 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/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 { 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 { 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/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 { 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 { diff --git a/tests/phpunit/tests/oembed/WpEmbed.php b/tests/phpunit/tests/oembed/WpEmbed.php index c7b0649867d97..42d9c9e0f4ed9 100644 --- a/tests/phpunit/tests/oembed/WpEmbed.php +++ b/tests/phpunit/tests/oembed/WpEmbed.php @@ -2,8 +2,10 @@ /** * @group oembed + * + * @coversDefaultClass WP_Embed */ -class Tests_WP_Embed extends WP_UnitTestCase { +class Tests_oEmbed_WpEmbed extends WP_UnitTestCase { /** * @var WP_Embed */ @@ -22,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( @@ -40,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( @@ -57,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( @@ -74,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'; @@ -92,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] ); @@ -107,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"; @@ -117,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__; @@ -133,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(); @@ -146,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' ) ); @@ -153,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' => '' ) ); @@ -160,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'; @@ -178,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; @@ -205,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; @@ -239,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/'; @@ -265,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/'; @@ -293,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' ); @@ -325,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'; @@ -335,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() ) ); @@ -342,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'; @@ -351,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'; @@ -359,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 ); @@ -366,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/'; 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 97% rename from tests/phpunit/tests/oembed/postEmbedUrl.php rename to tests/phpunit/tests/oembed/getPostEmbedUrl.php index ed674b8429c38..3f2cd23399126 100644 --- a/tests/phpunit/tests/oembed/postEmbedUrl.php +++ b/tests/phpunit/tests/oembed/getPostEmbedUrl.php @@ -2,8 +2,10 @@ /** * @group oembed + * + * @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 ); 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 */ diff --git a/tests/phpunit/tests/oembed/wpOembed.php b/tests/phpunit/tests/oembed/wpOembed.php index 76d733dbce2e0..bc10c2a10a7eb 100644 --- a/tests/phpunit/tests/oembed/wpOembed.php +++ b/tests/phpunit/tests/oembed/wpOembed.php @@ -2,8 +2,10 @@ /** * @group oembed + * + * @coversDefaultClass WP_oEmbed */ -class Tests_WP_oEmbed extends WP_UnitTestCase { +class Tests_oEmbed_wpOembed extends WP_UnitTestCase { /** * @var WP_oEmbed */ @@ -47,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 ); @@ -59,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 ); @@ -74,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 ); @@ -93,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(); @@ -110,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; @@ -139,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; @@ -176,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(); @@ -203,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; @@ -232,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(); 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 ) ) { 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." ); 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..f52dec3388cb0 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -0,0 +1,3195 @@ +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; + $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|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 ( is_array( $awareness ) && 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. + */ + + /** + * @ticket 64696 + */ + public function test_register_routes(): void { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * 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_without_option(): void { + global $wp_rest_server; + + // Ensure the option is not in the database. + delete_option( 'wp_collaboration_enabled' ); + + // Reset the REST server so routes are re-registered from scratch. + $wp_rest_server = null; + + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( '/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. + } + + /** + * @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() ) ) ); + + $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. + } + + /* + * HTTP method and request format tests. + */ + + /** + * 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_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->assertSame( 404, $response->get_status(), 'GET should return 404 on a POST-only route.' ); + } + + /** + * PUT requests should return 404 because the route is registered + * for POST only. + * + * @ticket 64696 + */ + 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->assertSame( 404, $response->get_status(), 'PUT should return 404 on a POST-only route.' ); + } + + /** + * DELETE requests should return 404 because the route is registered + * for POST only. + * + * @ticket 64696 + */ + 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->assertSame( 404, $response->get_status(), 'DELETE should return 404 on a POST-only route.' ); + } + + /** + * A POST with an invalid JSON body should return 400. + * + * @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->assertSame( 400, $response->get_status(), 'Malformed JSON should return 400.' ); + } + + /** + * 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. + */ + + /** + * @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() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + } + + /** + * @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() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @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() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @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' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @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' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @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' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @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' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @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' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @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' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @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' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @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. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + $this->build_room( 'unknown/entity' ), + ) + ); + + $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. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_invalid_room_format_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'invalid-room-format' ), + ) + ); + + $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.' ); + } + + /** + * 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. + * + * 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. + */ + + /** + * @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() ) ) ); + + $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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_response_room_matches_request(): void { + 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(): 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->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'] ); + } + + /** + * @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() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); + $this->assertEmpty( $data['rooms'][0]['updates'] ); + } + + /* + * Update tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_update_delivered_to_other_client(): void { + 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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_own_updates_not_returned(): void { + 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 ); + } + + /** + * 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(); + $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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_step2_update_stored_and_returned(): void { + 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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_updates_in_single_request(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_update_data_preserved(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_total_updates_increments(): void { + 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'] ); + } + + /** + * 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. + */ + + /** + * @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(); + $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'] ); + } + + /** + * @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(); + $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(): void { + 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.' ); + } + + /** + * @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(); + $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'] ); + } + + /** + * @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(); + $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. + */ + + /** + * 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' ); + $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'], '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.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_shows_multiple_clients(): void { + $room = $this->get_post_room(); + + // 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 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' ) ), + ) + ); + + $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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_updates_existing_client(): void { + 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'] ); + } + + /** + * @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(); + + // 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 ); + } + + /** + * 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(): void { + 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. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_rooms_in_single_request(): void { + 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'] ); + } + + /** + * @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 ) ); + $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.' ); + } + + /** + * 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.' ); + } + + /** + * 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. + */ + + /** + * 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 data column 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', + 'user_id' => self::$editor_id, + 'data' => 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', '%d', '%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 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.' ); + } + + /** + * @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.' + ); + } + + /** + * 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_collaboration_enabled', 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. + */ + + /** + * @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 61841.' ); + + // 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_through_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, + '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' ) + ); + + // 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, + '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' ) + ); + + // 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 data JSON blob. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_user_id_round_trip(): void { + 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, data 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->data, 'data column should not contain user_id.' ); + } + + /** + * Verifies that the is_array() guard in get_awareness_state() skips + * rows where the data column contains valid JSON that is not an array. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_non_array_json_ignored(): void { + 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, + 'data' => '"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(): void { + 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(): void { + 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(): void { + 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). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, null ), + ) + ); + $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.' ); + } + + /* + * 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(); + + // 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' ) ), + ) + ); + + $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( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $queries_warm = $wpdb->num_queries - $queries_before_warm; + + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Warm-cache dispatch should use fewer queries than cold-cache dispatch.' + ); + } + + /** + * 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.' + ); + } + + /** + * 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. + */ + + /** + * 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(): void { + 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. + */ + + /** + * 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 ); + } + + /* + * 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. 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_wp_is_collaboration_enabled_true_when_both_conditions_met(): void { + update_option( 'wp_collaboration_enabled', 1 ); + + $this->assertTrue( wp_is_collaboration_enabled() ); + } + + /** + * 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_false_when_db_version_too_low(): void { + update_option( 'wp_collaboration_enabled', 1 ); + update_option( 'db_version', 61839 ); + + $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() ); + } + + /* + * 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. + * + * @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(); + + // 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' ) ), + ) + ); + + $queries_initial = $wpdb->num_queries - $queries_before_initial; + + // 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', 0, array( 'user' => 'test' ) ), + ) + ); + + $queries_idle = $wpdb->num_queries - $queries_before_idle; + + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); + $this->assertLessThanOrEqual( + $queries_initial, + $queries_idle, + '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. + $id_count = count( $ids ); + for ( $i = 1; $i < $id_count; $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( + $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.' ); + } + + /* + * 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.' ); + } + + /* + * 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.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 9c6c431e5ef35..b88758097b23c 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,12 @@ 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_collaboration_enabled', '__return_true' ); + /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = new Spy_REST_Server(); @@ -203,6 +209,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 ); @@ -214,7 +224,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-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' ) ), - ); - } } 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 7a04226ced8c9..0000000000000 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ /dev/null @@ -1,868 +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 ) ); - - // 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 ); - - // 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; - - // Ensure the option is not in the database. - delete_option( 'wp_collaboration_enabled' ); - - // Reset the REST server so routes are re-registered from scratch. - $wp_rest_server = null; - - $routes = rest_get_server()->get_routes(); - $this->assertArrayNotHasKey( '/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->assertGreaterThanOrEqual( 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/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'] ); + } } 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, diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 003dc397ae305..98426b717f3b6 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,7 +20,9 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", - "wp-abilities/v1" + "wp-abilities/v1", + "wp-collaboration/v1", + "wp-sync/v1" ], "authentication": { "application-passwords": { @@ -12698,6 +12700,240 @@ 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, + "minLength": 1, + "maxLength": 32, + "required": true, + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 191 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true, + "maxLength": 1048576 + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "maxItems": 50, + "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": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-sync/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-sync/v1" + } + ] + } + }, + "/wp-sync/v1/updates": { + "namespace": "wp-sync/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, + "minLength": 1, + "maxLength": 32, + "required": true, + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 191 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true, + "maxLength": 1048576 + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "maxItems": 50, + "type": "array", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-sync/v1/updates" + } + ] + } } }, "site_logo": 0, @@ -14556,7 +14792,7 @@ mockedApiResponse.settings = { "use_smilies": true, "default_category": 1, "default_post_format": "0", - "wp_collaboration_enabled": false, + "wp_collaboration_enabled": true, "posts_per_page": 10, "show_on_front": "posts", "page_on_front": 0, diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 6b4c4a1dd8811..9fd35f0c1ffc2 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -245,7 +245,7 @@

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