1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Chore: Mobile: Add NoteBodyViewer tests (#10747)

This commit is contained in:
Henry Heino
2024-07-18 01:44:13 -07:00
committed by GitHub
parent 480bf238f6
commit 821daeca94
30 changed files with 419 additions and 109 deletions

View File

@@ -520,12 +520,15 @@ packages/app-mobile/components/CameraView.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
@@ -716,6 +719,7 @@ packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/pickDocument.js
@@ -1259,6 +1263,7 @@ packages/lib/urlUtils.js
packages/lib/utils/ActionLogger.test.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/dom/makeSandboxedIframe.js
packages/lib/utils/focusHandler.js
packages/lib/utils/frontMatter.js
packages/lib/utils/ipc/RemoteMessenger.test.js

7
.gitignore vendored
View File

@@ -499,12 +499,15 @@ packages/app-mobile/components/CameraView.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
@@ -695,6 +698,7 @@ packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/pickDocument.js
@@ -1238,6 +1242,7 @@ packages/lib/urlUtils.js
packages/lib/utils/ActionLogger.test.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/dom/makeSandboxedIframe.js
packages/lib/utils/focusHandler.js
packages/lib/utils/frontMatter.js
packages/lib/utils/ipc/RemoteMessenger.test.js

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useMemo, useRef,
} from 'react';
import { View } from 'react-native';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import { JSDOM } from 'jsdom';
const logger = Logger.create('ExtendedWebView');
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const dom = useMemo(() => {
// Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s.
// Use with caution.
return new JSDOM(props.html, { runScripts: 'dangerously' });
}, [props.html]);
useImperativeHandle(ref, (): WebViewControl => {
const result = {
injectJS(js: string) {
return dom.window.eval(js);
},
postMessage(message: unknown) {
const messageEventContent = {
data: message,
source: 'react-native',
};
return dom.window.eval(`
window.dispatchEvent(
new MessageEvent('message', ${JSON.stringify(messageEventContent)}),
);
`);
},
};
return result;
}, [dom]);
const onMessageRef = useRef(props.onMessage);
onMessageRef.current = props.onMessage;
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
useEffect(() => {
dom.window.eval(`
window.setWebViewApi = (api) => {
window.ReactNativeWebView = api;
};
`);
dom.window.setWebViewApi({
postMessage: (message: unknown) => {
logger.debug('Got message', message);
onMessageRef.current({ nativeEvent: { data: message } });
},
});
dom.window.eval(injectedJavaScriptRef.current);
}, [dom]);
const onLoadEndRef = useRef(props.onLoadEnd);
onLoadEndRef.current = props.onLoadEnd;
const onLoadStartRef = useRef(props.onLoadStart);
onLoadStartRef.current = props.onLoadStart;
useEffect(() => {
logger.debug(`DOM at ${dom.window?.location?.href} is reloading.`);
onLoadStartRef.current?.();
onLoadEndRef.current?.();
}, [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 };
return (
<View style={props.style} testID={props.testID} {...additionalProps}/>
);
};
export default forwardRef(ExtendedWebView);

View File

@@ -5,73 +5,17 @@ import * as React from 'react';
import {
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
} from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { WebViewErrorEvent, WebViewEvent, WebViewSource } from 'react-native-webview/lib/WebViewTypes';
import { WebView } from 'react-native-webview';
import { WebViewErrorEvent, WebViewSource } from 'react-native-webview/lib/WebViewTypes';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { StyleProp, ViewStyle } from 'react-native';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
const logger = Logger.create('ExtendedWebView');
export interface WebViewControl {
// Evaluate the given [script] in the context of the page.
// Unlike react-native-webview/WebView, this does not need to return true.
injectJS(script: string): void;
// message must be convertible to JSON
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
postMessage(message: any): void;
}
interface SourceFileUpdateEvent {
uri: string;
baseUrl: string;
filePath: string;
}
export type OnMessageCallback = (event: WebViewMessageEvent)=> void;
export type OnErrorCallback = (event: WebViewErrorEvent)=> void;
export type OnLoadCallback = (event: WebViewEvent)=> void;
type OnFileUpdateCallback = (event: SourceFileUpdateEvent)=> void;
interface Props {
// A name to be associated with the WebView (e.g. NoteEditor)
// This name should be unique.
webviewInstanceId: string;
// If HTML is still being loaded, [html] should be an empty string.
html: string;
// Allow a secure origin to load content from any other origin.
// Defaults to 'never'.
// See react-native-webview's prop with the same name.
mixedContentMode?: 'never' | 'always';
allowFileAccessFromJs?: boolean;
hasPluginScripts?: boolean;
// Initial javascript. Must evaluate to true.
injectedJavaScript: string;
// iOS only: Scroll the outer content of the view. Set this to `false` if
// the main view container doesn't scroll.
scrollEnabled?: boolean;
style?: StyleProp<ViewStyle>;
onMessage: OnMessageCallback;
onError?: OnErrorCallback;
onLoadStart?: OnLoadCallback;
onLoadEnd?: OnLoadCallback;
// Triggered when the file containing [html] is overwritten with new content.
onFileUpdate?: OnFileUpdateCallback;
// Defaults to the resource directory
baseDirectory?: string;
}
export { WebViewControl, Props };
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const webviewRef = useRef(null);
@@ -124,11 +68,6 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
baseUrl,
};
setSource(newSource);
props.onFileUpdate?.({
...newSource,
filePath: tempFile,
});
}
if (props.html && props.html.length > 0) {
@@ -140,7 +79,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
return () => {
cancelled = true;
};
}, [props.html, props.webviewInstanceId, props.onFileUpdate, baseDirectory, baseUrl]);
}, [props.html, props.webviewInstanceId, baseDirectory, baseUrl]);
const onError = useCallback((event: WebViewErrorEvent) => {
logger.error('Error', event.nativeEvent.description);

View File

@@ -0,0 +1,46 @@
import { StyleProp, ViewStyle } from 'react-native';
import { WebViewErrorEvent } from 'react-native-webview/lib/WebViewTypes';
export interface WebViewControl {
// Evaluate the given [script] in the context of the page.
// Unlike react-native-webview/WebView, this does not need to return true.
injectJS(script: string): void;
// message must be convertible to JSON
postMessage(message: unknown): void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before rule was applied.
export type OnMessageEvent = { nativeEvent: { data: any } };
export type OnMessageCallback = (event: OnMessageEvent)=> void;
export type OnErrorCallback = (event: WebViewErrorEvent)=> void;
export type OnLoadCallback = ()=> void;
export interface Props {
// A name to be associated with the WebView (e.g. NoteEditor)
// This name should be unique.
webviewInstanceId: string;
testID?: string;
hasPluginScripts?: boolean;
// Forwarded to RN WebView
scrollEnabled?: boolean;
allowFileAccessFromJs?: boolean;
mixedContentMode?: 'never'|'always';
// If HTML is still being loaded, [html] should be an empty string.
html: string;
// Initial javascript. Must evaluate to true.
injectedJavaScript: string;
style?: StyleProp<ViewStyle>;
onMessage: OnMessageCallback;
onError?: OnErrorCallback;
onLoadStart?: OnLoadCallback;
onLoadEnd?: OnLoadCallback;
// Defaults to the resource directory
baseDirectory?: string;
}

View File

@@ -0,0 +1,181 @@
import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import NoteBodyViewer from './NoteBodyViewer';
import Setting from '@joplin/lib/models/Setting';
import { MenuProvider } from 'react-native-popup-menu';
import { resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
import { MarkupLanguage } from '@joplin/renderer';
import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import Resource from '@joplin/lib/models/Resource';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
interface WrapperProps {
noteBody: string;
highlightedKeywords?: string[];
noteResources?: unknown;
onJoplinLinkClick?: HandleMessageCallback;
onScroll?: (percent: number)=> void;
onMarkForDownload?: OnMarkForDownloadCallback;
}
const emptyObject = {};
const emptyArray: string[] = [];
const noOpFunction = () => {};
const WrappedNoteViewer: React.FC<WrapperProps> = (
{
noteBody,
highlightedKeywords = emptyArray,
noteResources = emptyObject,
onJoplinLinkClick = noOpFunction,
onScroll = noOpFunction,
onMarkForDownload,
}: WrapperProps,
) => {
return <MenuProvider>
<NoteBodyViewer
themeId={Setting.THEME_LIGHT}
style={emptyObject}
noteBody={noteBody}
noteMarkupLanguage={MarkupLanguage.Markdown}
highlightedKeywords={highlightedKeywords}
noteResources={noteResources}
paddingBottom={0}
initialScroll={0}
noteHash={''}
onJoplinLinkClick={onJoplinLinkClick}
onMarkForDownload={onMarkForDownload}
onScroll={onScroll}
pluginStates={emptyObject}
/>
</MenuProvider>;
};
const getNoteViewerDom = async (): Promise<Document> => {
const webviewContent = await screen.findByTestId('NoteBodyViewer');
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;
};
describe('NoteBodyViewer', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
afterEach(() => {
screen.unmount();
});
it('should render markdown and re-render on change', async () => {
render(<WrappedNoteViewer noteBody='# Test'/>);
const expectHeaderToBe = async (text: string) => {
const noteViewer = await getNoteViewerDom();
await waitFor(async () => {
expect(noteViewer.querySelector('h1').textContent).toBe(text);
});
};
await expectHeaderToBe('Test');
screen.rerender(<WrappedNoteViewer noteBody='# Test 2'/>);
await expectHeaderToBe('Test 2');
screen.rerender(<WrappedNoteViewer noteBody='# Test 3'/>);
await expectHeaderToBe('Test 3');
});
it.each([
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },
{ keywords: ['a', 'b'], body: 'a, a, a, b, b, b', expectedMatchCount: 6 },
])('should highlight search terms (case %#)', async ({ keywords, body, expectedMatchCount }) => {
render(
<WrappedNoteViewer
highlightedKeywords={keywords}
noteBody={body}
/>,
);
let noteViewerDom = await getNoteViewerDom();
await waitFor(() => {
expect(noteViewerDom.querySelectorAll('.highlighted-keyword')).toHaveLength(expectedMatchCount);
});
// Should update highlights when the keywords change
screen.rerender(
<WrappedNoteViewer
highlightedKeywords={[]}
noteBody={body}
/>,
);
noteViewerDom = await getNoteViewerDom();
await waitFor(() => {
expect(noteViewerDom.querySelectorAll('.highlighted-keyword')).toHaveLength(0);
});
});
it('tapping on resource download icons should mark the resources for download', async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
let note1 = await Note.save({ title: 'Note 1', parent_id: '' });
note1 = await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
await synchronizerStart();
await switchClient(0);
Setting.setValue('sync.resourceDownloadMode', 'manual');
await synchronizerStart();
const allResources = await Resource.all();
expect(allResources.length).toBe(1);
const localResource = allResources[0];
const localState = await Resource.localState(localResource);
expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
const onMarkForDownload: OnMarkForDownloadCallback = jest.fn(({ resourceId }) => {
return resourceFetcher().markForDownload([resourceId]);
});
render(
<WrappedNoteViewer
noteBody={note1.body}
noteResources={{ [localResource.id]: { localState, item: localResource } }}
onMarkForDownload={onMarkForDownload}
/>,
);
// The resource placeholder should have rendered
const noteViewerDom = await getNoteViewerDom();
let resourcePlaceholder: HTMLElement|null = null;
await waitFor(() => {
const placeholders = noteViewerDom.querySelectorAll<HTMLElement>(`[data-resource-id=${JSON.stringify(localResource.id)}]`);
expect(placeholders).toHaveLength(1);
resourcePlaceholder = placeholders[0];
});
expect([...resourcePlaceholder.classList]).toContain('resource-status-notDownloaded');
// Clicking on the placeholder should download its resource
await waitFor(() => {
resourcePlaceholder.click();
expect(onMarkForDownload).toHaveBeenCalled();
});
await resourceFetcher().waitForAllFinished();
await waitFor(async () => {
expect(await Resource.localState(localResource.id)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
});
});
});

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import { useRef, useCallback, useState, useMemo } from 'react';
import { View } from 'react-native';
import { View, ViewStyle } from 'react-native';
import BackButtonDialogBox from '../BackButtonDialogBox';
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
@@ -14,13 +14,13 @@ import Setting from '@joplin/lib/models/Setting';
import uuid from '@joplin/lib/uuid';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useContentScripts from './hooks/useContentScripts';
import { MarkupLanguage } from '@joplin/renderer';
interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
style: ViewStyle;
noteBody: string;
noteMarkupLanguage: number;
noteMarkupLanguage: MarkupLanguage;
highlightedKeywords: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteResources: any;
@@ -111,6 +111,7 @@ export default function NoteBodyViewer(props: Props) {
<ExtendedWebView
ref={webviewRef}
webviewInstanceId='NoteBodyViewer'
testID='NoteBodyViewer'
html={html}
allowFileAccessFromJs={true}
injectedJavaScript={injectedJs}

View File

@@ -1,3 +1,4 @@
/** @jest-environment jsdom */
import Setting from '@joplin/lib/models/Setting';
import Renderer, { RendererSettings, RendererSetupOptions } from './Renderer';
import shim from '@joplin/lib/shim';

View File

@@ -6,6 +6,7 @@ import Renderer from './Renderer';
declare global {
interface Window {
rendererWebViewOptions: RendererWebViewOptions;
webviewLib: { postMessage: (message: string)=> void };
}
}
@@ -32,6 +33,8 @@ webviewLib.initialize({
messenger.remoteApi.onPostMessage(message);
},
});
// Share the webview library globally so that the renderer can access it.
window.webviewLib = webviewLib;
const renderer = new Renderer({
...window.rendererWebViewOptions,
@@ -58,5 +61,5 @@ const onMainContentScroll = () => {
// scroll. However, window.addEventListener('scroll', callback) does.
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
// the listener is added to window with window.addEventListener('scroll', ...).
document.scrollingElement.addEventListener('scroll', onMainContentScroll);
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
window.addEventListener('scroll', onMainContentScroll);

View File

@@ -19,7 +19,10 @@ const getEditIconSrc = (theme: Theme) => {
// Copy in the background -- the edit icon popover script doesn't need the
// icon immediately.
void (async () => {
await shim.fsDriver().copy(iconUri, destPath);
// Can be '' in a testing environment.
if (iconUri) {
await shim.fsDriver().copy(iconUri, destPath);
}
})();
return destPath;

View File

@@ -1,4 +1,4 @@
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo } from 'react';
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../ExtendedWebView';
import { OnScrollCallback, OnWebViewMessageHandler } from '../types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
@@ -35,11 +35,17 @@ const onPostPluginMessage = async (contentScriptId: string, message: any) => {
};
const useRenderer = (props: Props) => {
const onScrollRef = useRef(props.onScroll);
onScrollRef.current = props.onScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: props.onScroll,
onPostMessage: props.onPostMessage,
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
@@ -58,7 +64,7 @@ const useRenderer = (props: Props) => {
return new RNToWebViewMessenger<NoteViewerRemoteApi, NoteViewerLocalApi>(
'note-viewer', props.webviewRef, localApi,
);
}, [props.onScroll, props.onPostMessage, props.webviewRef, props.tempDir]);
}, [props.webviewRef, props.tempDir]);
useEffect(() => {
props.setOnWebViewMessage(() => (event: WebViewMessageEvent) => {

View File

@@ -1,4 +1,4 @@
import { WebViewMessageEvent } from 'react-native-webview';
import { OnMessageEvent } from '../ExtendedWebView/types';
export type OnScrollCallback = (scrollTop: number)=> void;
export type OnWebViewMessageHandler = (event: WebViewMessageEvent)=> void;
export type OnWebViewMessageHandler = (event: OnMessageEvent)=> void;

View File

@@ -7,11 +7,11 @@ import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, BackHandler } from 'react-native';
import { WebViewMessageEvent } from 'react-native-webview';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { clearAutosave, writeAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
import VersionInfo from 'react-native-version-info';
import { OnMessageEvent } from '../../ExtendedWebView/types';
const logger = Logger.create('ImageEditor');
@@ -280,7 +280,7 @@ const ImageEditor = (props: Props) => {
})();`);
}, [webviewRef, props.resourceFilename]);
const onMessage = useCallback(async (event: WebViewMessageEvent) => {
const onMessage = useCallback(async (event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (data.startsWith('error:')) {
logger.error('ImageEditor:', data);

View File

@@ -78,5 +78,7 @@ describe('NoteEditor', () => {
}
});
}
wrappedNoteEditor.unmount();
});
});

View File

@@ -21,11 +21,11 @@ import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { WebViewMessageEvent } from 'react-native-webview';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
import { OnMessageEvent } from '../ExtendedWebView/types';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
@@ -466,7 +466,7 @@ function NoteEditor(props: Props, ref: any) {
editorMessenger.onWebViewLoaded();
}, [editorMessenger]);
const onMessage = useCallback((event: WebViewMessageEvent) => {
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (data.indexOf('error:') === 0) {

View File

@@ -1,3 +1,5 @@
/** @jest-environment jsdom */
import CommandService from '@joplin/lib/services/CommandService';
import useEditorCommandHandler from './useEditorCommandHandler';
import commandDeclarations from '../commandDeclarations';

View File

@@ -1,18 +1,18 @@
import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner';
import PluginApiGlobal from '@joplin/lib/services/plugins/api/Global';
import Plugin from '@joplin/lib/services/plugins/Plugin';
import { WebViewControl } from '../../components/ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView';
import { RefObject } from 'react';
import { WebViewMessageEvent } from 'react-native-webview';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { PluginMainProcessApi, PluginWebViewApi } from './types';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import createOnLogHander from './utils/createOnLogHandler';
import { OnMessageEvent } from '../ExtendedWebView/types';
const logger = Logger.create('PluginRunner');
type MessageEventListener = (event: WebViewMessageEvent)=> boolean;
type MessageEventListener = (event: OnMessageEvent)=> boolean;
export default class PluginRunner extends BasePluginRunner {
private messageEventListeners: MessageEventListener[] = [];
@@ -71,7 +71,7 @@ export default class PluginRunner extends BasePluginRunner {
`);
}
public onWebviewMessage = (event: WebViewMessageEvent) => {
public onWebviewMessage = (event: OnMessageEvent) => {
this.messageEventListeners = this.messageEventListeners.filter(
// Remove all listeners that return false
listener => listener(event),

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import ExtendedWebView, { WebViewControl } from '../../components/ExtendedWebView';
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import shim from '@joplin/lib/shim';
import PluginRunner from './PluginRunner';

View File

@@ -1,3 +1,4 @@
/** @jest-environment jsdom */
import getFormData from './getFormData';
describe('getFormData', () => {

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer';
import ExtendedWebView, { WebViewControl } from '../../../components/ExtendedWebView';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { ViewStyle } from 'react-native';
import usePlugin from '@joplin/lib/hooks/usePlugin';
import shim from '@joplin/lib/shim';

View File

@@ -1,7 +1,7 @@
import { useMemo, RefObject } from 'react';
import { DialogMainProcessApi, DialogWebViewApi } from '../../types';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../../../components/ExtendedWebView';
import { WebViewControl } from '../../../ExtendedWebView';
import createOnLogHander from '../../utils/createOnLogHandler';
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';

View File

@@ -52,6 +52,8 @@ const backlinksPluginId = 'joplin.plugin.ambrt.backlinksToNote';
describe('PluginStates.installed', () => {
beforeEach(async () => {
jest.useRealTimers();
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
@@ -60,6 +62,9 @@ describe('PluginStates.installed', () => {
await mockMobilePlatform('android');
await mockRepositoryApiConstructor();
// Fake timers are necessary to prevent a warning.
jest.useFakeTimers();
});
afterEach(async () => {
for (const pluginId of PluginService.instance().pluginIds) {
@@ -228,7 +233,7 @@ describe('PluginStates.installed', () => {
// After updating, the update button should read "updated". Use a large
// timeout because updating plugins can be slow, particularly in CI.
const updatedButton = await screen.findByRole('button', { name: 'Updated', disabled: true, timeout: 16000 });
const updatedButton = await screen.findByRole('button', { name: 'Updated', disabled: true }, { timeout: 16000 });
expect(updatedButton).toBeVisible();
// Should be marked as updated.

View File

@@ -34,6 +34,7 @@ let reduxStore: Store<AppState>;
describe('PluginStates.search', () => {
beforeEach(async () => {
jest.useRealTimers();
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
@@ -42,6 +43,7 @@ describe('PluginStates.search', () => {
resetRepoApi();
await mockRepositoryApiConstructor();
jest.useFakeTimers();
});
it('should find results', async () => {

View File

@@ -12,7 +12,7 @@ module.exports = {
'\\.(ts|tsx)$': 'ts-jest',
},
testEnvironment: 'jsdom',
testEnvironment: 'node',
testMatch: ['**/*.test.(ts|tsx)'],
testPathIgnorePatterns: ['<rootDir>/node_modules/'],
@@ -20,7 +20,7 @@ module.exports = {
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
transformIgnorePatterns: ['<rootDir>/node_modules/jest', '<rootDir>/node_modules/js-draw'],
transformIgnorePatterns: ['<rootDir>/node_modules/jest', '<rootDir>/node_modules/js-draw', 'node_modules/jsdom'],
slowTestThreshold: 40,
};

View File

@@ -1,9 +1,12 @@
/* eslint-disable jest/require-top-level-describe */
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
const shim = require('@joplin/lib/shim').default;
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const injectedJs = require('./utils/injectedJs.js').default;
const { mkdir, rm } = require('fs-extra');
const path = require('path');
const sharp = require('sharp');
const { tmpdir } = require('os');
const uuid = require('@joplin/lib/uuid').default;
const sqlite3 = require('sqlite3');
@@ -19,7 +22,14 @@ window.setImmediate = setImmediate;
shimInit({
nodeSqlite: sqlite3,
React,
sharp,
});
shim.injectedJs = (name) => {
if (!(name in injectedJs)) {
throw new Error(`Cannot find injected JS with ID ${name}`);
}
return injectedJs[name];
};
// This library has the following error when running within Jest:
// Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.
@@ -40,17 +50,27 @@ jest.doMock('react-native-version-info', () => {
});
// react-native-webview expects native iOS/Android code so needs to be mocked.
jest.mock('react-native-webview', () => {
const { View } = require('react-native');
return {
WebView: View,
};
jest.mock('./components/ExtendedWebView', () => {
return require('./components/ExtendedWebView/index.jest.js');
});
jest.mock('@react-native-clipboard/clipboard', () => {
return { default: { getString: jest.fn(), setString: jest.fn() } };
});
jest.mock('react-native-share', () => {
return { default: { } };
});
// Used by the renderer
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {
default: class extends require('react-native').View {
static getImageSourceSync = () => ({ uri: '' });
},
};
});
// react-native-fs's CachesDirectoryPath export doesn't work in a testing environment.
// Use a temporary folder instead.
const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`);

View File

@@ -112,6 +112,7 @@
"nodemon": "3.0.3",
"punycode": "2.3.1",
"react-test-renderer": "18.2.0",
"sharp": "0.33.2",
"sqlite3": "5.1.6",
"ts-jest": "29.1.1",
"ts-loader": "9.5.1",

View File

@@ -0,0 +1,10 @@
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../lib/rnInjectedJs/codeMirrorBundle.bundle'),
svgEditorBundle: require('../lib/rnInjectedJs/svgEditorBundle.bundle'),
pluginBackgroundPage: require('../lib/rnInjectedJs/pluginBackgroundPage.bundle'),
noteBodyViewerBundle: require('../lib/rnInjectedJs/noteBodyViewerBundle.bundle'),
};
export default injectedJs;

View File

@@ -1,9 +1,9 @@
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';
import { WebViewMessageEvent } from 'react-native-webview';
import { WebViewControl } from '../../components/ExtendedWebView';
import { RefObject } from 'react';
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> {
public constructor(channelId: string, private webviewControl: WebViewControl|RefObject<WebViewControl>, localApi: LocalInterface) {
@@ -32,7 +32,7 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
`);
}
public onWebViewMessage = (event: WebViewMessageEvent) => {
public onWebViewMessage = (event: OnMessageEvent) => {
if (!this.hasBeenClosed()) {
void this.onMessage(JSON.parse(event.nativeEvent.data));
}

View File

@@ -14,14 +14,7 @@ import Resource from '@joplin/lib/models/Resource';
import { getLocales } from 'react-native-localize';
import { setLocale, defaultLocale, closestSupportedLocale } from '@joplin/lib/locale';
import type SettingType from '@joplin/lib/models/Setting';
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../lib/rnInjectedJs/codeMirrorBundle.bundle'),
svgEditorBundle: require('../lib/rnInjectedJs/svgEditorBundle.bundle'),
pluginBackgroundPage: require('../lib/rnInjectedJs/pluginBackgroundPage.bundle'),
noteBodyViewerBundle: require('../lib/rnInjectedJs/noteBodyViewerBundle.bundle'),
};
import injectedJs from './injectedJs';
export default function shimInit() {
shim.Geolocation = GeolocationReact;

View File

@@ -6734,6 +6734,7 @@ __metadata:
react-test-renderer: 18.2.0
redux: 4.2.1
rn-fetch-blob: 0.12.0
sharp: 0.33.2
sqlite3: 5.1.6
stream: 0.0.2
stream-browserify: 3.0.0