diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index e88b3d6c047f33..608d230503b679 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -44,7 +44,13 @@ export { MEDIA_TYPE_AUDIO, MEDIA_TYPE_ANY, } from './media-upload'; -export { default as MediaUploadProgress } from './media-upload-progress'; +export { + default as MediaUploadProgress, + MEDIA_UPLOAD_STATE_UPLOADING, + MEDIA_UPLOAD_STATE_SUCCEEDED, + MEDIA_UPLOAD_STATE_FAILED, + MEDIA_UPLOAD_STATE_RESET, +} from './media-upload-progress'; export { default as BlockMediaUpdateProgress } from './block-media-update-progress'; export { default as URLInput } from './url-input'; export { default as BlockInvalidWarning } from './block-list/block-invalid-warning'; diff --git a/packages/block-editor/src/components/media-placeholder/index.native.js b/packages/block-editor/src/components/media-placeholder/index.native.js index b7ac7f00e5755e..4b83c1e314fdef 100644 --- a/packages/block-editor/src/components/media-placeholder/index.native.js +++ b/packages/block-editor/src/components/media-placeholder/index.native.js @@ -130,12 +130,14 @@ function MediaPlaceholder( props ) { ); } else if ( isAppender && ! disableMediaButtons ) { return ( - + + + ); } }; diff --git a/packages/block-editor/src/components/media-upload/index.native.js b/packages/block-editor/src/components/media-upload/index.native.js index 397a70e006ac52..05aabd9a9a3cd1 100644 --- a/packages/block-editor/src/components/media-upload/index.native.js +++ b/packages/block-editor/src/components/media-upload/index.native.js @@ -294,6 +294,7 @@ export class MediaUpload extends Component { ref={ ( instance ) => ( this.picker = instance ) } options={ this.getMediaOptionsItems() } onChange={ this.onPickerSelect } + testID="media-options-picker" /> ); diff --git a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap index eadf547a190f01..1fd4dc765870d0 100644 --- a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap +++ b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap @@ -1,7 +1,171 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Gallery block Columns setting decrements columns 1`] = ` +" + +" +`; + +exports[`Gallery block Columns setting does not increment due to maximum value 1`] = ` +" + +" +`; + +exports[`Gallery block cancels uploads 1`] = ` +" + +" +`; + +exports[`Gallery block disables crop images setting 1`] = ` +" + +" +`; + +exports[`Gallery block finishes pending uploads upon opening the editor 1`] = ` +" + +" +`; + +exports[`Gallery block handles failed uploads 1`] = ` +" + +" +`; + exports[`Gallery block inserts block 1`] = ` " " `; + +exports[`Gallery block overrides "Link to" setting of gallery items 1`] = ` +" + +" +`; + +exports[`Gallery block rearranges gallery items 1`] = ` +" + +" +`; + +exports[`Gallery block sets caption to gallery 1`] = ` +" + +" +`; + +exports[`Gallery block sets caption to gallery items 1`] = ` +" + +" +`; + +exports[`Gallery block successfully uploads items 1`] = ` +" + +" +`; + +exports[`Gallery block takes a photo 1`] = ` +" + +" +`; + +exports[`Gallery block uploads from free photo library 1`] = ` +" + +" +`; + +exports[`Gallery block uploads from other apps 1`] = ` +" + +" +`; diff --git a/packages/block-library/src/gallery/test/helpers.native.js b/packages/block-library/src/gallery/test/helpers.native.js new file mode 100644 index 00000000000000..da99a166384480 --- /dev/null +++ b/packages/block-library/src/gallery/test/helpers.native.js @@ -0,0 +1,293 @@ +/** + * External dependencies + */ +import { + act, + initializeEditor, + fireEvent, + waitFor, + within, + waitForStoreResolvers, +} from 'test/helpers'; + +/** + * WordPress dependencies + */ +import { + requestMediaPicker, + subscribeMediaUpload, +} from '@wordpress/react-native-bridge'; +import { + MEDIA_UPLOAD_STATE_UPLOADING, + MEDIA_UPLOAD_STATE_SUCCEEDED, + MEDIA_UPLOAD_STATE_FAILED, + MEDIA_UPLOAD_STATE_RESET, +} from '@wordpress/block-editor'; + +/** + * Adds a Gallery block via the block picker. + * + * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen. + */ +export const addGalleryBlock = async () => { + const screen = await initializeEditor(); + const { getByA11yLabel, getByTestId, getByText } = screen; + + fireEvent.press( getByA11yLabel( 'Add block' ) ); + + const blockList = getByTestId( 'InserterUI-Blocks' ); + // onScroll event used to force the FlatList to render all items + fireEvent.scroll( blockList, { + nativeEvent: { + contentOffset: { y: 0, x: 0 }, + contentSize: { width: 100, height: 100 }, + layoutMeasurement: { width: 100, height: 100 }, + }, + } ); + + fireEvent.press( await waitFor( () => getByText( 'Gallery' ) ) ); + + return screen; +}; + +/** + * The gallery items 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 gallery items request the media data associated with the image to + * be rendered via the "getMedia" selector. + * + * @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance to trigger layout event. + * @param {Object} [options] Configuration options for the event. + * @param {number} [options.width] Width value to be passed to the event. + */ +export const triggerGalleryLayout = async ( + galleryBlock, + { width = 320 } = {} +) => + waitForStoreResolvers( () => + fireEvent( + within( galleryBlock ).getByTestId( 'block-list-wrapper' ), + 'layout', + { + nativeEvent: { + layout: { + width, + }, + }, + } + ) + ); + +/** + * Initialize the editor with HTML generated of Gallery block. + * + * @param {Object} [options] Configuration options for the initialization. + * @param {string} [options.html] String of block editor HTML to parse and render. + * @param {number} [options.numberOfItems] Number of gallery items to generate or already included in the provided block editor HTML. + * @param {Object} [options.media] Contains media data to be used in the generation. + * @param {number} [options.width] Width to be passed when triggering the "onLayout" event on the Gallery block. + * @param {boolean} [options.selected] Specifies if the Gallery block included in the initial HTML should be automatically selected. + * @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL. + * + * @return {import('@testing-library/react-native').RenderAPI} The Testing Library screen plus the Gallery block React Test instance. + */ +export const initializeWithGalleryBlock = async ( { + html, + numberOfItems = 0, + media = [], + width = 320, + selected = true, + useLocalUrl = false, +} = {} ) => { + const initialHtml = + html || + generateGalleryBlock( numberOfItems, media, { + useLocalUrl, + } ); + const screen = await initializeEditor( { initialHtml } ); + const { getByA11yLabel } = screen; + + const galleryBlock = getByA11yLabel( /Gallery Block\. Row 1/ ); + + if ( numberOfItems > 0 ) { + await triggerGalleryLayout( galleryBlock, { width } ); + } + + if ( selected ) { + fireEvent.press( galleryBlock ); + } + + return { ...screen, galleryBlock }; +}; + +/** + * Gets a gallery item within a Gallery block. + * + * @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance. + * @param {number} rowPosition Row position within the Gallery block. + * @return {import('react-test-renderer').ReactTestInstance} Gallery item. + */ +export const getGalleryItem = ( galleryBlock, rowPosition ) => { + return within( galleryBlock ).getByA11yLabel( + new RegExp( `Image Block\\. Row ${ rowPosition }` ) + ); +}; + +/** + * Sets up the media upload mock functions for testing. + * + * @typedef {Object} MediaUploadMockFunctions + * @property {Function} notifyUploadingState Notify uploading state for a media item. + * @property {Function} notifySucceedState Notify succeed state for a media item. + * @property {Function} notifyFailedState Notify failed state for a media item. + * @property {Function} notifyResetState Notify reset state for a media item. + * + * @return {MediaUploadMockFunctions} Notify state functions. + */ +export const setupMediaUpload = () => { + const mediaUploadListeners = []; + subscribeMediaUpload.mockImplementation( ( callback ) => { + mediaUploadListeners.push( callback ); + return { remove: jest.fn() }; + } ); + const notifyMediaUpload = ( payload ) => + mediaUploadListeners.forEach( ( listener ) => listener( payload ) ); + + return { + notifyUploadingState: async ( mediaItem ) => + act( async () => { + notifyMediaUpload( { + state: MEDIA_UPLOAD_STATE_UPLOADING, + mediaId: mediaItem.localId, + progress: 0.25, + } ); + } ), + notifySucceedState: async ( mediaItem ) => + act( async () => { + notifyMediaUpload( { + state: MEDIA_UPLOAD_STATE_SUCCEEDED, + mediaId: mediaItem.localId, + mediaUrl: mediaItem.serverUrl, + mediaServerId: mediaItem.serverId, + } ); + } ), + notifyFailedState: async ( mediaItem ) => + act( async () => { + notifyMediaUpload( { + state: MEDIA_UPLOAD_STATE_FAILED, + mediaId: mediaItem.localId, + progress: 0.5, + } ); + } ), + notifyResetState: async ( mediaItem ) => + act( async () => { + notifyMediaUpload( { + state: MEDIA_UPLOAD_STATE_RESET, + mediaId: mediaItem.localId, + progress: 0, + } ); + } ), + }; +}; + +/** + * + * Sets up Media Picker mock functions. + * + * @typedef {Object} MediaPickerMockFunctions + * @property {Function} expectMediaPickerCall Checks if the request media picker function has been called with specific arguments. + * @property {Function} mediaPickerCallback Callback function to notify the media items picked from the media picker. + * + * @return {MediaPickerMockFunctions} Media picker mock functions. + */ +export const setupMediaPicker = () => { + let mediaPickerCallback; + requestMediaPicker.mockImplementation( + ( source, filter, multiple, callback ) => { + mediaPickerCallback = callback; + } + ); + return { + expectMediaPickerCall: ( source, filter, multiple ) => + expect( requestMediaPicker ).toHaveBeenCalledWith( + source, + filter, + multiple, + mediaPickerCallback + ), + mediaPickerCallback: async ( ...mediaItems ) => + act( async () => + mediaPickerCallback( + mediaItems.map( ( { localId, localUrl } ) => ( { + type: 'image', + url: localUrl, + id: localId, + } ) ) + ) + ), + }; +}; + +/** + * Generates the HTML of a Gallery block. + * + * @param {number} numberOfItems Number of gallery items to generate. + * @param {Object} media Contains media data to be used in the generation. + * @param {Object} [options] Configuration options for the generation. + * @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL. + * @return {string} Gallery block HTML. + */ +export const generateGalleryBlock = ( + numberOfItems, + media, + { useLocalUrl = false } = {} +) => { + const galleryItems = [ ...Array( numberOfItems ) ] + .map( ( _, index ) => { + const id = useLocalUrl + ? media[ index ].localId + : media[ index ].serverId; + const url = useLocalUrl + ? media[ index ].localUrl + : media[ index ].serverUrl; + return ` +
+ `; + } ) + .join( '\n\n' ); + + return ` + + `; +}; + +/** + * Sets the text of a caption. + * + * @param {import('react-test-renderer').ReactTestInstance} element Caption test instance. + * @param {string} text Text to be set. + */ +export const setCaption = ( element, text ) => { + fireEvent( element, 'focus' ); + fireEvent( element, 'onChange', { + nativeEvent: { + eventCount: 1, + target: undefined, + text, + }, + } ); +}; + +/** + * Opens the block settings of the current selected block. + * + * @param {import('@testing-library/react-native').RenderAPI} screen The Testing Library screen. + */ +export const openBlockSettings = async ( screen ) => { + const { getByA11yLabel, getByTestId } = screen; + fireEvent.press( getByA11yLabel( 'Open Settings' ) ); + await waitFor( + () => getByTestId( 'block-settings-modal' ).props.isVisible + ); +}; diff --git a/packages/block-library/src/gallery/test/index.native.js b/packages/block-library/src/gallery/test/index.native.js index d5f8edfe81c9c8..295859fb039903 100644 --- a/packages/block-library/src/gallery/test/index.native.js +++ b/packages/block-library/src/gallery/test/index.native.js @@ -2,10 +2,10 @@ * External dependencies */ import { + act, getEditorHtml, initializeEditor, fireEvent, - waitFor, within, } from 'test/helpers'; @@ -14,6 +14,48 @@ import { */ import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; +import { Platform } from '@wordpress/element'; +import { + getOtherMediaOptions, + requestImageFailedRetryDialog, + requestImageUploadCancelDialog, +} from '@wordpress/react-native-bridge'; + +/** + * Internal dependencies + */ +import { + addGalleryBlock, + initializeWithGalleryBlock, + getGalleryItem, + setupMediaUpload, + generateGalleryBlock, + setCaption, + setupMediaPicker, + triggerGalleryLayout, + openBlockSettings, +} from './helpers'; + +const media = [ + { + localId: 1, + localUrl: 'file:///local-image-1.jpeg', + serverId: 2000, + serverUrl: 'https://test-site.files.wordpress.com/local-image-1.jpeg', + }, + { + localId: 2, + localUrl: 'file:///local-image-2.jpeg', + serverId: 2001, + serverUrl: 'https://test-site.files.wordpress.com/local-image-2.jpeg', + }, + { + localId: 3, + localUrl: 'file:///local-image-3.jpeg', + serverId: 2002, + serverUrl: 'https://test-site.files.wordpress.com/local-image-3.jpeg', + }, +]; beforeAll( () => { // Register all core blocks @@ -27,102 +69,578 @@ afterAll( () => { } ); } ); -const GALLERY_WITH_ONE_IMAGE = ` - -`; +describe( 'Gallery block', () => { + it( 'inserts block', async () => { + const { getByA11yLabel } = await addGalleryBlock(); -const addGalleryBlock = async () => { - const screen = await initializeEditor(); - const { getByA11yLabel, getByTestId, getByText } = screen; + expect( getByA11yLabel( /Gallery Block\. Row 1/ ) ).toBeVisible(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); - fireEvent.press( getByA11yLabel( 'Add block' ) ); + it( 'selects a gallery item', async () => { + const { galleryBlock } = await initializeWithGalleryBlock( { + numberOfItems: 1, + media, + selected: false, + } ); + + const galleryItem = getGalleryItem( galleryBlock, 1 ); + fireEvent.press( galleryItem ); - const blockList = getByTestId( 'InserterUI-Blocks' ); - // onScroll event used to force the FlatList to render all items - fireEvent.scroll( blockList, { - nativeEvent: { - contentOffset: { y: 0, x: 0 }, - contentSize: { width: 100, height: 100 }, - layoutMeasurement: { width: 100, height: 100 }, - }, + expect( galleryItem ).toBeVisible(); } ); - fireEvent.press( await waitFor( () => getByText( 'Gallery' ) ) ); + it( 'shows appender button when gallery has images', async () => { + const { galleryBlock, getByText } = await initializeWithGalleryBlock( { + numberOfItems: 1, + media, + } ); - return screen; -}; + const appenderButton = within( galleryBlock ).getByTestId( + 'media-placeholder-appender-icon' + ); + fireEvent.press( appenderButton ); -describe( 'Gallery block', () => { - it( 'inserts block', async () => { - const { getByA11yLabel } = await addGalleryBlock(); + expect( getByText( 'Choose from device' ) ).toBeVisible(); + expect( getByText( 'Take a Photo' ) ).toBeVisible(); + expect( getByText( 'WordPress Media Library' ) ).toBeVisible(); + } ); + + // This case is disabled until the issue (https://github.com/WordPress/gutenberg/issues/38444) + // is addressed. + it.skip( 'displays media options picker when selecting the block', async () => { + // Initialize with an empty gallery + const { + getByA11yLabel, + getByText, + getByTestId, + } = await initializeEditor( { + initialHtml: generateGalleryBlock( 0 ), + } ); + + // Tap on Gallery block + fireEvent.press( getByText( 'ADD MEDIA' ) ); + + // Observe that media options picker is displayed + expect( getByText( 'Choose images' ) ).toBeVisible(); + expect( getByText( 'WordPress Media Library' ) ).toBeVisible(); + + // Dimiss the picker + if ( Platform.isIOS ) { + fireEvent.press( getByText( 'Cancel' ) ); + } else { + fireEvent( getByTestId( 'media-options-picker' ), 'backdropPress' ); + } - const galleryBlock = await waitFor( () => - getByA11yLabel( /Gallery Block\. Row 1/ ) + // Observe that the block is selected, this is done by checking if the block settings + // button is visible + const blockActionsButton = getByA11yLabel( /Open Block Actions Menu/ ); + expect( blockActionsButton ).toBeVisible(); + } ); + + // Test case related to TC001 - Close/Re-open post with an ongoing image upload + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc001 + it( 'finishes pending uploads upon opening the editor', async () => { + const { notifyUploadingState, notifySucceedState } = setupMediaUpload(); + + // Initialize with a gallery that contains two items that are being uploaded + const { galleryBlock } = await initializeWithGalleryBlock( { + numberOfItems: 2, + media, + useLocalUrl: true, + } ); + + // Notify that the media items are uploading + await notifyUploadingState( media[ 0 ] ); + await notifyUploadingState( media[ 1 ] ); + + // Check that images are showing a loading state + expect( + within( getGalleryItem( galleryBlock, 1 ) ).getByTestId( 'spinner' ) + ).toBeVisible(); + expect( + within( getGalleryItem( galleryBlock, 2 ) ).getByTestId( 'spinner' ) + ).toBeVisible(); + + // Notify that the media items upload succeeded + await notifySucceedState( media[ 0 ] ); + await notifySucceedState( media[ 1 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC003 - Add caption to gallery + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc003 + it( 'sets caption to gallery', async () => { + // Initialize with a gallery that contains one item + const { getByA11yLabel } = await initializeWithGalleryBlock( { + numberOfItems: 1, + media, + } ); + + // Check gallery item caption is not visible + const galleryItemCaption = getByA11yLabel( /Image caption. Empty/ ); + expect( galleryItemCaption ).not.toBeVisible(); + + // Set gallery caption + const captionField = within( + getByA11yLabel( /Gallery caption. Empty/ ) + ).getByPlaceholderText( 'Add caption' ); + setCaption( + captionField, + 'Bold italic strikethrough gallery caption' ); - expect( galleryBlock ).toHaveProperty( 'type', 'View' ); expect( getEditorHtml() ).toMatchSnapshot(); } ); - it( 'selects a gallery item', async () => { - const { getByA11yLabel } = await initializeEditor( { - initialHtml: GALLERY_WITH_ONE_IMAGE, + // Test case related to TC004 - Add caption to gallery images + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc004 + it( 'sets caption to gallery items', async () => { + // Initialize with a gallery that contains one item + const { galleryBlock } = await initializeWithGalleryBlock( { + numberOfItems: 1, + media, } ); - const galleryBlock = await waitFor( () => - getByA11yLabel( /Gallery Block\. Row 1/ ) + // Select gallery item + const galleryItem = getGalleryItem( galleryBlock, 1 ); + fireEvent.press( galleryItem ); + + // Set gallery item caption + const captionField = within( galleryItem ).getByPlaceholderText( + 'Add caption' + ); + setCaption( + captionField, + 'Bold italic strikethrough image caption' ); - fireEvent.press( galleryBlock ); - const innerBlockListWrapper = await waitFor( () => - within( galleryBlock ).getByTestId( 'block-list-wrapper' ) + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC005 - Choose from device (stay in editor) - Successful upload + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc005 + it( 'successfully uploads items', async () => { + const { notifyUploadingState, notifySucceedState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Upload images from device + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Choose from device' ) ); + expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true ); + + // Return media items picked + await mediaPickerCallback( media[ 0 ], media[ 1 ] ); + + // Check that gallery items are visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem2 = getGalleryItem( galleryBlock, 2 ); + expect( galleryItem1 ).toBeVisible(); + expect( galleryItem2 ).toBeVisible(); + + // Check that images are showing a loading state + await notifyUploadingState( media[ 0 ] ); + await notifyUploadingState( media[ 1 ] ); + expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible(); + expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Notify that the media items upload succeeded + await notifySucceedState( media[ 0 ] ); + await notifySucceedState( media[ 1 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC006 - Choose from device (stay in editor) - Failed upload + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc006 + it( 'handles failed uploads', async () => { + const { notifyUploadingState, notifyFailedState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Upload images from device + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Choose from device' ) ); + expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true ); + + // Return media items picked + await mediaPickerCallback( media[ 0 ], media[ 1 ] ); + + // Check that gallery items are visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem2 = getGalleryItem( galleryBlock, 2 ); + expect( galleryItem1 ).toBeVisible(); + expect( galleryItem2 ).toBeVisible(); + + // Check that images are showing a loading state + await notifyUploadingState( media[ 0 ] ); + await notifyUploadingState( media[ 1 ] ); + expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible(); + expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Notify that the media items uploads failed + await notifyFailedState( media[ 0 ] ); + await notifyFailedState( media[ 1 ] ); + + // Check that failed images provide the option to retry the upload + fireEvent.press( galleryItem1 ); + fireEvent.press( + within( galleryItem1 ).getByText( /Failed to insert media/ ) ); - fireEvent( innerBlockListWrapper, 'layout', { - nativeEvent: { - layout: { - width: 100, - }, - }, + expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith( + media[ 0 ].localId + ); + fireEvent.press( galleryItem2 ); + fireEvent.press( + within( galleryItem2 ).getByText( /Failed to insert media/ ) + ); + expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith( + media[ 1 ].localId + ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC007 - Take a photo + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc007 + it( 'takes a photo', async () => { + const { notifyUploadingState, notifySucceedState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Take a photo + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Take a Photo' ) ); + expectMediaPickerCall( 'DEVICE_CAMERA', [ 'image' ], true ); + + // Return media item from photo taken + await mediaPickerCallback( media[ 0 ] ); + + // Check gallery item is visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem = getGalleryItem( galleryBlock, 1 ); + expect( galleryItem ).toBeVisible(); + + // Check image is showing a loading state + await notifyUploadingState( media[ 0 ] ); + expect( within( galleryItem ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Notify that the media item upload succeeded + await notifySucceedState( media[ 0 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC008 - Choose from the free photo library + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc008 + it( 'uploads from free photo library', async () => { + const freePhotoMedia = [ ...media ].map( ( item, index ) => ( { + ...item, + localUrl: `https://images.pexels.com/photos/110854/pexels-photo-${ + index + 1 + }.jpeg`, + } ) ); + const { notifyUploadingState, notifySucceedState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + let otherMediaOptionsCallback; + getOtherMediaOptions.mockImplementation( ( filter, callback ) => { + otherMediaOptionsCallback = callback; } ); - const galleryItem = await waitFor( () => - getByA11yLabel( /Image Block\. Row 1/ ) + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Notify other media options + act( () => + otherMediaOptionsCallback( [ + { + label: 'Free Photo Library', + value: 'stock-photo-library', + }, + ] ) + ); + + // Upload images from free photo library + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Free Photo Library' ) ); + expectMediaPickerCall( 'stock-photo-library', [ 'image' ], true ); + + // Return media items picked + await act( async () => + mediaPickerCallback( freePhotoMedia[ 0 ], freePhotoMedia[ 1 ] ) ); - fireEvent.press( galleryItem ); - expect( galleryItem ).toHaveProperty( 'type', 'View' ); + // Check that gallery items are visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem2 = getGalleryItem( galleryBlock, 2 ); + expect( galleryItem1 ).toBeVisible(); + expect( galleryItem2 ).toBeVisible(); + + // Check that images are showing a loading state + await notifyUploadingState( freePhotoMedia[ 0 ] ); + await notifyUploadingState( freePhotoMedia[ 1 ] ); + expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible(); + expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Notify that the media items upload succeeded + await notifySucceedState( freePhotoMedia[ 0 ] ); + await notifySucceedState( freePhotoMedia[ 1 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); } ); - it( 'shows appender button when gallery has images', async () => { - const { getByA11yLabel, getByText } = await initializeEditor( { - initialHtml: GALLERY_WITH_ONE_IMAGE, + // Test case related to TC009 - Choose from device (stay in editor) - Cancel upload + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc009 + it( 'cancels uploads', async () => { + const { notifyUploadingState, notifyResetState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Upload images from device + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Choose from device' ) ); + expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true ); + + // Return media items picked + await mediaPickerCallback( media[ 0 ], media[ 1 ] ); + + // Check that gallery items are visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem2 = getGalleryItem( galleryBlock, 2 ); + expect( galleryItem1 ).toBeVisible(); + expect( galleryItem2 ).toBeVisible(); + + // Check that images are showing a loading state + await notifyUploadingState( media[ 0 ] ); + await notifyUploadingState( media[ 1 ] ); + expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible(); + expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Cancel uploads + fireEvent.press( galleryItem1 ); + fireEvent.press( within( galleryItem1 ).getByTestId( 'spinner' ) ); + expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith( + media[ 0 ].localId + ); + await notifyResetState( media[ 0 ] ); + + fireEvent.press( galleryItem2 ); + fireEvent.press( within( galleryItem2 ).getByTestId( 'spinner' ) ); + expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith( + media[ 1 ].localId + ); + await notifyResetState( media[ 1 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC010 - Rearrange images in Gallery + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc010 + it( 'rearranges gallery items', async () => { + // Initialize with a gallery that contains three items + const { galleryBlock } = await initializeWithGalleryBlock( { + numberOfItems: 3, + media, } ); - const galleryBlock = await waitFor( () => - getByA11yLabel( /Gallery Block\. Row 1/ ) + // Rearrange items (final disposition will be: Image 3 - Image 1 - Image 2) + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem3 = getGalleryItem( galleryBlock, 3 ); + + fireEvent.press( galleryItem3 ); + await act( () => + fireEvent.press( + within( galleryItem3 ).getByA11yLabel( + /Move block left from position 3 to position 2/ + ) + ) ); - fireEvent.press( galleryBlock ); - const innerBlockListWrapper = await waitFor( () => - within( galleryBlock ).getByTestId( 'block-list-wrapper' ) + fireEvent.press( galleryItem1 ); + await act( () => + fireEvent.press( + within( galleryItem1 ).getByA11yLabel( + /Move block right from position 1 to position 2/ + ) + ) ); - fireEvent( innerBlockListWrapper, 'layout', { - nativeEvent: { - layout: { - width: 100, - }, - }, + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC011 - Choose from Other Apps (iOS Files App) + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc011 + it( 'uploads from other apps', async () => { + const otherAppsMedia = [ ...media ].map( ( item, index ) => ( { + ...item, + localUrl: `file:///IMG_${ index + 1 }.JPG`, + } ) ); + const { notifyUploadingState, notifySucceedState } = setupMediaUpload(); + const { + expectMediaPickerCall, + mediaPickerCallback, + } = setupMediaPicker(); + + let otherMediaOptionsCallback; + getOtherMediaOptions.mockImplementation( ( filter, callback ) => { + otherMediaOptionsCallback = callback; } ); - const appenderButton = await waitFor( () => - within( galleryBlock ).getByA11yLabel( /Gallery block\. Empty/ ) + // Initialize with an empty gallery + const { galleryBlock, getByText } = await initializeWithGalleryBlock(); + + // Notify other media options + act( () => + otherMediaOptionsCallback( [ + { label: 'Other Apps', value: 'other-files' }, + ] ) ); - fireEvent.press( appenderButton ); - expect( getByText( 'Choose from device' ) ).toBeDefined(); - expect( getByText( 'Take a Photo' ) ).toBeDefined(); - expect( getByText( 'WordPress Media Library' ) ).toBeDefined(); + // Upload images from other apps + fireEvent.press( getByText( 'ADD MEDIA' ) ); + fireEvent.press( getByText( 'Other Apps' ) ); + expectMediaPickerCall( 'other-files', [ 'image' ], true ); + + // Return media items picked + await mediaPickerCallback( otherAppsMedia[ 0 ], otherAppsMedia[ 1 ] ); + + // Check that gallery items are visible + await triggerGalleryLayout( galleryBlock ); + const galleryItem1 = getGalleryItem( galleryBlock, 1 ); + const galleryItem2 = getGalleryItem( galleryBlock, 2 ); + expect( galleryItem1 ).toBeVisible(); + expect( galleryItem2 ).toBeVisible(); + + // Check that images are showing a loading state + await notifyUploadingState( otherAppsMedia[ 0 ] ); + await notifyUploadingState( otherAppsMedia[ 1 ] ); + expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible(); + expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible(); + + // Notify that the media items upload succeeded + await notifySucceedState( otherAppsMedia[ 0 ] ); + await notifySucceedState( otherAppsMedia[ 1 ] ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test case related to TC012 - Settings - Link to + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc012 + it( 'overrides "Link to" setting of gallery items', async () => { + // Initialize with a gallery that contains two items, the latter includes "linkDestination" attribute + const screen = await initializeWithGalleryBlock( { + html: ` + + `, + numberOfItems: 2, + } ); + const { getByText } = screen; + + // Set "Link to" setting via Gallery block settings + await openBlockSettings( screen ); + fireEvent.press( getByText( 'Link to' ) ); + fireEvent.press( getByText( 'Media File' ) ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // Test cases related to TC013 - Settings - Columns + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc013 + describe( 'Columns setting', () => { + it( 'does not increment due to maximum value', async () => { + // Initialize with a gallery that contains three items + const screen = await initializeWithGalleryBlock( { + numberOfItems: 3, + media, + } ); + const { getByA11yLabel } = screen; + + await openBlockSettings( screen ); + + // Can't increment due to maximum value + // NOTE: Default columns value is 3 + fireEvent( + getByA11yLabel( /Columns\. Value is 3/ ), + 'accessibilityAction', + { + nativeEvent: { actionName: 'increment' }, + } + ); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'decrements columns', async () => { + // Initialize with a gallery that contains three items + const screen = await initializeWithGalleryBlock( { + numberOfItems: 3, + media, + } ); + const { getByA11yLabel } = screen; + + await openBlockSettings( screen ); + + // Decrement columns + fireEvent( + getByA11yLabel( /Columns\. Value is 3/ ), + 'accessibilityAction', + { + nativeEvent: { actionName: 'decrement' }, + } + ); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + } ); + + // Test case related to TC014 - Settings - Crop images + // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc014 + it( 'disables crop images setting', async () => { + // Initialize with a gallery that contains one item + const screen = await initializeWithGalleryBlock( { + numberOfItems: 1, + media, + } ); + const { getByText } = screen; + + await openBlockSettings( screen ); + + // Disable crop images setting + fireEvent.press( getByText( 'Crop images' ) ); + expect( getEditorHtml() ).toMatchSnapshot(); } ); } ); diff --git a/test/native/helpers.js b/test/native/helpers.js index 3a2386d46bf376..a2918aa6354f1d 100644 --- a/test/native/helpers.js +++ b/test/native/helpers.js @@ -35,48 +35,26 @@ provideToNativeHtml.mockImplementation( ( html ) => { } ); /** - * Initialize an editor for test assertions. + * Executes a function that triggers store resolvers and waits for them to be finished. * - * @param {Object} props Properties passed to the editor component. - * @param {string} props.initialHtml String of block editor HTML to parse and render. - * @param {Object} [options] Configuration options for the editor. - * @param {import('react').ReactNode} [options.component] A specific editor component to render. - * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen. + * 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 triggers store resolvers. + * @return {*} The result of the function call. */ -export async function initializeEditor( props, { component = Editor } = {} ) { +export async function waitForStoreResolvers( fn ) { // 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; - // During editor initialization, 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. jest.useFakeTimers( 'legacy' ); - // Arrange. - const EditorComponent = component; - const screen = render( - - ); - - // A layout event must be explicitly dispatched in BlockList component, - // otherwise the inner blocks are not rendered. - fireEvent( screen.getByTestId( 'block-list-wrapper' ), 'layout', { - nativeEvent: { - layout: { - width: 100, - }, - }, - } ); + const result = fn(); // Advance all timers allowing store resolvers to resolve. act( () => jest.runAllTimers() ); @@ -97,7 +75,42 @@ export async function initializeEditor( props, { component = Editor } = {} ) { global.requestAnimationFrame = originalRAF; global.cancelAnimationFrame = originalCAF; - return screen; + return result; +} + +/** + * Initialize an editor for test assertions. + * + * @param {Object} props Properties passed to the editor component. + * @param {string} props.initialHtml String of block editor HTML to parse and render. + * @param {Object} [options] Configuration options for the editor. + * @param {import('react').ReactNode} [options.component] A specific editor component to render. + * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen. + */ +export async function initializeEditor( props, { component = Editor } = {} ) { + return waitForStoreResolvers( () => { + const EditorComponent = component; + const screen = render( + + ); + + // A layout event must be explicitly dispatched in BlockList component, + // otherwise the inner blocks are not rendered. + fireEvent( screen.getByTestId( 'block-list-wrapper' ), 'layout', { + nativeEvent: { + layout: { + width: 100, + }, + }, + } ); + + return screen; + } ); } export * from '@testing-library/react-native'; diff --git a/test/native/jest.config.js b/test/native/jest.config.js index f1bbb921925b6d..b46e9d6651db62 100644 --- a/test/native/jest.config.js +++ b/test/native/jest.config.js @@ -28,7 +28,7 @@ module.exports = { setupFiles: [ '/' + configPath + '/setup.js' ], setupFilesAfterEnv: [ '/' + configPath + '/setup-after-env.js' ], testMatch: [ - '**/test/*.native.[jt]s?(x)', + '**/test/!(helper)*.native.[jt]s?(x)', '/packages/react-native-*/**/?(*.)+(spec|test).[jt]s?(x)', ], testPathIgnorePatterns: [ diff --git a/test/native/matchers/to-be-visible.js b/test/native/matchers/to-be-visible.js index 709e2a0aa707dd..b2b19d239a8561 100644 --- a/test/native/matchers/to-be-visible.js +++ b/test/native/matchers/to-be-visible.js @@ -17,7 +17,7 @@ function isStyleVisible( element ) { } function isAttributeVisible( element ) { - return element.type !== 'Modal' || ! element.props.visible === false; + return element.type !== 'Modal' || element.props.visible !== false; } function isElementVisible( element ) { diff --git a/test/native/setup.js b/test/native/setup.js index 4d8a52127ec405..4d65686a620b65 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -81,6 +81,8 @@ jest.mock( '@wordpress/react-native-bridge', () => { subscribeMediaSave: jest.fn(), getOtherMediaOptions: jest.fn(), provideToNative_Html: jest.fn(), + requestImageFailedRetryDialog: jest.fn(), + requestImageUploadCancelDialog: jest.fn(), requestMediaEditor: jest.fn(), requestMediaPicker: jest.fn(), requestUnsupportedBlockFallback: jest.fn(),