diff --git a/packages/block-library/src/cover/test/block-controls.js b/packages/block-library/src/cover/test/block-controls.js deleted file mode 100644 index ea14dfb38d7b7..0000000000000 --- a/packages/block-library/src/cover/test/block-controls.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import { render, screen, fireEvent } from '@testing-library/react'; - -// Need to mock the BlockControls wrapper as this requires a slot to run -// so can't be easily unit tested. -jest.mock( '@wordpress/block-editor', () => ( { - ...jest.requireActual( '@wordpress/block-editor' ), - BlockControls: ( { children } ) =>
{ children }
, -} ) ); - -/** - * Internal dependencies - */ -import CoverBlockControls from '../edit/block-controls'; - -const setAttributes = jest.fn(); -const onSelectMedia = jest.fn(); - -const currentSettings = { hasInnerBlocks: true, url: undefined }; -const defaultAttributes = { - contentPosition: undefined, - id: 1, - useFeaturedImage: false, - dimRatio: 50, - minHeight: 300, - minHeightUnit: 'px', -}; -const defaultProps = { - attributes: defaultAttributes, - currentSettings, - setAttributes, - onSelectMedia, -}; - -beforeEach( () => { - setAttributes.mockClear(); - onSelectMedia.mockClear(); -} ); - -describe( 'Cover block controls', () => { - describe( 'Full height toggle', () => { - test( 'displays toggle full height button toggled off if minHeight not 100vh', () => { - render( ); - expect( - screen.getByRole( 'button', { - pressed: false, - name: 'Toggle full height', - } ) - ).toBeInTheDocument(); - } ); - test( 'sets minHeight attributes to 100vh when clicked', () => { - render( ); - fireEvent.click( screen.getByLabelText( 'Toggle full height' ) ); - expect( setAttributes ).toHaveBeenCalledWith( { - minHeight: 100, - minHeightUnit: 'vh', - } ); - } ); - } ); -} ); diff --git a/packages/block-library/src/cover/test/edit.js b/packages/block-library/src/cover/test/edit.js new file mode 100644 index 0000000000000..c2510dcb666b6 --- /dev/null +++ b/packages/block-library/src/cover/test/edit.js @@ -0,0 +1,324 @@ +/** + * External dependencies + */ +import { screen, fireEvent, act, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { + initializeEditor, + selectBlock, +} from 'test/integration/helpers/integration-test-editor'; + +async function setup( attributes ) { + const testBlock = { name: 'core/cover', attributes }; + return initializeEditor( testBlock ); +} + +async function createAndSelectBlock() { + await userEvent.click( + screen.getByRole( 'button', { + name: 'Color: Black', + } ) + ); + await userEvent.click( + screen.getByRole( 'button', { + name: 'Select Cover', + } ) + ); +} + +describe( 'Cover block', () => { + describe( 'Editor canvas', () => { + test( 'shows placeholder if background image and color not set', async () => { + await setup(); + + expect( + screen.getByRole( 'group', { + name: 'To edit this block, you need permission to upload media.', + } ) + ).toBeInTheDocument(); + } ); + + test( 'can set overlay color using color picker on block placeholder', async () => { + const { container } = await setup(); + const colorPicker = screen.getByRole( 'button', { + name: 'Color: Black', + } ); + await userEvent.click( colorPicker ); + const color = colorPicker.style.backgroundColor; + expect( + screen.queryByRole( 'group', { + name: 'To edit this block, you need permission to upload media.', + } ) + ).not.toBeInTheDocument(); + + // eslint-disable-next-line testing-library/no-node-access + const overlay = container.getElementsByClassName( + 'wp-block-cover__background' + ); + expect( overlay[ 0 ] ).toHaveStyle( + `background-color: ${ color }` + ); + } ); + + test( 'can have the title edited', async () => { + await setup(); + + await userEvent.click( + screen.getByRole( 'button', { + name: 'Color: Black', + } ) + ); + + const title = screen.getByLabelText( 'Empty block;', { + exact: false, + } ); + await userEvent.click( title ); + await userEvent.keyboard( 'abc' ); + expect( title ).toHaveTextContent( 'abc' ); + } ); + } ); + + describe( 'Block toolbar', () => { + test( 'full height toggle sets minHeight style attribute to 100vh when clicked', async () => { + await setup(); + await createAndSelectBlock(); + + expect( screen.getByLabelText( 'Block: Cover' ) ).not.toHaveStyle( + 'min-height: 100vh;' + ); + + await userEvent.click( + screen.getByLabelText( 'Toggle full height' ) + ); + + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveStyle( + 'min-height: 100vh;' + ); + } ); + + test( 'content position button sets content position', async () => { + await setup(); + await createAndSelectBlock(); + + await userEvent.click( + screen.getByLabelText( 'Change content position' ) + ); + + expect( screen.getByLabelText( 'Block: Cover' ) ).not.toHaveClass( + 'has-custom-content-position' + ); + + await act( async () => + within( screen.getByRole( 'grid' ) ) + .getByRole( 'gridcell', { + name: 'top left', + } ) + .focus() + ); + + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveClass( + 'has-custom-content-position' + ); + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveClass( + 'is-position-top-left' + ); + } ); + } ); + + describe( 'Inspector controls', () => { + describe( 'Media settings', () => { + test( 'does not display media settings panel if url is not set', async () => { + await setup(); + expect( + screen.queryByRole( 'button', { + name: 'Media settings', + } ) + ).not.toBeInTheDocument(); + } ); + test( 'displays media settings panel if url is set', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + + await selectBlock( 'Block: Cover' ); + expect( + screen.getByRole( 'button', { + name: 'Media settings', + } ) + ).toBeInTheDocument(); + } ); + } ); + + test( 'sets hasParallax attribute to true if fixed background toggled', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + expect( screen.getByLabelText( 'Block: Cover' ) ).not.toHaveClass( + 'has-parallax' + ); + await selectBlock( 'Block: Cover' ); + await userEvent.click( + screen.getByLabelText( 'Fixed background' ) + ); + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveClass( + 'has-parallax' + ); + } ); + + test( 'sets isRepeated attribute to true if repeated background toggled', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + expect( screen.getByLabelText( 'Block: Cover' ) ).not.toHaveClass( + 'is-repeated' + ); + await selectBlock( 'Block: Cover' ); + await userEvent.click( + screen.getByLabelText( 'Repeated background' ) + ); + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveClass( + 'is-repeated' + ); + } ); + + test( 'sets left focalPoint attribute when focal point values changed', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + + await selectBlock( 'Block: Cover' ); + await userEvent.clear( screen.getByLabelText( 'Left' ) ); + await userEvent.type( screen.getByLabelText( 'Left' ), '100' ); + + expect( + within( screen.getByLabelText( 'Block: Cover' ) ).getByRole( + 'img' + ) + ).toHaveStyle( 'object-position: 100% 50%;' ); + } ); + + test( 'sets alt attribute if text entered in alt text box', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + + await selectBlock( 'Block: Cover' ); + await userEvent.type( + screen.getByLabelText( 'Alt text (alternative text)' ), + 'Me' + ); + expect( screen.getByAltText( 'Me' ) ).toBeInTheDocument(); + } ); + + test( 'clears media when clear media button clicked', async () => { + await setup( { + url: 'http://localhost/my-image.jpg', + } ); + + await selectBlock( 'Block: Cover' ); + expect( + within( screen.getByLabelText( 'Block: Cover' ) ).getByRole( + 'img' + ) + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole( 'button', { + name: 'Clear Media', + } ) + ); + expect( + within( screen.queryByLabelText( 'Block: Cover' ) ).queryByRole( + 'img' + ) + ).not.toBeInTheDocument(); + } ); + + describe( 'Color panel', () => { + test( 'applies selected opacity to block when number control value changed', async () => { + const { container } = await setup(); + + await createAndSelectBlock(); + + // eslint-disable-next-line testing-library/no-node-access + const overlay = container.getElementsByClassName( + 'wp-block-cover__background' + ); + + expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-100' ); + + await userEvent.click( + screen.getByRole( 'tab', { + name: 'Styles', + } ) + ); + + fireEvent.change( + screen.getByRole( 'spinbutton', { + name: 'Overlay opacity', + } ), + { + target: { value: '40' }, + } + ); + + expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-40' ); + } ); + + test( 'applies selected opacity to block when slider moved', async () => { + const { container } = await setup(); + + await createAndSelectBlock(); + + // eslint-disable-next-line testing-library/no-node-access + const overlay = container.getElementsByClassName( + 'wp-block-cover__background' + ); + + expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-100' ); + + await userEvent.click( + screen.getByRole( 'tab', { + name: 'Styles', + } ) + ); + + fireEvent.change( + screen.getByRole( 'slider', { + name: 'Overlay opacity', + } ), + { target: { value: 30 } } + ); + + expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-30' ); + } ); + } ); + + describe( 'Dimensions panel', () => { + test( 'sets minHeight attribute when number control value changed', async () => { + await setup(); + await createAndSelectBlock(); + await userEvent.click( + screen.getByRole( 'tab', { + name: 'Styles', + } ) + ); + await userEvent.clear( + screen.getByLabelText( 'Minimum height of cover' ) + ); + await userEvent.type( + screen.getByLabelText( 'Minimum height of cover' ), + '300' + ); + + expect( screen.getByLabelText( 'Block: Cover' ) ).toHaveStyle( + 'min-height: 300px;' + ); + } ); + } ); + } ); +} ); diff --git a/packages/e2e-tests/specs/editor/blocks/cover.test.js b/packages/e2e-tests/specs/editor/blocks/cover.test.js index 092a0276ba0f2..56ed8455aefa9 100644 --- a/packages/e2e-tests/specs/editor/blocks/cover.test.js +++ b/packages/e2e-tests/specs/editor/blocks/cover.test.js @@ -42,30 +42,6 @@ describe( 'Cover', () => { await createNewPost(); } ); - it( 'can set overlay color using color picker on block placeholder', async () => { - await insertBlock( 'Cover' ); - // Get the first color option from the block placeholder's color picker. - const colorPickerButton = await page.waitForSelector( - '.wp-block-cover__placeholder-background-options .components-circular-option-picker__option-wrapper:first-child button' - ); - // Get the RGB value of the picked color. - const pickedColor = await colorPickerButton.evaluate( - ( node ) => node.style.backgroundColor - ); - // Create the block by clicking selected color button. - await colorPickerButton.click(); - // Get the block's background dim element. - const backgroundDim = await page.waitForSelector( - '.wp-block-cover .has-background-dim' - ); - // Get the RGB value of the background dim. - const dimColor = await backgroundDim.evaluate( - ( node ) => node.style.backgroundColor - ); - - expect( pickedColor ).toEqual( dimColor ); - } ); - it( 'can set background image using image upload on block placeholder', async () => { await insertBlock( 'Cover' ); // Create the block using uploaded image. @@ -95,27 +71,6 @@ describe( 'Cover', () => { expect( backgroundDimOpacity ).toBe( '0.5' ); } ); - it( 'can have the title edited', async () => { - await insertBlock( 'Cover' ); - // Click first color option from the block placeholder's color picker. - const colorPickerButton = await page.waitForSelector( - '.wp-block-cover__placeholder-background-options .components-circular-option-picker__option-wrapper:first-child button' - ); - await colorPickerButton.click(); - // Click the title placeholder to put the cursor inside. - const coverTitle = await page.waitForSelector( - '.wp-block-cover .wp-block-paragraph' - ); - await coverTitle.click(); - // Type the title. - await page.keyboard.type( 'foo' ); - const coverTitleText = await coverTitle.evaluate( - ( el ) => el.innerText - ); - - expect( coverTitleText ).toEqual( 'foo' ); - } ); - it( 'can be resized using drag & drop', async () => { await insertBlock( 'Cover' ); // Close the inserter. diff --git a/test/integration/helpers/integration-test-editor.js b/test/integration/helpers/integration-test-editor.js new file mode 100644 index 0000000000000..6b954a895a51a --- /dev/null +++ b/test/integration/helpers/integration-test-editor.js @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { + BlockEditorKeyboardShortcuts, + BlockEditorProvider, + BlockList, + BlockTools, + BlockInspector, + WritingFlow, + ObserveTyping, +} from '@wordpress/block-editor'; +import { Popover, SlotFillProvider } from '@wordpress/components'; +import { registerCoreBlocks } from '@wordpress/block-library'; +import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; +import '@wordpress/format-library'; +import { + createBlock, + unregisterBlockType, + getBlockTypes, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { waitForStoreResolvers } from './wait-for-store-resolvers'; + +// Polyfill for String.prototype.replaceAll until CI is runnig Node 15 or higher. +if ( ! String.prototype.replaceAll ) { + String.prototype.replaceAll = function ( str, newStr ) { + // If a regex pattern + if ( + Object.prototype.toString.call( str ).toLowerCase() === + '[object regexp]' + ) { + return this.replace( str, newStr ); + } + + // If a string + return this.replace( new RegExp( str, 'g' ), newStr ); + }; +} + +/** + * Selects the block to be tested by the aria-label on the block wrapper, eg. "Block: Cover". + * + * @param {string} name + */ +export async function selectBlock( name ) { + await userEvent.click( screen.getByLabelText( name ) ); +} + +export function Editor( { testBlocks, settings = {} } ) { + const [ currentBlocks, updateBlocks ] = useState( testBlocks ); + + useEffect( () => { + return () => { + getBlockTypes().forEach( ( { name } ) => + unregisterBlockType( name ) + ); + }; + }, [] ); + + return ( + + + + + + + + + + + + + + + + + + ); +} + +/** + * Registers the core block, creates the test block instances, and then instantiates the Editor. + * + * @param {Object | Array} testBlocks Block or array of block settings for blocks to be tested. + * @param {boolean} useCoreBlocks Defaults to true. If false, core blocks will not be registered. + * @param {Object} settings Any additional editor settings to be passed to the editor. + */ +export async function initializeEditor( + testBlocks, + useCoreBlocks = true, + settings +) { + if ( useCoreBlocks ) { + registerCoreBlocks(); + } + const blocks = Array.isArray( testBlocks ) ? testBlocks : [ testBlocks ]; + const newBlocks = blocks.map( ( testBlock ) => + createBlock( + testBlock.name, + testBlock.attributes, + testBlock.innerBlocks + ) + ); + return waitForStoreResolvers( () => { + return render( + + ); + } ); +} diff --git a/test/integration/helpers/wait-for-store-resolvers.js b/test/integration/helpers/wait-for-store-resolvers.js new file mode 100644 index 0000000000000..fbb35c4e73461 --- /dev/null +++ b/test/integration/helpers/wait-for-store-resolvers.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { act } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { withFakeTimers } from './with-fake-timers'; + +/** + * Executes a function that triggers store resolvers and waits for them to be finished. + * + * Asynchronous store resolvers leverage `setTimeout` to run at the end of + * the current JavaScript block execution. In order to prevent "act" warnings + * triggered by updates to the React tree, we manually tick fake timers and + * await the resolution of the current block execution before proceeding. + * + * @param {Function} fn Function that to trigger. + * + * @return {*} The result of the function call. + */ +export async function waitForStoreResolvers( fn ) { + return withFakeTimers( async () => { + const result = fn(); + + // Advance all timers allowing store resolvers to resolve. + act( () => jest.runAllTimers() ); + + // The store resolvers perform several API fetches during editor + // initialization. The most straightforward approach to ensure all of them + // resolve before we consider the editor initialized is to flush micro tasks, + // similar to the approach found in `@testing-library/react`. + // https://github.com/callstack/react-native-testing-library/blob/a010ffdbca906615279ecc3abee423525e528101/src/flushMicroTasks.js#L15-L23. + // eslint-disable-next-line testing-library/no-unnecessary-act + await act( async () => {} ); + + return result; + } ); +} diff --git a/test/integration/helpers/with-fake-timers.js b/test/integration/helpers/with-fake-timers.js new file mode 100644 index 0000000000000..91e3ead0a2bf0 --- /dev/null +++ b/test/integration/helpers/with-fake-timers.js @@ -0,0 +1,30 @@ +/** + * Set up fake timers for executing a function and restores them afterwards. + * + * @param {Function} fn Function to trigger. + * + * @return {*} The result of the function call. + */ +export async function withFakeTimers( fn ) { + const usingFakeTimers = jest.isMockFunction( setTimeout ); + + // Portions of the React Native Animation API rely upon these APIs. However, + // Jest's 'legacy' fake timers mutate these globals, which breaks the Animated + // API. We preserve the original implementations to restore them later. + const requestAnimationFrameCopy = global.requestAnimationFrame; + const cancelAnimationFrameCopy = global.cancelAnimationFrame; + + if ( ! usingFakeTimers ) { + jest.useFakeTimers( { legacyFakeTimers: true } ); + } + + const result = await fn(); + + if ( ! usingFakeTimers ) { + jest.useRealTimers(); + + global.requestAnimationFrame = requestAnimationFrameCopy; + global.cancelAnimationFrame = cancelAnimationFrameCopy; + } + return result; +}