文件
mynode/packages/app-mobile/components/screens/Note/Note.test.tsx
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

974 行
35 KiB
TypeScript

import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../utils/testing/testingLibrary';
import NoteScreen from './Note';
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers, encryptionService } from '@joplin/lib/testing/test-utils';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { enableNoteLock, disableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
import waitForWithRealTimers from '@joplin/lib/testing/waitFor';
import Note from '@joplin/lib/models/Note';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import getWebViewDomById from '../../../utils/testing/getWebViewDomById';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import ItemChange from '@joplin/lib/models/ItemChange';
import { getDisplayParentId } from '@joplin/lib/services/trash';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { LayoutChangeEvent } from 'react-native';
import shim from '@joplin/lib/shim';
import getWebViewWindowById from '../../../utils/testing/getWebViewWindowById';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
import TestProviderStack from '../../testing/TestProviderStack';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import CommandService from '@joplin/lib/services/CommandService';
import BackButtonService from '../../../services/BackButtonService';
import shared from '@joplin/lib/components/shared/note-screen-shared';
import { DialogContext, DialogControl } from '../../DialogManager';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import mockPluginServiceSetup from '../../../utils/testing/mockPluginServiceSetup';
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import JoplinViewsEditors from '@joplin/lib/services/plugins/api/JoplinViewsEditor';
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
import getShownPluginEditorView from '@joplin/lib/services/plugins/utils/getShownPluginEditorView';
import { defaultWindowId } from '@joplin/lib/reducer';
jest.mock('@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds', () => jest.fn((): string[] => []));
const mockUseVisiblePluginEditorViewIds = useVisiblePluginEditorViewIds as jest.MockedFunction<typeof useVisiblePluginEditorViewIds>;
jest.retryTimes(2);
interface WrapperProps {
}
let store: Store<AppState>;
function deferred<T>() {
let resolve: (value: T)=> void;
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve: resolve! };
}
const mockNavigation = { state: { } };
const WrappedNoteScreen: React.FC<WrapperProps> = _props => {
return <TestProviderStack store={store}>
<NoteScreen navigation={mockNavigation}/>
</TestProviderStack>;
};
const getNoteViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer');
};
const getMarkdownEditorControl = async () => {
const noteEditor = await getWebViewWindowById('MarkdownEditor');
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(() => waitForWithRealTimers(async () => {
const loadedNote = await Note.load(noteId);
expect(loadedNote).toMatchObject(note);
}));
};
const openExistingNote = async (noteId: string) => {
const note = await Note.load(noteId);
const displayParentId = getDisplayParentId(note, await Folder.load(note.parent_id));
store.dispatch({
type: 'NOTE_UPDATE_ALL',
notes: await Note.previews(displayParentId),
});
store.dispatch({
type: 'FOLDER_AND_NOTE_SELECT',
id: note.id,
folderId: displayParentId,
});
};
const openNewNote = async (noteProperties: NoteEntity) => {
const note = await Note.save({
parent_id: (await Folder.defaultFolder()).id,
...noteProperties,
});
await openExistingNote(note.id);
await waitForNoteToMatch(note.id, { parent_id: note.parent_id, title: note.title, body: note.body });
return note.id;
};
const openNoteActionsMenu = async () => {
// It doesn't seem possible to find the menu trigger with role/label.
const actionMenuButton = await screen.findByTestId('screen-header-menu-trigger');
// react-native-action-menu only shows the menu content after receiving onLayout
// events from various components (including a View that wraps the screen).
let cursor = actionMenuButton;
while (cursor.parent) {
if (cursor.props.onLayout) {
const mockedEvent = { nativeEvent: { layout: { x: 0, y: 0, width: 120, height: 100 } } };
cursor.props.onLayout(mockedEvent as LayoutChangeEvent);
}
cursor = cursor.parent;
}
// act() rather than waitFor(): waitFor rejects if the timer mode changes while it polls, and
// draining the fake timers updates the menu drawer's state.
await act(async () => {
await runWithFakeTimers(async () => {
await userEvent.press(actionMenuButton);
});
});
// State can update until the menu content is marked as open (part of the
// menu transition).
await waitFor(async () => {
expect(await screen.findByTestId('menu-content-open')).toBeVisible();
});
};
const setupUnlockedNoteLock = async (password: string) => {
Setting.setValue('featureFlag.noteLock', true);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create(password);
await NoteLockSession.instance().unlock(password);
};
// enableNoteLock only emits an event for a mounted screen, so the fixture locks the row
// directly with a gated save, then rotates the key from under it.
const setupUndecryptableNote = async (noteProperties: NoteEntity) => {
await setupUnlockedNoteLock('111111');
const noteId = await openNewNote(noteProperties);
const lockedNote = { ...await Note.load(noteId, { useNoteLock: true }), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
await act(async () => {
await NoteLockSession.instance().reset('222222');
await NoteLockSession.instance().unlock('222222');
});
store.dispatch({ type: 'SET_NOTE_LOCK_SESSION_UNLOCKED', value: true });
return noteId;
};
const expectToBeEditing = async (editing: boolean) => {
if (editing) {
await getMarkdownEditorControl();
} else {
await getNoteViewerDom();
}
};
const openEditor = async () => {
const editToggle = await screen.findByLabelText('Edit');
fireEvent.press(editToggle);
await expectToBeEditing(true);
};
const runEditorCommand = async (commandName: string) => {
await act(() => {
return CommandService.instance().execute(commandName);
});
};
const setupNoteWithPanes = async (panes: string[], noteTitle = 'Test note') => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes,
});
await openNewNote({ title: noteTitle, body: 'Test body' });
const renderResult = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue(noteTitle);
expect(titleInput).toBeVisible();
return renderResult;
};
const registerEditorPlugin = async () => {
const pluginId = 'org.joplinapp.editor-save-test';
mockPluginServiceSetup(store);
await createTestPlugin({
manifest_version: 1,
version: '0.1.0',
app_min_version: '2.3.4',
platforms: ['desktop', 'mobile'],
name: 'Editor save test',
id: pluginId,
});
const pluginService = PluginService.instance();
await pluginService.loadAndRunPlugins(
[`${Setting.value('pluginDir')}/${pluginId}.js`],
pluginService.unserializePluginSettings(Setting.value('plugins.states')),
);
const plugin = pluginService.pluginById(pluginId);
const editors = new JoplinViewsEditors(plugin, store);
let viewId = '';
await editors.register('test-editor', {
onSetup: async handle => {
viewId = handle;
},
onActivationCheck: async () => true,
});
const controller = plugin.viewController(viewId) as WebviewController;
controller.setActive(true);
await controller.setOpen(true);
mockUseVisiblePluginEditorViewIds.mockReturnValue([viewId]);
return { editors, pluginId, viewId };
};
describe('screens/Note/Note', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
Setting.setValue('editor.mobile.defaultEditState', 'view');
store = createMockReduxStore();
setupGlobalStore(store);
// In order for note changes to be saved, note-screen-shared requires
// that at least one folder exist.
await Folder.save({ title: 'test', parent_id: '' });
mockUseVisiblePluginEditorViewIds.mockReturnValue([]);
jest.useRealTimers();
});
it('should show the currently selected note', async () => {
await openNewNote({ title: 'Test note (title)', body: '# Testing...' });
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Test note (title)');
expect(titleInput).toBeVisible();
const renderedNote = await getNoteViewerDom();
expect(renderedNote.querySelector('h1')).toMatchObject({ textContent: 'Testing...' });
unmount();
});
it('changing the note title input should update the note\'s title', async () => {
const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' });
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Change me!');
const user = userEvent.setup();
await user.clear(titleInput);
await user.type(titleInput, 'New title');
await waitForNoteToMatch(noteId, { title: 'New title', body: 'Unchanged body' });
// Use fake timers to allow advancing timers without pausing the test
await runWithFakeTimers(async () => {
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 });
}
});
unmount();
});
it('should discard a queued save when the reload time has moved on', async () => {
const noteId = await openNewNote({ title: 'Original title', body: 'Body' });
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Original title');
await act(async () => {
fireEvent.changeText(titleInput, 'Stale local title');
const remotelyUpdatedNote = await Note.load(noteId);
remotelyUpdatedNote.title = 'Remote title';
await Note.save(remotelyUpdatedNote);
store.dispatch({ type: 'EDITOR_NOTE_NEEDS_RELOAD', noteId });
});
await waitForNoteToMatch(noteId, { title: 'Remote title' });
unmount();
await waitForNoteToMatch(noteId, { title: 'Remote title' });
});
it('should ignore header-menu saves during a reload when an editor plugin is visible', async () => {
const noteId = await openNewNote({ title: 'Original title', body: 'Original body' });
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Original title');
await openNoteActionsMenu();
const originalReloadNote = shared.reloadNote;
const continueReload = deferred<void>();
const reloadSpy = jest.spyOn(shared, 'reloadNote').mockImplementation(async component => {
await continueReload.promise;
return originalReloadNote(component);
});
try {
await Note.save({ id: noteId, body: 'Remote body' });
mockUseVisiblePluginEditorViewIds.mockReturnValue(['test-editor-plugin']);
// Awaiting an unbounded promise inside act() leaks an open act scope if the test times out,
// which breaks render() in the retries.
await act(async () => {
store.dispatch({ type: 'EDITOR_NOTE_NEEDS_RELOAD', noteId });
});
await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1));
fireEvent.press(await screen.findByText('Convert to todo'));
// Give the save queue enough time to process any incorrectly scheduled action.
await act(async () => new Promise(resolve => setTimeout(resolve, 600)));
await waitForNoteToMatch(noteId, { body: 'Remote body', is_todo: 0 });
continueReload.resolve();
await waitFor(() => expect(titleInput).toBeEnabled());
await waitForNoteToMatch(noteId, { body: 'Remote body', is_todo: 0 });
} finally {
continueReload.resolve();
reloadSpy.mockRestore();
unmount();
}
});
it('should save editor plugin changes during a reload', async () => {
const noteId = await openNewNote({ title: 'Original title', body: 'Original body' });
const { editors, pluginId, viewId } = await registerEditorPlugin();
expect(getShownPluginEditorView(store.getState().pluginService.plugins, defaultWindowId)).toMatchObject({
editorPlugin: { id: pluginId },
editorView: { id: viewId },
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Original title');
await screen.findByTestId('joplin__PluginDialogWebView');
const originalReloadNote = shared.reloadNote;
const continueReload = deferred<void>();
const reloadSpy = jest.spyOn(shared, 'reloadNote').mockImplementation(async component => {
await continueReload.promise;
return originalReloadNote(component);
});
try {
await Note.save({ id: noteId, body: 'Remote body' });
await act(async () => {
store.dispatch({ type: 'EDITOR_NOTE_NEEDS_RELOAD', noteId });
});
await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1));
await act(() => editors.saveNote(viewId, { noteId, body: 'Plugin body' }));
await waitForNoteToMatch(noteId, { body: 'Plugin body' });
continueReload.resolve();
await waitFor(() => expect(titleInput).toBeEnabled());
await waitForNoteToMatch(noteId, { body: 'Plugin body' });
} finally {
continueReload.resolve();
reloadSpy.mockRestore();
unmount();
await PluginService.instance().unloadPlugin(pluginId);
}
});
it('changing the note body in the editor should update the note\'s body', async () => {
const defaultBody = 'Change me!';
const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody });
const noteScreen = render(<WrappedNoteScreen />);
await openEditor();
const editor = await getMarkdownEditorControl();
await act(async () => {
editor.select(defaultBody.length, defaultBody.length);
editor.insertText(' Testing!!!');
expect(editor.editor.state.doc.toString()).toBe('Change me! Testing!!!');
});
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!!' });
await act(async () => {
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!');
noteScreen.unmount();
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test. Test!' });
});
});
it('should re-encrypt a pending save with the captured key when the session locks right after enabling the note lock', async () => {
await setupUnlockedNoteLock('123456');
const defaultBody = 'plain body';
const noteId = await openNewNote({ title: 'To lock', body: defaultBody });
const { unmount } = render(<WrappedNoteScreen />);
await openEditor();
const editor = await getMarkdownEditorControl();
await act(async () => {
editor.select(defaultBody.length, defaultBody.length);
// The insert queues a save that is still pending when the session locks below, so it
// can only complete through the key captured by the state change handler.
editor.insertText(' edited');
await enableNoteLock(noteId);
NoteLockSession.instance().lock();
});
await act(() => waitForWithRealTimers(async () => {
const row = await Note.load(noteId);
expect(row.is_locked).toBe(1);
expect(row.body.includes('edited')).toBe(false);
}));
await NoteLockSession.instance().unlock('123456');
expect((await Note.load(noteId, { useNoteLock: true })).body).toBe('plain body edited');
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('should become read-only as soon as a note inside a share is locked', async () => {
await setupUnlockedNoteLock('123456');
const noteId = await openNewNote({ title: 'Shared then locked', body: 'plain', share_id: 'share-1' });
const { unmount } = render(<WrappedNoteScreen />);
expect(await screen.findByDisplayValue('Shared then locked')).not.toBeDisabled();
await act(async () => {
await enableNoteLock(noteId);
});
expect(screen.getByDisplayValue('Shared then locked')).toBeDisabled();
await act(() => waitForWithRealTimers(async () => {
expect((await Note.load(noteId)).is_locked).toBe(1);
}));
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('should store the plaintext body again when the note lock is removed', async () => {
await setupUnlockedNoteLock('123456');
const noteId = await openNewNote({ title: 'To unlock', body: 'plain body' });
await Note.save({ ...await Note.load(noteId, { useNoteLock: true }), is_locked: 1 }, { useNoteLock: true });
const { unmount } = render(<WrappedNoteScreen />);
await screen.findByDisplayValue('To unlock');
await act(async () => {
await disableNoteLock(noteId);
});
await act(() => waitForWithRealTimers(async () => {
const row = await Note.load(noteId);
expect(row.is_locked).toBe(0);
expect(row.body).toBe('plain body');
}));
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('should disable tags for a note that cannot be unlocked while the session is unlocked', async () => {
await setupUndecryptableNote({ title: 'Old key note', body: 'secret' });
const { unmount } = render(<WrappedNoteScreen />);
expect(await screen.findByText('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')).toBeVisible();
await openNoteActionsMenu();
const tagsButton = await screen.findByText('Tags');
expect(tagsButton).toHaveProp('disabled', true);
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('should keep the locked panel when toggling the checkbox of an undecryptable to-do', async () => {
const noteId = await setupUndecryptableNote({ title: 'Old key todo', body: 'secret', is_todo: 1 });
const { unmount } = render(<WrappedNoteScreen />);
const undecryptableMessage = 'This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.';
expect(await screen.findByText(undecryptableMessage)).toBeVisible();
fireEvent.press(screen.getByRole('checkbox'));
await act(() => waitForWithRealTimers(async () => {
expect((await Note.load(noteId)).todo_completed).toBeGreaterThan(0);
}));
// The metadata save must not hide the panel and expose the encrypted body.
expect(screen.getByText(undecryptableMessage)).toBeVisible();
const row = await Note.load(noteId);
expect(row.is_locked).toBe(1);
expect(row.body.includes('secret')).toBe(false);
// The metadata save must leave the screen unmodified: otherwise going back gate-saves the
// still encrypted body and throws. Stubbed so a regression cannot leak the save mutex.
const defaultBackHandler = jest.fn(async () => true);
BackButtonService.initialize(defaultBackHandler);
const saveSpy = jest.spyOn(shared, 'saveNoteButton_press').mockResolvedValue(undefined);
try {
await act(async () => {
await BackButtonService.back();
});
expect(saveSpy).not.toHaveBeenCalled();
expect(defaultBackHandler).toHaveBeenCalled();
} finally {
saveSpy.mockRestore();
}
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('should offer relocking from a note that is not locked', async () => {
await setupUnlockedNoteLock('123456');
await openNewNote({ title: 'Plain note', body: 'not locked' });
store.dispatch({ type: 'SET_NOTE_LOCK_SESSION_UNLOCKED', value: true });
const { unmount } = render(<WrappedNoteScreen />);
await openNoteActionsMenu();
expect(await screen.findByText('Relock all notes')).toHaveProp('disabled', false);
unmount();
Setting.setValue('featureFlag.noteLock', false);
});
it('pressing "delete" should move the note to the trash', async () => {
const noteId = await openNewNote({ title: 'To be deleted', body: '...' });
const { unmount } = render(<WrappedNoteScreen />);
await openNoteActionsMenu();
const deleteButton = await screen.findByText('Delete');
fireEvent.press(deleteButton);
await waitFor(async () => {
expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0);
});
unmount();
});
it('pressing "delete permanently" should permanently delete a note', async () => {
const noteId = await openNewNote({ title: 'To be deleted', body: '...', deleted_time: Date.now() });
const { unmount } = render(<WrappedNoteScreen />);
// Permanently delete note shows a confirmation dialog -- mock it.
const deleteId = 0;
shim.showMessageBox = jest.fn(async () => deleteId);
await openNoteActionsMenu();
const deleteButton = await screen.findByText('Permanently delete note');
fireEvent.press(deleteButton);
await waitFor(async () => {
expect(await Note.load(noteId)).toBeUndefined();
});
expect(shim.showMessageBox).toHaveBeenCalled();
unmount();
});
it('should make a locked note inside a share read-only with the note lock feature turned off', async () => {
Setting.setValue('featureFlag.noteLock', false);
await openNewNote({ title: 'Locked in a share', body: 'plain', is_locked: 1, share_id: 'share-1' });
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Locked in a share');
expect(titleInput).toBeDisabled();
expect(await screen.findByText('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.')).toBeVisible();
expect(screen.getByHintText(/Selects a notebook/)).toBeVisible();
unmount();
});
it('should make a locked note editable as soon as it is moved out of a share', async () => {
const sharedFolder = await Folder.save({ title: 'Shared', share_id: 'share-1', parent_id: '' });
await Folder.save({ title: 'Outside the share', parent_id: '' });
await openNewNote({ title: 'Locked in a share', body: 'plain', is_locked: 1, share_id: 'share-1', parent_id: sharedFolder.id });
const { unmount } = render(<WrappedNoteScreen />);
expect(await screen.findByDisplayValue('Locked in a share')).toBeDisabled();
fireEvent.press(screen.getByHintText(/Selects a notebook/));
fireEvent.press(await screen.findByRole('menuitem', { name: 'Outside the share' }));
await waitFor(() => expect(screen.getByDisplayValue('Locked in a share')).not.toBeDisabled());
expect(screen.queryByText(/read-only because it is locked/)).toBeNull();
unmount();
});
it('should not tick the to-do checkbox of a locked note inside a share', async () => {
const noteId = await openNewNote({ title: 'Locked to-do', body: 'plain', is_todo: 1, is_locked: 1, share_id: 'share-1' });
const dialogs: Partial<DialogControl> = { error: jest.fn() };
const { unmount } = render(<DialogContext.Provider value={dialogs as DialogControl}><WrappedNoteScreen /></DialogContext.Provider>);
fireEvent.press(await screen.findByRole('checkbox'));
await waitFor(() => expect(dialogs.error).toHaveBeenCalledWith(expect.stringMatching(/read-only because it is locked/)));
expect(screen.getByRole('checkbox')).not.toBeChecked();
expect((await Note.load(noteId)).todo_completed).toBe(0);
unmount();
});
it('should hide the move picker for a locked note inside a read-only share', async () => {
const shareId = 'testShare';
await openNewNote({ title: 'Locked in a read-only share', body: 'plain', is_locked: 1, share_id: shareId });
const cleanup = simulateReadOnlyShareEnv(shareId, store);
const { unmount } = render(<WrappedNoteScreen />);
expect(await screen.findByDisplayValue('Locked in a read-only share')).toBeDisabled();
expect(screen.queryByHintText(/Selects a notebook/)).toBeNull();
act(() => cleanup());
unmount();
});
it('should show the unreadable share warning while the lock panel is showing', async () => {
await setupUndecryptableNote({ title: 'Locked in a share', body: 'secret', share_id: 'share-1' });
const { unmount } = render(<WrappedNoteScreen />);
expect(await screen.findByText('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')).toBeVisible();
expect(screen.getByText('This note may not be readable because it is contained within a share.')).toBeVisible();
expect(screen.queryByText(/read-only because it is locked/)).toBeNull();
unmount();
});
it('delete should be disabled in a read-only note', async () => {
const shareId = 'testShare';
const noteId = await openNewNote({
title: 'Title: Read-only note',
body: 'A **read-only** note.',
share_id: shareId,
});
const cleanup = simulateReadOnlyShareEnv(shareId, store);
expect(
itemIsReadOnlySync(
ModelType.Note,
ItemChange.SOURCE_UNSPECIFIED,
await Note.load(noteId) as ItemSlice,
'',
BaseItem.syncShareCache,
),
).toBe(true);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Title: Read-only note');
expect(titleInput).toBeVisible();
expect(titleInput).toBeDisabled();
await openNoteActionsMenu();
const deleteButton = await screen.findByText('Delete');
expect(deleteButton).toHaveProp('disabled', true);
act(() => cleanup());
unmount();
});
it.each([
'auto',
'manual',
])('should correctly auto-download or not auto-download resources in %j mode', async (downloadMode) => {
let note = await Note.save({ title: 'Note 1', parent_id: (await Folder.defaultFolder()).id });
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
await synchronizerStart();
await switchClient(1);
Setting.setValue('sync.resourceDownloadMode', downloadMode);
await synchronizerStart();
// Before opening the note, the resource should not be marked for download
const allResources = await Resource.all();
expect(allResources.length).toBe(1);
const resource = allResources[0];
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
await openExistingNote(note.id);
const { unmount } = render(<WrappedNoteScreen />);
// Note should render
const titleInput = await screen.findByDisplayValue('Note 1');
expect(titleInput).toBeVisible();
// Wrap in act() -- the component may update in the background during this.
await act(async () => {
await resourceFetcher().waitForAllFinished();
// After opening the note, the resource should be marked for download only in automatic mode
if (downloadMode === 'auto') {
await waitFor(async () => {
expect(await Resource.localState(resource.id)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
});
} else if (downloadMode === 'manual') {
// In manual mode, should not mark for download
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
} else {
throw new Error(`Should not be testing downloadMode: ${downloadMode}.`);
}
});
unmount();
});
it('the toggleVisiblePanes command should start and stop editing', async () => {
await openNewNote({ title: 'To be edited', body: '...' });
const { unmount } = render(<WrappedNoteScreen />);
await expectToBeEditing(false);
await runEditorCommand('toggleVisiblePanes');
await expectToBeEditing(true);
await runEditorCommand('toggleVisiblePanes');
await expectToBeEditing(false);
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should initialize in the correct mode when noteVisiblePanes is %j, when default edit state is set to last', async (panes) => {
Setting.setValue('editor.mobile.defaultEditState', 'last');
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(panes.includes('editor'));
unmount();
});
it('should show edit button when in view mode', async () => {
Setting.setValue('editor.mobile.defaultEditState', 'last');
const { unmount } = await setupNoteWithPanes(['viewer']);
const editButton = await screen.findByLabelText('Edit');
expect(editButton).toBeVisible();
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should switch modes when toggle button is pressed, when default edit state is set to last', async (panes) => {
Setting.setValue('editor.mobile.defaultEditState', 'last');
const initialEditing = panes.includes('editor');
const expectedEditing = !initialEditing;
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(initialEditing);
const toggleButton = await screen.findByLabelText(/Edit|Stop editing/);
fireEvent.press(toggleButton);
await expectToBeEditing(expectedEditing);
unmount();
});
it('should set title, body, and parent_id correctly when a note is created via share', async () => {
const folder = await Folder.save({ title: 'Share target folder', parent_id: '' });
const note = await Note.save({ parent_id: folder.id }, { provisional: true });
store.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: note.id,
sharedData: { title: 'Shared title', text: 'https://example.com' },
});
store.dispatch({ type: 'NOTE_UPDATE_ONE', note: { ...note }, provisional: true });
const { unmount } = render(<WrappedNoteScreen />);
await waitForNoteToMatch(note.id, {
title: 'Shared title',
body: 'https://example.com',
parent_id: folder.id,
});
unmount();
});
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: ['viewer'],
});
const noteId = await openNewNote({ title: 'Provisional note', body: 'Test body' });
// Mark note as provisional by dispatching NOTE_UPDATE_ONE with provisional flag
const note = await Note.load(noteId);
store.dispatch({
type: 'NOTE_UPDATE_ONE',
note: note,
provisional: true,
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Provisional note');
expect(titleInput).toBeVisible();
await expectToBeEditing(true);
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should preserve noteVisiblePanes state when leaving and returning to the same note, when default edit state is set to last', async (panes) => {
Setting.setValue('editor.mobile.defaultEditState', 'last');
const firstRender = await setupNoteWithPanes(panes);
await expectToBeEditing(panes.includes('editor'));
// Navigate away
await act(async () => {
store.dispatch({
type: 'NAV_GO',
routeName: 'Notes',
});
});
firstRender.unmount();
// Navigate back to the same note
const currentState = store.getState();
const noteId = currentState.selectedNoteIds[0];
await act(async () => {
await openExistingNote(noteId);
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Test note');
expect(titleInput).toBeVisible();
// Should still be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should preserve noteVisiblePanes state when navigating from note 1 to note 2, when default edit state is set to last', async (panes) => {
Setting.setValue('editor.mobile.defaultEditState', 'last');
// Open note 1
await act(async () => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes,
});
});
await act(async () => {
return await openNewNote({ title: 'Note 1', body: 'Test body 1' });
});
const render1 = render(<WrappedNoteScreen />);
const titleInput1 = await screen.findByDisplayValue('Note 1');
expect(titleInput1).toBeVisible();
await expectToBeEditing(panes.includes('editor'));
render1.unmount();
// Open note 2
const note2Id = await act(async () => {
return await openNewNote({ title: 'Note 2', body: 'Test body 2' });
});
await act(async () => {
await openExistingNote(note2Id);
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput2 = await screen.findByDisplayValue('Note 2');
expect(titleInput2).toBeVisible();
// Note 2 should be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
// Regression test: when a provisional note has no title, saving derives the
// title from the body. The provisional-note branch of saveNoteButton_press
// also kicks off a background geolocation update; that callback used to
// close over a stale `state.note` (captured before the title was derived)
// and wipe the derived title back to empty when it resolved.
it('should not clear the auto-derived title when the background geolocation update resolves', async () => {
Setting.setValue('trackLocation', true);
const originalGeolocation = shim.Geolocation;
let resolveGeoloc: (v: unknown)=> void = () => {};
shim.Geolocation = {
currentPosition: () => new Promise(resolve => { resolveGeoloc = resolve; }),
};
let unmount = () => {};
try {
const noteId = await openNewNote({ title: '', body: '' });
store.dispatch({
type: 'NOTE_UPDATE_ONE',
note: await Note.load(noteId),
provisional: true,
});
({ unmount } = render(<WrappedNoteScreen />));
const editor = await getMarkdownEditorControl();
await act(async () => {
editor.insertText('Derived from body');
});
await screen.findByDisplayValue('Derived from body');
await act(async () => {
resolveGeoloc({ timestamp: Date.now(), coords: { latitude: 1, longitude: 2, altitude: 3 } });
});
await waitForNoteToMatch(noteId, { title: 'Derived from body' });
await waitFor(async () => {
const loaded = await Note.load(noteId);
expect(Number(loaded.latitude)).toBe(1);
});
expect(screen.getByDisplayValue('Derived from body')).toBeVisible();
} finally {
unmount();
shim.Geolocation = originalGeolocation;
Setting.setValue('trackLocation', false);
}
});
it('should set the initial editor cursor location to the specified hash', async () => {
await openNewNote({ title: 'To be edited', body: 'a test\n\n# Test\n\n# Test 2\n\n# Test 3' });
store.dispatch({ type: 'NAV_GO', noteHash: 'test-2' });
const { unmount } = render(<WrappedNoteScreen />);
await openEditor();
const editor = await getMarkdownEditorControl();
expect(editor.getCursor().line).toBe(4);
unmount();
});
});