1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Chore: Mobile: Improve note screen tests and fix CI warning (#11126)

This commit is contained in:
Henry Heino 2024-09-27 07:23:02 -07:00 committed by GitHub
parent 90640e590e
commit 5935c9c147
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 143 additions and 29 deletions

View File

@ -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/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/types.js packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js packages/default-plugins/build.js

1
.gitignore vendored
View File

@ -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/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/types.js packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js packages/default-plugins/build.js

View File

@ -93,7 +93,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, [dom]); }, [dom]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HACK: Allow wrapper testing logic to access the 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 ( return (
<View style={props.style} testID={props.testID} {...additionalProps}/> <View style={props.style} testID={props.testID} {...additionalProps}/>
); );

View File

@ -569,6 +569,7 @@ function NoteEditor(props: Props, ref: any) {
}}> }}>
<ExtendedWebView <ExtendedWebView
webviewInstanceId='NoteEditor' webviewInstanceId='NoteEditor'
testID='NoteEditor'
scrollEnabled={true} scrollEnabled={true}
ref={webviewRef} ref={webviewRef}
html={html} html={html}

View File

@ -1,13 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals'; import { describe, it, beforeEach } from '@jest/globals';
import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; import { act, fireEvent, render, screen, userEvent } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect'; import '@testing-library/jest-native/extend-expect';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import NoteScreen from './Note'; import NoteScreen from './Note';
import { MenuProvider } from 'react-native-popup-menu'; import { MenuProvider } from 'react-native-popup-menu';
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv } from '@joplin/lib/testing/test-utils'; import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, waitFor } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import { AppState } from '../../utils/types'; import { AppState } from '../../utils/types';
import { Store } from 'redux'; import { Store } from 'redux';
@ -24,6 +24,8 @@ import { getDisplayParentId } from '@joplin/lib/services/trash';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { LayoutChangeEvent } from 'react-native'; import { LayoutChangeEvent } from 'react-native';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
interface WrapperProps { interface WrapperProps {
} }
@ -44,6 +46,27 @@ const getNoteViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer'); 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<NoteEntity>) => {
await act(() => waitFor(async () => {
const loadedNote = await Note.load(noteId);
expect(loadedNote).toMatchObject(note);
}));
};
const openNewNote = async (noteProperties: NoteEntity) => { const openNewNote = async (noteProperties: NoteEntity) => {
const note = await Note.save({ const note = await Note.save({
parent_id: (await Folder.defaultFolder()).id, parent_id: (await Folder.defaultFolder()).id,
@ -62,6 +85,9 @@ const openNewNote = async (noteProperties: NoteEntity) => {
id: note.id, id: note.id,
folderId: displayParentId, folderId: displayParentId,
}); });
await waitForNoteToMatch(note.id, { parent_id: note.parent_id, title: note.title, body: note.body });
return note.id; return note.id;
}; };
@ -80,10 +106,20 @@ const openNoteActionsMenu = async () => {
cursor = cursor.parent; 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 () => { beforeEach(async () => {
await setupDatabaseAndSynchronizer(0); await setupDatabaseAndSynchronizer(0);
await switchClient(0); await switchClient(0);
@ -113,19 +149,59 @@ describe('Note', () => {
it('changing the note title input should update the note\'s title', async () => { it('changing the note title input should update the note\'s title', async () => {
const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' }); const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' });
render(<WrappedNoteScreen />); render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Change me!'); 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(); const user = userEvent.setup();
await user.clear(titleInput); await user.clear(titleInput);
await user.type(titleInput, 'New title'); 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 });
}
}); });
await waitFor(async () => { it('changing the note body in the editor should update the note\'s body', async () => {
expect(await Note.load(noteId)).toMatchObject({ title: 'New title', body: 'Unchanged body' }); const defaultBody = 'Change me!';
}); const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody });
const noteScreen = render(<WrappedNoteScreen />);
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 () => { it('pressing "delete" should move the note to the trash', async () => {
@ -136,9 +212,9 @@ describe('Note', () => {
const deleteButton = await screen.findByText('Delete'); const deleteButton = await screen.findByText('Delete');
fireEvent.press(deleteButton); fireEvent.press(deleteButton);
await waitFor(async () => { await act(() => waitFor(async () => {
expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0); expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0);
}); }));
}); });
it('pressing "delete permanently" should permanently delete a note', async () => { it('pressing "delete permanently" should permanently delete a note', async () => {
@ -153,9 +229,9 @@ describe('Note', () => {
const deleteButton = await screen.findByText('Permanently delete note'); const deleteButton = await screen.findByText('Permanently delete note');
fireEvent.press(deleteButton); fireEvent.press(deleteButton);
await waitFor(async () => { await act(() => waitFor(async () => {
expect(await Note.load(noteId)).toBeUndefined(); expect(await Note.load(noteId)).toBeUndefined();
}); }));
expect(shim.showMessageBox).toHaveBeenCalled(); expect(shim.showMessageBox).toHaveBeenCalled();
}); });

View File

@ -1,15 +1,7 @@
import { screen, waitFor } from '@testing-library/react-native'; import getWebViewWindowById from './getWebViewWindowById';
const getWebViewDomById = async (id: string): Promise<Document> => { const getWebViewDomById = async (id: string): Promise<Document> => {
const webviewContent = await screen.findByTestId(id); return (await getWebViewWindowById(id)).document;
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;
}; };
export default getWebViewDomById; export default getWebViewDomById;

View File

@ -0,0 +1,15 @@
import { screen, waitFor } from '@testing-library/react-native';
const getWebViewWindowById = async (id: string): Promise<Window> => {
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;

View File

@ -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<void>) => {
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<void>(resolve => {
realSetTimeout(() => resolve(), 10);
});
}
}
if (lastError) {
throw lastError;
}
};
export const runWithFakeTimers = async (callback: ()=> Promise<void>) => { export const runWithFakeTimers = async (callback: ()=> Promise<void>) => {
if (typeof jest === 'undefined') { if (typeof jest === 'undefined') {
throw new Error('Fake timers are only supported in jest.'); throw new Error('Fake timers are only supported in jest.');