diff --git a/.eslintignore b/.eslintignore index 3aac494c7..69e7bd397 100644 --- a/.eslintignore +++ b/.eslintignore @@ -787,6 +787,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js packages/app-mobile/utils/shim-init-react/shimInitShared.js packages/app-mobile/utils/testing/createMockReduxStore.js packages/app-mobile/utils/testing/getWebViewDomById.js +packages/app-mobile/utils/testing/getWebViewWindowById.js packages/app-mobile/utils/types.js packages/app-mobile/web/serviceWorker.js packages/default-plugins/build.js diff --git a/.gitignore b/.gitignore index 7bb7996a2..cba79acff 100644 --- a/.gitignore +++ b/.gitignore @@ -764,6 +764,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js packages/app-mobile/utils/shim-init-react/shimInitShared.js packages/app-mobile/utils/testing/createMockReduxStore.js packages/app-mobile/utils/testing/getWebViewDomById.js +packages/app-mobile/utils/testing/getWebViewWindowById.js packages/app-mobile/utils/types.js packages/app-mobile/web/serviceWorker.js packages/default-plugins/build.js diff --git a/packages/app-mobile/components/ExtendedWebView/index.jest.tsx b/packages/app-mobile/components/ExtendedWebView/index.jest.tsx index ad8c6f6d2..359138361 100644 --- a/packages/app-mobile/components/ExtendedWebView/index.jest.tsx +++ b/packages/app-mobile/components/ExtendedWebView/index.jest.tsx @@ -93,7 +93,7 @@ const ExtendedWebView = (props: Props, ref: Ref) => { }, [dom]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- HACK: Allow wrapper testing logic to access the DOM. - const additionalProps: any = { document: dom?.window?.document }; + const additionalProps: any = { window: dom?.window }; return ( ); diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index e97383cb1..c10fed28a 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -569,6 +569,7 @@ function NoteEditor(props: Props, ref: any) { }}> { return await getWebViewDomById('NoteBodyViewer'); }; +const getNoteEditorControl = async () => { + const noteEditor = await getWebViewWindowById('NoteEditor'); + const getEditorControl = () => { + if ('cm' in noteEditor.window && noteEditor.window.cm) { + return noteEditor.window.cm as CodeMirrorControl; + } + return null; + }; + await waitFor(async () => { + expect(getEditorControl()).toBeTruthy(); + }); + return getEditorControl(); +}; + +const waitForNoteToMatch = async (noteId: string, note: Partial) => { + await act(() => waitFor(async () => { + const loadedNote = await Note.load(noteId); + expect(loadedNote).toMatchObject(note); + })); +}; + const openNewNote = async (noteProperties: NoteEntity) => { const note = await Note.save({ parent_id: (await Folder.defaultFolder()).id, @@ -62,6 +85,9 @@ const openNewNote = async (noteProperties: NoteEntity) => { id: note.id, folderId: displayParentId, }); + + await waitForNoteToMatch(note.id, { parent_id: note.parent_id, title: note.title, body: note.body }); + return note.id; }; @@ -80,10 +106,20 @@ const openNoteActionsMenu = async () => { cursor = cursor.parent; } - await runWithFakeTimers(() => userEvent.press(actionMenuButton)); + await userEvent.press(actionMenuButton); }; -describe('Note', () => { +const openEditor = async () => { + const editButton = await screen.findByLabelText('Edit'); + await userEvent.press(editButton); +}; + +describe('screens/Note', () => { + beforeAll(() => { + // advanceTimers: Needed by internal note save logic + jest.useFakeTimers({ advanceTimers: true }); + }); + beforeEach(async () => { await setupDatabaseAndSynchronizer(0); await switchClient(0); @@ -113,19 +149,59 @@ describe('Note', () => { it('changing the note title input should update the note\'s title', async () => { const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' }); + render(); const titleInput = await screen.findByDisplayValue('Change me!'); - // We need to use fake timers while using userEvent to avoid warnings: - await runWithFakeTimers(async () => { - const user = userEvent.setup(); - await user.clear(titleInput); - await user.type(titleInput, 'New title'); - }); - await waitFor(async () => { - expect(await Note.load(noteId)).toMatchObject({ title: 'New title', body: 'Unchanged body' }); - }); + const user = userEvent.setup(); + await user.clear(titleInput); + await user.type(titleInput, 'New title'); + + await waitForNoteToMatch(noteId, { title: 'New title', body: 'Unchanged body' }); + + let expectedTitle = 'New title'; + for (let i = 0; i <= 10; i++) { + for (const chunk of ['!', ' test', '!!!', ' Testing']) { + jest.advanceTimersByTime(i % 5); + await user.type(titleInput, chunk); + expectedTitle += chunk; + + // Don't verify after each input event -- this allows the save action queue to fill. + if (i % 4 === 0) { + await waitForNoteToMatch(noteId, { title: expectedTitle }); + } + } + await waitForNoteToMatch(noteId, { title: expectedTitle }); + } + }); + + it('changing the note body in the editor should update the note\'s body', async () => { + const defaultBody = 'Change me!'; + const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody }); + + const noteScreen = render(); + + await openEditor(); + const editor = await getNoteEditorControl(); + editor.select(defaultBody.length, defaultBody.length); + + editor.insertText(' Testing!!!'); + await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!!' }); + + editor.insertText(' This is a test.'); + await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test.' }); + + // should also save changes made shortly before unmounting + editor.insertText(' Test!'); + + // TODO: Decreasing this below 100 causes the test to fail. + // See issue #11125. + await jest.advanceTimersByTimeAsync(150); + + noteScreen.unmount(); + await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test. Test!' }); + }); it('pressing "delete" should move the note to the trash', async () => { @@ -136,9 +212,9 @@ describe('Note', () => { const deleteButton = await screen.findByText('Delete'); fireEvent.press(deleteButton); - await waitFor(async () => { + await act(() => waitFor(async () => { expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0); - }); + })); }); it('pressing "delete permanently" should permanently delete a note', async () => { @@ -153,9 +229,9 @@ describe('Note', () => { const deleteButton = await screen.findByText('Permanently delete note'); fireEvent.press(deleteButton); - await waitFor(async () => { + await act(() => waitFor(async () => { expect(await Note.load(noteId)).toBeUndefined(); - }); + })); expect(shim.showMessageBox).toHaveBeenCalled(); }); diff --git a/packages/app-mobile/utils/testing/getWebViewDomById.ts b/packages/app-mobile/utils/testing/getWebViewDomById.ts index 2d8d526d0..eb878af94 100644 --- a/packages/app-mobile/utils/testing/getWebViewDomById.ts +++ b/packages/app-mobile/utils/testing/getWebViewDomById.ts @@ -1,15 +1,7 @@ -import { screen, waitFor } from '@testing-library/react-native'; +import getWebViewWindowById from './getWebViewWindowById'; + const getWebViewDomById = async (id: string): Promise => { - const webviewContent = await screen.findByTestId(id); - expect(webviewContent).toBeVisible(); - - await waitFor(() => { - expect(!!webviewContent.props.document).toBe(true); - }); - - // Return the composite ExtendedWebView component - // See https://callstack.github.io/react-native-testing-library/docs/advanced/testing-env#tree-navigation - return webviewContent.props.document; + return (await getWebViewWindowById(id)).document; }; export default getWebViewDomById; diff --git a/packages/app-mobile/utils/testing/getWebViewWindowById.ts b/packages/app-mobile/utils/testing/getWebViewWindowById.ts new file mode 100644 index 000000000..97583edda --- /dev/null +++ b/packages/app-mobile/utils/testing/getWebViewWindowById.ts @@ -0,0 +1,15 @@ +import { screen, waitFor } from '@testing-library/react-native'; + +const getWebViewWindowById = async (id: string): Promise => { + const webviewContent = await screen.findByTestId(id); + expect(webviewContent).toBeVisible(); + + await waitFor(() => { + expect(!!webviewContent.props.window).toBe(true); + }); + + const webviewWindow = webviewContent.props.window; + return webviewWindow; +}; + +export default getWebViewWindowById; diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index 392af0449..cc5f558ce 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -1092,6 +1092,34 @@ export const mockMobilePlatform = (platform: string) => { }; }; +// Waits for callback to not throw. Similar to react-native-testing-library's waitFor, but works better +// with Joplin's mix of real and fake Jest timers. +const realSetTimeout = setTimeout; +export const waitFor = async (callback: ()=> Promise) => { + const timeout = 10_000; + const startTime = performance.now(); + let passed = false; + let lastError: Error|null = null; + + while (!passed && performance.now() - startTime < timeout) { + try { + await callback(); + passed = true; + lastError = null; + } catch (error) { + lastError = error; + + await new Promise(resolve => { + realSetTimeout(() => resolve(), 10); + }); + } + } + + if (lastError) { + throw lastError; + } +}; + export const runWithFakeTimers = async (callback: ()=> Promise) => { if (typeof jest === 'undefined') { throw new Error('Fake timers are only supported in jest.');