From ce2a651be8f8c735fc8f3247cb37e5ea2a4232cd Mon Sep 17 00:00:00 2001 From: Bernie Reiter <96308+ockham@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:57:10 +0100 Subject: [PATCH] E2E Tests: Add Block Hooks Test Coverage (#69044) Add end-to-end test coverage for Block Hooks; specifically for insertion into post content, synced patterns, and Navigation blocks. The tests check that hooked blocks are inserted correctly on the frontend, and that any changes made in the editor are persisted and respected. They cover both "normal" insertion (i.e. before or after a given anchor block), and first/last child insertion with the containing block serving as the anchor block (which is when the corresponding post object's post meta is used to store `_wp_ignored_hooked_blocks` data). Co-authored-by: ockham Co-authored-by: gziolo Co-authored-by: Mamaduka --- packages/e2e-tests/plugins/block-hooks.php | 74 +++++ .../specs/editor/plugins/block-hooks.spec.js | 289 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 packages/e2e-tests/plugins/block-hooks.php create mode 100644 test/e2e/specs/editor/plugins/block-hooks.spec.js diff --git a/packages/e2e-tests/plugins/block-hooks.php b/packages/e2e-tests/plugins/block-hooks.php new file mode 100644 index 0000000000000..3490595477055 --- /dev/null +++ b/packages/e2e-tests/plugins/block-hooks.php @@ -0,0 +1,74 @@ + "hooked-block-{$relative_position}-" . str_replace( 'core/', '', $anchor_block['blockName'] ), + ); + $hooked_block['innerContent'] = array( + sprintf( + '

This block was inserted by the Block Hooks API in the %2$s position next to the %3$s anchor block.

', + $hooked_block['attrs']['className'], + $relative_position, + $anchor_block['blockName'] + ), + ); + } + + return $hooked_block; +} +add_filter( 'hooked_block_core/paragraph', 'gutenberg_test_set_hooked_block_inner_html', 10, 4 ); + +function gutenberg_register_wp_ignored_hooked_blocks_meta() { + register_post_meta( + 'post', + '_wp_ignored_hooked_blocks', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'auth_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_wp_ignored_hooked_blocks_meta' ); diff --git a/test/e2e/specs/editor/plugins/block-hooks.spec.js b/test/e2e/specs/editor/plugins/block-hooks.spec.js new file mode 100644 index 0000000000000..ec78db6b3a8fe --- /dev/null +++ b/test/e2e/specs/editor/plugins/block-hooks.spec.js @@ -0,0 +1,289 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const dummyBlockContent = ` +

This is a dummy heading

+ + +

This is a dummy paragraph.

+`; + +const getHookedBlockClassName = ( relativePosition, anchorBlock ) => + `hooked-block-${ relativePosition }-${ anchorBlock.replace( + 'core/', + '' + ) }`; + +const getHookedBlockContent = ( relativePosition, anchorBlock ) => + `This block was inserted by the Block Hooks API in the ${ relativePosition } position next to the ${ anchorBlock } anchor block.`; + +test.describe( 'Block Hooks API', () => { + [ + { + name: 'Post Content', + postType: 'post', + blockType: 'core/post-content', + createMethod: 'createPost', + }, + { + name: 'Synced Pattern', + postType: 'wp_block', + blockType: 'core/block', + createMethod: 'createBlock', + }, + ].forEach( ( { name, postType, blockType, createMethod } ) => { + test.describe( `Hooked blocks in ${ name }`, () => { + let postObject, containerPost; + test.beforeAll( async ( { requestUtils } ) => { + postObject = await requestUtils[ createMethod ]( { + title: name, + status: 'publish', + content: dummyBlockContent, + } ); + + await requestUtils.activatePlugin( + 'gutenberg-test-block-hooks' + ); + + if ( postType !== 'post' ) { + // We need a container post to hold our block instance. + containerPost = await requestUtils.createPost( { + title: `Block Hooks in ${ name }`, + status: 'publish', + content: ``, + meta: { + // Prevent Block Hooks from injecting blocks into the container + // post content so they won't distract from the ones injected + // into the block instance. + _wp_ignored_hooked_blocks: '["core/paragraph"]', + }, + } ); + } else { + containerPost = postObject; + } + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-hooks' + ); + + await requestUtils.deleteAllPosts(); + await requestUtils.deleteAllBlocks(); + } ); + + test( `should insert hooked blocks into ${ name } on frontend`, async ( { + page, + } ) => { + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.entry-content > *' ) + ).toHaveClass( [ + 'wp-block-heading', + getHookedBlockClassName( 'after', 'core/heading' ), + 'dummy-paragraph', + getHookedBlockClassName( 'last_child', blockType ), + ] ); + } ); + + test( `should insert hooked blocks into ${ name } in editor and respect changes made there`, async ( { + admin, + editor, + page, + } ) => { + const expectedHookedBlockAfterHeading = { + name: 'core/paragraph', + attributes: { + className: getHookedBlockClassName( + 'after', + 'core/heading' + ), + }, + }; + + const expectedHookedBlockLastChild = { + name: 'core/paragraph', + attributes: { + className: getHookedBlockClassName( + 'last_child', + blockType + ), + }, + }; + + await admin.editPost( postObject.id ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/heading' }, + expectedHookedBlockAfterHeading, + { name: 'core/paragraph' }, + expectedHookedBlockLastChild, + ] ); + + const hookedBlock = editor.canvas.getByText( + getHookedBlockContent( 'last_child', blockType ) + ); + await editor.selectBlocks( hookedBlock ); + await editor.clickBlockToolbarButton( 'Move up' ); + + // Save updated post. + const saveButton = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save', exact: true } ); + await saveButton.click(); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'updated' } ) + .waitFor(); + + // Reload and verify that the new position of the hooked block has been persisted. + await page.reload(); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/heading' }, + expectedHookedBlockAfterHeading, + expectedHookedBlockLastChild, + { name: 'core/paragraph' }, + ] ); + + // Verify that the frontend reflects the changes made in the editor. + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.entry-content > *' ) + ).toHaveClass( [ + 'wp-block-heading', + getHookedBlockClassName( 'after', 'core/heading' ), + getHookedBlockClassName( 'last_child', blockType ), + 'dummy-paragraph', + ] ); + } ); + } ); + } ); + + test.describe( 'Hooked blocks in Navigation Menu', () => { + let postObject, containerPost; + test.beforeAll( async ( { requestUtils } ) => { + postObject = await requestUtils.createNavigationMenu( { + title: 'Navigation Menu', + status: 'publish', + content: + '', + } ); + + await requestUtils.activatePlugin( 'gutenberg-test-block-hooks' ); + + // We need a container to hold our Navigation block instance. + // We create a page (instead of a post) so that it will also + // populate the Page List block, which is one of the hooked blocks + // we use in our testing. + containerPost = await requestUtils.createPage( { + title: 'Block Hooks in Navigation Menu', + status: 'publish', + content: ``, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-block-hooks' ); + + await requestUtils.deleteAllPages(); + await requestUtils.deleteAllMenus(); + } ); + + test( 'should insert hooked blocks into Navigation Menu on frontend', async ( { + page, + } ) => { + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.wp-block-navigation__container > *' ) + ).toHaveClass( [ + 'wp-block-navigation-item wp-block-home-link', + ' wp-block-navigation-item wp-block-navigation-link', + 'wp-block-page-list', + ] ); + } ); + + test( 'should insert hooked blocks into Navigation Menu in editor and respect changes made there', async ( { + admin, + editor, + page, + } ) => { + await admin.visitSiteEditor( { + postId: postObject.id, + postType: 'wp_navigation', + canvas: 'edit', + } ); + + // Since the Navigation block is a controlled block, we need + // to specify its client ID when calling `getBlocks`. + let navigationBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', + } ); + let navigationClientId = + await navigationBlock.getAttribute( 'data-block' ); + + await expect + .poll( () => + editor.getBlocks( { + clientId: navigationClientId, + } ) + ) + .toMatchObject( [ + { name: 'core/home-link' }, + { name: 'core/navigation-link' }, + { name: 'core/page-list' }, + ] ); + + const hookedBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Home Link', + } ); + await editor.selectBlocks( hookedBlock ); + await editor.clickBlockToolbarButton( 'Move right' ); + + // Save updated post. + const saveButton = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save', exact: true } ); + await saveButton.click(); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'updated' } ) + .waitFor(); + + // Reload and verify that the new position of the hooked block has been persisted. + await page.reload(); + + navigationBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', + } ); + navigationClientId = + await navigationBlock.getAttribute( 'data-block' ); + + await expect + .poll( () => + editor.getBlocks( { + clientId: navigationClientId, + } ) + ) + .toMatchObject( [ + { name: 'core/navigation-link' }, + { name: 'core/home-link' }, + { name: 'core/page-list' }, + ] ); + + // Verify that the frontend reflects the changes made in the editor. + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.wp-block-navigation__container > *' ) + ).toHaveClass( [ + ' wp-block-navigation-item wp-block-navigation-link', + 'wp-block-navigation-item wp-block-home-link', + 'wp-block-page-list', + ] ); + } ); + } ); +} );