From 2b8651bab409792dba7ffe03c2cd8eb2d2645c06 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 19:10:05 +0100 Subject: [PATCH 1/4] Editor: Register the core entity fields on the server. Copies the PHP of the `core-fields` package from the Gutenberg build and loads it, so WordPress registers its own fields through the entity fields API: the default fields every post type derives from its supports, and the field collections of pages, templates, template parts, patterns, attachments, navigation menus, and the site. Synced from https://github.com/WordPress/gutenberg/pull/84696. See #66229. --- Gruntfile.js | 4 +- src/wp-includes/entity-fields.php | 4 +- src/wp-settings.php | 3 + tests/phpunit/tests/entity-fields/base.php | 6 +- .../wpRegisterCoreFieldCollections.php | 732 ++++++++++++++++++ 5 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php diff --git a/Gruntfile.js b/Gruntfile.js index 9340b45302a33..ef82a84128ce9 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -697,7 +697,7 @@ module.exports = function(grunt) { src: 'vendor/composer/ca-bundle/res/cacert.pem', dest: SOURCE_DIR + 'wp-includes/certificates/ca-bundle.crt' }, - // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/, core actions). + // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/, core actions, core fields). 'gutenberg-php': { options: { process: function( content ) { @@ -718,6 +718,8 @@ module.exports = function(grunt) { 'pages/**/*.php', // The actions Core registers through the entity actions API. 'scripts/core-actions/**/*.php', + // The fields Core registers through the entity fields API. + 'scripts/core-fields/**/*.php', ], dest: SOURCE_DIR + 'wp-includes/build/', } ], diff --git a/src/wp-includes/entity-fields.php b/src/wp-includes/entity-fields.php index fff833b6788b8..91a0547557a3e 100644 --- a/src/wp-includes/entity-fields.php +++ b/src/wp-includes/entity-fields.php @@ -19,7 +19,9 @@ * Fields can also be declared as files, grouped in a field collection, which * a `wp_entity_fields_api_init` callback registers with * {@see wp_register_entity_field_collection()}. A collection is for a single - * entity. + * entity. WordPress core registers its own fields this way, from + * packages/core-fields: the defaults every post type derives from its + * supports in code, the fields of single post types as collections. * * The registered fields are exposed by the `/wp/v2/entity-fields` REST API * route, see {@see WP_REST_Entity_Fields_Controller}. diff --git a/src/wp-settings.php b/src/wp-settings.php index cc7a1e00a38c3..8bd37b43d635e 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -257,6 +257,9 @@ if ( file_exists( ABSPATH . WPINC . '/build/scripts/core-actions/index.php' ) ) { require ABSPATH . WPINC . '/build/scripts/core-actions/index.php'; } +if ( file_exists( ABSPATH . WPINC . '/build/scripts/core-fields/index.php' ) ) { + require ABSPATH . WPINC . '/build/scripts/core-fields/index.php'; +} require ABSPATH . WPINC . '/taxonomy.php'; require ABSPATH . WPINC . '/class-wp-taxonomy.php'; require ABSPATH . WPINC . '/class-wp-term.php'; diff --git a/tests/phpunit/tests/entity-fields/base.php b/tests/phpunit/tests/entity-fields/base.php index b95d57897a7ce..5ef0d39f0cddf 100644 --- a/tests/phpunit/tests/entity-fields/base.php +++ b/tests/phpunit/tests/entity-fields/base.php @@ -27,12 +27,14 @@ abstract class WP_Entity_Fields_UnitTestCase extends WP_UnitTestCase { * Sets up each test. * * Registers the fixture defaults, see register_default_fields(), at - * priority 0, before the callbacks of the tests, and resets the registry - * so its next read fires `wp_entity_fields_api_init` anew. + * priority 0, before the callbacks of the tests, in place of the fields + * WordPress core registers, and resets the registry so its next read + * fires `wp_entity_fields_api_init` anew. */ public function set_up() { parent::set_up(); + remove_action( 'wp_entity_fields_api_init', 'wp_register_core_field_collections', 0 ); add_action( 'wp_entity_fields_api_init', array( static::class, 'register_default_fields' ), 0 ); self::reset_registry(); } diff --git a/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php new file mode 100644 index 0000000000000..199be49d5b89c --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php @@ -0,0 +1,732 @@ +theme_features = $_wp_theme_features; + remove_action( 'wp_entity_fields_api_init', array( static::class, 'register_default_fields' ), 0 ); + add_action( 'wp_entity_fields_api_init', 'wp_register_core_field_collections', 0 ); + self::reset_registry(); + } + + /** + * Tears down each test. + * + * Restores the theme features a test changed. + */ + public function tear_down() { + global $_wp_theme_features; + + $_wp_theme_features = $this->theme_features; + + parent::tear_down(); + } + + /** + * Returns the fields registered on an entity, keyed by id. + * + * @param string $kind The entity kind. + * @param string $name The entity name. + * @return array[] The field definitions, keyed by id. + */ + private static function get_fields( $kind, $name ) { + return array_column( wp_get_registered_entity_fields( $kind, $name ), null, 'id' ); + } + + /** + * Returns the ids of the fields registered on a post type. + * + * @param string $post_type The post type. + * @return string[] The ids, in registration order. + */ + private static function get_field_ids( $post_type ) { + return array_column( wp_get_registered_entity_fields( 'postType', $post_type ), 'id' ); + } + + /** + * Returns the ids of the fields registered on a post type, but for the + * defaults every post type gets, see EVERY_POST_TYPE_FIELDS. + * + * @param string $post_type The post type. + * @return string[] The ids, in registration order. + */ + private static function get_support_field_ids( $post_type ) { + return array_values( array_diff( self::get_field_ids( $post_type ), self::EVERY_POST_TYPE_FIELDS ) ); + } + + /** + * The core fields are registered on `wp_entity_fields_api_init` at + * priority 0, before the plugins hooking the action at the default + * priority. + * + * @ticket 66229 + */ + public function test_the_core_fields_are_registered_at_priority_zero() { + // The hooks as WordPress sets them up, before the other entity fields + // tests replace the core fields with their fixture defaults. + $hook = self::$hooks_saved['wp_filter']['wp_entity_fields_api_init']; + + $this->assertSame( 0, $hook->has_filter( 'wp_entity_fields_api_init', 'wp_register_core_field_collections' ), 'The core fields are registered at priority 0.' ); + } + + /** + * The script module of the default fields is declared as a dependency + * of the editor script, since the author field of every post type + * supporting authors ships its JavaScript parts in it. + * + * @ticket 66229 + */ + public function test_the_default_fields_module_is_a_dependency_of_the_editor_script() { + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->enqueue( 'wp-editor' ); + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertContains( + array( + 'id' => self::POST_TYPE_SUPPORTS, + 'import' => 'dynamic', + ), + $scripts->get_data( 'wp-editor', 'module_dependencies' ), + 'The module of the default fields is a dynamic dependency of the editor script.' + ); + } + + /** + * The script modules of the core fields are registered, so they reach + * the import map. + * + * @ticket 66229 + */ + public function test_the_script_modules_of_the_core_fields_are_registered() { + foreach ( wp_get_all_registered_entity_field_modules() as $kind => $entities ) { + foreach ( $entities as $name => $modules ) { + foreach ( $modules as $module ) { + $this->assertNotNull( wp_script_modules()->get_registered( $module ), "The $module module of $kind $name is registered." ); + } + } + } + } + + /** + * The labels are translated with the default text domain of WordPress. + * + * @ticket 66229 + */ + public function test_the_labels_are_translated_with_the_default_text_domain() { + $domains = array(); + add_filter( + 'gettext', + static function ( $translation, $text, $domain ) use ( &$domains ) { + if ( 'Discussion' === $text ) { + $domains[] = $domain; + } + return $translation; + }, + 10, + 3 + ); + + self::reset_registry(); + wp_get_registered_entity_fields( 'postType', 'post' ); + + $this->assertSame( array( 'default' ), array_unique( $domains ), 'The label of the discussion field is translated in the default text domain.' ); + } + + /** + * Any post type supporting authors, comments, or notes, including a + * custom one, gets the default fields: the defaults derive from the post + * types registered when the action fires. + * + * @ticket 66229 + */ + public function test_a_custom_post_type_gets_the_default_fields_of_its_supports() { + $this->register_post_types( + array( + 'test_book' => array( + 'title', + 'author', + 'comments', + 'editor' => array( 'notes' => true ), + ), + ) + ); + self::reset_registry(); + + $fields = wp_get_registered_entity_fields( 'postType', 'test_book' ); + + $this->assertSame( array( 'author', 'comment_status', 'discussion', 'notesCount', 'post-content-info', 'title' ), self::get_support_field_ids( 'test_book' ), 'The post type gets the fields of its supports.' ); + $this->assertSame( array( 'core' ), array_unique( array_column( array_column( $fields, 'origin' ), 'registeredBy' ) ), 'The fields carry the origin of WordPress core.' ); + $this->assertSame( + array( self::POST_TYPE_SUPPORTS => array_column( $fields, 'id' ) ), + wp_get_registered_entity_field_modules( 'postType', 'test_book' ), + 'Every default field is registered with the module of its folder.' + ); + } + + /** + * The notes field follows the `notes` argument of the `editor` support, + * which WordPress stores as a list of argument arrays; a bare `editor` + * support does not enable it. + * + * @ticket 66229 + */ + public function test_the_notes_field_follows_the_editor_support_argument() { + $this->register_post_types( + array( + 'test_book' => array( 'editor' => array( 'notes' => true ) ), + 'test_note' => array( 'editor' ), + ) + ); + self::reset_registry(); + + $this->assertContains( 'notesCount', self::get_field_ids( 'test_book' ), 'The post type with notes gets the field.' ); + $this->assertNotContains( 'notesCount', self::get_field_ids( 'test_note' ), 'The post type without notes does not get the field.' ); + } + + /** + * Data provider. + * + * @return array[] The id of each field and whether the design post types + * exclude it. + */ + public function data_default_fields_of_every_post_type() { + return array( + 'date' => array( 'date', true ), + 'last_edited_date' => array( 'last_edited_date', false ), + 'password' => array( 'password', true ), + 'scheduled_date' => array( 'scheduled_date', true ), + 'status' => array( 'status', true ), + 'template' => array( 'template', true ), + ); + } + + /** + * A default field of every post type is registered on a post type + * supporting nothing, and on the design post types unless they exclude + * it. + * + * @ticket 66229 + * + * @dataProvider data_default_fields_of_every_post_type + * + * @param string $field_id The id of the field. + * @param bool $excluded_for_design Whether the design post types exclude it. + */ + public function test_a_default_field_of_every_post_type( $field_id, $excluded_for_design ) { + $this->register_post_types( array( 'test_plain' => array( 'title' ) ) ); + self::reset_registry(); + + $this->assertContains( $field_id, self::get_field_ids( 'test_plain' ), 'A post type supporting only titles gets the field.' ); + foreach ( array( 'wp_template', 'wp_template_part', 'wp_block', 'wp_navigation' ) as $post_type ) { + if ( $excluded_for_design ) { + $this->assertNotContains( $field_id, self::get_field_ids( $post_type ), "$post_type excludes the field." ); + } else { + $this->assertContains( $field_id, self::get_field_ids( $post_type ), "$post_type gets the field." ); + } + } + } + + /** + * Data provider. + * + * @return array[] The id of each field and the supports that give it. + */ + public function data_default_fields_and_their_supports() { + return array( + 'discussion' => array( 'discussion', array( 'comments', 'trackbacks' ) ), + 'excerpt' => array( 'excerpt', array( 'excerpt' ) ), + 'parent' => array( 'parent', array( 'page-attributes' ) ), + 'ping_status' => array( 'ping_status', array( 'trackbacks' ) ), + 'title' => array( 'title', array( 'title' ) ), + 'post-content-info' => array( 'post-content-info', array( 'editor' ) ), + ); + } + + /** + * A default field is registered on the post types with one of its + * supports, and only on them. + * + * @ticket 66229 + * + * @dataProvider data_default_fields_and_their_supports + * + * @param string $field_id The id of the field. + * @param string[] $supports The supports that give the field. + */ + public function test_a_default_field_follows_its_supports( $field_id, $supports ) { + $with_support = array(); + foreach ( $supports as $index => $support ) { + $with_support[ "test_with_$index" ] = array( 'title', $support ); + } + $this->register_post_types( $with_support + array( 'test_without' => array( 'author' ) ) ); + self::reset_registry(); + + foreach ( array_keys( $with_support ) as $post_type ) { + $this->assertContains( $field_id, self::get_field_ids( $post_type ), "$post_type gets the field." ); + } + $this->assertNotContains( $field_id, self::get_field_ids( 'test_without' ), 'A post type without the supports does not get the field.' ); + } + + /** + * The default fields come from the `field.php` files of the + * `post_type_supports` folder. + * + * @ticket 66229 + */ + public function test_the_default_fields_come_from_the_post_type_supports_folder() { + $fields = self::get_fields( 'postType', 'post' ); + + $this->assertSame( 'integer', $fields['author']['type'], 'The author field is the post author.' ); + $this->assertSame( 'core', $fields['author']['origin']['registeredBy'], 'The author field is registered by WordPress core.' ); + $this->assertSame( 'radio', $fields['comment_status']['Edit'], 'The comment status field is edited with radio buttons.' ); + $this->assertSame( 'boolean', $fields['ping_status']['type'], 'The ping status field is a boolean.' ); + $this->assertSame( 'Discussion', $fields['discussion']['label'], 'The discussion field has its label.' ); + $this->assertSame( 'textarea', $fields['excerpt']['Edit']['control'], 'The excerpt field is edited in a textarea.' ); + $this->assertTrue( $fields['post-content-info']['readOnly'], 'The content information field is read-only.' ); + } + + /** + * The defaults make no exception for any post type: on their own, they + * register the author field on templates, template parts, and + * attachments, which support authors. + * + * @ticket 66229 + */ + public function test_the_defaults_make_no_exception() { + remove_action( 'wp_entity_fields_api_init', 'wp_register_core_field_collections', 0 ); + $this->on_entity_fields_api_init( 'wp_register_core_post_type_supports_fields', 0 ); + + foreach ( array( 'wp_template', 'wp_template_part', 'attachment' ) as $post_type ) { + $fields = self::get_fields( 'postType', $post_type ); + $this->assertArrayHasKey( 'author', $fields, "The defaults register the author field on $post_type." ); + $this->assertSame( 'integer', $fields['author']['type'], "The default author field of $post_type is the post author." ); + } + } + + /** + * Pages support titles but their title shows the page's role on the + * site: they get the title field of their collection, with its own + * script module, instead of the default one. + * + * @ticket 66229 + */ + public function test_pages_get_their_own_title_field() { + $fields = self::get_fields( 'postType', 'page' ); + $modules = wp_get_registered_entity_field_modules( 'postType', 'page' ); + + $this->assertFalse( $fields['title']['enableHiding'], 'The page title is the one of the collection.' ); + $this->assertSame( 'core', $fields['title']['origin']['registeredBy'], 'The page title is registered by WordPress core.' ); + $this->assertSame( array( 'title' ), $modules['@wordpress/core-fields/page'], 'The page title ships its JavaScript parts in the module of the collection.' ); + $this->assertNotContains( 'title', $modules[ self::POST_TYPE_SUPPORTS ], 'The default title field is not registered.' ); + } + + /** + * Templates support authors but their author is the theme, plugin, site, + * or user that provides them: they get the fields of their collection, + * with its own script module, instead of the default author and title + * fields. + * + * @ticket 66229 + */ + public function test_templates_get_their_own_fields() { + $ids = array( 'author', 'default_comment_status', 'description', 'description_readonly', 'posts_page_title', 'posts_per_page', 'title' ); + $fields = self::get_fields( 'postType', 'wp_template' ); + + $this->assertTrue( post_type_supports( 'wp_template', 'author' ), 'The post type supports authors.' ); + $this->assertSame( $ids, self::get_support_field_ids( 'wp_template' ), 'The fields are the ones of the collection.' ); + $this->assertSame( 'Template', $fields['title']['label'], 'The title is the one of the collection.' ); + $this->assertArrayNotHasKey( 'type', $fields['author'], 'The template author is not the integer post author.' ); + $this->assertSame( 'core', $fields['author']['origin']['registeredBy'], 'The template author is registered by WordPress core.' ); + $this->assertSame( + $ids, + wp_get_registered_entity_field_modules( 'postType', 'wp_template' )['@wordpress/core-fields/wp_template'], + 'The fields of templates ship their JavaScript parts in the module of the collection.' + ); + } + + /** + * Template parts support authors but their author is the theme, plugin, + * site, or user that provides them: they get the fields of their + * collection, with its own script module, instead of the default author + * and title fields. + * + * @ticket 66229 + */ + public function test_template_parts_get_their_own_fields() { + $fields = self::get_fields( 'postType', 'wp_template_part' ); + + $this->assertTrue( post_type_supports( 'wp_template_part', 'author' ), 'The post type supports authors.' ); + $this->assertSame( array( 'author', 'title' ), self::get_support_field_ids( 'wp_template_part' ), 'The fields are the ones of the collection.' ); + $this->assertArrayNotHasKey( 'type', $fields['author'], 'The template part author is not the integer post author.' ); + $this->assertSame( 'Title', $fields['title']['label'], 'The title is the one of the collection.' ); + $this->assertSame( + array( 'author', 'title' ), + wp_get_registered_entity_field_modules( 'postType', 'wp_template_part' )['@wordpress/core-fields/wp_template_part'], + 'The fields of template parts ship their JavaScript parts in the module of the collection.' + ); + } + + /** + * Patterns have their description, title, and sync status in their + * collection instead of the default excerpt and title fields. + * + * @ticket 66229 + */ + public function test_patterns_get_their_own_fields() { + $fields = self::get_fields( 'postType', 'wp_block' ); + $modules = wp_get_registered_entity_field_modules( 'postType', 'wp_block' ); + + $this->assertTrue( post_type_supports( 'wp_block', 'excerpt' ), 'The post type supports excerpts.' ); + $this->assertSame( 'Description', $fields['excerpt']['label'], 'The excerpt is the description of the collection.' ); + $this->assertSame( 'Title', $fields['title']['label'], 'The title is the one of the collection.' ); + $this->assertTrue( $fields['sync-status']['readOnly'], 'The sync status is read-only.' ); + $this->assertSame( array( 'excerpt', 'sync-status', 'title' ), $modules['@wordpress/core-fields/wp_block'], 'The fields of patterns ship their JavaScript parts in the module of the collection.' ); + $this->assertNotContains( 'excerpt', $modules[ self::POST_TYPE_SUPPORTS ], 'The default excerpt field is not registered.' ); + } + + /** + * Patterns are sorted on the client, where the id of their author is not + * a useful order, so their author field cannot sort them. + * + * @ticket 66229 + */ + public function test_patterns_cannot_be_sorted_by_author() { + $author = self::get_fields( 'postType', 'wp_block' )['author']; + + $this->assertFalse( $author['enableSorting'], 'The author of patterns cannot sort them.' ); + $this->assertSame( + array( + 'registeredBy' => 'core', + 'updatedBy' => array( 'core' ), + ), + $author['origin'], + 'WordPress core updates the default author field of patterns.' + ); + } + + /** + * Navigation menus support the editor, but their content is blocks + * laying out a site rather than text to read, so they do not get the + * content information field. Patterns keep it. + * + * @ticket 66229 + */ + public function test_navigation_menus_do_not_get_the_content_information_field() { + $this->assertTrue( post_type_supports( 'wp_navigation', 'editor' ), 'The post type supports the editor.' ); + $this->assertNotContains( 'post-content-info', self::get_field_ids( 'wp_navigation' ), 'Navigation menus do not get the field.' ); + $this->assertContains( 'post-content-info', self::get_field_ids( 'wp_block' ), 'Patterns get the field.' ); + } + + /** + * Attachments support authors and comments but the media editor has its + * own fields, so they get the media fields of their collection, with its + * script module, instead of every default field. + * + * @ticket 66229 + */ + public function test_attachments_get_the_media_fields_instead_of_the_defaults() { + $ids = array( 'alt_text', 'attached_to', 'author', 'caption', 'date', 'description', 'filename', 'filesize', 'media_dimensions', 'media_thumbnail', 'mime_type', 'modified', 'title' ); + $fields = self::get_fields( 'postType', 'attachment' ); + + $this->assertTrue( post_type_supports( 'attachment', 'author' ), 'The post type supports authors.' ); + $this->assertTrue( post_type_supports( 'attachment', 'comments' ), 'The post type supports comments.' ); + $this->assertSame( $ids, array_keys( $fields ), 'Only the media fields are registered.' ); + $this->assertSame( 'datetime', $fields['date']['type'], 'The date is the one of the collection.' ); + $this->assertSame( 'core', $fields['date']['origin']['registeredBy'], 'The media fields are registered by WordPress core.' ); + $this->assertSame( + array( '@wordpress/core-fields/attachment' => $ids ), + wp_get_registered_entity_field_modules( 'postType', 'attachment' ), + 'Every media field is registered with the module of the collection.' + ); + } + + /** + * The site is not a post type: its collection has all of its fields, + * the ones the identity screen of the site editor shows, with its + * script module. + * + * @ticket 66229 + */ + public function test_the_site_gets_the_fields_of_its_collection() { + $ids = array( 'description', 'site_icon', 'site_logo', 'title' ); + $fields = self::get_fields( 'root', 'site' ); + + $this->assertSame( $ids, array_keys( $fields ), 'The fields are the ones of the collection.' ); + $this->assertSame( 'text', $fields['title']['type'], 'The title of the site is text.' ); + $this->assertSame( 'media', $fields['site_logo']['type'], 'The logo of the site is media.' ); + $this->assertSame( 'core', $fields['title']['origin']['registeredBy'], 'The fields of the site are registered by WordPress core.' ); + $this->assertSame( + array( '@wordpress/core-fields/root_site' => $ids ), + wp_get_registered_entity_field_modules( 'root', 'site' ), + 'Every field of the site is registered with the module of the collection.' + ); + } + + /** + * The slug field is registered on the viewable post types, which have a + * permalink, but for the design ones. + * + * @ticket 66229 + */ + public function test_the_viewable_post_types_get_the_slug_field() { + register_post_type( + 'test_book', + array( + 'public' => true, + 'show_in_rest' => true, + ) + ); + register_post_type( + 'test_note', + array( + 'public' => false, + 'show_in_rest' => true, + ) + ); + self::reset_registry(); + + $this->assertContains( 'slug', self::get_field_ids( 'test_book' ), 'A viewable post type gets the field.' ); + $this->assertNotContains( 'slug', self::get_field_ids( 'test_note' ), 'A post type that is not viewable has no permalink.' ); + $this->assertContains( 'slug', self::get_field_ids( 'page' ), 'Pages get the field.' ); + $this->assertNotContains( 'slug', self::get_field_ids( 'wp_template' ), 'Templates do not get the field.' ); + } + + /** + * Only posts have sticky posts, so only they get the sticky field. + * + * @ticket 66229 + */ + public function test_only_posts_get_the_sticky_field() { + $this->register_post_types( array( 'test_book' => array( 'title', 'editor' ) ) ); + self::reset_registry(); + + $this->assertContains( 'sticky', self::get_field_ids( 'post' ), 'Posts get the field.' ); + $this->assertNotContains( 'sticky', self::get_field_ids( 'page' ), 'Pages do not get the field.' ); + $this->assertNotContains( 'sticky', self::get_field_ids( 'test_book' ), 'Custom post types do not get the field.' ); + } + + /** + * The featured media field needs the post type to support `thumbnail` + * and the theme to support post thumbnails for that post type, which a + * theme may opt into for some post types only. + * + * @ticket 66229 + */ + public function test_the_featured_media_field_follows_the_post_type_support_and_the_theme() { + $this->register_post_types( + array( + 'test_book' => array( 'title', 'thumbnail' ), + 'test_note' => array( 'title', 'thumbnail' ), + 'test_page' => array( 'title' ), + ) + ); + + // Naming post types only narrows the support when the theme does not + // already support post thumbnails for every post type. + remove_theme_support( 'post-thumbnails' ); + add_theme_support( 'post-thumbnails', array( 'test_book' ) ); + self::reset_registry(); + + $this->assertContains( 'featured_media', self::get_support_field_ids( 'test_book' ), 'The theme opted the post type into post thumbnails.' ); + $this->assertNotContains( 'featured_media', self::get_support_field_ids( 'test_note' ), 'The theme did not opt the post type into post thumbnails.' ); + $this->assertNotContains( 'featured_media', self::get_support_field_ids( 'test_page' ), 'The post type does not support thumbnails.' ); + + remove_theme_support( 'post-thumbnails' ); + add_theme_support( 'post-thumbnails' ); + self::reset_registry(); + + $this->assertContains( 'featured_media', self::get_support_field_ids( 'test_note' ), 'A theme may opt every post type in at once.' ); + + remove_theme_support( 'post-thumbnails' ); + self::reset_registry(); + + $this->assertNotContains( 'featured_media', self::get_support_field_ids( 'test_book' ), 'A theme without post thumbnails has no featured image to set.' ); + } + + /** + * The format field needs the post type to support `post-formats` and + * the theme to support post formats. Its elements are the formats of + * the theme, plus `standard`, sorted by label. + * + * @ticket 66229 + */ + public function test_the_format_field_follows_the_post_type_support_and_the_theme() { + $this->register_post_types( + array( + 'test_book' => array( 'title', 'post-formats' ), + 'test_note' => array( 'title' ), + ) + ); + + add_theme_support( 'post-formats', array( 'gallery', 'aside' ) ); + self::reset_registry(); + $fields = self::get_fields( 'postType', 'test_book' ); + + $this->assertArrayHasKey( 'format', $fields, 'The post type supporting post formats gets the field.' ); + $this->assertNotContains( 'format', self::get_field_ids( 'test_note' ), 'A post type that does not support post formats has no format field.' ); + $this->assertSame( + array( 'aside', 'gallery', 'standard' ), + array_column( $fields['format']['elements'], 'value' ), + 'The elements are the formats of the theme, plus standard, sorted by label.' + ); + + remove_theme_support( 'post-formats' ); + self::reset_registry(); + + $this->assertNotContains( 'format', self::get_field_ids( 'test_book' ), 'A theme without post formats has none to assign.' ); + } + + /** + * A plugin hooking the action at the default priority sees the core + * fields: it can update a default field, or remove it. + * + * @ticket 66229 + */ + public function test_a_plugin_can_alter_the_default_fields() { + $this->on_entity_fields_api_init( + static function () { + wp_update_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + array( + 'id' => 'comment_status', + 'enableSorting' => true, + ), + ) + ); + wp_unregister_entity_fields( 'postType', 'page', array( 'author' ) ); + } + ); + + $fields = self::get_fields( 'postType', 'page' ); + + $this->assertTrue( $fields['comment_status']['enableSorting'], 'The property is updated.' ); + $this->assertSame( 'text', $fields['comment_status']['type'], 'The rest of the default definition is kept.' ); + $this->assertSame( + array( + 'registeredBy' => 'core', + 'updatedBy' => array( 'test-plugin' ), + ), + $fields['comment_status']['origin'], + 'Updating a field keeps who registered it and records who updated it.' + ); + $this->assertArrayNotHasKey( 'author', $fields, 'The default field is removed.' ); + } + + /** + * A plugin drops a default field from its post type by unregistering + * it at the default priority, keeping the others. + * + * @ticket 66229 + */ + public function test_a_plugin_can_drop_a_default_field_from_a_post_type() { + $this->register_post_types( + array( + 'test_book' => array( 'title', 'author', 'comments' ), + 'test_note' => array( 'title', 'author', 'comments' ), + ) + ); + $this->on_entity_fields_api_init( + static function () { + wp_unregister_entity_fields( 'postType', 'test_book', array( 'comment_status' ) ); + } + ); + + $this->assertSame( array( 'author', 'discussion', 'title' ), self::get_support_field_ids( 'test_book' ), 'The post type drops the field.' ); + $this->assertSame( array( 'author', 'comment_status', 'discussion', 'title' ), self::get_support_field_ids( 'test_note' ), 'The other post types keep the field.' ); + } + + /** + * A plugin can replace the author field of templates. + * + * @ticket 66229 + */ + public function test_a_plugin_can_replace_the_author_field_of_templates() { + $this->on_entity_fields_api_init( + static function () { + wp_unregister_entity_fields( 'postType', 'wp_template', array( 'author' ) ); + wp_register_entity_fields( + 'my-plugin', + 'postType', + 'wp_template', + array( + array( + 'id' => 'author', + 'type' => 'text', + 'label' => 'Maker', + ), + ) + ); + } + ); + + $modules = wp_get_registered_entity_field_modules( 'postType', 'wp_template' ); + + $this->assertSame( 'my-plugin', self::get_fields( 'postType', 'wp_template' )['author']['origin']['registeredBy'], 'The author field is the one of the plugin.' ); + $this->assertNotContains( 'author', $modules['@wordpress/core-fields/wp_template'], 'The module of the replaced field no longer applies to it.' ); + } + + /** + * A plugin registering attachment fields keeps them next to the media + * fields. + * + * @ticket 66229 + */ + public function test_attachments_keep_the_fields_plugins_register() { + $this->register_fields( 'postType', 'attachment', array( $this->field( 'credit' ) ) ); + + $ids = self::get_field_ids( 'attachment' ); + + $this->assertContains( 'date', $ids, 'The media fields are kept.' ); + $this->assertSame( 'credit', end( $ids ), 'The plugin field follows the media fields.' ); + } +} From 20a2118d54da19635a413c41ff20ce2053bd2e6f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 19:19:30 +0100 Subject: [PATCH 2/4] Tests: Cover the core entity fields in the REST API. Ports the REST controller tests of https://github.com/WordPress/gutenberg/pull/84696: the fields route exposes the default fields of pages and the fields of patterns, with the script modules of their collections and the `core` origin. See #66229. --- .../rest-entity-fields-controller.php | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php index 400bbb5740f76..693295574fa82 100644 --- a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php +++ b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php @@ -69,8 +69,9 @@ public static function wpTearDownAfterClass() { /** * Tears down each test. * - * Resetting the registry drops the fields a test registered; the next - * read fires `wp_entity_fields_api_init` again. + * Resetting the registry drops the fields a test registered along with + * the defaults; the next read fires `wp_entity_fields_api_init` again and + * registers the defaults anew. */ public function tear_down() { foreach ( $this->callbacks as $callback ) { @@ -100,7 +101,8 @@ private static function reset_registry() { * * Registering only runs on the action, so the registration is hooked to * it, as a plugin does, and the action fired anew: the registry is reset - * and read, which replays the registrations made so far, in order. + * and read, which registers the defaults again and replays the + * registrations made so far, in order. * * @param string $kind The entity kind. * @param string $name The entity name. @@ -425,6 +427,81 @@ public function test_get_items_returns_entity_and_fields_shape() { $this->assertIsArray( $data['script_modules'] ); } + /** + * The default fields registered for a post type are exposed. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_exposes_the_default_post_type_fields() { + wp_set_current_user( self::$editor_id ); + + $data = $this->dispatch_request( 'postType', 'page' )->get_data(); + $ids = array_column( $data['fields'], 'id' ); + + $this->assertContains( 'author', $ids, 'Pages support authors.' ); + $this->assertContains( 'comment_status', $ids, 'Pages support comments.' ); + $this->assertSame( wp_get_registered_entity_fields( 'postType', 'page' ), $data['fields'], 'The fields are exposed as registered.' ); + $this->assertSame( + array( + array( + 'id' => '@wordpress/core-fields/post_type_supports', + 'fields' => array_values( array_diff( $ids, array( 'title' ) ) ), + ), + array( + 'id' => '@wordpress/core-fields/page', + 'fields' => array( 'title' ), + ), + ), + $data['script_modules'], + 'The fields are registered with the module of their collection.' + ); + } + + /** + * The pattern collection exposes its fields and their script module. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_exposes_pattern_fields_and_their_script_module() { + wp_set_current_user( self::$editor_id ); + + $data = $this->dispatch_request( 'postType', 'wp_block' )->get_data(); + $ids = array_column( $data['fields'], 'id' ); + $modules = array_column( $data['script_modules'], 'fields', 'id' ); + + $this->assertContains( 'excerpt', $ids, 'Patterns have a description.' ); + $this->assertContains( 'sync-status', $ids, 'Patterns have a sync status.' ); + $this->assertContains( 'title', $ids, 'Patterns have a title.' ); + $this->assertSame( wp_get_registered_entity_fields( 'postType', 'wp_block' ), $data['fields'], 'The fields of patterns are exposed as registered.' ); + $this->assertSame( + array( 'excerpt', 'sync-status', 'title' ), + $modules['@wordpress/core-fields/wp_block'], + 'The fields of the pattern collection are registered with its module.' + ); + } + + /** + * The default fields are exposed with the `core` origin. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_exposes_the_default_fields_with_the_core_origin() { + wp_set_current_user( self::$editor_id ); + + $data = $this->dispatch_request( 'postType', 'page' )->get_data(); + + $this->assertNotEmpty( $data['fields'], 'Pages have default fields.' ); + foreach ( $data['fields'] as $field ) { + $this->assertSame( 'core', $field['origin']['registeredBy'], "The {$field['id']} field is registered by WordPress core." ); + } + } + /** * An entity without registered fields returns empty lists, which encode as * JSON arrays. From f47493fff445997a142571179ff1215c146f43d6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 19:44:57 +0100 Subject: [PATCH 3/4] Tests: Skip the core entity fields tests until the Gutenberg pin includes them. The core fields are copied from the Gutenberg build, so their tests are skipped while the pinned build does not include them. See #66229. --- .../wpRegisterCoreFieldCollections.php | 7 +++++++ .../rest-api/rest-entity-fields-controller.php | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php index 199be49d5b89c..5ac659477b7f2 100644 --- a/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php +++ b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php @@ -41,12 +41,19 @@ class Tests_Entity_Fields_WpRegisterCoreFieldCollections extends WP_Entity_Field * * Registers the fields WordPress core registers instead of the fixture * defaults of the other entity fields tests. + * + * The core fields are copied from the Gutenberg build, so the tests are + * skipped until the pinned build includes them. */ public function set_up() { global $_wp_theme_features; parent::set_up(); + if ( ! function_exists( 'wp_register_core_field_collections' ) ) { + $this->markTestSkipped( 'The pinned Gutenberg build does not include the core fields.' ); + } + $this->theme_features = $_wp_theme_features; remove_action( 'wp_entity_fields_api_init', array( static::class, 'register_default_fields' ), 0 ); add_action( 'wp_entity_fields_api_init', 'wp_register_core_field_collections', 0 ); diff --git a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php index 693295574fa82..12c855bc6afaf 100644 --- a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php +++ b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php @@ -149,6 +149,16 @@ private function update_fields( $kind, $name, $fields, $module = null ) { return $updated; } + /** + * Skips a test of the fields WordPress core registers, which are copied + * from the Gutenberg build, until the pinned build includes them. + */ + private function skip_without_core_fields() { + if ( ! function_exists( 'wp_register_core_field_collections' ) ) { + $this->markTestSkipped( 'The pinned Gutenberg build does not include the core fields.' ); + } + } + /** * Dispatches a request to the fields route. * @@ -435,6 +445,7 @@ public function test_get_items_returns_entity_and_fields_shape() { * @ticket 66229 */ public function test_get_items_exposes_the_default_post_type_fields() { + $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'page' )->get_data(); @@ -467,6 +478,7 @@ public function test_get_items_exposes_the_default_post_type_fields() { * @ticket 66229 */ public function test_get_items_exposes_pattern_fields_and_their_script_module() { + $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'wp_block' )->get_data(); @@ -492,6 +504,7 @@ public function test_get_items_exposes_pattern_fields_and_their_script_module() * @ticket 66229 */ public function test_get_items_exposes_the_default_fields_with_the_core_origin() { + $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'page' )->get_data(); From 203f0456b6fa510cb956df6d1cd89da497b2a485 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 20:53:11 +0100 Subject: [PATCH 4/4] Tests: Skip the core entity fields tests with `@requires`. Skipping in `set_up()` still ran `tear_down()`, which restored the theme features before they were captured and set the global to null, dropping the `title-tag` support of the test theme for the rest of the run. A class-level `@requires function` skips before either runs. The REST tests use the same annotation in place of the skip helper. --- .../wpRegisterCoreFieldCollections.php | 11 ++++------- .../rest-api/rest-entity-fields-controller.php | 16 +++------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php index 5ac659477b7f2..7749ccc0a5ec9 100644 --- a/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php +++ b/tests/phpunit/tests/entity-fields/wpRegisterCoreFieldCollections.php @@ -5,11 +5,15 @@ /** * Tests for the fields WordPress core registers through the entity fields API. * + * The core fields are copied from the Gutenberg build, so the tests are + * skipped until the pinned build includes them. + * * @package WordPress * @subpackage Entity Fields API * @since 7.2.0 * * @group entity-fields + * @requires function wp_register_core_field_collections * * @covers ::wp_register_core_post_type_supports_fields * @covers ::wp_register_core_field_collections @@ -41,19 +45,12 @@ class Tests_Entity_Fields_WpRegisterCoreFieldCollections extends WP_Entity_Field * * Registers the fields WordPress core registers instead of the fixture * defaults of the other entity fields tests. - * - * The core fields are copied from the Gutenberg build, so the tests are - * skipped until the pinned build includes them. */ public function set_up() { global $_wp_theme_features; parent::set_up(); - if ( ! function_exists( 'wp_register_core_field_collections' ) ) { - $this->markTestSkipped( 'The pinned Gutenberg build does not include the core fields.' ); - } - $this->theme_features = $_wp_theme_features; remove_action( 'wp_entity_fields_api_init', array( static::class, 'register_default_fields' ), 0 ); add_action( 'wp_entity_fields_api_init', 'wp_register_core_field_collections', 0 ); diff --git a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php index 12c855bc6afaf..f6d424817d7df 100644 --- a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php +++ b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php @@ -149,16 +149,6 @@ private function update_fields( $kind, $name, $fields, $module = null ) { return $updated; } - /** - * Skips a test of the fields WordPress core registers, which are copied - * from the Gutenberg build, until the pinned build includes them. - */ - private function skip_without_core_fields() { - if ( ! function_exists( 'wp_register_core_field_collections' ) ) { - $this->markTestSkipped( 'The pinned Gutenberg build does not include the core fields.' ); - } - } - /** * Dispatches a request to the fields route. * @@ -443,9 +433,9 @@ public function test_get_items_returns_entity_and_fields_shape() { * @covers ::get_items * * @ticket 66229 + * @requires function wp_register_core_field_collections */ public function test_get_items_exposes_the_default_post_type_fields() { - $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'page' )->get_data(); @@ -476,9 +466,9 @@ public function test_get_items_exposes_the_default_post_type_fields() { * @covers ::get_items * * @ticket 66229 + * @requires function wp_register_core_field_collections */ public function test_get_items_exposes_pattern_fields_and_their_script_module() { - $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'wp_block' )->get_data(); @@ -502,9 +492,9 @@ public function test_get_items_exposes_pattern_fields_and_their_script_module() * @covers ::get_items * * @ticket 66229 + * @requires function wp_register_core_field_collections */ public function test_get_items_exposes_the_default_fields_with_the_core_origin() { - $this->skip_without_core_fields(); wp_set_current_user( self::$editor_id ); $data = $this->dispatch_request( 'postType', 'page' )->get_data();