From 4db2c8e32f7b3951ccaa9652748c05df556086cb Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Mon, 23 May 2022 16:02:04 +0200 Subject: [PATCH 1/9] Add testID prop to Draggable components --- packages/components/src/draggable/index.native.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index 57f2849d430291..abdc830aef0657 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -34,10 +34,17 @@ const { Provider } = Context; * @param {Function} [props.onDragEnd] Callback when dragging ends. * @param {Function} [props.onDragOver] Callback when dragging happens over an element. * @param {Function} [props.onDragStart] Callback when dragging starts. + * @param {string} [props.testID] Id used for querying the pan gesture in tests. * * @return {JSX.Element} The component to be rendered. */ -const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { +const Draggable = ( { + children, + onDragEnd, + onDragOver, + onDragStart, + testID, +} ) => { const isDragging = useSharedValue( false ); const isPanActive = useSharedValue( false ); const draggingId = useSharedValue( '' ); @@ -130,7 +137,8 @@ const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { isDragging.value = false; } ) .withRef( panGestureRef ) - .shouldCancelWhenOutside( false ); + .shouldCancelWhenOutside( false ) + .withTestId( testID ); const providerValue = useMemo( () => { return { panGestureRef, isDragging, isPanActive, draggingId }; @@ -158,6 +166,7 @@ const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { * @param {number} [props.minDuration] Minimum time, that a finger must remain pressed on the corresponding view. * @param {Function} [props.onLongPress] Callback when long-press gesture is triggered over an element. * @param {Function} [props.onLongPressEnd] Callback when long-press gesture ends. + * @param {string} [props.testID] Id used for querying the long-press gesture handler in tests. * * @return {JSX.Element} The component to be rendered. */ @@ -169,6 +178,7 @@ const DraggableTrigger = ( { minDuration = 500, onLongPress, onLongPressEnd, + testID, } ) => { const { panGestureRef, isDragging, isPanActive, draggingId } = useContext( Context @@ -205,6 +215,7 @@ const DraggableTrigger = ( { simultaneousHandlers={ panGestureRef } shouldCancelWhenOutside={ false } onGestureEvent={ gestureHandler } + testID={ testID } > { children } From 8820a4b221014619c451481e4e07dec8f4681496 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Mon, 23 May 2022 20:48:42 +0200 Subject: [PATCH 2/9] Add unit tests for Draggable component --- .../src/draggable/test/index.native.js | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 packages/components/src/draggable/test/index.native.js diff --git a/packages/components/src/draggable/test/index.native.js b/packages/components/src/draggable/test/index.native.js new file mode 100644 index 00000000000000..e2f3e1b658fc3f --- /dev/null +++ b/packages/components/src/draggable/test/index.native.js @@ -0,0 +1,130 @@ +/** + * External dependencies + */ +import { render } from 'test/helpers'; +import { + fireGestureHandler, + getByGestureTestId, +} from 'react-native-gesture-handler/jest-utils'; +import { State } from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { Draggable, DraggableTrigger } from '@wordpress/components'; + +// Touch event type constants have been extracted from original source code, as they are not exported in the package. +// Reference: https://github.com/software-mansion/react-native-gesture-handler/blob/90895e5f38616a6be256fceec6c6a391cd9ad7c7/src/TouchEventType.ts +const TouchEventType = { + UNDETERMINED: 0, + TOUCHES_DOWN: 1, + TOUCHES_MOVE: 2, + TOUCHES_UP: 3, + TOUCHES_CANCELLED: 4, +}; + +// Reanimated uses "requestAnimationFrame" for notifying shared value updates when using "useAnimatedReaction" hook. +// For testing, we mock the "requestAnimationFrame" so it calls the callback passed instantly. +let requestAnimationFrameCopy; +beforeEach( () => { + requestAnimationFrameCopy = global.requestAnimationFrame; + global.requestAnimationFrame = ( callback ) => callback(); +} ); +afterEach( () => { + global.requestAnimationFrame = requestAnimationFrameCopy; +} ); + +describe( 'Draggable', () => { + it( 'triggers onLongPress handler', () => { + const triggerId = 'trigger-id'; + const onLongPress = jest.fn(); + const { getByTestId } = render( + + + + + + ); + + const draggableTrigger = getByTestId( 'draggableTrigger' ); + fireGestureHandler( draggableTrigger, [ + { oldState: State.BEGAN, state: State.ACTIVE }, + { state: State.ACTIVE }, + ] ); + + expect( onLongPress ).toBeCalledTimes( 1 ); + expect( onLongPress ).toHaveBeenCalledWith( triggerId ); + } ); + + it( 'triggers dragging handlers', () => { + const triggerId = 'trigger-id'; + const onDragStart = jest.fn(); + const onDragOver = jest.fn(); + const onDragEnd = jest.fn(); + const { getByTestId } = render( + + + + + + ); + + const draggableTrigger = getByTestId( 'draggableTrigger' ); + const draggable = getByGestureTestId( 'draggable' ); + const touchEventId = 1; + const touchEvents = [ + { id: touchEventId, x: 0, y: 0 }, + { id: touchEventId, x: 100, y: 100 }, + { id: touchEventId, x: 50, y: 50 }, + ]; + fireGestureHandler( draggableTrigger, [ + { oldState: State.BEGAN, state: State.ACTIVE }, + { state: State.ACTIVE }, + ] ); + fireGestureHandler( draggable, [ + // TOUCHES_DOWN event is only received on ACTIVE state, so we have to fire it manually. + { oldState: State.BEGAN, state: State.ACTIVE }, + { + allTouches: [ touchEvents[ 0 ] ], + eventType: TouchEventType.TOUCHES_DOWN, + }, + { + allTouches: [ touchEvents[ 1 ] ], + eventType: TouchEventType.TOUCHES_MOVE, + }, + { + allTouches: [ touchEvents[ 2 ] ], + eventType: TouchEventType.TOUCHES_MOVE, + }, + { state: State.END }, + ] ); + + expect( onDragStart ).toBeCalledTimes( 1 ); + expect( onDragStart ).toHaveBeenCalledWith( { + id: triggerId, + x: touchEvents[ 0 ].x, + y: touchEvents[ 0 ].y, + } ); + expect( onDragOver ).toBeCalledTimes( 2 ); + expect( onDragOver ).toHaveBeenNthCalledWith( 1, touchEvents[ 1 ] ); + expect( onDragOver ).toHaveBeenNthCalledWith( 2, touchEvents[ 2 ] ); + expect( onDragEnd ).toBeCalledTimes( 1 ); + expect( onDragEnd ).toHaveBeenCalledWith( { + id: triggerId, + x: touchEvents[ 2 ].x, + y: touchEvents[ 2 ].y, + } ); + } ); +} ); From 8e6b615ffa951a60e6a3fa79c9cfb146ef3f8691 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Mon, 23 May 2022 20:50:25 +0200 Subject: [PATCH 3/9] Set draggingId shared value before enable dragging This change is required for testing, otherwise the dragging id is not passed when the dragging gesture begins. --- packages/components/src/draggable/index.native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index abdc830aef0657..a9cc6b40070ed4 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -190,8 +190,8 @@ const DraggableTrigger = ( { return; } - isDragging.value = true; draggingId.value = id; + isDragging.value = true; if ( onLongPress ) { runOnJS( onLongPress )( id ); } From 5d55b1d710027fc4a003cd3e39f4b4d0570eb513 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 24 May 2022 12:33:07 +0200 Subject: [PATCH 4/9] Mock generateHapticFeedback function --- test/native/setup.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/native/setup.js b/test/native/setup.js index 6a4884c2417323..69a4b32d93674e 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -98,6 +98,7 @@ jest.mock( '@wordpress/react-native-bridge', () => { }, fetchRequest: jest.fn(), requestPreview: jest.fn(), + generateHapticFeedback: jest.fn(), }; } ); From 187e8bad09b530294f924e5214dc21841566ffcf Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 24 May 2022 12:33:47 +0200 Subject: [PATCH 5/9] Add testID to draggable chip component --- .../src/components/block-draggable/draggable-chip.native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/block-draggable/draggable-chip.native.js b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js index 682c4f5b2cd49c..597279fc03d24e 100644 --- a/packages/block-editor/src/components/block-draggable/draggable-chip.native.js +++ b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js @@ -41,7 +41,7 @@ export default function BlockDraggableChip( { icon } ) { ); return ( - + { icon && } From 19495b44d847988bac8b3aadf6274855f5b37c64 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 24 May 2022 12:37:43 +0200 Subject: [PATCH 6/9] Add testID to BlockDraggable component --- .../src/components/block-draggable/index.native.js | 4 ++++ .../block-editor/src/components/block-list/block.native.js | 1 + .../src/components/block-mobile-toolbar/index.native.js | 1 + 3 files changed, 6 insertions(+) diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index ec76781d449fcc..ecf4b6a8259eb3 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -270,6 +270,7 @@ const BlockDraggableWrapper = ( { children, isRTL } ) => { onDragStart={ startDragging } onDragOver={ updateDragging } onDragEnd={ stopDragging } + testID="block-draggable-wrapper" > { children( { onScroll: scrollHandler } ) } @@ -302,6 +303,7 @@ const BlockDraggableWrapper = ( { children, isRTL } ) => { * @param {string} props.clientId Client id of the block. * @param {string} [props.draggingClientId] Client id to use for dragging. If not defined, the value from `clientId` will be used. * @param {boolean} [props.enabled] Enables the draggable trigger. + * @param {string} [props.testID] Id used for querying the long-press gesture handler in tests. * * @return {Function} Render function which includes the parameter `isDraggable` to determine if the block can be dragged. */ @@ -310,6 +312,7 @@ const BlockDraggable = ( { children, draggingClientId, enabled = true, + testID, } ) => { const wasBeingDragged = useRef( false ); const [ isEditingText, setIsEditingText ] = useState( false ); @@ -446,6 +449,7 @@ const BlockDraggable = ( { android: DEFAULT_LONG_PRESS_MIN_DURATION, } ) } onLongPress={ onLongPressDraggable } + testID={ testID } > { children( { isDraggable: true } ) } diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index 822194e749906d..4ea24537e2e87c 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -263,6 +263,7 @@ class BlockListBlock extends Component { clientId={ clientId } draggingClientId={ draggingClientId } enabled={ draggingEnabled } + testID="draggable-trigger-content" > { () => isValid ? ( diff --git a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js b/packages/block-editor/src/components/block-mobile-toolbar/index.native.js index 7e0ba45b76d422..b1b9c4635a29b7 100644 --- a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js +++ b/packages/block-editor/src/components/block-mobile-toolbar/index.native.js @@ -78,6 +78,7 @@ const BlockMobileToolbar = ( { { () => } From eebe3fc38c0abd5b420fc68f5512739b5ec3932a Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 24 May 2022 13:12:49 +0200 Subject: [PATCH 7/9] Add test helpers for BlockDraggable component Additionally, helpers related to fake timers have been added and updated in the global helpers file. --- .../block-draggable/test/helpers.native.js | 183 ++++++++++++++++++ test/native/helpers.js | 134 ++++++++++--- 2 files changed, 289 insertions(+), 28 deletions(-) create mode 100644 packages/block-editor/src/components/block-draggable/test/helpers.native.js diff --git a/packages/block-editor/src/components/block-draggable/test/helpers.native.js b/packages/block-editor/src/components/block-draggable/test/helpers.native.js new file mode 100644 index 00000000000000..8408b80f2e8270 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/test/helpers.native.js @@ -0,0 +1,183 @@ +/** + * External dependencies + */ +import { + act, + fireEvent, + initializeEditor, + waitForStoreResolvers, + within, + advanceAnimationByFrame, +} from 'test/helpers'; +import { fireGestureHandler } from 'react-native-gesture-handler/jest-utils'; +import { State } from 'react-native-gesture-handler'; + +// Touch event type constants have been extracted from original source code, as they are not exported in the package. +// Reference: https://github.com/software-mansion/react-native-gesture-handler/blob/90895e5f38616a6be256fceec6c6a391cd9ad7c7/src/TouchEventType.ts +export const TouchEventType = { + UNDETERMINED: 0, + TOUCHES_DOWN: 1, + TOUCHES_MOVE: 2, + TOUCHES_UP: 3, + TOUCHES_CANCELLED: 4, +}; + +const DEFAULT_TOUCH_EVENTS = [ + { + id: 1, + eventType: TouchEventType.TOUCHES_DOWN, + x: 0, + y: 0, + }, +]; + +/** + * @typedef {Object} WPBlockWithLayout + * @property {string} name Name of the block (e.g. Paragraph). + * @property {string} html HTML content. + * @property {Object} layout Layout data. + * @property {Object} layout.x X position. + * @property {Object} layout.y Y position. + * @property {Object} layout.width Width. + * @property {Object} layout.height Height. + */ + +/** + * Initialize the editor with an array of blocks that include their HTML and layout. + * + * @param {WPBlockWithLayout[]} blocks Initial blocks. + * + * @return {import('@testing-library/react-native').RenderAPI} The Testing Library screen. + */ +export const initializeWithBlocksLayouts = async ( blocks ) => { + const initialHtml = blocks.map( ( block ) => block.html ).join( '\n' ); + + const screen = await initializeEditor( { initialHtml } ); + const { getByA11yLabel } = screen; + + const waitPromises = []; + blocks.forEach( ( block, index ) => { + const a11yLabel = new RegExp( + `${ block.name } Block\\. Row ${ index + 1 }` + ); + const element = getByA11yLabel( a11yLabel ); + // "onLayout" event will populate the blocks layouts data. + fireEvent( element, 'layout', { + nativeEvent: { layout: block.layout }, + } ); + if ( block.nestedBlocks ) { + // Nested blocks are rendered via the FlatList of the inner block list. + // In order to render the items of a FlatList, it's required to trigger the + // "onLayout" event. Additionally, the call is wrapped over "waitForStoreResolvers" + // because the nested blocks might make API requests (e.g. the Gallery block). + waitPromises.push( + waitForStoreResolvers( () => + fireEvent( + within( element ).getByTestId( 'block-list-wrapper' ), + 'layout', + { + nativeEvent: { + layout: { + width: block.layout.width, + height: block.layout.height, + }, + }, + } + ) + ) + ); + + block.nestedBlocks.forEach( ( nestedBlock, nestedIndex ) => { + const nestedA11yLabel = new RegExp( + `${ nestedBlock.name } Block\\. Row ${ nestedIndex + 1 }` + ); + fireEvent( + within( element ).getByA11yLabel( nestedA11yLabel ), + 'layout', + { + nativeEvent: { layout: nestedBlock.layout }, + } + ); + } ); + } + } ); + await Promise.all( waitPromises ); + + return screen; +}; + +/** + * Fires long-press gesture event on a block. + * + * @param {import('react-test-renderer').ReactTestInstance} block Block test instance. + * @param {string} testID Id for querying the draggable trigger element. + * @param {Object} [options] Configuration options for the gesture event. + * @param {boolean} [options.failed] Determines if the gesture should fail. + * @param {number} [options.triggerIndex] In case there are multiple draggable triggers, this specifies the index to use. + */ +export const fireLongPress = ( + block, + testID, + { failed = false, triggerIndex } = {} +) => { + const draggableTrigger = + typeof triggerIndex !== 'undefined' + ? within( block ).getAllByTestId( testID )[ triggerIndex ] + : within( block ).getByTestId( testID ); + if ( failed ) { + fireGestureHandler( draggableTrigger, [ { state: State.FAILED } ] ); + } else { + fireGestureHandler( draggableTrigger, [ + { oldState: State.BEGAN, state: State.ACTIVE }, + { state: State.ACTIVE }, + { state: State.END }, + ] ); + } + // Advance timers one frame to ensure that shared values + // are updated and trigger animation reactions. + act( () => advanceAnimationByFrame( 1 ) ); +}; + +/** + * Fires pan gesture event on a BlockDraggable component. + * + * @param {import('react-test-renderer').ReactTestInstance} blockDraggable BlockDraggable test instance. + * @param {Object} [touchEvents] Array of touch events to dispatch on the pan gesture. + */ +export const firePanGesture = ( + blockDraggable, + touchEvents = DEFAULT_TOUCH_EVENTS +) => { + const gestureTouchEvents = touchEvents.map( + ( { eventType, ...touchEvent } ) => ( { + allTouches: [ touchEvent ], + eventType, + } ) + ); + fireGestureHandler( blockDraggable, [ + // TOUCHES_DOWN event is only received on ACTIVE state, so we have to fire it manually. + { oldState: State.BEGAN, state: State.ACTIVE }, + ...gestureTouchEvents, + { state: State.END }, + ] ); + // Advance timers one frame to ensure that shared values + // are updated and trigger animation reactions. + act( () => advanceAnimationByFrame( 1 ) ); +}; + +/** + * Gets the draggable chip element. + * + * @param {import('@testing-library/react-native').RenderAPI} screen The Testing Library screen. + * + * @return {import('react-test-renderer').ReactTestInstance} Draggable chip test instance. + */ +export const getDraggableChip = ( { getByTestId } ) => { + let draggableChip; + try { + draggableChip = getByTestId( 'draggable-chip' ); + } catch ( e ) { + // NOOP. + } + return draggableChip; +}; diff --git a/test/native/helpers.js b/test/native/helpers.js index e40c95273db74b..7217cef19439bc 100644 --- a/test/native/helpers.js +++ b/test/native/helpers.js @@ -31,48 +31,123 @@ provideToNativeHtml.mockImplementation( ( html ) => { serializedHtml = html; } ); +const frameTime = 1000 / 60; + /** - * Executes a function that triggers store resolvers and waits for them to be finished. + * Set up fake timers for executing a function and restores them afterwards. * - * 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 to trigger. * - * @param {Function} fn Function that triggers store resolvers. * @return {*} The result of the function call. */ -export async function waitForStoreResolvers( fn ) { +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 originalRAF = global.requestAnimationFrame; - const originalCAF = global.cancelAnimationFrame; + const requestAnimationFrameCopy = global.requestAnimationFrame; + const cancelAnimationFrameCopy = global.cancelAnimationFrame; + + if ( ! usingFakeTimers ) { + jest.useFakeTimers( 'legacy' ); + } - jest.useFakeTimers( 'legacy' ); + const result = await fn(); - const result = fn(); + if ( ! usingFakeTimers ) { + jest.useRealTimers(); + + global.requestAnimationFrame = requestAnimationFrameCopy; + global.cancelAnimationFrame = cancelAnimationFrameCopy; + } + return result; +} - // Advance all timers allowing store resolvers to resolve. - act( () => jest.runAllTimers() ); +/** + * Prepare timers for executing a function that uses the Reanimated APIs. + * + * NOTE: This code is based on a similar function provided by the Reanimated library. + * Reference: https://github.com/software-mansion/react-native-reanimated/blob/b4ee4ea9a1f246c461dd1819c6f3d48440a25756/src/reanimated2/jestUtils.ts#L170-L174 + * + * @param {Function} fn Function to trigger. + * + * @return {*} The result of the function call. + */ +export async function withReanimatedTimer( fn ) { + return withFakeTimers( async () => { + global.requestAnimationFrame = ( callback ) => + setTimeout( callback, frameTime ); - // 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-native`. - // https://github.com/callstack/react-native-testing-library/blob/a010ffdbca906615279ecc3abee423525e528101/src/flushMicroTasks.js#L15-L23. - await act( async () => {} ); + const result = await fn(); - // Restore the default timer APIs for remainder of test arrangement, act, and - // assertion. - jest.useRealTimers(); + // As part of the clean up, we run all pending timers that might have been derived from animations. + act( () => jest.runOnlyPendingTimers() ); - // Restore the global animation frame APIs to their original state for the - // React Native Animated API. - global.requestAnimationFrame = originalRAF; - global.cancelAnimationFrame = originalCAF; + return result; + } ); +} - return result; +/** + * Advance Reanimated animations by time. + * This helper should be called within a function invoked by "withReanimatedTimer". + * + * NOTE: This code is based on a similar function provided by the Reanimated library. + * Reference: https://github.com/software-mansion/react-native-reanimated/blob/b4ee4ea9a1f246c461dd1819c6f3d48440a25756/src/reanimated2/jestUtils.ts#L176-L181 + * + * @param {number} time Time to advance timers. + */ +export const advanceAnimationByTime = ( time = frameTime ) => { + for ( let i = 0; i <= Math.ceil( time / frameTime ); i++ ) { + jest.advanceTimersByTime( frameTime ); + } + jest.advanceTimersByTime( frameTime ); +}; + +/** + * Advance Reanimated animations by frames. + * This helper should be called within a function invoked by "withReanimatedTimer". + * + * NOTE: This code is based on a similar function provided by the Reanimated library. + * Reference: https://github.com/software-mansion/react-native-reanimated/blob/b4ee4ea9a1f246c461dd1819c6f3d48440a25756/src/reanimated2/jestUtils.ts#L183-L188 + * + * @param {number} count Number of frames to advance timers. + */ +export const advanceAnimationByFrame = ( count ) => { + for ( let i = 0; i <= count; i++ ) { + jest.advanceTimersByTime( frameTime ); + } + jest.advanceTimersByTime( frameTime ); +}; + +/** + * 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-native`. + // https://github.com/callstack/react-native-testing-library/blob/a010ffdbca906615279ecc3abee423525e528101/src/flushMicroTasks.js#L15-L23. + await act( async () => {} ); + + return result; + } ); } /** @@ -95,7 +170,10 @@ export async function initializeEditor( props, { component } = {} ) { : internalInitializeEditor( uniqueId, postType, postId ); const screen = render( - cloneElement( editorElement, { initialTitle: 'test', ...props } ) + cloneElement( editorElement, { + initialTitle: 'test', + ...props, + } ) ); // A layout event must be explicitly dispatched in BlockList component, From 920901577689238cfdbdef22426e87ae640bc59c Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 24 May 2022 13:30:33 +0200 Subject: [PATCH 8/9] Add drag and drop integration tests --- .../test/__snapshots__/index.native.js.snap | 73 +++ .../block-draggable/test/index.native.js | 496 ++++++++++++++++++ .../test/__snapshots__/index.native.js.snap | 4 + 3 files changed, 573 insertions(+) create mode 100644 packages/block-editor/src/components/block-draggable/test/__snapshots__/index.native.js.snap create mode 100644 packages/block-editor/src/components/block-draggable/test/index.native.js diff --git a/packages/block-editor/src/components/block-draggable/test/__snapshots__/index.native.js.snap b/packages/block-editor/src/components/block-draggable/test/__snapshots__/index.native.js.snap new file mode 100644 index 00000000000000..286c6e11e2a305 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/test/__snapshots__/index.native.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockDraggable moves blocks: Initial order 1`] = ` +" +

This is a paragraph.

+ + + +
\\"\\"/
+ + + +
+ + + + +" +`; + +exports[`BlockDraggable moves blocks: Paragraph block moved from first to second position 1`] = ` +" +
\\"\\"/
+ + + +

This is a paragraph.

+ + + +
+ + + + +" +`; + +exports[`BlockDraggable moves blocks: Spacer block moved from third to first position 1`] = ` +" +
+ + + +
\\"\\"/
+ + + +

This is a paragraph.

+ + + + +" +`; diff --git a/packages/block-editor/src/components/block-draggable/test/index.native.js b/packages/block-editor/src/components/block-draggable/test/index.native.js new file mode 100644 index 00000000000000..30c9d7c8baae16 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/test/index.native.js @@ -0,0 +1,496 @@ +/** + * External dependencies + */ +import { + fireEvent, + getEditorHtml, + within, + waitForStoreResolvers, + withReanimatedTimer, +} from 'test/helpers'; +import { getByGestureTestId } from 'react-native-gesture-handler/jest-utils'; +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; + +/** + * WordPress dependencies + */ +import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; +import { registerCoreBlocks } from '@wordpress/block-library'; +import '@wordpress/jest-console'; + +/** + * Internal dependencies + */ +import { + initializeWithBlocksLayouts, + fireLongPress, + firePanGesture, + TouchEventType, + getDraggableChip, +} from './helpers'; + +// Mock throttle to allow updating the dragging position on every "onDragOver" event. +jest.mock( 'lodash', () => ( { + ...jest.requireActual( 'lodash' ), + throttle: ( fn ) => { + fn.cancel = jest.fn(); + return fn; + }, +} ) ); + +beforeAll( () => { + // Register all core blocks + registerCoreBlocks(); +} ); + +afterAll( () => { + // Clean up registered blocks + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); +} ); + +const TOUCH_EVENT_ID = 1; +const BLOCKS = [ + { + name: 'Paragraph', + html: ` + +

This is a paragraph.

+ `, + layout: { x: 0, y: 0, width: 100, height: 100 }, + }, + { + name: 'Image', + html: ` + +
+ `, + layout: { x: 0, y: 100, width: 100, height: 100 }, + }, + { + name: 'Spacer', + html: ` + + + `, + layout: { x: 0, y: 200, width: 100, height: 100 }, + }, + { + name: 'Gallery', + html: ` + + + `, + layout: { x: 0, y: 300, width: 100, height: 100 }, + nestedBlocks: [ + { name: 'Image', layout: { x: 0, y: 300, width: 50, height: 50 } }, + { name: 'Image', layout: { x: 50, y: 300, width: 50, height: 50 } }, + ], + }, +]; + +describe( 'BlockDraggable', () => { + describe( 'drag mode', () => { + describe( 'Text block', () => { + it( 'enables drag mode when unselected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + + // Start dragging from block's content + fireLongPress( + getByA11yLabel( /Paragraph Block\. Row 1/ ), + 'draggable-trigger-content' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + + // "firePanGesture" finishes the dragging gesture + firePanGesture( + getByGestureTestId( 'block-draggable-wrapper' ) + ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'enables drag mode when selected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + const paragraphBlock = getByA11yLabel( + /Paragraph Block\. Row 1/ + ); + fireEvent.press( paragraphBlock ); + + // Start dragging from block's content + fireLongPress( + paragraphBlock, + 'draggable-trigger-content' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + + // Start dragging from block's mobile toolbar + fireLongPress( + paragraphBlock, + 'draggable-trigger-mobile-toolbar' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'does not enable drag mode when selected and editing text', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + + const paragraphBlock = getByA11yLabel( + /Paragraph Block\. Row 1/ + ); + + // Select Paragraph block and start editing text + fireEvent.press( paragraphBlock ); + fireEvent( + within( paragraphBlock ).getByPlaceholderText( + 'Start writing…' + ), + 'focus' + ); + + // Start dragging from block's content + fireLongPress( + paragraphBlock, + 'draggable-trigger-content', + { failed: true } + ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + // Check that no text input has been unfocused to confirm + // that editing text is still enabled. + expect( + TextInputState.blurTextInput + ).not.toHaveBeenCalled(); + } ) ); + + it( 'finishes editing text and enables drag mode when long-pressing over a different block', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + + const paragraphBlock = getByA11yLabel( + /Paragraph Block\. Row 1/ + ); + const spacerBlock = getByA11yLabel( + /Spacer Block\. Row 3/ + ); + + // Select Paragraph block and start editing text + fireEvent.press( paragraphBlock ); + fireEvent( + within( paragraphBlock ).getByPlaceholderText( + 'Start writing…' + ), + 'focus' + ); + + // Start dragging from a different block's content + fireLongPress( spacerBlock, 'draggable-trigger-content' ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // Check that any text input has been unfocused to confirm + // that editing text finished. + expect( TextInputState.blurTextInput ).toHaveBeenCalled(); + } ) ); + } ); + + describe( 'Media block', () => { + it( 'enables drag mode when unselected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getAllByA11yLabel } = screen; + + // We select the first Image block as the Gallery block + // also contains Image blocks. + const imageBlock = getAllByA11yLabel( + /Image Block\. Row 2/ + )[ 0 ]; + // Start dragging from block's content + fireLongPress( imageBlock, 'draggable-trigger-content' ); + expect( getDraggableChip( screen ) ).toBeVisible(); + + // "firePanGesture" finishes the dragging gesture + firePanGesture( + getByGestureTestId( 'block-draggable-wrapper' ) + ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'enables drag mode when selected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getAllByA11yLabel } = screen; + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + // We select the first Image block as the Gallery block + // also contains Image blocks. + const imageBlock = getAllByA11yLabel( + /Image Block\. Row 2/ + )[ 0 ]; + fireEvent.press( imageBlock ); + + // Start dragging from block's content + fireLongPress( imageBlock, 'draggable-trigger-content' ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + + // Start dragging from block's mobile toolbar + fireLongPress( + imageBlock, + 'draggable-trigger-mobile-toolbar' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + } ); + + describe( 'Nested block', () => { + it( 'enables drag mode when unselected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + + // Start dragging from block's content, specifically the first + // trigger index, which corresponds to the Gallery block content. + fireLongPress( + getByA11yLabel( /Gallery Block\. Row 4/ ), + 'draggable-trigger-content', + { triggerIndex: 0 } + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + + // "firePanGesture" finishes the dragging gesture + firePanGesture( + getByGestureTestId( 'block-draggable-wrapper' ) + ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'enables drag mode when selected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + const galleryBlock = getByA11yLabel( + /Gallery Block\. Row 4/ + ); + await waitForStoreResolvers( () => + fireEvent.press( galleryBlock ) + ); + + // Start dragging from block's content, specifically the first + // trigger index, which corresponds to the Gallery block content. + fireLongPress( galleryBlock, 'draggable-trigger-content', { + triggerIndex: 0, + } ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + + // Start dragging from block's mobile toolbar + fireLongPress( + galleryBlock, + 'draggable-trigger-mobile-toolbar' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'enables drag mode when nested block is selected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + const galleryBlock = getByA11yLabel( + /Gallery Block\. Row 4/ + ); + const galleryItem = within( galleryBlock ).getByA11yLabel( + /Image Block\. Row 2/ + ); + fireEvent.press( galleryBlock ); + fireEvent.press( galleryItem ); + + // Start dragging from nested block's content + fireLongPress( galleryItem, 'draggable-trigger-content' ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + + // After dropping the block, the gallery item gets automatically selected. + // Hence, we have to select the gallery item again. + fireEvent.press( galleryItem ); + + // Start dragging from nested block's mobile toolbar + fireLongPress( + galleryItem, + 'draggable-trigger-mobile-toolbar' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + } ); + + describe( 'Other block', () => { + it( 'enables drag mode when unselected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + + // Start dragging from block's content + fireLongPress( + getByA11yLabel( /Spacer Block\. Row 3/ ), + 'draggable-trigger-content' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + + // "firePanGesture" finishes the dragging gesture + firePanGesture( + getByGestureTestId( 'block-draggable-wrapper' ) + ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + + it( 'enables drag mode when selected', async () => + withReanimatedTimer( async () => { + const screen = await initializeWithBlocksLayouts( BLOCKS ); + const { getByA11yLabel } = screen; + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + const spacerBlock = getByA11yLabel( + /Spacer Block\. Row 3/ + ); + await waitForStoreResolvers( () => + fireEvent.press( spacerBlock ) + ); + + // Start dragging from block's content + fireLongPress( spacerBlock, 'draggable-trigger-content' ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + + // Start dragging from block's mobile toolbar + fireLongPress( + spacerBlock, + 'draggable-trigger-mobile-toolbar' + ); + expect( getDraggableChip( screen ) ).toBeVisible(); + // "firePanGesture" finishes the dragging gesture + firePanGesture( blockDraggableWrapper ); + expect( getDraggableChip( screen ) ).not.toBeDefined(); + } ) ); + } ); + } ); + + it( 'moves blocks', async () => + withReanimatedTimer( async () => { + const { getByA11yLabel } = await initializeWithBlocksLayouts( + BLOCKS + ); + const blockDraggableWrapper = getByGestureTestId( + 'block-draggable-wrapper' + ); + + expect( getEditorHtml() ).toMatchSnapshot( 'Initial order' ); + + // Move Paragraph block from first to second position + fireLongPress( + getByA11yLabel( /Paragraph Block\. Row 1/ ), + 'draggable-trigger-content' + ); + firePanGesture( blockDraggableWrapper, [ + { + id: TOUCH_EVENT_ID, + eventType: TouchEventType.TOUCHES_DOWN, + x: 0, + y: 0, + }, + { + id: TOUCH_EVENT_ID, + eventType: TouchEventType.TOUCHES_MOVE, + x: 0, + // Dropping position is in the second half of the second block's height. + y: 180, + }, + ] ); + // Draggable Pan gesture uses the Gesture state manager to manually + // activate the gesture. Since this not available in tests, the library + // displays a warning message. + expect( console ).toHaveWarnedWith( + '[react-native-gesture-handler] You have to use react-native-reanimated in order to control the state of the gesture.' + ); + expect( getEditorHtml() ).toMatchSnapshot( + 'Paragraph block moved from first to second position' + ); + + // Move Spacer block from third to first position + fireLongPress( + getByA11yLabel( /Spacer Block\. Row 3/ ), + 'draggable-trigger-content' + ); + firePanGesture( blockDraggableWrapper, [ + { + id: TOUCH_EVENT_ID, + eventType: TouchEventType.TOUCHES_DOWN, + x: 0, + y: 250, + }, + { + id: TOUCH_EVENT_ID, + eventType: TouchEventType.TOUCHES_MOVE, + x: 0, + y: 0, + }, + ] ); + // Draggable Pan gesture uses the Gesture state manager to manually + // activate the gesture. Since this not available in tests, the library + // displays a warning message. + expect( console ).toHaveWarnedWith( + '[react-native-gesture-handler] You have to use react-native-reanimated in order to control the state of the gesture.' + ); + expect( getEditorHtml() ).toMatchSnapshot( + 'Spacer block moved from third to first position' + ); + } ) ); +} ); diff --git a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap index 79b79766caaa68..ec1b5b196d57bb 100644 --- a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap +++ b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap @@ -35,6 +35,8 @@ Array [ > Date: Wed, 25 May 2022 17:00:03 +0200 Subject: [PATCH 9/9] Update react-native-aztec mock to use AztecInputState --- .../@wordpress/react-native-aztec/index.js | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/test/native/__mocks__/@wordpress/react-native-aztec/index.js b/test/native/__mocks__/@wordpress/react-native-aztec/index.js index dfd2f21c0504b9..f64e93af002952 100644 --- a/test/native/__mocks__/@wordpress/react-native-aztec/index.js +++ b/test/native/__mocks__/@wordpress/react-native-aztec/index.js @@ -7,31 +7,48 @@ import { omit } from 'lodash'; /** * WordPress dependencies */ -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useImperativeHandle, useRef } from '@wordpress/element'; -const reactNativeAztecMock = jest.createMockFromModule( - '@wordpress/react-native-aztec' -); // Preserve the mock of AztecInputState to be exported with the AztecView mock. -const AztecInputState = reactNativeAztecMock.default.InputState; +const AztecInputState = jest.requireActual( '@wordpress/react-native-aztec' ) + .default.InputState; const UNSUPPORTED_PROPS = [ 'style' ]; -const AztecView = ( { accessibilityLabel, text, ...rest }, ref ) => { +const RCTAztecView = ( { accessibilityLabel, text, ...rest }, ref ) => { + const inputRef = useRef(); + + useImperativeHandle( ref, () => ( { + // We need to reference the props of TextInput because they are used in TextColorEdit to calculate the color indicator. + // Reference: https://github.com/WordPress/gutenberg/blob/4407ae6fa20bdd3c3aa62d50344e796467359246/packages/format-library/src/text-color/index.native.js#L83-L86 + props: { ...inputRef.current.props }, + blur: () => { + AztecInputState.blur( inputRef.current ); + inputRef.current.blur(); + }, + focus: () => { + AztecInputState.focus( inputRef.current ); + inputRef.current.focus(); + }, + isFocused: () => { + const focusedElement = AztecInputState.getCurrentFocusedElement(); + return focusedElement && focusedElement === inputRef.current; + }, + } ) ); + return ( ); }; -// Replace default mock of AztecView component with custom implementation. -reactNativeAztecMock.default = forwardRef( AztecView ); -reactNativeAztecMock.default.InputState = AztecInputState; +const AztecView = forwardRef( RCTAztecView ); +AztecView.InputState = AztecInputState; -module.exports = reactNativeAztecMock; +export default AztecView;