一些检查失败了
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
414 行
14 KiB
TypeScript
414 行
14 KiB
TypeScript
import { expectNotThrow, naughtyStrings, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
|
|
import Note from '../models/Note';
|
|
import Revision, { ObjectPatch } from '../models/Revision';
|
|
import { NoteEntity, RevisionEntity } from '../services/database/types';
|
|
import BaseModel from '../BaseModel';
|
|
|
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
const halfDayMs = 12 * 60 * 60 * 1000;
|
|
// cSpell:disable
|
|
const testBody = `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text
|
|
ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five
|
|
centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset
|
|
sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.`;
|
|
// cSpell:enable
|
|
|
|
const createChangeAndRevision = async (note: NoteEntity, parentRevId: string = null) => {
|
|
const parentRev = parentRevId ? await Revision.load(parentRevId) : await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
|
|
note.title = `${note.title}a`;
|
|
note.body = `${note.body}a`;
|
|
jest.advanceTimersByTime(oneDayMs);
|
|
note.updated_time = Date.now();
|
|
|
|
const output: RevisionEntity = {
|
|
item_type: BaseModel.TYPE_NOTE,
|
|
item_id: note.id,
|
|
item_updated_time: note.updated_time,
|
|
encryption_applied: 0,
|
|
};
|
|
|
|
if (!parentRev) {
|
|
output.title_diff = Revision.createTextPatch('', note.title);
|
|
output.body_diff = Revision.createTextPatch('', note.body);
|
|
output.metadata_diff = Revision.createObjectPatch({}, {});
|
|
} else {
|
|
const merged = await Revision.mergeDiffs(parentRev);
|
|
output.parent_id = parentRev.id;
|
|
output.title_diff = Revision.createTextPatch(merged.title, note.title);
|
|
output.body_diff = Revision.createTextPatch(merged.body, note.body);
|
|
output.metadata_diff = Revision.createObjectPatch({}, {});
|
|
}
|
|
|
|
return Revision.save(output);
|
|
};
|
|
|
|
describe('models/Revision', () => {
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await switchClient(1);
|
|
jest.useFakeTimers({ advanceTimers: true });
|
|
});
|
|
|
|
it('should add lock defaults for old sync revisions', () => {
|
|
expect(Revision.filter({
|
|
is_locked: undefined,
|
|
})).toMatchObject({
|
|
is_locked: 0,
|
|
});
|
|
expect(Revision.filter({
|
|
is_locked: null,
|
|
})).toMatchObject({
|
|
is_locked: 0,
|
|
});
|
|
});
|
|
|
|
it('should create patches of text and apply it', (async () => {
|
|
const note1 = await Note.save({ body: 'my note\nsecond line' });
|
|
|
|
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
|
|
const merged = Revision.applyTextPatch(note1.body, patch);
|
|
|
|
expect(merged).toBe('my new note\nsecond line');
|
|
}));
|
|
|
|
it('should check if it is an empty revision', async () => {
|
|
const testCases = [
|
|
[false, {
|
|
title_diff: '',
|
|
body_diff: '',
|
|
metadata_diff: '{"new":{"id":"aaa"},"deleted":[]}',
|
|
}],
|
|
[true, {
|
|
title_diff: '',
|
|
body_diff: '',
|
|
metadata_diff: '',
|
|
}],
|
|
[true, {
|
|
title_diff: '[]',
|
|
body_diff: '',
|
|
metadata_diff: '{"new":{},"deleted":[]}',
|
|
}],
|
|
[true, {
|
|
title_diff: '',
|
|
body_diff: '[]',
|
|
metadata_diff: '{"new":{},"deleted":[]}',
|
|
}],
|
|
[false, {
|
|
title_diff: '[{"diffs":[[1,"hello"]],"start1":0,"start2":0,"length1":0,"length2":5}]',
|
|
body_diff: '[]',
|
|
metadata_diff: '{"new":{},"deleted":[]}',
|
|
}],
|
|
];
|
|
|
|
for (const t of testCases) {
|
|
const [expected, input] = t;
|
|
expect(Revision.isEmptyRevision(input as RevisionEntity)).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it('should not fail to create revisions on naughty strings', (async () => {
|
|
// Previously this pattern would fail:
|
|
// - Create a patch between an empty string and smileys
|
|
// - Use that patch on the empty string to get back the smileys
|
|
// - Create a patch between those smileys and new smileys
|
|
// https://github.com/JackuB/diff-match-patch/issues/22
|
|
|
|
const nss = await naughtyStrings();
|
|
|
|
// First confirm that it indeed fails with the legacy approach.
|
|
let errorCount = 0;
|
|
|
|
for (let i = 0; i < nss.length - 1; i++) {
|
|
const ns1 = nss[i];
|
|
const ns2 = nss[i + 1];
|
|
try {
|
|
const patchText = Revision.createTextPatchLegacy('', ns1);
|
|
const patchedText = Revision.applyTextPatchLegacy('', patchText);
|
|
Revision.createTextPatchLegacy(patchedText, ns2);
|
|
} catch (error) {
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
expect(errorCount).toBe(10);
|
|
|
|
// Now feed the naughty list again but using the new approach. In that
|
|
// case it should work fine.
|
|
await expectNotThrow(async () => {
|
|
for (let i = 0; i < nss.length - 1; i++) {
|
|
const ns1 = nss[i];
|
|
const ns2 = nss[i + 1];
|
|
const patchText = Revision.createTextPatch('', ns1);
|
|
const patchedText = Revision.applyTextPatch('', patchText);
|
|
Revision.createTextPatch(patchedText, ns2);
|
|
}
|
|
});
|
|
}));
|
|
|
|
it('should successfully handle legacy patches', async () => {
|
|
// The code should handle applying a series of new style patches and
|
|
// legacy patches, and the correct text should be recovered at the end.
|
|
const changes = [
|
|
'',
|
|
'one',
|
|
'one three',
|
|
'one two three',
|
|
];
|
|
|
|
const patches = [
|
|
Revision.createTextPatch(changes[0], changes[1]),
|
|
Revision.createTextPatchLegacy(changes[1], changes[2]),
|
|
Revision.createTextPatch(changes[2], changes[3]),
|
|
];
|
|
|
|
// Sanity check - verify that the patches are as expected
|
|
expect(patches[0].substr(0, 2)).toBe('[{'); // New
|
|
expect(patches[1].substr(0, 2)).toBe('@@'); // Legacy
|
|
expect(patches[2].substr(0, 2)).toBe('[{'); // New
|
|
|
|
let finalString = Revision.applyTextPatch(changes[0], patches[0]);
|
|
finalString = Revision.applyTextPatch(finalString, patches[1]);
|
|
finalString = Revision.applyTextPatch(finalString, patches[2]);
|
|
|
|
expect(finalString).toBe('one two three');
|
|
});
|
|
|
|
it('should create patches of objects and apply it', (async () => {
|
|
const oldObject = {
|
|
one: '123',
|
|
two: '456',
|
|
three: '789',
|
|
};
|
|
|
|
const newObject = {
|
|
one: '123',
|
|
three: '999',
|
|
};
|
|
|
|
const patch = Revision.createObjectPatch(oldObject, newObject);
|
|
const merged = Revision.applyObjectPatch(oldObject, patch);
|
|
|
|
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
|
|
}));
|
|
|
|
it('should handle invalid object patch', (async () => {
|
|
const oldObject = {
|
|
one: '123',
|
|
two: '456',
|
|
three: '789',
|
|
};
|
|
|
|
const brokenPatch = `{"new":{"four":"444
|
|
"},"deleted":["one"]}`;
|
|
|
|
const expected = {
|
|
two: '456',
|
|
three: '789',
|
|
four: '444',
|
|
};
|
|
|
|
const merged = Revision.applyObjectPatch(oldObject, brokenPatch);
|
|
|
|
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
|
|
}));
|
|
|
|
it('should not strip off newlines from object values', (async () => {
|
|
const oldObject = {
|
|
one: '123',
|
|
two: '456',
|
|
three: '789',
|
|
};
|
|
|
|
const patch: ObjectPatch = {
|
|
'new': {
|
|
'four': 'one line\ntwo line',
|
|
},
|
|
'deleted': [],
|
|
};
|
|
|
|
const expected = {
|
|
one: '123',
|
|
two: '456',
|
|
three: '789',
|
|
four: 'one line\ntwo line',
|
|
};
|
|
|
|
const merged = Revision.applyObjectPatch(oldObject, JSON.stringify(patch));
|
|
|
|
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
|
|
}));
|
|
|
|
it('should move target revision to the top', (async () => {
|
|
const revs = [
|
|
{ id: '123' },
|
|
{ id: '456' },
|
|
{ id: '789' },
|
|
];
|
|
|
|
let newRevs;
|
|
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
|
|
expect(newRevs[0].id).toBe('123');
|
|
expect(newRevs[1].id).toBe('789');
|
|
expect(newRevs[2].id).toBe('456');
|
|
|
|
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
|
|
expect(newRevs[0].id).toBe('123');
|
|
expect(newRevs[1].id).toBe('456');
|
|
expect(newRevs[2].id).toBe('789');
|
|
}));
|
|
|
|
// cSpell:disable
|
|
it('should create patch stats', (async () => {
|
|
const tests = [
|
|
{
|
|
patch: `@@ -625,16 +625,48 @@
|
|
rrupted download
|
|
+%0A- %5B %5D Fix mobile screen options`,
|
|
expected: [-0, +32],
|
|
},
|
|
{
|
|
patch: `@@ -564,17 +564,17 @@
|
|
ages%0A- %5B
|
|
-
|
|
+x
|
|
%5D Check `,
|
|
expected: [-1, +1],
|
|
},
|
|
{
|
|
patch: `@@ -1022,56 +1022,415 @@
|
|
.%0A%0A#
|
|
- How to view a note history%0A%0AWhile all the apps
|
|
+%C2%A0How does it work?%0A%0AAll the apps save a version of the modified notes every 10 minutes.
|
|
%0A%0A# `,
|
|
expected: [-(19 + 27 + 2), 17 + 67 + 4],
|
|
},
|
|
{
|
|
patch: '',
|
|
expected: [-0, +0],
|
|
},
|
|
];
|
|
|
|
for (const test of tests) {
|
|
const stats = Revision.patchStats(test.patch);
|
|
expect(stats.removed).toBe(-test.expected[0]);
|
|
expect(stats.added).toBe(test.expected[1]);
|
|
}
|
|
}));
|
|
// cSpell:enable
|
|
|
|
test('deleteOldRevisions should merge 2 revisions when the revision chain has 2 branches', (async () => {
|
|
const revs = [];
|
|
const note = await Note.save({ title: 'test', body: testBody });
|
|
|
|
revs.push(await createChangeAndRevision(note)); // Rev 1
|
|
revs.push(await createChangeAndRevision(note)); // Rev 2a
|
|
revs.push(await createChangeAndRevision(note)); // Rev 3
|
|
revs.push(await createChangeAndRevision(note, revs[0].id)); // Rev 2b
|
|
revs.push(await createChangeAndRevision(note, revs[2].id)); // Rev 4
|
|
revs.push(await createChangeAndRevision(note)); // Rev 5
|
|
|
|
const timeBeforeCleaning = Date.now();
|
|
const ttl = (2 * oneDayMs) + halfDayMs; // Delete revs 1-3
|
|
await Revision.deleteOldRevisions(ttl);
|
|
|
|
expect(revs[3].body_diff.length).toBeLessThan(testBody.length);
|
|
expect(revs[4].body_diff.length).toBeLessThan(testBody.length);
|
|
expect(revs[5].body_diff.length).toBeLessThan(testBody.length);
|
|
|
|
const newRevPos3 = await Revision.load(revs[3].id);
|
|
const newRevPos4 = await Revision.load(revs[4].id);
|
|
const newRevPos5 = await Revision.load(revs[5].id);
|
|
|
|
// Ensure both revisions expected to be merged contain the full note contents
|
|
expect(newRevPos3.body_diff.length).toBeGreaterThan(testBody.length);
|
|
expect(newRevPos4.body_diff.length).toBeGreaterThan(testBody.length);
|
|
expect(newRevPos3.title_diff).not.toBe(revs[3].title_diff);
|
|
expect(newRevPos4.title_diff).not.toBe(revs[4].title_diff);
|
|
expect(revs[5].body_diff).toBe(newRevPos5.body_diff);
|
|
expect(revs[5].title_diff).toBe(newRevPos5.title_diff);
|
|
|
|
// Ensure the merged revisions will be synced
|
|
expect(newRevPos3.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
|
|
expect(newRevPos4.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
|
|
|
|
const remainingRevs = await Revision.allByType(BaseModel.TYPE_NOTE, note.id);
|
|
expect(remainingRevs.length).toBe(3);
|
|
}));
|
|
|
|
test('deleteOldRevisions should merge 3 revisions when the revision chain has 3 branches on 1 revision', (async () => {
|
|
const revs = [];
|
|
const note = await Note.save({ title: 'test', body: testBody });
|
|
|
|
revs.push(await createChangeAndRevision(note)); // Rev 1
|
|
revs.push(await createChangeAndRevision(note)); // Rev 2
|
|
revs.push(await createChangeAndRevision(note)); // Rev 2a
|
|
revs.push(await createChangeAndRevision(note, revs[1].id)); // Rev 2b
|
|
revs.push(await createChangeAndRevision(note, revs[1].id)); // Rev 2c
|
|
|
|
const timeBeforeCleaning = Date.now();
|
|
const ttl = (2 * oneDayMs) + halfDayMs; // Delete revs 1-2
|
|
await Revision.deleteOldRevisions(ttl);
|
|
|
|
|
|
expect(revs[2].body_diff.length).toBeLessThan(testBody.length);
|
|
expect(revs[3].body_diff.length).toBeLessThan(testBody.length);
|
|
expect(revs[4].body_diff.length).toBeLessThan(testBody.length);
|
|
|
|
const newRevPos2 = await Revision.load(revs[2].id);
|
|
const newRevPos3 = await Revision.load(revs[3].id);
|
|
const newRevPos4 = await Revision.load(revs[4].id);
|
|
|
|
// Ensure the 3 revisions expected to be merged contain the full note contents
|
|
expect(newRevPos2.body_diff.length).toBeGreaterThan(testBody.length);
|
|
expect(newRevPos3.body_diff.length).toBeGreaterThan(testBody.length);
|
|
expect(newRevPos4.body_diff.length).toBeGreaterThan(testBody.length);
|
|
expect(newRevPos2.title_diff).not.toBe(revs[2].title_diff);
|
|
expect(newRevPos3.title_diff).not.toBe(revs[3].title_diff);
|
|
expect(newRevPos4.title_diff).not.toBe(revs[4].title_diff);
|
|
|
|
// Ensure the merged revisions will be synced
|
|
expect(newRevPos2.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
|
|
expect(newRevPos3.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
|
|
expect(newRevPos4.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
|
|
|
|
const remainingRevs = await Revision.allByType(BaseModel.TYPE_NOTE, note.id);
|
|
expect(remainingRevs.length).toBe(3);
|
|
}));
|
|
|
|
// cSpell:disable
|
|
test('mergeDiffs should stop traversal when parent_id is not set', (async () => {
|
|
const note = await Note.save({ title: 'test', body: testBody });
|
|
|
|
// Prove a broken revision state causes duplication in the evaluated revision, if a middle revision is rebased, but the parent_id
|
|
// still matches an existing revision
|
|
const rev1 = await createChangeAndRevision(note); // testa
|
|
const rev2 = await createChangeAndRevision(note); // testaa
|
|
const rev3 = await createChangeAndRevision(note); // testaaa
|
|
|
|
const mergedRev2 = await Revision.mergeDiffs(rev2, [rev1, rev2]);
|
|
expect(mergedRev2.title).toBe('testaa');
|
|
|
|
// Do not save - update in memory only for the assertion
|
|
rev2.title_diff = Revision.createTextPatch('', mergedRev2.title);
|
|
rev2.body_diff = Revision.createTextPatch('', mergedRev2.body);
|
|
|
|
const mergedRev3 = await Revision.mergeDiffs(rev3, [rev1, rev2, rev3]);
|
|
expect(mergedRev3.title).toBe('testaatestaa');
|
|
|
|
// Merge the middle revision using the real revision cleaning logic, but verify the end revision is not broken when evaluated, if the
|
|
// older revision is still available when merging the revision chain
|
|
const ttl = (1 * oneDayMs) + halfDayMs; // Delete rev 1
|
|
await Revision.deleteOldRevisions(ttl);
|
|
const newRev2 = await Revision.load(rev2.id);
|
|
const newMergedRev3 = await Revision.mergeDiffs(rev3, [rev1, newRev2, rev3]);
|
|
|
|
expect(newMergedRev3.title).toBe('testaaa');
|
|
expect(newRev2.title_diff).toBe(rev2.title_diff);
|
|
expect(newRev2.body_diff).toBe(rev2.body_diff);
|
|
expect(newRev2.parent_id).toBe('');
|
|
}));
|
|
// cSpell:enable
|
|
|
|
});
|