mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-18 20:16:34 +02:00
Chore: Mobile: Add NoteBodyViewer tests (#10747)
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
+5
-66
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,5 +78,7 @@ describe('NoteEditor', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import useEditorCommandHandler from './useEditorCommandHandler';
|
||||
import commandDeclarations from '../commandDeclarations';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** @jest-environment jsdom */
|
||||
import getFormData from './getFormData';
|
||||
|
||||
describe('getFormData', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
+6
-1
@@ -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.
|
||||
|
||||
+2
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user