1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Chore: Mobile: Add additional plugin panel integration tests (#13152)

This commit is contained in:
Henry Heino
2025-09-08 16:31:12 -07:00
committed by GitHub
parent 1762f9485f
commit 15c973e885
7 changed files with 285 additions and 211 deletions

View File

@@ -924,6 +924,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js

1
.gitignore vendored
View File

@@ -897,6 +897,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js

View File

@@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import PluginRunnerWebView from './PluginRunnerWebView';
import TestProviderStack from '../testing/TestProviderStack';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import { act, render, screen, waitFor } from '../../utils/testing/testingLibrary';
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import Setting from '@joplin/lib/models/Setting';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import CommandService from '@joplin/lib/services/CommandService';
let store: Store<AppState>;
@@ -30,6 +31,16 @@ const defaultManifestProperties = {
name: 'Some plugin name',
};
type PluginSlice = { manifest: { id: string } };
const waitForPluginToLoad = (plugin: PluginSlice) => {
return waitFor(async () => {
expect(PluginService.instance().pluginById(plugin.manifest.id)).toBeTruthy();
});
};
const webViewId = 'joplin__PluginDialogWebView';
const getUserWebViewDom = () => getWebViewDomById(webViewId);
describe('PluginRunnerWebView', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
@@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => {
`,
});
render(<WrappedPluginRunnerWebView/>);
// Should load the plugin
await waitFor(async () => {
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
});
await waitForPluginToLoad(testPlugin);
// Should show the dialog
await waitFor(async () => {
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
const dom = await getUserWebViewDom();
expect(dom.querySelector('h1').textContent).toBe('Test!');
});
});
test('should load a plugin that adds a panel', async () => {
const testPlugin = await createTestPlugin({
...defaultManifestProperties,
id: 'org.joplinapp.panel-test',
}, {
onStart: `
const panels = joplin.views.panels;
const handle = await panels.create('test-panel');
await panels.setHtml(
handle,
'<h1>Panel content</h1><p>Test</p>',
);
const commands = joplin.commands;
await commands.register({
name: 'hideTestPanel',
label: 'Hide the test plugin panel',
execute: async () => {
await panels.hide(handle);
},
});
await commands.register({
name: 'showTestPanel',
execute: async () => {
await panels.show(handle);
},
});
`,
});
render(<WrappedPluginRunnerWebView/>);
await waitForPluginToLoad(testPlugin);
act(() => {
store.dispatch({ type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE', visible: true });
});
const expectPanelVisible = async () => {
const dom = await getUserWebViewDom();
await waitFor(async () => {
expect(dom.querySelector('h1').textContent).toBe('Panel content');
});
};
await expectPanelVisible();
// Should hide the panel
await act(() => CommandService.instance().execute('hideTestPanel'));
await waitFor(() => {
expect(screen.queryByTestId('webViewId')).toBeNull();
});
// Should show the panel again
await act(() => CommandService.instance().execute('showTestPanel'));
await expectPanelVisible();
});
});

View File

@@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
}
return (
<View style={styles.webViewContainer}>
<View style={styles.webViewContainer} testID='plugin-tab-content'>
<PluginUserWebView
key={selectedTabId}
themeId={props.themeId}

View File

@@ -14,7 +14,7 @@ import NoteScreen from './components/screens/Note/Note';
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
import Setting, { } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import reducer, { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
import ShareExtension, { UnsubscribeShareListener } from './utils/ShareExtension';
import handleShared from './utils/shareHandler';
import { _, setLocale } from '@joplin/lib/locale';
@@ -28,7 +28,6 @@ import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
import SafeAreaView from './components/SafeAreaView';
const { connect, Provider } = require('react-redux');
import fastDeepEqual = require('fast-deep-equal');
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
import NavService from '@joplin/lib/services/NavService';
@@ -95,7 +94,6 @@ import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTh
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
import ShareManager from './components/screens/ShareManager';
import appDefaultState from './utils/appDefaultState';
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
import DialogManager from './components/DialogManager';
import { AppState } from './utils/types';
@@ -108,6 +106,7 @@ import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import appReducer from './utils/appReducer';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create();
@@ -235,204 +234,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
return result;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const navHistory: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function historyCanGoBackTo(route: any) {
if (route.routeName === 'Folder') return false;
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
// navigation history.
if (route.routeName === 'DocumentScanner') return false;
// There's no point going back to these screens in general and, at least in OneDrive case,
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
let historyGoingBack = false;
try {
switch (action.type) {
case 'NAV_BACK':
case 'NAV_GO':
if (action.type === 'NAV_BACK') {
if (!navHistory.length) break;
const newAction = navHistory.pop();
action = newAction ? newAction : navHistory.pop();
historyGoingBack = true;
}
{
const currentRoute = state.route;
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
// pressing "back" seem to have no effect.
if (isDifferentRoute) {
navHistory.push(currentRoute);
}
}
if (action.clearHistory) {
navHistory.splice(0, navHistory.length);
}
newState = { ...state };
newState.selectedNoteHash = '';
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}
if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
}
if ('folderId' in action) {
newState.selectedFolderId = action.folderId;
newState.notesParentType = 'Folder';
}
if ('tagId' in action) {
newState.selectedTagId = action.tagId;
newState.notesParentType = 'Tag';
}
if ('smartFilterId' in action) {
newState.smartFilterId = action.smartFilterId;
newState.selectedSmartFilterId = action.smartFilterId;
newState.notesParentType = 'SmartFilter';
}
if ('itemType' in action) {
newState.selectedItemType = action.itemType;
}
if ('noteHash' in action) {
newState.selectedNoteHash = action.noteHash;
}
if ('sharedData' in action) {
newState.sharedData = action.sharedData;
} else {
newState.sharedData = null;
}
newState.route = action;
newState.historyCanGoBack = !!navHistory.length;
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
}
break;
case 'SIDE_MENU_TOGGLE':
newState = { ...state };
newState.showSideMenu = !newState.showSideMenu;
break;
case 'SIDE_MENU_OPEN':
newState = { ...state };
newState.showSideMenu = true;
break;
case 'SIDE_MENU_CLOSE':
newState = { ...state };
newState.showSideMenu = false;
break;
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
newState = { ...state };
newState.showPanelsDialog = action.visible;
break;
case 'NOTE_SELECTION_TOGGLE':
{
newState = { ...state };
const noteId = action.id;
const newSelectedNoteIds = state.selectedNoteIds.slice();
const existingIndex = state.selectedNoteIds.indexOf(noteId);
if (existingIndex >= 0) {
newSelectedNoteIds.splice(existingIndex, 1);
} else {
newSelectedNoteIds.push(noteId);
}
newState.selectedNoteIds = newSelectedNoteIds;
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
}
break;
case 'NOTE_SELECTION_START':
if (!state.noteSelectionEnabled) {
newState = { ...state };
newState.noteSelectionEnabled = true;
newState.selectedNoteIds = [action.id];
}
break;
case 'NOTE_SELECTION_END':
newState = { ...state };
newState.noteSelectionEnabled = false;
newState.selectedNoteIds = [];
break;
case 'NOTE_SIDE_MENU_OPTIONS_SET':
newState = { ...state };
newState.noteSideMenuOptions = action.options;
break;
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
newState = { ...state };
newState.disableSideMenuGestures = action.disableSideMenuGestures;
break;
case 'MOBILE_DATA_WARNING_UPDATE':
newState = { ...state };
newState.isOnMobileData = action.isOnMobileData;
break;
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
throw error;
}
return reducer(newState, action) as AppState;
};
const store = createStore(appReducer, applyMiddleware(generalMiddleware));
storeDispatch = store.dispatch;

View File

@@ -0,0 +1,207 @@
import reducer from '@joplin/lib/reducer';
import { AppState } from './types';
import appDefaultState from './appDefaultState';
import fastDeepEqual = require('fast-deep-equal');
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('appReducer');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const navHistory: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function historyCanGoBackTo(route: any) {
if (route.routeName === 'Folder') return false;
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
// navigation history.
if (route.routeName === 'DocumentScanner') return false;
// There's no point going back to these screens in general and, at least in OneDrive case,
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
let historyGoingBack = false;
try {
switch (action.type) {
case 'NAV_BACK':
case 'NAV_GO':
if (action.type === 'NAV_BACK') {
if (!navHistory.length) break;
const newAction = navHistory.pop();
action = newAction ? newAction : navHistory.pop();
historyGoingBack = true;
}
{
const currentRoute = state.route;
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
// pressing "back" seem to have no effect.
if (isDifferentRoute) {
navHistory.push(currentRoute);
}
}
if (action.clearHistory) {
navHistory.splice(0, navHistory.length);
}
newState = { ...state };
newState.selectedNoteHash = '';
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}
if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
}
if ('folderId' in action) {
newState.selectedFolderId = action.folderId;
newState.notesParentType = 'Folder';
}
if ('tagId' in action) {
newState.selectedTagId = action.tagId;
newState.notesParentType = 'Tag';
}
if ('smartFilterId' in action) {
newState.smartFilterId = action.smartFilterId;
newState.selectedSmartFilterId = action.smartFilterId;
newState.notesParentType = 'SmartFilter';
}
if ('itemType' in action) {
newState.selectedItemType = action.itemType;
}
if ('noteHash' in action) {
newState.selectedNoteHash = action.noteHash;
}
if ('sharedData' in action) {
newState.sharedData = action.sharedData;
} else {
newState.sharedData = null;
}
newState.route = action;
newState.historyCanGoBack = !!navHistory.length;
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
}
break;
case 'SIDE_MENU_TOGGLE':
newState = { ...state };
newState.showSideMenu = !newState.showSideMenu;
break;
case 'SIDE_MENU_OPEN':
newState = { ...state };
newState.showSideMenu = true;
break;
case 'SIDE_MENU_CLOSE':
newState = { ...state };
newState.showSideMenu = false;
break;
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
newState = { ...state };
newState.showPanelsDialog = action.visible;
break;
case 'NOTE_SELECTION_TOGGLE':
{
newState = { ...state };
const noteId = action.id;
const newSelectedNoteIds = state.selectedNoteIds.slice();
const existingIndex = state.selectedNoteIds.indexOf(noteId);
if (existingIndex >= 0) {
newSelectedNoteIds.splice(existingIndex, 1);
} else {
newSelectedNoteIds.push(noteId);
}
newState.selectedNoteIds = newSelectedNoteIds;
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
}
break;
case 'NOTE_SELECTION_START':
if (!state.noteSelectionEnabled) {
newState = { ...state };
newState.noteSelectionEnabled = true;
newState.selectedNoteIds = [action.id];
}
break;
case 'NOTE_SELECTION_END':
newState = { ...state };
newState.noteSelectionEnabled = false;
newState.selectedNoteIds = [];
break;
case 'NOTE_SIDE_MENU_OPTIONS_SET':
newState = { ...state };
newState.noteSideMenuOptions = action.options;
break;
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
newState = { ...state };
newState.disableSideMenuGestures = action.disableSideMenuGestures;
break;
case 'MOBILE_DATA_WARNING_UPDATE':
newState = { ...state };
newState.isOnMobileData = action.isOnMobileData;
break;
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
throw error;
}
return reducer(newState, action) as AppState;
};
export default appReducer;

View File

@@ -1,15 +1,16 @@
import reducer from '@joplin/lib/reducer';
import { createStore } from 'redux';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
import { AppState } from '../types';
import appReducer from '../appReducer';
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
state ??= {
...appDefaultState,
settings: Setting.toPlainObject(),
};
return { ...state, ...reducer(state, action) };
return { ...state, ...appReducer(state, action) };
};
const createMockReduxStore = () => {