From 0fee7372995c67ce1904c000d2bb92579ed2be5f Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 4 Nov 2021 10:14:43 +0100 Subject: [PATCH] [RNMobile][Embed block] Integration tests (#35476) * Add testID to embed bottom sheet * Export registerBlock from block library * [WIP] Add embed block integration tests * Fix set valid URL embed integration test * Add waitForElement to initialize editor helper * Add embed preview mocked styles * Add most used embed providers insertion tests * Add test cases for setting URL by tapping on block * Add mock embed responses helper * Add edit URL test cases * Add invalid URL test case * Refactor embed block integration tests * Mock RN clipboard library * Add auto-paste URL from clipboard test cases * Use snapshots and simplify integration tests * Add update test snapshots command * Add change alignment test case * Add retry test case * Add preview coming soon test cases * Mocked RCTAztecView to utilize an underlying TextInput. * Add testID to Android version of picker * Omit style prop in Aztec mock * Add paste URL to create embed test cases * Update snapshots due to mocking Aztec * Revert "Update snapshots due to mocking Aztec" This reverts commit 2114925c5572b724061793c5c4a2bb113b371883. * Unmock react-native-aztec for some block tests * Remove commented code * Embed block integration tests part 2 (#35533) * added test for Embed block caption. * WIP * fixed unneeded diff change. * WIP block settings * Mocked RCTAztecView to utilize an underlying TextInput. * Fixed Embed block caption test issues. * Created test - toggle resize for smaller devices media settings * Added cannot embed test. * Removed unneeded test id. * WIP insert embed from slash inserter. * Mock fetch request in cannot embed test case * Trigger onSelectionChange event instead of onChange * Query slash inserter item by text * Add expected HTML to slash inserter test case * Mock autocomplete component styles * Set paragraph as default block * Add empty paragraph HTML constant * Add test suite for insert via slash inserter case * Update toggle responsive test case * Fix request mock for theme endpoint * Add slash inserter cases for most used providers * Expect for block settings button instead edit URL button * Use snapshot testing instead of checking HTML * Add block settings test suite * Add embed test snapshots * Use snapshot in insert generic embed block test Co-authored-by: Carlos Garcia * Use promise in initializeEditor to prevent act warnings * Simplify tests using initializeWithEmbedBlock * Add test case to cover an already fixed bug * Add test case to cover an already fixed bug (#35013) Co-authored-by: Joel Dean --- .../rich-text/embed-handler-picker.native.js | 1 + .../src/audio/test/edit.native.js | 4 + .../src/embed/embed-bottom-sheet.native.js | 1 + .../src/embed/embed-no-preview.native.js | 1 + .../test/__snapshots__/index.native.js.snap | 181 ++++ .../src/embed/test/index.native.js | 991 ++++++++++++++++++ .../src/file/test/edit.native.js | 4 + packages/block-library/src/index.native.js | 2 +- .../src/search/test/edit.native.js | 4 + .../link-settings-navigation.native.js | 1 + .../src/mobile/picker/index.android.js | 3 +- packages/react-native-editor/package.json | 1 + .../@wordpress/react-native-aztec/index.js | 27 + test/native/__mocks__/styleMock.js | 15 + test/native/helpers.js | 35 +- test/native/setup.js | 5 + 16 files changed, 1259 insertions(+), 17 deletions(-) create mode 100644 packages/block-library/src/embed/test/__snapshots__/index.native.js.snap create mode 100644 packages/block-library/src/embed/test/index.native.js create mode 100644 test/native/__mocks__/@wordpress/react-native-aztec/index.js diff --git a/packages/block-editor/src/components/rich-text/embed-handler-picker.native.js b/packages/block-editor/src/components/rich-text/embed-handler-picker.native.js index 355d136dc7076..62894addb024c 100644 --- a/packages/block-editor/src/components/rich-text/embed-handler-picker.native.js +++ b/packages/block-editor/src/components/rich-text/embed-handler-picker.native.js @@ -58,6 +58,7 @@ const EmbedHandlerPicker = forwardRef( ( {}, ref ) => { ref={ pickerRef } options={ pickerOptions } onChange={ onPickerSelect } + testID="embed-handler-picker" hideCancelButton leftAlign /> diff --git a/packages/block-library/src/audio/test/edit.native.js b/packages/block-library/src/audio/test/edit.native.js index c41beb5b05860..ef0b678f36313 100644 --- a/packages/block-library/src/audio/test/edit.native.js +++ b/packages/block-library/src/audio/test/edit.native.js @@ -14,6 +14,10 @@ import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; */ import { metadata, settings, name } from '../index'; +// react-native-aztec shouldn't be mocked because these tests are based on +// snapshot testing where we want to keep the original component. +jest.unmock( '@wordpress/react-native-aztec' ); + const AudioEdit = ( { clientId, ...props } ) => ( ); diff --git a/packages/block-library/src/embed/embed-bottom-sheet.native.js b/packages/block-library/src/embed/embed-bottom-sheet.native.js index cbcf1beb17af1..f525d22d23ad9 100644 --- a/packages/block-library/src/embed/embed-bottom-sheet.native.js +++ b/packages/block-library/src/embed/embed-bottom-sheet.native.js @@ -68,6 +68,7 @@ const EmbedBottomSheet = ( { value, label, isVisible, onClose, onSubmit } ) => { onDismiss={ onDismiss } setAttributes={ setAttributes } options={ linkSettingsOptions } + testID="embed-edit-url-modal" withBottomSheet showIcon /> diff --git a/packages/block-library/src/embed/embed-no-preview.native.js b/packages/block-library/src/embed/embed-no-preview.native.js index a448cb11bf2e0..cac8c5d1a4c10 100644 --- a/packages/block-library/src/embed/embed-no-preview.native.js +++ b/packages/block-library/src/embed/embed-no-preview.native.js @@ -163,6 +163,7 @@ const EmbedNoPreview = ( { hideHeader onDismiss={ onDismissSheet } onClose={ onCloseSheet } + testID="embed-no-preview-modal" > diff --git a/packages/block-library/src/embed/test/__snapshots__/index.native.js.snap b/packages/block-library/src/embed/test/__snapshots__/index.native.js.snap new file mode 100644 index 0000000000000..d1e287c1168d3 --- /dev/null +++ b/packages/block-library/src/embed/test/__snapshots__/index.native.js.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Embed block alignment options sets Align center option 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block alignment options sets Align left option 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block alignment options sets Align right option 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block alignment options sets Full width option 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block alignment options sets Wide width option 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block block settings toggles resize for smaller devices media settings 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block create by pasting URL creates embed block when pasting URL in paragraph block 1`] = ` +" +
+https://www.youtube.com/watch?v=lXMskKTw3Bc +
+" +`; + +exports[`Embed block create by pasting URL creates link when pasting URL in paragraph block 1`] = ` +" +

https://www.youtube.com/watch?v=lXMskKTw3Bc

+" +`; + +exports[`Embed block displays cannot embed on the placeholder if preview data is null 1`] = ` +" +
+https://twitter.com/testing +
+" +`; + +exports[`Embed block edit URL edits URL when edited after setting a bad URL of a provider 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block edit URL keeps the previous URL if an invalid URL is set 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block edit URL keeps the previous URL if no URL is set 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block edit URL replaces URL 1`] = ` +" +
+https://www.youtube.com/watch?v=lXMskKTw3Bc +
+" +`; + +exports[`Embed block insert via slash inserter insert generic embed block 1`] = `""`; + +exports[`Embed block insert via slash inserter inserts Twitter embed block 1`] = `""`; + +exports[`Embed block insert via slash inserter inserts Vimeo embed block 1`] = `""`; + +exports[`Embed block insert via slash inserter inserts WordPress embed block 1`] = `""`; + +exports[`Embed block insert via slash inserter inserts YouTube embed block 1`] = `""`; + +exports[`Embed block insertion inserts Twitter embed block 1`] = `""`; + +exports[`Embed block insertion inserts Vimeo embed block 1`] = `""`; + +exports[`Embed block insertion inserts WordPress embed block 1`] = `""`; + +exports[`Embed block insertion inserts YouTube embed block 1`] = `""`; + +exports[`Embed block insertion inserts generic embed block 1`] = `""`; + +exports[`Embed block retry converts to link if preview request failed 1`] = ` +" +

https://twitter.com/notnownikki

+" +`; + +exports[`Embed block retry retries loading the preview if initial request failed 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block set URL upon block insertion auto-pastes the URL from clipboard 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block set URL upon block insertion sets a valid URL when dismissing edit URL modal 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block set URL upon block insertion sets empty URL when dismissing edit URL modal 1`] = `""`; + +exports[`Embed block set URL when empty block auto-pastes the URL from clipboard 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block set URL when empty block sets a valid URL when dismissing edit URL modal 1`] = ` +" +
+https://twitter.com/notnownikki +
+" +`; + +exports[`Embed block set URL when empty block sets empty URL when dismissing edit URL modal 1`] = `""`; + +exports[`Embed block sets block caption 1`] = ` +" +
+https://twitter.com/notnownikki +
Caption
+" +`; diff --git a/packages/block-library/src/embed/test/index.native.js b/packages/block-library/src/embed/test/index.native.js new file mode 100644 index 0000000000000..c2ff8fe0e0a35 --- /dev/null +++ b/packages/block-library/src/embed/test/index.native.js @@ -0,0 +1,991 @@ +/** + * External dependencies + */ +import { + getEditorHtml, + initializeEditor, + fireEvent, + waitFor, + within, +} from 'test/helpers'; +import { Clipboard, Platform } from 'react-native'; + +/** + * WordPress dependencies + */ +import { + getBlockTypes, + setDefaultBlockName, + unregisterBlockType, +} from '@wordpress/blocks'; +import fetchRequest from '@wordpress/api-fetch'; +import { store as coreStore } from '@wordpress/core-data'; +import { dispatch } from '@wordpress/data'; +import { requestPreview } from '@wordpress/react-native-bridge'; + +/** + * Internal dependencies + */ +import * as paragraph from '../../paragraph'; +import * as embed from '..'; +import { registerBlock } from '../..'; + +// Override modal mock to prevent unmounting it when is not visible. +// This is required to be able to trigger onClose and onDismiss events when +// the modal is dismissed. +jest.mock( 'react-native-modal', () => { + const mockComponent = require( 'react-native/jest/mockComponent' ); + return mockComponent( 'react-native-modal' ); +} ); +const MODAL_DISMISS_EVENT = Platform.OS === 'ios' ? 'onDismiss' : 'onModalHide'; + +// oEmbed response mocks +const RICH_TEXT_EMBED_SUCCESS_RESPONSE = { + url: 'https://twitter.com/notnownikki', + html: '

Mock success response.

', + type: 'rich', + provider_name: 'Twitter', + provider_url: 'https://twitter.com', + version: '1.0', +}; +const VIDEO_EMBED_SUCCESS_RESPONSE = { + url: 'https://www.youtube.com/watch?v=lXMskKTw3Bc', + html: '', + type: 'video', + provider_name: 'YouTube', + provider_url: 'https://youtube.com', + version: '1.0', +}; +const MOCK_EMBED_PHOTO_SUCCESS_RESPONSE = { + url: 'https://cloudup.com/cQFlxqtY4ob', + html: '

Mock success response.

', + type: 'photo', + provider_name: 'Cloudup', + provider_url: 'https://cloudup.com', + version: '1.0', +}; +const MOCK_BAD_WORDPRESS_RESPONSE = { + code: 'oembed_invalid_url', + message: 'Not Found', + data: { + status: 404, + }, + html: false, +}; +const MOCK_BAD_EMBED_PROVIDER_RESPONSE = { + url: 'https://youtu.be/BAD_URL', + html: '

Mock bad response.

', + provider_name: 'Embed Provider', + version: '1.0', +}; +const EMBED_NULL_RESPONSE = null; + +// Embed block HTML examples +const EMPTY_EMBED_HTML = ''; +const RICH_TEXT_EMBED_HTML = ` +
+https://twitter.com/notnownikki +
+`; +const RICH_TEXT_ERROR_EMBED_HTML = ` +
+https://twitter.com/testing +
+`; +const PHOTO_EMBED_HTML = ` +
+https://cloudup.com/cQFlxqtY4ob +
+`; +const WP_EMBED_HTML = ` +
+https://wordpress.org/news/2021/07/tatum/ +
+`; + +const EMPTY_PARAGRAPH_HTML = + '

'; + +const MOST_USED_PROVIDERS = embed.settings.variations.filter( ( { name } ) => + [ 'youtube', 'twitter', 'wordpress', 'vimeo' ].includes( name ) +); + +// Return specified mocked responses for the oembed endpoint. +const mockEmbedResponses = ( mockedResponses ) => { + fetchRequest.mockImplementation( ( { path } ) => { + if ( path.startsWith( '/wp/v2/themes' ) ) { + return Promise.resolve( [ + { theme_supports: { 'responsive-embeds': true } }, + ] ); + } + + const matchedEmbedResponse = mockedResponses.find( + ( mockedResponse ) => + path === + `/oembed/1.0/proxy?url=${ encodeURIComponent( + mockedResponse.url + ) }` + ); + return Promise.resolve( matchedEmbedResponse || {} ); + } ); +}; + +const insertEmbedBlock = async ( blockTitle = 'Embed' ) => { + const editor = await initializeEditor( { + initialHtml: '', + } ); + const { getByA11yLabel, getByText } = editor; + + // Open inserter menu + fireEvent.press( await waitFor( () => getByA11yLabel( 'Add block' ) ) ); + + // Insert embed block + fireEvent.press( await waitFor( () => getByText( blockTitle ) ) ); + + // Return the embed block + const block = await waitFor( () => + getByA11yLabel( /Embed Block\. Row 1/ ) + ); + + return { ...editor, block }; +}; + +const initializeWithEmbedBlock = async ( initialHtml, selectBlock = true ) => { + const editor = await initializeEditor( { initialHtml } ); + const { getByA11yLabel } = editor; + + const block = await waitFor( () => + getByA11yLabel( /Embed Block\. Row 1/ ) + ); + + if ( selectBlock ) { + // Select block + fireEvent.press( block ); + } + + return { ...editor, block }; +}; + +beforeAll( () => { + // Paragraph block needs to be registered because by default a paragraph + // block is added to empty posts. + registerBlock( paragraph ); + registerBlock( embed ); + setDefaultBlockName( paragraph.name ); +} ); + +beforeEach( () => { + // Invalidate embed preview resolutions + dispatch( coreStore ).invalidateResolutionForStoreSelector( + 'getEmbedPreview' + ); + // Mock embed responses + mockEmbedResponses( [ + RICH_TEXT_EMBED_SUCCESS_RESPONSE, + VIDEO_EMBED_SUCCESS_RESPONSE, + MOCK_EMBED_PHOTO_SUCCESS_RESPONSE, + MOCK_BAD_EMBED_PROVIDER_RESPONSE, + ] ); +} ); + +afterAll( () => { + // Clean up registered blocks + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); +} ); + +describe( 'Embed block', () => { + describe( 'insertion', () => { + it( 'inserts generic embed block', async () => { + const { block } = await insertEmbedBlock(); + + const blockName = within( block ).getByText( 'Embed' ); + + expect( blockName ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + MOST_USED_PROVIDERS.forEach( ( { title } ) => + it( `inserts ${ title } embed block`, async () => { + const { block } = await insertEmbedBlock( title ); + const blockName = within( block ).getByText( title ); + + expect( blockName ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ) + ); + } ); + + describe( 'set URL upon block insertion', () => { + it( 'sets empty URL when dismissing edit URL modal', async () => { + const { getByTestId } = await insertEmbedBlock(); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'sets a valid URL when dismissing edit URL modal', async () => { + const expectedURL = 'https://twitter.com/notnownikki'; + + const { + getByA11yLabel, + getByPlaceholderText, + getByTestId, + } = await insertEmbedBlock(); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Set an URL + const linkTextInput = getByPlaceholderText( 'Add link' ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, expectedURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Wait for block settings button to be present + const settingsButton = await waitFor( () => + getByA11yLabel( 'Open Settings' ) + ); + + expect( settingsButton ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'auto-pastes the URL from clipboard', async () => { + const clipboardURL = 'https://twitter.com/notnownikki'; + + // Mock clipboard + Clipboard.getString.mockResolvedValue( clipboardURL ); + + const { + getByA11yLabel, + getByTestId, + getByText, + } = await insertEmbedBlock(); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Get embed link + const embedLink = await waitFor( () => getByText( clipboardURL ) ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Wait for block settings button to be present + const settingsButton = await waitFor( () => + getByA11yLabel( 'Open Settings' ) + ); + + expect( embedLink ).toBeDefined(); + expect( settingsButton ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + + Clipboard.getString.mockReset(); + } ); + } ); + + describe( 'set URL when empty block', () => { + it( 'sets empty URL when dismissing edit URL modal', async () => { + const { getByTestId, getByText } = await initializeWithEmbedBlock( + EMPTY_EMBED_HTML + ); + + // Edit URL + fireEvent.press( await waitFor( () => getByText( 'ADD LINK' ) ) ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'sets a valid URL when dismissing edit URL modal', async () => { + const expectedURL = 'https://twitter.com/notnownikki'; + + const { + getByA11yLabel, + getByPlaceholderText, + getByTestId, + getByText, + } = await initializeWithEmbedBlock( EMPTY_EMBED_HTML ); + + // Edit URL + fireEvent.press( getByText( 'ADD LINK' ) ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Set an URL + const linkTextInput = getByPlaceholderText( 'Add link' ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, expectedURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Wait for block settings button to be present + const settingsButton = await waitFor( () => + getByA11yLabel( 'Open Settings' ) + ); + + expect( settingsButton ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'auto-pastes the URL from clipboard', async () => { + const clipboardURL = 'https://twitter.com/notnownikki'; + + // Mock clipboard + Clipboard.getString.mockResolvedValue( clipboardURL ); + + const { + getByA11yLabel, + getByTestId, + getByText, + } = await initializeWithEmbedBlock( EMPTY_EMBED_HTML ); + + // Edit URL + fireEvent.press( getByText( 'ADD LINK' ) ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Get embed link + const embedLink = await waitFor( () => getByText( clipboardURL ) ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Wait for block settings button to be present + const settingsButton = await waitFor( () => + getByA11yLabel( 'Open Settings' ) + ); + + expect( embedLink ).toBeDefined(); + expect( settingsButton ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + + Clipboard.getString.mockReset(); + } ); + } ); + + describe( 'edit URL', () => { + it( 'keeps the previous URL if no URL is set', async () => { + const { + getByA11yLabel, + getByTestId, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Edit URL + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Edit URL' ) ) + ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'replaces URL', async () => { + const initialURL = 'https://twitter.com/notnownikki'; + const expectedURL = 'https://www.youtube.com/watch?v=lXMskKTw3Bc'; + + const { + getByA11yLabel, + getByDisplayValue, + getByTestId, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Edit URL + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Edit URL' ) ) + ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Start editing link + fireEvent.press( + getByA11yLabel( `Twitter link, ${ initialURL }` ) + ); + + // Replace URL + const linkTextInput = getByDisplayValue( initialURL ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, expectedURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Get YouTube link field + const youtubeLinkField = await waitFor( () => + getByA11yLabel( `YouTube link, ${ expectedURL }` ) + ); + + expect( youtubeLinkField ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'keeps the previous URL if an invalid URL is set', async () => { + const previousURL = 'https://twitter.com/notnownikki'; + const invalidURL = 'http://'; + + const { + getByA11yLabel, + getByDisplayValue, + getByTestId, + getByText, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Edit URL + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Edit URL' ) ) + ); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Start editing link + fireEvent.press( + getByA11yLabel( `Twitter link, ${ previousURL }` ) + ); + + // Replace URL + const linkTextInput = getByDisplayValue( previousURL ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, invalidURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + const errorNotice = await waitFor( () => + getByText( 'Invalid URL. Please enter a valid URL.' ) + ); + + expect( errorNotice ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + // This test case covers the bug fixed in PR #35460 + it( 'edits URL after dismissing two times the edit URL bottom sheet with empty value', async () => { + const { block, getByTestId, getByText } = await insertEmbedBlock(); + + // Wait for edit URL modal to be visible + const embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Select block + fireEvent.press( block ); + + // Edit URL + fireEvent.press( getByText( 'ADD LINK' ) ); + + // Wait for edit URL modal to be visible + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Edit URL + fireEvent.press( getByText( 'ADD LINK' ) ); + + // Wait for edit URL modal to be visible + const isVisibleThirdTime = await waitFor( + () => embedEditURLModal.props.isVisible + ); + + expect( isVisibleThirdTime ).toBeTruthy(); + } ); + + // This test case covers the bug fixed in PR #35013 + it( 'edits URL when edited after setting a bad URL of a provider', async () => { + const badURL = 'https://youtu.be/BAD_URL'; + const expectedURL = 'https://twitter.com/notnownikki'; + + const { + getByA11yLabel, + getByDisplayValue, + getByPlaceholderText, + getByTestId, + } = await insertEmbedBlock(); + + // Wait for edit URL modal to be visible + let embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Set an bad URL + let linkTextInput = getByPlaceholderText( 'Add link' ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, badURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Edit URL + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Edit URL' ) ) + ); + + // Wait for edit URL modal to be visible + embedEditURLModal = getByTestId( 'embed-edit-url-modal' ); + await waitFor( () => embedEditURLModal.props.isVisible ); + + // Start editing link + fireEvent.press( getByA11yLabel( `Embed link, ${ badURL }` ) ); + + // Replace URL + linkTextInput = getByDisplayValue( badURL ); + fireEvent( linkTextInput, 'focus' ); + fireEvent.changeText( linkTextInput, expectedURL ); + + // Dismiss the edit URL modal + fireEvent( embedEditURLModal, 'backdropPress' ); + fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); + + // Get Twitter link field + const twitterLinkField = await waitFor( () => + getByA11yLabel( `Twitter link, ${ expectedURL }` ) + ); + + expect( twitterLinkField ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + } ); + + describe( 'alignment options', () => { + [ + 'Align left', + 'Align center', + 'Align right', + 'Wide width', + 'Full width', + ].forEach( ( alignmentOption ) => + it( `sets ${ alignmentOption } option`, async () => { + const { + getByA11yLabel, + getByText, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Open alignment options + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Align' ) ) + ); + + // Select alignment option + fireEvent.press( + await waitFor( () => getByText( alignmentOption ) ) + ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ) + ); + } ); + + describe( 'retry', () => { + it( 'retries loading the preview if initial request failed', async () => { + // Return bad response for the first request to oembed endpoint + // and success response for the rest of requests. + let isFirstEmbedRequest = true; + fetchRequest.mockImplementation( ( { path } ) => { + let response = {}; + const isEmbedRequest = path.startsWith( '/oembed/1.0/proxy' ); + if ( isEmbedRequest ) { + if ( isFirstEmbedRequest ) { + isFirstEmbedRequest = false; + response = MOCK_BAD_WORDPRESS_RESPONSE; + } else { + response = RICH_TEXT_EMBED_SUCCESS_RESPONSE; + } + } + return Promise.resolve( response ); + } ); + + const { + getByA11yLabel, + getByText, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Retry request + fireEvent.press( getByText( 'More options' ) ); + fireEvent.press( getByText( 'Retry' ) ); + + // Wait for edit URL button to be present + const editURLButton = await waitFor( () => + getByA11yLabel( 'Edit URL' ) + ); + + expect( editURLButton ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + it( 'converts to link if preview request failed', async () => { + // Return bad response for requests to oembed endpoint. + fetchRequest.mockImplementation( ( { path } ) => { + const isEmbedRequest = path.startsWith( '/oembed/1.0/proxy' ); + return Promise.resolve( + isEmbedRequest ? MOCK_BAD_WORDPRESS_RESPONSE : {} + ); + } ); + + const { + getByA11yLabel, + getByText, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Convert embed to link + fireEvent.press( getByText( 'More options' ) ); + fireEvent.press( getByText( 'Convert to link' ) ); + + // Get paragraph block where the link is created + const paragraphBlock = await waitFor( () => + getByA11yLabel( /Paragraph Block\. Row 1/ ) + ); + + expect( paragraphBlock ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + } ); + + describe( 'preview coming soon', () => { + it( 'previews post for providers which embed preview is not available yet', async () => { + const { getByText, getByTestId } = await initializeWithEmbedBlock( + PHOTO_EMBED_HTML + ); + + // Try to preview the post + fireEvent.press( getByText( 'PREVIEW POST' ) ); + + // Wait for no preview modal to be visible + const noPreviewModal = getByTestId( 'embed-no-preview-modal' ); + await waitFor( () => noPreviewModal.props.isVisible ); + + // Preview post + fireEvent.press( getByText( 'Preview post' ) ); + + // Dismiss the no preview modal + fireEvent( noPreviewModal, 'backdropPress' ); + fireEvent( noPreviewModal, MODAL_DISMISS_EVENT ); + + expect( requestPreview ).toHaveBeenCalled(); + } ); + + it( 'dismisses no preview modal', async () => { + const { getByText, getByTestId } = await initializeWithEmbedBlock( + PHOTO_EMBED_HTML + ); + + // Try to preview the post + fireEvent.press( getByText( 'PREVIEW POST' ) ); + + // Wait for no preview modal to be visible + const noPreviewModal = getByTestId( 'embed-no-preview-modal' ); + await waitFor( () => noPreviewModal.props.isVisible ); + + // Dismiss modal + fireEvent.press( getByText( 'Dismiss' ) ); + + // Wait for no preview modal to be not visible + await waitFor( () => ! noPreviewModal.props.isVisible ); + + expect( requestPreview ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'create by pasting URL', () => { + it( 'creates embed block when pasting URL in paragraph block', async () => { + const expectedURL = 'https://www.youtube.com/watch?v=lXMskKTw3Bc'; + + const { + getByA11yLabel, + getByPlaceholderText, + getByTestId, + getByText, + } = await initializeEditor( { + initialHtml: EMPTY_PARAGRAPH_HTML, + } ); + + // Paste URL in paragraph block + const paragraphText = getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphText, 'focus' ); + fireEvent( paragraphText, 'paste', { + preventDefault: jest.fn(), + nativeEvent: { + eventCount: 1, + target: undefined, + files: [], + pastedHtml: expectedURL, + pastedText: expectedURL, + }, + } ); + + // Wait for embed handler picker to be visible + await waitFor( + () => getByTestId( 'embed-handler-picker' ).props.isVisible + ); + + // Select create embed option + fireEvent.press( getByText( 'Create embed' ) ); + + // Get the created embed block + const embedBlock = await waitFor( () => + getByA11yLabel( /Embed Block\. Row 1/ ) + ); + + expect( embedBlock ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'creates link when pasting URL in paragraph block', async () => { + const expectedURL = 'https://www.youtube.com/watch?v=lXMskKTw3Bc'; + + const { + getByDisplayValue, + getByPlaceholderText, + getByTestId, + getByText, + } = await initializeEditor( { + initialHtml: EMPTY_PARAGRAPH_HTML, + } ); + + // Paste URL in paragraph block + const paragraphText = getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphText, 'focus' ); + fireEvent( paragraphText, 'paste', { + preventDefault: jest.fn(), + nativeEvent: { + eventCount: 1, + target: undefined, + files: [], + pastedHtml: expectedURL, + pastedText: expectedURL, + }, + } ); + + // Wait for embed handler picker to be visible + await waitFor( + () => getByTestId( 'embed-handler-picker' ).props.isVisible + ); + + // Select create link option + fireEvent.press( getByText( 'Create link' ) ); + + // Get the link text + const linkText = await waitFor( () => + getByDisplayValue( + `

${ expectedURL }

` + ) + ); + + expect( linkText ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + } ); + + describe( 'insert via slash inserter', () => { + it( 'insert generic embed block', async () => { + const embedBlockSlashInserter = '/Embed'; + const { + getByPlaceholderText, + getByA11yLabel, + getByText, + } = await initializeEditor( { initialHtml: EMPTY_PARAGRAPH_HTML } ); + + const paragraphText = getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphText, 'focus' ); + // Trigger onSelectionChange to update both the current text and text selection. + // This event is required by the autocompleter, as it only displays the slash inserter + // if the text selection is located at the end of the text, for this reason, + // the start and end arguments match the text length. + fireEvent( + paragraphText, + 'onSelectionChange', + embedBlockSlashInserter.length, + embedBlockSlashInserter.length, + embedBlockSlashInserter, + { + nativeEvent: { + eventCount: 1, + target: undefined, + text: embedBlockSlashInserter, + }, + } + ); + + fireEvent.press( await waitFor( () => getByText( 'Embed' ) ) ); + + const block = await waitFor( () => + getByA11yLabel( /Embed Block\. Row 1/ ) + ); + + const blockName = within( block ).getByText( 'Embed' ); + + expect( blockName ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + MOST_USED_PROVIDERS.forEach( ( { title } ) => + it( `inserts ${ title } embed block`, async () => { + const embedBlockSlashInserter = `/${ title }`; + const { + getByPlaceholderText, + getByA11yLabel, + getByText, + } = await initializeEditor( { + initialHtml: EMPTY_PARAGRAPH_HTML, + } ); + + const paragraphText = getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphText, 'focus' ); + // Trigger onSelectionChange to update both the current text and text selection. + // This event is required by the autocompleter, as it only displays the slash inserter + // if the text selection is located at the end of the text, for this reason, + // the start and end arguments match the text length. + fireEvent( + paragraphText, + 'onSelectionChange', + embedBlockSlashInserter.length, + embedBlockSlashInserter.length, + embedBlockSlashInserter, + { + nativeEvent: { + eventCount: 1, + target: undefined, + text: embedBlockSlashInserter, + }, + } + ); + + fireEvent.press( await waitFor( () => getByText( title ) ) ); + + const block = await waitFor( () => + getByA11yLabel( /Embed Block\. Row 1/ ) + ); + + const blockName = within( block ).getByText( title ); + + expect( blockName ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ) + ); + } ); + + it( 'sets block caption', async () => { + const expectedCaption = 'Caption'; + + const { + getByPlaceholderText, + getByDisplayValue, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + // Set a caption + const captionField = getByPlaceholderText( 'Add caption' ); + fireEvent( captionField, 'focus' ); + fireEvent( captionField, 'onChange', { + nativeEvent: { + eventCount: 1, + target: undefined, + text: expectedCaption, + }, + } ); + + // Get current caption + const caption = await waitFor( () => + getByDisplayValue( `

${ expectedCaption }

` ) + ); + + expect( caption ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'displays cannot embed on the placeholder if preview data is null', async () => { + // Return null response for requests to oembed endpoint. + fetchRequest.mockImplementation( ( { path } ) => { + const isEmbedRequest = path.startsWith( '/oembed/1.0/proxy' ); + return Promise.resolve( isEmbedRequest ? EMBED_NULL_RESPONSE : {} ); + } ); + + const { getByText } = await initializeWithEmbedBlock( + RICH_TEXT_ERROR_EMBED_HTML + ); + + const cannotEmbedText = getByText( 'Unable to embed media' ); + + expect( cannotEmbedText ).toBeDefined(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + describe( 'block settings', () => { + it( 'toggles resize for smaller devices media settings', async () => { + const { + getByA11yLabel, + getByText, + } = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + + fireEvent.press( + await waitFor( () => getByA11yLabel( 'Open Settings' ) ) + ); + + fireEvent.press( + await waitFor( () => getByText( /Resize for smaller devices/ ) ) + ); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'does not show settings button if responsive is not supported', async () => { + const { getByA11yLabel } = await initializeWithEmbedBlock( + WP_EMBED_HTML + ); + + let settingsButton; + try { + settingsButton = await waitFor( () => + getByA11yLabel( 'Open Settings' ) + ); + } catch ( e ) { + // NOOP + } + + expect( settingsButton ).not.toBeDefined(); + } ); + } ); +} ); diff --git a/packages/block-library/src/file/test/edit.native.js b/packages/block-library/src/file/test/edit.native.js index c9d5453ce8f5b..80a49f5980f78 100644 --- a/packages/block-library/src/file/test/edit.native.js +++ b/packages/block-library/src/file/test/edit.native.js @@ -13,6 +13,10 @@ import { MediaUploadProgress } from '@wordpress/block-editor'; */ import { FileEdit } from '../edit.native.js'; +// react-native-aztec shouldn't be mocked because these tests are based on +// snapshot testing where we want to keep the original component. +jest.unmock( '@wordpress/react-native-aztec' ); + const getTestComponentWithContent = ( attributes = {} ) => { return renderer.create( { +export const registerBlock = ( block ) => { if ( ! block ) { return; } diff --git a/packages/block-library/src/search/test/edit.native.js b/packages/block-library/src/search/test/edit.native.js index f3622c684ea7c..f37417501b6df 100644 --- a/packages/block-library/src/search/test/edit.native.js +++ b/packages/block-library/src/search/test/edit.native.js @@ -13,6 +13,10 @@ import { Icon } from '@wordpress/components'; */ import SearchEdit from '../edit.native.js'; +// react-native-aztec shouldn't be mocked because these tests are based on +// snapshot testing where we want to keep the original component. +jest.unmock( '@wordpress/react-native-aztec' ); + const defaultAttributes = { label: 'Search', buttonText: 'Search Button', diff --git a/packages/components/src/mobile/link-settings/link-settings-navigation.native.js b/packages/components/src/mobile/link-settings/link-settings-navigation.native.js index 8554c4848f431..7f82ca49a9676 100644 --- a/packages/components/src/mobile/link-settings/link-settings-navigation.native.js +++ b/packages/components/src/mobile/link-settings/link-settings-navigation.native.js @@ -23,6 +23,7 @@ function LinkSettingsNavigation( props ) { isVisible={ props.isVisible } onClose={ props.onClose } onDismiss={ props.onDismiss } + testID={ props.testID } hideHeader hasNavigation > diff --git a/packages/components/src/mobile/picker/index.android.js b/packages/components/src/mobile/picker/index.android.js index 0d991e0348149..0158ce5628018 100644 --- a/packages/components/src/mobile/picker/index.android.js +++ b/packages/components/src/mobile/picker/index.android.js @@ -71,7 +71,7 @@ export default class Picker extends Component { } render() { - const { hideCancelButton, title } = this.props; + const { hideCancelButton, title, testID } = this.props; const { isVisible } = this.state; return ( @@ -80,6 +80,7 @@ export default class Picker extends Component { onClose={ this.onClose } style={ { paddingBottom: 20 } } hideHeader + testID={ testID } > { this.getOptions() } diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index dee4d07629bf5..e2aaeb99f66dc 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -102,6 +102,7 @@ "ios:fast": "react-native run-ios", "test": "cross-env NODE_ENV=test jest --verbose --config ../../test/native/jest.config.js", "test:debug": "cross-env NODE_ENV=test node --inspect-brk ../../node_modules/.bin/jest --runInBand --verbose --config ../../test/native/jest.config.js", + "test:update": "npm run test -- --updateSnapshot", "device-tests": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=3 --verbose --config ./jest_ui.config.js", "device-tests-canary": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=2 --testPathPattern=@canary --verbose --config ./jest_ui.config.js", "device-tests:local": "cross-env NODE_ENV=test jest --runInBand --detectOpenHandles --verbose --forceExit --config ./jest_ui.config.js", diff --git a/test/native/__mocks__/@wordpress/react-native-aztec/index.js b/test/native/__mocks__/@wordpress/react-native-aztec/index.js new file mode 100644 index 0000000000000..49ebf60786dc4 --- /dev/null +++ b/test/native/__mocks__/@wordpress/react-native-aztec/index.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { TextInput } from 'react-native'; +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +const UNSUPPORTED_PROPS = [ 'style' ]; + +const RCTAztecView = ( { accessibilityLabel, text, ...rest }, ref ) => { + return ( + + ); +}; + +export default forwardRef( RCTAztecView ); diff --git a/test/native/__mocks__/styleMock.js b/test/native/__mocks__/styleMock.js index 848c5854668d7..8320d51778b68 100644 --- a/test/native/__mocks__/styleMock.js +++ b/test/native/__mocks__/styleMock.js @@ -138,4 +138,19 @@ module.exports = { isSelected: { color: 'blue', }, + 'embed-no-preview__help-icon': { + fill: 'gray', + }, + 'embed-no-preview__sheet-icon': { + fill: 'gray', + }, + fullAlignment: { + width: '100%', + }, + 'embed__icon--error': { + fill: 'red', + }, + 'components-autocomplete': { + height: 100, + }, }; diff --git a/test/native/helpers.js b/test/native/helpers.js index de5fcb2ccce86..487c8c7f83bc4 100644 --- a/test/native/helpers.js +++ b/test/native/helpers.js @@ -34,7 +34,7 @@ provideToNativeHtml.mockImplementation( ( html ) => { serializedHtml = html; } ); -export async function initializeEditor( props ) { +export function initializeEditor( props ) { const renderResult = render( - getByTestId( 'block-list-wrapper' ) - ); - - // onLayout event has to be explicitly dispatched in BlockList component, - // otherwise the inner blocks are not rendered. - fireEvent( blockListWrapper, 'layout', { - nativeEvent: { - layout: { - width: 100, - }, - }, + // A promise is used here, instead of making the function async, to prevent + // the React Native testing library from warning of potential undesired React state updates + // that can be covered in the integration tests. + // Reference: https://git.io/JPHn6 + return new Promise( ( resolve ) => { + waitFor( () => getByTestId( 'block-list-wrapper' ) ).then( + ( blockListWrapper ) => { + // onLayout event has to be explicitly dispatched in BlockList component, + // otherwise the inner blocks are not rendered. + fireEvent( blockListWrapper, 'layout', { + nativeEvent: { + layout: { + width: 100, + }, + }, + } ); + resolve( renderResult ); + } + ); } ); - - return renderResult; } export * from '@testing-library/react-native'; diff --git a/test/native/setup.js b/test/native/setup.js index ffd651680b45b..9af497f621b29 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -75,6 +75,7 @@ jest.mock( '@wordpress/react-native-bridge', () => { siteMediaLibrary: 'SITE_MEDIA_LIBRARY', }, fetchRequest: jest.fn(), + requestPreview: jest.fn(), }; } ); @@ -174,6 +175,10 @@ jest.mock( } ) ), } ) ); +jest.mock( 'react-native/Libraries/Components/Clipboard/Clipboard', () => ( { + getString: jest.fn( () => Promise.resolve( '' ) ), + setString: jest.fn(), +} ) ); // Silences the warning: dispatchCommand was called with a ref that isn't a native // component. Use React.forwardRef to get access to the underlying native component.