一些检查失败了
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
307 行
12 KiB
TypeScript
307 行
12 KiB
TypeScript
import BaseApplication, { shouldPreserveSelectedNoteOnSmartFilterSelect } from '@joplin/lib/BaseApplication';
|
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
|
import Note from '@joplin/lib/models/Note';
|
|
import reducer, { defaultState, defaultWindowId, State } from '@joplin/lib/reducer';
|
|
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
|
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
|
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
|
import { setEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
|
import { loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
|
import waitFor from '@joplin/lib/testing/waitFor';
|
|
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
|
import { appendFile, readFile } from 'fs/promises';
|
|
import { TextEncoder as NodeTextEncoder } from 'util';
|
|
import app from './app';
|
|
|
|
type NoteUpdateAction = {
|
|
changeSource: number;
|
|
changedFields: string[];
|
|
note: NoteEntity;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
const mockBaseMiddleware = () => {
|
|
return jest.spyOn(BaseApplication.prototype as unknown as {
|
|
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
|
|
}, 'generalMiddleware').mockImplementation(async (_store, next, action) => next(action));
|
|
};
|
|
|
|
const createDesktopActionHandler = (watchedNoteFiles: string[] = []) => {
|
|
const middleware = app().generalMiddlewareFn()({ getState: () => ({ watchedNoteFiles }) });
|
|
return middleware(jest.fn());
|
|
};
|
|
|
|
describe('app', () => {
|
|
const watcher = ExternalEditWatcher.instance();
|
|
const originalTextEncoder = globalThis.TextEncoder;
|
|
const originalNoteDispatch = Note.dispatch;
|
|
let baseMiddlewareMock: jest.SpyInstance;
|
|
|
|
test.each([
|
|
['regular', { deleted_time: 0, is_conflict: 0 }, true],
|
|
['trashed', { deleted_time: 1, is_conflict: 0 }, false],
|
|
['conflict', { deleted_time: 0, is_conflict: 1 }, false],
|
|
])('should determine whether to preserve a %s note when selecting all notes', (_noteType, noteProperties, expected) => {
|
|
watcher.initialize(jest.fn(), jest.fn());
|
|
const note = { id: 'note-id', ...noteProperties } as NoteEntity;
|
|
const state = { ...defaultState, notes: [note], selectedNoteIds: [note.id] };
|
|
|
|
expect(shouldPreserveSelectedNoteOnSmartFilterSelect(state, ALL_NOTES_FILTER_ID)).toBe(expected);
|
|
});
|
|
|
|
test('should not apply a note refresh after the active window changes', async () => {
|
|
const secondaryState = {
|
|
...defaultState,
|
|
windowId: 'secondary-window',
|
|
notesParentType: 'Folder',
|
|
selectedFolderId: 'secondary-folder',
|
|
selectedNoteIds: ['secondary-note'],
|
|
} as State;
|
|
let activeState = secondaryState;
|
|
const dispatch = jest.fn();
|
|
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
|
dispatch,
|
|
getState: () => activeState,
|
|
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
|
const previewsMock = jest.spyOn(Note, 'previews').mockImplementation(async () => {
|
|
activeState = { ...defaultState, windowId: defaultWindowId } as State;
|
|
return [{ id: 'secondary-note' }] as NoteEntity[];
|
|
});
|
|
|
|
try {
|
|
await app().refreshNotes(secondaryState, true);
|
|
expect(dispatch).not.toHaveBeenCalled();
|
|
} finally {
|
|
previewsMock.mockRestore();
|
|
storeMock.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('should not apply a note refresh after a newer refresh has started', async () => {
|
|
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
|
|
const dispatch = jest.fn();
|
|
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
|
dispatch,
|
|
getState: () => state,
|
|
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
|
let resolveSlowPreviews: (notes: NoteEntity[])=> void = null;
|
|
const previewsMock = jest.spyOn(Note, 'previews')
|
|
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowPreviews = resolve; }))
|
|
.mockResolvedValueOnce([{ id: 'fast-note' }]);
|
|
|
|
try {
|
|
const slowRefresh = app().refreshNotes(state);
|
|
const fastRefresh = (async () => {
|
|
await app().refreshNotes(state);
|
|
resolveSlowPreviews([{ id: 'slow-note' }]);
|
|
})();
|
|
await Promise.all([slowRefresh, fastRefresh]);
|
|
|
|
const noteUpdates = dispatch.mock.calls.map(call => call[0]).filter(action => action.type === 'NOTE_UPDATE_ALL');
|
|
expect(noteUpdates.map(action => action.notes)).toEqual([[{ id: 'fast-note' }]]);
|
|
} finally {
|
|
previewsMock.mockRestore();
|
|
storeMock.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('should not let a refresh for an inactive window cancel the active window refresh', async () => {
|
|
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
|
|
const dispatch = jest.fn();
|
|
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
|
dispatch,
|
|
getState: () => state,
|
|
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
|
const previewsMock = jest.spyOn(Note, 'previews').mockResolvedValue([]);
|
|
|
|
try {
|
|
await Promise.all([app().refreshNotes(state), app().refreshNotes({ ...state, windowId: 'inactive-window' })]);
|
|
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'NOTE_UPDATE_ALL' }));
|
|
} finally {
|
|
previewsMock.mockRestore();
|
|
storeMock.mockRestore();
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
['the active window', true, [[expect.objectContaining({ windowId: defaultWindowId }), false, '']]],
|
|
['a background window', false, []],
|
|
])('should only refresh notes when the active window is closed (closing %s)', async (_name, focusSecondaryWindow, expectedRefreshCalls) => {
|
|
let state = reducer(defaultState, { type: 'WINDOW_OPEN', windowId: 'secondary-window', folderId: 'folder', noteId: 'note' });
|
|
if (focusSecondaryWindow) state = reducer(state, { type: 'WINDOW_FOCUS', windowId: 'secondary-window' });
|
|
const store = { getState: () => state, dispatch: jest.fn() };
|
|
const next = (action: unknown) => { state = reducer(state, action); };
|
|
const refreshNotesMock = jest.spyOn(app(), 'refreshNotes').mockResolvedValue(undefined);
|
|
baseMiddlewareMock.mockRestore();
|
|
|
|
try {
|
|
await (BaseApplication.prototype as unknown as {
|
|
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
|
|
}).generalMiddleware.call(app(), store, next, { type: 'WINDOW_CLOSE', windowId: 'secondary-window' });
|
|
expect(refreshNotesMock.mock.calls).toEqual(expectedRefreshCalls);
|
|
} finally {
|
|
refreshNotesMock.mockRestore();
|
|
}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(0);
|
|
await switchClient(0);
|
|
baseMiddlewareMock = mockBaseMiddleware();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
Note.dispatch = originalNoteDispatch;
|
|
baseMiddlewareMock.mockRestore();
|
|
setEncryptionEnabled(false);
|
|
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: originalTextEncoder });
|
|
await watcher.stopWatchingAll();
|
|
});
|
|
|
|
const startExternalEditing = async () => {
|
|
const openItem = jest.fn();
|
|
watcher.initialize(jest.fn(() => ({
|
|
openItem,
|
|
})), jest.fn());
|
|
|
|
const originalNote = await Note.save({
|
|
title: 'Test note',
|
|
body: 'Original body',
|
|
});
|
|
await watcher.openAndWatch(originalNote);
|
|
|
|
return {
|
|
originalNote,
|
|
externalFilePath: openItem.mock.calls[0][0] as string,
|
|
};
|
|
};
|
|
|
|
test('should update the external edit file after a synced note changes', async () => {
|
|
const { originalNote, externalFilePath } = await startExternalEditing();
|
|
const syncedNote = await Note.save({
|
|
...originalNote,
|
|
body: 'Changed on another device',
|
|
}, { changeSource: ItemChange.SOURCE_SYNC });
|
|
const syncedUpdateAction = {
|
|
type: 'NOTE_UPDATE_ONE',
|
|
changeSource: ItemChange.SOURCE_SYNC,
|
|
changedFields: ['body'],
|
|
note: syncedNote,
|
|
};
|
|
|
|
const handleDesktopAction = createDesktopActionHandler();
|
|
await handleDesktopAction(syncedUpdateAction);
|
|
|
|
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(syncedNote));
|
|
});
|
|
|
|
test('should not update the external edit file when the note is not watched', async () => {
|
|
const note = await Note.save({
|
|
title: 'Test note',
|
|
body: 'Changed on another device',
|
|
}, { changeSource: ItemChange.SOURCE_SYNC });
|
|
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
|
|
|
|
const handleDesktopAction = createDesktopActionHandler();
|
|
await handleDesktopAction({
|
|
type: 'NOTE_UPDATE_ONE',
|
|
changeSource: ItemChange.SOURCE_SYNC,
|
|
changedFields: ['body'],
|
|
note,
|
|
});
|
|
|
|
expect(watcher.noteIsWatched(note)).toBe(false);
|
|
expect(updateNoteFile).not.toHaveBeenCalled();
|
|
updateNoteFile.mockRestore();
|
|
});
|
|
|
|
test('should not update the external edit file when the synced note is locked', async () => {
|
|
const { originalNote } = await startExternalEditing();
|
|
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
|
|
|
|
const handleDesktopAction = createDesktopActionHandler();
|
|
await handleDesktopAction({
|
|
type: 'NOTE_UPDATE_ONE',
|
|
changeSource: ItemChange.SOURCE_SYNC,
|
|
changedFields: ['body', 'is_locked'],
|
|
note: { ...originalNote, body: 'Locked note ciphertext', is_locked: 1 },
|
|
});
|
|
|
|
expect(updateNoteFile).not.toHaveBeenCalled();
|
|
updateNoteFile.mockRestore();
|
|
});
|
|
|
|
test('should drop pending external edits when a synced note arrives locked', async () => {
|
|
const { originalNote, externalFilePath } = await startExternalEditing();
|
|
const lockedNote = await Note.save({
|
|
...originalNote,
|
|
body: 'Locked note ciphertext',
|
|
is_locked: 1,
|
|
}, { changeSource: ItemChange.SOURCE_SYNC });
|
|
|
|
await appendFile(externalFilePath, ' (plaintext edit)');
|
|
const changeEventQueue = (watcher as unknown as { changeEventQueue_: AsyncActionQueue<unknown> }).changeEventQueue_;
|
|
await waitFor(() => {
|
|
expect(changeEventQueue.isEmpty).toBe(false);
|
|
});
|
|
|
|
const handleDesktopAction = createDesktopActionHandler([originalNote.id]);
|
|
await handleDesktopAction({
|
|
type: 'NOTE_UPDATE_ONE',
|
|
changeSource: ItemChange.SOURCE_SYNC,
|
|
changedFields: ['body', 'is_locked'],
|
|
note: lockedNote,
|
|
});
|
|
await changeEventQueue.waitForAllDone();
|
|
|
|
expect(watcher.noteIsWatched(originalNote)).toBe(false);
|
|
expect((await Note.load(originalNote.id)).body).toBe('Locked note ciphertext');
|
|
});
|
|
|
|
test('should wait for a synced encrypted note to be decrypted before updating the external edit file', async () => {
|
|
const { originalNote, externalFilePath } = await startExternalEditing();
|
|
const noteActions = jest.fn();
|
|
Note.dispatch = noteActions;
|
|
|
|
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: NodeTextEncoder });
|
|
setEncryptionEnabled(true);
|
|
await loadEncryptionMasterKey();
|
|
|
|
const encryptedSyncPayload = await Note.serializeForSync({
|
|
...originalNote,
|
|
body: 'Changed on another device',
|
|
} as NoteEntity);
|
|
const encryptedNote = Note.filter(await Note.unserialize(encryptedSyncPayload));
|
|
const savedEncryptedNote = await Note.save(encryptedNote, {
|
|
autoTimestamp: false,
|
|
changeSource: ItemChange.SOURCE_SYNC,
|
|
oldItem: originalNote as unknown as Record<string, unknown>,
|
|
});
|
|
const encryptedSyncAction = noteActions.mock.calls[0][0] as NoteUpdateAction;
|
|
|
|
expect(encryptedSyncAction.note.encryption_applied).toBe(1);
|
|
|
|
const handleDesktopAction = createDesktopActionHandler();
|
|
await handleDesktopAction(encryptedSyncAction);
|
|
|
|
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(originalNote));
|
|
|
|
noteActions.mockClear();
|
|
const decryptedNote = await Note.decrypt(savedEncryptedNote);
|
|
const decryptedUpdateActions = noteActions.mock.calls
|
|
.map(call => call[0] as NoteUpdateAction)
|
|
.filter(action => action.type === 'NOTE_UPDATE_ONE');
|
|
expect(decryptedUpdateActions).toHaveLength(1);
|
|
const decryptedAction = decryptedUpdateActions[0];
|
|
|
|
expect(decryptedAction.note).toEqual(decryptedNote);
|
|
expect(decryptedAction.note.body).toBe('Changed on another device');
|
|
expect(decryptedAction.note.encryption_applied).toBe(0);
|
|
expect(decryptedAction.changedFields).toEqual(expect.arrayContaining(['body', 'encryption_applied']));
|
|
|
|
await handleDesktopAction(decryptedAction);
|
|
|
|
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(decryptedNote));
|
|
});
|
|
});
|