diff --git a/Gruntfile.js b/Gruntfile.js index b720379a23101..9340b45302a33 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/). + // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/, core actions). 'gutenberg-php': { options: { process: function( content ) { @@ -716,6 +716,8 @@ module.exports = function(grunt) { 'pages.php', 'constants.php', 'pages/**/*.php', + // The actions Core registers through the entity actions API. + 'scripts/core-actions/**/*.php', ], dest: SOURCE_DIR + 'wp-includes/build/', } ], diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 16afab80fcd58..d2fb5d59a8b7a 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -855,6 +855,7 @@ // Entity Actions API. add_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10, 0 ); +add_filter( 'block_editor_rest_api_preload_paths', '_wp_preload_entity_actions', 10, 2 ); // Entity Fields API. add_action( 'admin_footer', '_wp_add_entity_field_modules_to_editor_script', 10, 0 ); diff --git a/src/wp-includes/entity-actions.php b/src/wp-includes/entity-actions.php index 6496048f71d9e..13ec78bc03e33 100644 --- a/src/wp-includes/entity-actions.php +++ b/src/wp-includes/entity-actions.php @@ -15,7 +15,8 @@ * * Actions are registered on the `wp_entity_actions_api_init` action, and only there, * with {@see wp_register_entity_actions()} and {@see wp_unregister_entity_actions()}. The - * other functions read the registry. + * other functions read the registry. WordPress core registers its own + * actions the same way, from packages/core-actions. * * @package WordPress * @subpackage Entity Actions @@ -186,3 +187,32 @@ function _wp_add_entity_action_modules_to_editor_script( $scripts = null ) { $scripts->add_data( 'wp-editor', 'module_dependencies', $dependencies ); } } + +/** + * Preloads the actions of the post type the post editor edits. + * + * The actions menu of the post editor reads them from the `wp/v2/entity-actions` + * route as it loads, see `useEntityActions()` in + * packages/editor/src/dataviews/entity-actions-loader.ts. The path must match the + * request of the `getEntityActionsConfig` core data resolver, see + * packages/core-data/src/resolvers.js. + * + * @since 7.2.0 + * + * @param array $paths REST API paths to preload. + * @param WP_Block_Editor_Context $context Block editor context. + * @return array Filtered preload paths. + */ +function _wp_preload_entity_actions( $paths, $context ) { + if ( 'core/edit-post' === $context->name && isset( $context->post ) ) { + $paths[] = add_query_arg( + array( + 'kind' => 'postType', + 'name' => $context->post->post_type, + ), + '/wp/v2/entity-actions' + ); + } + + return $paths; +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 98ecc5781917f..cc7a1e00a38c3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -254,6 +254,9 @@ if ( file_exists( ABSPATH . WPINC . '/build/pages.php' ) ) { require ABSPATH . WPINC . '/build/pages.php'; } +if ( file_exists( ABSPATH . WPINC . '/build/scripts/core-actions/index.php' ) ) { + require ABSPATH . WPINC . '/build/scripts/core-actions/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-actions.php b/tests/phpunit/tests/entity-actions.php index 8283a81b6b64d..17e73686efcf7 100644 --- a/tests/phpunit/tests/entity-actions.php +++ b/tests/phpunit/tests/entity-actions.php @@ -8,6 +8,7 @@ * @group entity-actions * * @covers ::_wp_add_entity_action_modules_to_editor_script + * @covers ::_wp_preload_entity_actions * @covers ::wp_register_entity_actions * @covers ::wp_unregister_entity_actions * @covers WP_Entity_Actions_Registry::initialize @@ -17,12 +18,15 @@ class Tests_Entity_Actions extends WP_UnitTestCase { /** - * Sets up each test: the default actions of the tests are registered at - * priority 0, so every test runs against a registry holding actions - * registered before the callbacks hooked at the default priority run. + * Sets up each test: the actions WordPress registers are replaced with + * the default actions of the tests, hooked at priority 0 as WordPress + * registers its own, so every test runs against a registry holding + * actions registered before the callbacks hooked at the default priority + * run. */ public function set_up() { parent::set_up(); + remove_action( 'wp_entity_actions_api_init', 'register_core_entity_actions', 0 ); add_action( 'wp_entity_actions_api_init', array( __CLASS__, 'register_default_actions' ), 0 ); } @@ -31,8 +35,9 @@ public function set_up() { * * Resetting the registry drops the actions a test registered along with * the defaults; the next read fires `wp_entity_actions_api_init` again and - * registers the defaults anew. The test case removes the callbacks the - * test hooked, the defaults included. + * registers the defaults anew. The test case restores the hooks: it + * removes the callbacks the test hooked, the defaults included, and hooks + * the actions WordPress registers back. */ public function tear_down() { self::reset_registry(); @@ -387,6 +392,51 @@ public function test_action_script_modules_reach_the_import_map() { $this->assertArrayHasKey( 'plugin/feature', $import_map['imports'] ); } + /** + * The post editor preloads the actions of the post type it edits, with + * the path the `getEntityActionsConfig` resolver of core data requests. + * + * @ticket 66343 + */ + public function test_the_post_editor_preloads_the_actions_of_the_post_type() { + $context = new WP_Block_Editor_Context( array( 'post' => self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ) ) ); + + $paths = apply_filters( 'block_editor_rest_api_preload_paths', array(), $context ); + + $this->assertContains( '/wp/v2/entity-actions?kind=postType&name=page', $paths, 'The actions of pages are preloaded.' ); + } + + /** + * The preloaded path serves the actions of the post type. + * + * @ticket 66343 + */ + public function test_the_preloaded_actions_path_serves_the_actions() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $context = new WP_Block_Editor_Context( array( 'post' => self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ) ) ); + + $paths = _wp_preload_entity_actions( array(), $context ); + $preloaded = array_reduce( $paths, 'rest_preload_api_request', array() ); + + $this->assertSame( + array( 'rename-post', 'move-to-trash' ), + array_column( $preloaded['/wp/v2/entity-actions?kind=postType&name=page']['body']['actions'], 'id' ), + 'The preloaded response holds the actions of pages.' + ); + } + + /** + * Only the post editor preloads the actions: the other editors have no + * post type of their own. + * + * @ticket 66343 + */ + public function test_other_editors_do_not_preload_the_actions() { + $context = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); + + $this->assertSame( array( '/wp/v2/types' ), _wp_preload_entity_actions( array( '/wp/v2/types' ), $context ), 'The paths are unchanged.' ); + } + /** * Reading the registry fires `wp_entity_actions_api_init` once, whichever * getter is read first and however many times it is read. diff --git a/tests/phpunit/tests/entity-actions/registerCoreEntityActions.php b/tests/phpunit/tests/entity-actions/registerCoreEntityActions.php new file mode 100644 index 0000000000000..c7b960d2349fe --- /dev/null +++ b/tests/phpunit/tests/entity-actions/registerCoreEntityActions.php @@ -0,0 +1,534 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + } + + /** + * Deletes shared users. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$contributor_id ); + } + + /** + * Sets up each test. + * + * The core actions are copied from the Gutenberg build, so the tests are + * skipped until the pinned build includes them. + */ + public function set_up() { + parent::set_up(); + + if ( ! function_exists( 'register_core_entity_actions' ) ) { + $this->markTestSkipped( 'The pinned Gutenberg build does not include the core actions.' ); + } + } + + /** + * Tears down each test. + * + * Resetting the registry drops the actions registered for the user and + * theme of the test; the next read fires `wp_entity_actions_api_init` again. + */ + public function tear_down() { + self::reset_registry(); + + parent::tear_down(); + } + + /** + * Resets the registry: drops the singleton instance, so the next + * get_instance() creates an empty registry and its first read fires + * `wp_entity_actions_api_init` again. + */ + private static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Actions_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + } + + /** + * Returns the ids of the actions registered for a post type, from a + * registry built anew for the current user and theme, as each request + * builds it. + * + * @param string $post_type The post type. + * @return string[] The ids of the actions, in registration order. + */ + private function get_action_ids( $post_type ) { + self::reset_registry(); + return array_column( wp_get_registered_entity_actions( 'postType', $post_type ), 'id' ); + } + + /** + * The core actions are registered at priority 0, so a plugin hooking the + * action at the default priority sees them. + * + * @ticket 66343 + */ + public function test_the_core_actions_are_registered_at_priority_0() { + $this->assertSame( 0, has_action( 'wp_entity_actions_api_init', 'register_core_entity_actions' ) ); + } + + /** + * Pages are viewable and support revisions, titles, and page attributes, + * and the user can manage the site options: they have every action of the + * `post_type_supports` module, then the actions of the `page` module, in + * the order the actions are shown. + * + * @ticket 66343 + */ + public function test_the_actions_of_pages() { + wp_set_current_user( self::$admin_id ); + + $ids = array_merge( array( 'view-post', 'view-post-revisions', 'rename-post', 'order-pages' ), self::EVERY_POST_TYPE ); + $this->assertSame( array_merge( $ids, self::PAGE_ACTIONS ), $this->get_action_ids( 'page' ), 'Pages have these actions, in this order.' ); + $this->assertSame( + array( + self::POST_TYPE_SUPPORTS => $ids, + self::PAGE => self::PAGE_ACTIONS, + ), + wp_get_registered_entity_action_modules( 'postType', 'page' ), + 'Each action of pages is registered with the module of its folder.' + ); + $this->assertSame( array( 'core' ), array_unique( array_column( array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'origin' ), 'registeredBy' ) ), 'Every action is registered by core.' ); + } + + /** + * Posts do not support page attributes, so they cannot be ordered. + * + * @ticket 66343 + */ + public function test_the_actions_of_posts() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post', 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'post' ), + 'Posts have these actions, in this order.' + ); + } + + /** + * Patterns are not viewable and are duplicated and exported by the + * actions of the `wp_block` module, placed among the others in the order + * the actions are shown. + * + * @ticket 66343 + */ + public function test_the_actions_of_patterns() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post-revisions', 'duplicate-pattern', 'rename-post', 'export-pattern' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_block' ), + 'Patterns have these actions, in this order.' + ); + $this->assertSame( + array( + self::POST_TYPE_SUPPORTS => array_merge( array( 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + self::WP_BLOCK => array( 'duplicate-pattern', 'export-pattern' ), + ), + wp_get_registered_entity_action_modules( 'postType', 'wp_block' ), + 'The actions of patterns come from the `wp_block` module.' + ); + } + + /** + * Templates are not duplicated: the editor creates them from the theme. + * + * @ticket 66343 + */ + public function test_the_actions_of_templates() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + + $this->assertSame( + array_merge( array( 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_template' ), + 'Templates have these actions, in this order.' + ); + } + + /** + * Template parts are duplicated by the action of the `wp_template_part` + * module, with a block theme only. + * + * @ticket 66343 + */ + public function test_template_parts_are_only_duplicated_with_a_block_theme() { + wp_set_current_user( self::$admin_id ); + + switch_theme( 'block-theme' ); + $this->assertSame( + array_merge( array( 'view-post-revisions', 'duplicate-template-part', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_template_part' ), + 'A block theme can duplicate template parts.' + ); + $this->assertSame( + array( 'duplicate-template-part' ), + wp_get_registered_entity_action_modules( 'postType', 'wp_template_part' )[ self::WP_TEMPLATE_PART ], + 'The action comes from the `wp_template_part` module.' + ); + + switch_theme( 'default' ); + $this->assertNotContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ), 'A classic theme cannot.' ); + } + + /** + * The "Duplicate…" action of the other post types is only registered by + * the Gutenberg plugin. + * + * @ticket 66343 + */ + public function test_posts_are_not_duplicated() { + wp_set_current_user( self::$admin_id ); + + foreach ( get_post_types( array( 'show_in_rest' => true ) ) as $post_type ) { + $this->assertNotContains( 'duplicate-post', $this->get_action_ids( $post_type ), "The $post_type post type has no duplicate action." ); + } + } + + /** + * The duplicate actions are only registered for the users who can create + * the posts they duplicate into: the registry is built for the current + * user. + * + * @ticket 66343 + */ + public function test_the_duplicate_actions_depend_on_the_current_user() { + switch_theme( 'block-theme' ); + + wp_set_current_user( self::$admin_id ); + $this->assertContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ), 'Administrators can create patterns.' ); + $this->assertContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ), 'Administrators can create template parts.' ); + + wp_set_current_user( self::$editor_id ); + $this->assertContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ), 'Editors can create patterns.' ); + $this->assertNotContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ), 'Editors cannot create template parts.' ); + + wp_set_current_user( self::$contributor_id ); + $this->assertNotContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ), 'Contributors cannot create patterns.' ); + $this->assertContains( 'export-pattern', $this->get_action_ids( 'wp_block' ), 'Contributors can export them.' ); + } + + /** + * The actions of a custom post type follow what it supports. + * + * @ticket 66343 + */ + public function test_the_actions_of_a_custom_post_type_follow_its_supports() { + register_post_type( + 'test_book', + array( + 'public' => true, + 'show_in_rest' => true, + 'supports' => array( 'title', 'revisions' ), + ) + ); + register_post_type( + 'test_note', + array( + 'show_ui' => true, + 'show_in_rest' => true, + 'supports' => array( 'editor' ), + ) + ); + register_post_type( + 'test_hidden', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post', 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'test_book' ), + 'A viewable post type with titles and revisions.' + ); + $this->assertSame( + self::EVERY_POST_TYPE, + $this->get_action_ids( 'test_note' ), + 'A post type that is not viewable, without titles or revisions.' + ); + $this->assertSame( array(), $this->get_action_ids( 'test_hidden' ), 'A post type not exposed in the REST API has no action.' ); + } + + /** + * Setting the homepage or the posts page changes the site settings, so + * the actions of the `page` module are only registered for the users who + * can manage the site options. + * + * @ticket 66343 + */ + public function test_the_homepage_actions_depend_on_the_current_user() { + wp_set_current_user( self::$admin_id ); + $this->assertSame( self::PAGE_ACTIONS, array_values( array_intersect( $this->get_action_ids( 'page' ), self::PAGE_ACTIONS ) ), 'Administrators can manage the site options.' ); + + wp_set_current_user( self::$editor_id ); + $this->assertSame( array(), array_intersect( $this->get_action_ids( 'page' ), self::PAGE_ACTIONS ), 'Editors cannot manage the site options.' ); + } + + /** + * A `front-page` template shows on the front page whatever the site + * settings say, so the actions of the `page` module are not registered + * when the theme has one. + * + * @ticket 66343 + */ + public function test_the_homepage_actions_are_not_registered_with_a_front_page_template() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + $this->assertContains( 'set-as-homepage', $this->get_action_ids( 'page' ), 'The theme has no `front-page` template.' ); + + $template_id = self::factory()->post->create( + array( + 'post_type' => 'wp_template', + 'post_name' => 'front-page', + 'post_title' => 'Front Page', + 'post_content' => '
Front page
', + ) + ); + wp_set_post_terms( $template_id, get_stylesheet(), 'wp_theme' ); + + $this->assertSame( array(), array_intersect( $this->get_action_ids( 'page' ), self::PAGE_ACTIONS ), 'The `front-page` template removes the actions.' ); + } + + /** + * The definitions are the ones of the `action.php` files, with the id + * first. + * + * @ticket 66343 + */ + public function test_the_definitions_come_from_the_action_files() { + wp_set_current_user( self::$admin_id ); + + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( + array( + 'id' => 'move-to-trash', + 'label' => 'Trash…', + 'isPrimary' => true, + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', + 'origin' => array( 'registeredBy' => 'core' ), + ), + $actions['move-to-trash'], + 'The definition of the trash action.' + ); + $this->assertSame( + array( + 'id' => 'view-post-revisions', + 'label' => 'View revisions', + 'context' => 'list', + 'origin' => array( 'registeredBy' => 'core' ), + ), + $actions['view-post-revisions'], + 'The definition of the revisions action.' + ); + } + + /** + * The labels are translated with the default text domain of WordPress. + * + * @ticket 66343 + */ + 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 ( 'Trash…' === $text ) { + $domains[] = $domain; + } + return $translation; + }, + 10, + 3 + ); + add_filter( + 'gettext_with_context', + static function ( $translation, $text, $context, $domain ) use ( &$domains ) { + if ( 'Trash…' === $text ) { + $domains[] = $domain; + } + return $translation; + }, + 10, + 4 + ); + wp_set_current_user( self::$admin_id ); + + $this->get_action_ids( 'page' ); + + $this->assertSame( array( 'default' ), array_unique( $domains ), 'The label of the trash action is translated in the default text domain.' ); + } + + /** + * The REST route serves the core actions of each post type, with their + * script modules, in a response that validates against its schema. + * + * @ticket 66343 + */ + public function test_the_rest_route_serves_the_core_actions() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + $schema = ( new WP_REST_Entity_Actions_Controller() )->get_item_schema(); + + $modules = array( + 'page' => array( self::POST_TYPE_SUPPORTS, self::PAGE ), + 'wp_block' => array( self::POST_TYPE_SUPPORTS, self::WP_BLOCK ), + 'wp_template_part' => array( self::POST_TYPE_SUPPORTS, self::WP_TEMPLATE_PART ), + ); + foreach ( $modules as $post_type => $module_ids ) { + self::reset_registry(); + $request = new WP_REST_Request( 'GET', '/wp/v2/entity-actions' ); + $request->set_query_params( + array( + 'kind' => 'postType', + 'name' => $post_type, + ) + ); + $data = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertSame( array_column( wp_get_registered_entity_actions( 'postType', $post_type ), 'id' ), array_column( $data['actions'], 'id' ), "The actions of $post_type are served." ); + $this->assertSame( $module_ids, array_column( $data['script_modules'], 'id' ), "The script modules of $post_type are served." ); + $this->assertTrue( rest_validate_value_from_schema( $data, $schema ), "The response for $post_type matches the schema." ); + } + } + + /** + * The script modules of the core actions are registered, so the editor + * script can add them to the import map. + * + * @ticket 66343 + */ + public function test_the_script_modules_of_the_core_actions_are_registered() { + wp_set_current_user( self::$admin_id ); + + foreach ( wp_get_all_registered_entity_action_modules()['postType'] as $post_type => $modules ) { + foreach ( $modules as $module ) { + $this->assertNotNull( wp_script_modules()->get_registered( $module ), "The $module module of $post_type is registered." ); + } + } + } + + /** + * A plugin hooking the action at the default priority can remove a core + * action, and replace one: it unregisters the action and registers its + * own definition with the script module of the core action, which keeps + * its JavaScript parts. The replaced action moves after the others. + * + * @ticket 66343 + */ + public function test_a_plugin_can_remove_and_replace_core_actions() { + $callback = static function () { + wp_unregister_entity_actions( 'postType', 'page', array( 'order-pages' ) ); + $trash = wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + wp_register_entity_actions( + 'test-plugin', + 'postType', + 'page', + array( array_merge( $trash[0], array( 'label' => 'Bin…' ) ) ), + '@wordpress/core-actions/post_type_supports' + ); + }; + add_action( 'wp_entity_actions_api_init', $callback ); + wp_set_current_user( self::$admin_id ); + + $ids = array( 'view-post', 'view-post-revisions', 'rename-post', 'restore', 'reset-post', 'delete-post', 'permanently-delete' ); + $this->assertSame( array_merge( $ids, self::PAGE_ACTIONS, array( 'move-to-trash' ) ), $this->get_action_ids( 'page' ), 'The order action is removed and the trash action moves last.' ); + $this->assertSame( + array( + self::POST_TYPE_SUPPORTS => array_merge( $ids, array( 'move-to-trash' ) ), + self::PAGE => self::PAGE_ACTIONS, + ), + wp_get_registered_entity_action_modules( 'postType', 'page' ), + 'The replaced action keeps the module of the core action.' + ); + + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'Bin…', $actions['move-to-trash']['label'], 'The label of the plugin is used.' ); + $this->assertSame( 'test-plugin', $actions['move-to-trash']['origin']['registeredBy'], 'The plugin registered the action.' ); + $this->assertTrue( $actions['move-to-trash']['isPrimary'], 'The rest of the core definition is kept.' ); + } +} diff --git a/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php b/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php index 8dc91728ff123..f345156089e5d 100644 --- a/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php +++ b/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php @@ -79,6 +79,9 @@ public function test_the_admin_footer_action_uses_the_global_scripts() { $wp_scripts->enqueue( 'wp-editor' ); $wp_scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + // The core entity actions add their own modules to the editor script. + remove_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10 ); + // Other callbacks of the action may print markup. get_echo( 'do_action', array( 'admin_footer', 'site-editor.php' ) );