summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/tests/sync
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/tests/sync')
-rw-r--r--toolkit/components/places/tests/sync/head_sync.js461
-rw-r--r--toolkit/components/places/tests/sync/mirror_corrupt.sqlite1
-rw-r--r--toolkit/components/places/tests/sync/mirror_v1.sqlitebin0 -> 294912 bytes
-rw-r--r--toolkit/components/places/tests/sync/mirror_v5.sqlitebin0 -> 262144 bytes
-rw-r--r--toolkit/components/places/tests/sync/mirror_v8.sqlitebin0 -> 393216 bytes
-rw-r--r--toolkit/components/places/tests/sync/sync_utils_bookmarks.html18
-rw-r--r--toolkit/components/places/tests/sync/sync_utils_bookmarks.json94
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_abort_merging.js220
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_chunking.js165
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_corruption.js3290
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_deduping.js1290
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_deletion.js1602
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_haschanges.js228
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_kinds.js312
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js193
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js246
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js670
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_reconcile.js191
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_structure_changes.js2966
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js206
-rw-r--r--toolkit/components/places/tests/sync/test_bookmark_value_changes.js2639
-rw-r--r--toolkit/components/places/tests/sync/test_sync_utils.js3130
-rw-r--r--toolkit/components/places/tests/sync/xpcshell.toml40
23 files changed, 17962 insertions, 0 deletions
diff --git a/toolkit/components/places/tests/sync/head_sync.js b/toolkit/components/places/tests/sync/head_sync.js
new file mode 100644
index 0000000000..7dd69e275b
--- /dev/null
+++ b/toolkit/components/places/tests/sync/head_sync.js
@@ -0,0 +1,461 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+var { CanonicalJSON } = ChromeUtils.importESModule(
+ "resource://gre/modules/CanonicalJSON.sys.mjs"
+);
+var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
+
+var { PlacesSyncUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs"
+);
+var { SyncedBookmarksMirror } = ChromeUtils.importESModule(
+ "resource://gre/modules/SyncedBookmarksMirror.sys.mjs"
+);
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var {
+ HTTP_400,
+ HTTP_401,
+ HTTP_402,
+ HTTP_403,
+ HTTP_404,
+ HTTP_405,
+ HTTP_406,
+ HTTP_407,
+ HTTP_408,
+ HTTP_409,
+ HTTP_410,
+ HTTP_411,
+ HTTP_412,
+ HTTP_413,
+ HTTP_414,
+ HTTP_415,
+ HTTP_417,
+ HTTP_500,
+ HTTP_501,
+ HTTP_502,
+ HTTP_503,
+ HTTP_504,
+ HTTP_505,
+ HttpError,
+ HttpServer,
+} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");
+
+// These titles are defined in Database::CreateBookmarkRoots
+const BookmarksMenuTitle = "menu";
+const BookmarksToolbarTitle = "toolbar";
+const UnfiledBookmarksTitle = "unfiled";
+const MobileBookmarksTitle = "mobile";
+
+function run_test() {
+ let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror");
+ bufLog.level = Log.Level.All;
+
+ let sqliteLog = Log.repository.getLogger("Sqlite");
+ sqliteLog.level = Log.Level.Error;
+
+ let formatter = new Log.BasicFormatter();
+ let appender = new Log.DumpAppender(formatter);
+ appender.level = Log.Level.All;
+
+ for (let log of [bufLog, sqliteLog]) {
+ log.addAppender(appender);
+ }
+
+ do_get_profile();
+ run_next_test();
+}
+
+// A test helper to insert local roots directly into Places, since the public
+// bookmarks APIs no longer support custom roots.
+async function insertLocalRoot({ guid, title }) {
+ await PlacesUtils.withConnectionWrapper(
+ "insertLocalRoot",
+ async function (db) {
+ let dateAdded = PlacesUtils.toPRTime(new Date());
+ await db.execute(
+ `
+ INSERT INTO moz_bookmarks(guid, type, parent, position, title,
+ dateAdded, lastModified)
+ VALUES(:guid, :type, (SELECT id FROM moz_bookmarks
+ WHERE guid = :parentGuid),
+ (SELECT COUNT(*) FROM moz_bookmarks
+ WHERE parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = :parentGuid)),
+ :title, :dateAdded, :dateAdded)`,
+ {
+ guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.rootGuid,
+ title,
+ dateAdded,
+ }
+ );
+ }
+ );
+}
+
+// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext.
+// This exists to avoid importing `record.js` from Sync.
+function makeRecord(cleartext) {
+ return new Proxy(
+ { cleartext },
+ {
+ get(target, property, receiver) {
+ if (property == "cleartext") {
+ return target.cleartext;
+ }
+ if (property == "cleartextToString") {
+ return () => JSON.stringify(target.cleartext);
+ }
+ return target.cleartext[property];
+ },
+ set(target, property, value, receiver) {
+ if (property == "cleartext") {
+ target.cleartext = value;
+ } else if (property != "cleartextToString") {
+ target.cleartext[property] = value;
+ }
+ },
+ has(target, property) {
+ return property == "cleartext" || property in target.cleartext;
+ },
+ deleteProperty(target, property) {},
+ ownKeys(target) {
+ return ["cleartext", ...Reflect.ownKeys(target)];
+ },
+ }
+ );
+}
+
+async function storeRecords(buf, records, options) {
+ await buf.store(records.map(makeRecord), options);
+}
+
+async function storeChangesInMirror(buf, changesToUpload) {
+ let cleartexts = [];
+ for (let recordId in changesToUpload) {
+ changesToUpload[recordId].synced = true;
+ cleartexts.push(changesToUpload[recordId].cleartext);
+ }
+ await storeRecords(buf, cleartexts, { needsMerge: false });
+ await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload);
+}
+
+function inspectChangeRecords(changeRecords) {
+ let results = { updated: [], deleted: [] };
+ for (let [id, record] of Object.entries(changeRecords)) {
+ (record.tombstone ? results.deleted : results.updated).push(id);
+ }
+ results.updated.sort();
+ results.deleted.sort();
+ return results;
+}
+
+async function promiseManyDatesAdded(guids) {
+ let datesAdded = new Map();
+ let db = await PlacesUtils.promiseDBConnection();
+ for (let chunk of PlacesUtils.chunkArray(guids, 100)) {
+ let rows = await db.executeCached(
+ `
+ SELECT guid, dateAdded FROM moz_bookmarks
+ WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`,
+ chunk
+ );
+ if (rows.length != chunk.length) {
+ throw new TypeError("Can't fetch date added for nonexistent items");
+ }
+ for (let row of rows) {
+ let dateAdded = row.getResultByName("dateAdded") / 1000;
+ datesAdded.set(row.getResultByName("guid"), dateAdded);
+ }
+ }
+ return datesAdded;
+}
+
+async function fetchLocalTree(rootGuid) {
+ function bookmarkNodeToInfo(node) {
+ let { guid, index, title, typeCode: type } = node;
+ let itemInfo = { guid, index, title, type };
+ if (node.annos) {
+ let syncableAnnos = node.annos.filter(anno =>
+ [PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes(
+ anno.name
+ )
+ );
+ if (syncableAnnos.length) {
+ itemInfo.annos = syncableAnnos;
+ }
+ }
+ if (node.uri) {
+ itemInfo.url = node.uri;
+ }
+ if (node.keyword) {
+ itemInfo.keyword = node.keyword;
+ }
+ if (node.children) {
+ itemInfo.children = node.children.map(bookmarkNodeToInfo);
+ }
+ if (node.tags) {
+ itemInfo.tags = node.tags.split(",").sort();
+ }
+ return itemInfo;
+ }
+ let root = await PlacesUtils.promiseBookmarksTree(rootGuid);
+ return bookmarkNodeToInfo(root);
+}
+
+async function assertLocalTree(rootGuid, expected, message) {
+ let actual = await fetchLocalTree(rootGuid);
+ if (!ObjectUtils.deepEqual(actual, expected)) {
+ info(
+ `Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}`
+ );
+ info(
+ `Actual structure for ${rootGuid}: ${CanonicalJSON.stringify(actual)}`
+ );
+ throw new Assert.constructor.AssertionError({ actual, expected, message });
+ }
+}
+
+function makeLivemarkServer() {
+ let server = new HttpServer();
+ server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
+ server.start(-1);
+ return {
+ server,
+ get site() {
+ let { identity } = server;
+ let host = identity.primaryHost.includes(":")
+ ? `[${identity.primaryHost}]`
+ : identity.primaryHost;
+ return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
+ },
+ stopServer() {
+ return new Promise(resolve => server.stop(resolve));
+ },
+ };
+}
+
+function shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+}
+
+async function fetchAllKeywords(info) {
+ let entries = [];
+ await PlacesUtils.keywords.fetch(info, entry => entries.push(entry));
+ return entries;
+}
+
+async function openMirror(name, options = {}) {
+ let buf = await SyncedBookmarksMirror.open({
+ path: `${name}_buf.sqlite`,
+ recordStepTelemetry(...args) {
+ if (options.recordStepTelemetry) {
+ options.recordStepTelemetry.call(this, ...args);
+ }
+ },
+ recordValidationTelemetry(...args) {
+ if (options.recordValidationTelemetry) {
+ options.recordValidationTelemetry.call(this, ...args);
+ }
+ },
+ });
+ return buf;
+}
+
+function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) {
+ this.notifications = [];
+ this.ignoreDates = ignoreDates;
+ this.skipTags = skipTags;
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+}
+
+BookmarkObserver.prototype = {
+ handlePlacesEvents(events) {
+ for (let event of events) {
+ switch (event.type) {
+ case "bookmark-added": {
+ if (this.skipTags && event.isTagging) {
+ continue;
+ }
+ let params = {
+ itemId: event.id,
+ parentId: event.parentId,
+ index: event.index,
+ type: event.itemType,
+ urlHref: event.url,
+ title: event.title,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ tags: event.tags,
+ frecency: event.frecency,
+ hidden: event.hidden,
+ visitCount: event.visitCount,
+ };
+ if (!this.ignoreDates) {
+ params.dateAdded = event.dateAdded;
+ params.lastVisitDate = event.lastVisitDate;
+ }
+ this.notifications.push({ name: "bookmark-added", params });
+ break;
+ }
+ case "bookmark-removed": {
+ if (this.skipTags && event.isTagging) {
+ continue;
+ }
+ // Since we are now skipping tags on the listener side we don't
+ // prevent unTagging notifications from going out. These events cause empty
+ // tags folders to be removed which creates another bookmark-removed notification
+ if (
+ this.skipTags &&
+ event.parentGuid == PlacesUtils.bookmarks.tagsGuid
+ ) {
+ continue;
+ }
+ let params = {
+ itemId: event.id,
+ parentId: event.parentId,
+ index: event.index,
+ type: event.itemType,
+ urlHref: event.url || null,
+ title: event.title,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ };
+ this.notifications.push({ name: "bookmark-removed", params });
+ break;
+ }
+ case "bookmark-moved": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ source: event.source,
+ guid: event.guid,
+ newIndex: event.index,
+ newParentGuid: event.parentGuid,
+ oldIndex: event.oldIndex,
+ oldParentGuid: event.oldParentGuid,
+ isTagging: event.isTagging,
+ title: event.title,
+ tags: event.tags,
+ frecency: event.frecency,
+ hidden: event.hidden,
+ visitCount: event.visitCount,
+ dateAdded: event.dateAdded,
+ lastVisitDate: event.lastVisitDate,
+ };
+ this.notifications.push({ name: "bookmark-moved", params });
+ break;
+ }
+ case "bookmark-guid-changed": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ isTagging: event.isTagging,
+ };
+ this.notifications.push({ name: "bookmark-guid-changed", params });
+ break;
+ }
+ case "bookmark-title-changed": {
+ const params = {
+ itemId: event.id,
+ guid: event.guid,
+ title: event.title,
+ parentGuid: event.parentGuid,
+ };
+ this.notifications.push({ name: "bookmark-title-changed", params });
+ break;
+ }
+ case "bookmark-url-changed": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ isTagging: event.isTagging,
+ };
+ this.notifications.push({ name: "bookmark-url-changed", params });
+ break;
+ }
+ }
+ }
+ },
+
+ check(expectedNotifications) {
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-guid-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) {
+ info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`);
+ info(`Actual notifications: ${JSON.stringify(this.notifications)}`);
+ throw new Assert.constructor.AssertionError({
+ actual: this.notifications,
+ expected: expectedNotifications,
+ });
+ }
+ },
+};
+
+function expectBookmarkChangeNotifications(options) {
+ let observer = new BookmarkObserver(options);
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-guid-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ observer.handlePlacesEvents
+ );
+ return observer;
+}
+
+// Copies a support file to a temporary fixture file, allowing the support
+// file to be reused for multiple tests.
+async function setupFixtureFile(fixturePath) {
+ let fixtureFile = do_get_file(fixturePath);
+ let tempFile = FileTestUtils.getTempFile(fixturePath);
+ await IOUtils.copy(fixtureFile.path, tempFile.path);
+ return tempFile;
+}
diff --git a/toolkit/components/places/tests/sync/mirror_corrupt.sqlite b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite
new file mode 100644
index 0000000000..ed3613447c
--- /dev/null
+++ b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite
@@ -0,0 +1 @@
+Not a database!
diff --git a/toolkit/components/places/tests/sync/mirror_v1.sqlite b/toolkit/components/places/tests/sync/mirror_v1.sqlite
new file mode 100644
index 0000000000..f0b8853616
--- /dev/null
+++ b/toolkit/components/places/tests/sync/mirror_v1.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/sync/mirror_v5.sqlite b/toolkit/components/places/tests/sync/mirror_v5.sqlite
new file mode 100644
index 0000000000..2a798ae908
--- /dev/null
+++ b/toolkit/components/places/tests/sync/mirror_v5.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/sync/mirror_v8.sqlite b/toolkit/components/places/tests/sync/mirror_v8.sqlite
new file mode 100644
index 0000000000..94d559f08d
--- /dev/null
+++ b/toolkit/components/places/tests/sync/mirror_v8.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.html b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html
new file mode 100644
index 0000000000..53ad366b1f
--- /dev/null
+++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html
@@ -0,0 +1,18 @@
+<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<!-- This is an automatically generated file.
+ It will be read and overwritten.
+ DO NOT EDIT! -->
+<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+<TITLE>Bookmarks</TITLE>
+<H1>Bookmarks Menu</H1>
+
+<DL><p>
+ <DT><A HREF="https://www.mozilla.org/" ADD_DATE="1471365662" LAST_MODIFIED="1471366005" LAST_CHARSET="UTF-8">Mozilla</A>
+ <DD>Mozilla home
+ <DT><H3 ADD_DATE="1449080379" LAST_MODIFIED="1471366005" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
+ <DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar
+ <DL><p>
+ <DT><A HREF="https://www.mozilla.org/en-US/firefox/" ADD_DATE="1471365681" LAST_MODIFIED="1471366005" SHORTCUTURL="fx" LAST_CHARSET="UTF-8" TAGS="browser">Firefox</A>
+ <DD>Firefox home
+ </DL><p>
+</DL>
diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.json b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json
new file mode 100644
index 0000000000..961140843d
--- /dev/null
+++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json
@@ -0,0 +1,94 @@
+{
+ "guid": "root________",
+ "title": "",
+ "index": 0,
+ "dateAdded": 1449080379324000,
+ "lastModified": 1471365727344000,
+ "id": 1,
+ "type": "text/x-moz-place-container",
+ "root": "placesRoot",
+ "children": [
+ {
+ "guid": "menu________",
+ "title": "Bookmarks Menu",
+ "index": 0,
+ "dateAdded": 1449080379324000,
+ "lastModified": 1471365683893000,
+ "id": 2,
+ "type": "text/x-moz-place-container",
+ "root": "bookmarksMenuFolder",
+ "children": [
+ {
+ "guid": "NnvGl3CRA4hC",
+ "title": "Mozilla",
+ "index": 0,
+ "dateAdded": 1471365662585000,
+ "lastModified": 1471365667573000,
+ "id": 6,
+ "charset": "UTF-8",
+ "annos": [
+ {
+ "name": "bookmarkProperties/description",
+ "flags": 0,
+ "expires": 4,
+ "value": "Mozilla home"
+ }
+ ],
+ "type": "text/x-moz-place",
+ "uri": "https://www.mozilla.org/"
+ }
+ ]
+ },
+ {
+ "guid": "toolbar_____",
+ "title": "Bookmarks Toolbar",
+ "index": 1,
+ "dateAdded": 1449080379324000,
+ "lastModified": 1471365683893000,
+ "id": 3,
+ "annos": [
+ {
+ "name": "bookmarkProperties/description",
+ "flags": 0,
+ "expires": 4,
+ "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar"
+ }
+ ],
+ "type": "text/x-moz-place-container",
+ "root": "toolbarFolder",
+ "children": [
+ {
+ "guid": "APzP8MupzA8l",
+ "title": "Firefox",
+ "index": 0,
+ "dateAdded": 1471365681801000,
+ "lastModified": 1471365687887000,
+ "id": 7,
+ "charset": "UTF-8",
+ "tags": "browser",
+ "annos": [
+ {
+ "name": "bookmarkProperties/description",
+ "flags": 0,
+ "expires": 4,
+ "value": "Firefox home"
+ }
+ ],
+ "type": "text/x-moz-place",
+ "uri": "https://www.mozilla.org/en-US/firefox/",
+ "keyword": "fx"
+ }
+ ]
+ },
+ {
+ "guid": "unfiled_____",
+ "title": "Other Bookmarks",
+ "index": 3,
+ "dateAdded": 1449080379324000,
+ "lastModified": 1471365629626000,
+ "id": 5,
+ "type": "text/x-moz-place-container",
+ "root": "unfiledBookmarksFolder"
+ }
+ ]
+}
diff --git a/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js
new file mode 100644
index 0000000000..877feb99f4
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js
@@ -0,0 +1,220 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+
+add_task(async function test_transaction_in_progress() {
+ let buf = await openMirror("transaction_in_progress");
+
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ ]);
+
+ // This transaction should block merging until the transaction is committed.
+ info("Open transaction on Places connection");
+ await buf.db.execute("BEGIN EXCLUSIVE");
+
+ await Assert.rejects(
+ buf.apply(),
+ ex => ex.name == "MergeConflictError",
+ "Should not merge when a transaction is in progress"
+ );
+
+ info("Commit open transaction");
+ await buf.db.execute("COMMIT");
+
+ info("Merging should succeed after committing");
+ await buf.apply();
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_abort_store() {
+ let buf = await openMirror("abort_store");
+
+ let controller = new AbortController();
+ controller.abort();
+ await Assert.rejects(
+ storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ ],
+ { signal: controller.signal }
+ ),
+ ex => ex.name == "InterruptedError",
+ "Should abort storing when signaled"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_abort_merging() {
+ let buf = await openMirror("abort_merging");
+
+ let controller = new AbortController();
+ controller.abort();
+ await Assert.rejects(
+ buf.apply({ signal: controller.signal }),
+ ex => ex.name == "InterruptedError",
+ "Should abort merge when signaled"
+ );
+
+ // Even though the merger is already finalized on the Rust side, the DB
+ // connection is still open on the JS side. Finalizing `buf` closes it.
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_blocker_state() {
+ let barrier = new AsyncShutdown.Barrier("Test");
+ let buf = await SyncedBookmarksMirror.open({
+ path: "blocker_state_buf.sqlite",
+ finalizeAt: barrier.client,
+ recordStepTelemetry(...args) {},
+ recordValidationTelemetry(...args) {},
+ });
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ ]);
+
+ await buf.tryApply(buf.finalizeController.signal);
+ await barrier.wait();
+
+ let state = buf.progress.fetchState();
+ let names = [];
+ for (let s of state.steps) {
+ equal(typeof s.at, "number", `Should report timestamp for ${s.step}`);
+ switch (s.step) {
+ case "fetchLocalTree":
+ greaterOrEqual(
+ s.took,
+ 0,
+ "Should report time taken to fetch local tree"
+ );
+ deepEqual(
+ s.counts,
+ [
+ { name: "items", count: 6 },
+ { name: "deletions", count: 0 },
+ ],
+ "Should report number of items in local tree"
+ );
+ break;
+
+ case "fetchRemoteTree":
+ greaterOrEqual(
+ s.took,
+ 0,
+ "Should report time taken to fetch remote tree"
+ );
+ deepEqual(
+ s.counts,
+ [
+ { name: "items", count: 6 },
+ { name: "deletions", count: 0 },
+ ],
+ "Should report number of items in remote tree"
+ );
+ break;
+
+ case "merge":
+ greaterOrEqual(s.took, 0, "Should report time taken to merge");
+ deepEqual(
+ s.counts,
+ [{ name: "items", count: 6 }],
+ "Should report merge stats"
+ );
+ break;
+
+ case "apply":
+ greaterOrEqual(s.took, 0, "Should report time taken to apply");
+ ok(!("counts" in s), "Should not report counts for applying");
+ break;
+
+ case "notifyObservers":
+ greaterOrEqual(
+ s.took,
+ 0,
+ "Should report time taken to notify observers"
+ );
+ ok(!("counts" in s), "Should not report counts for observers");
+ break;
+
+ case "fetchLocalChangeRecords":
+ greaterOrEqual(
+ s.took,
+ 0,
+ "Should report time taken to fetch records for upload"
+ );
+ deepEqual(
+ s.counts,
+ [{ name: "items", count: 4 }],
+ "Should report number of records to upload"
+ );
+ break;
+
+ case "finalize":
+ ok(!("took" in s), "Should not report time taken to finalize");
+ ok(!("counts" in s), "Should not report counts for finalizing");
+ }
+ names.push(s.step);
+ }
+ deepEqual(
+ names,
+ [
+ "fetchLocalTree",
+ "fetchRemoteTree",
+ "merge",
+ "apply",
+ "notifyObservers",
+ "fetchLocalChangeRecords",
+ "finalize",
+ ],
+ "Should report merge progress after waiting on blocker"
+ );
+ ok(
+ buf.finalizeController.signal.aborted,
+ "Should abort finalize signal on shutdown"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_chunking.js b/toolkit/components/places/tests/sync/test_bookmark_chunking.js
new file mode 100644
index 0000000000..3652502a3d
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_chunking.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// These tests ensure we correctly chunk statements that exceed SQLite's
+// binding parameter limit.
+
+// Inserts 1500 unfiled bookmarks. Using `PlacesUtils.bookmarks.insertTree`
+// is an order of magnitude slower, so we write bookmarks directly into the
+// database.
+async function insertManyUnfiledBookmarks(db, url) {
+ await db.executeCached(
+ `
+ INSERT OR IGNORE INTO moz_places(id, url, url_hash, rev_host, hidden,
+ frecency, guid)
+ VALUES((SELECT id FROM moz_places
+ WHERE url_hash = hash(:url) AND
+ url = :url), :url, hash(:url), :revHost, 0, -1,
+ generate_guid())`,
+ { url: url.href, revHost: PlacesUtils.getReversedHost(url) }
+ );
+
+ let guids = [];
+
+ for (let position = 0; position < 1500; ++position) {
+ let title = position.toString(10);
+ let guid = title.padStart(12, "A");
+ await db.executeCached(
+ `
+ INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title,
+ syncStatus, syncChangeCounter)
+ VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid),
+ (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND
+ url = :url),
+ :position, :type, :title, :syncStatus, 1)`,
+ {
+ guid,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ position,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ }
+ );
+ guids.push(guid);
+ }
+
+ return guids;
+}
+
+add_task(async function test_merged_item_chunking() {
+ let buf = await openMirror("merged_item_chunking");
+
+ info("Set up local tree with 1500 bookmarks");
+ let localGuids = await buf.db.executeTransaction(function () {
+ let url = new URL("http://example.com/a");
+ return insertManyUnfiledBookmarks(buf.db, url);
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Set up remote tree with 1500 bookmarks");
+ let toolbarRecord = makeRecord({
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ });
+ let records = [toolbarRecord];
+ for (let i = 0; i < 1500; ++i) {
+ let title = i.toString(10);
+ let guid = title.padStart(12, "B");
+ toolbarRecord.children.push(guid);
+ records.push(
+ makeRecord({
+ id: guid,
+ parentid: "toolbar",
+ type: "bookmark",
+ title,
+ bmkUri: "http://example.com/b",
+ })
+ );
+ }
+ await buf.store(shuffle(records));
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.unfiledGuid],
+ "Should leave unfiled with new remote structure unmerged"
+ );
+
+ let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ "toolbar"
+ );
+ deepEqual(
+ localChildRecordIds,
+ toolbarRecord.children,
+ "Should apply all remote toolbar children"
+ );
+
+ let guidsToUpload = Object.keys(changesToUpload);
+ deepEqual(
+ guidsToUpload.sort(),
+ ["unfiled", ...localGuids].sort(),
+ "Should upload unfiled and all new local children"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_deletion_chunking() {
+ let buf = await openMirror("deletion_chunking");
+
+ info("Set up local tree with 1500 bookmarks");
+ let guids = await buf.db.executeTransaction(function () {
+ let url = new URL("http://example.com/a");
+ return insertManyUnfiledBookmarks(buf.db, url);
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Delete them all on the server");
+ let records = [
+ makeRecord({
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ }),
+ ];
+ for (let guid of guids) {
+ records.push(
+ makeRecord({
+ id: guid,
+ deleted: true,
+ })
+ );
+ }
+ await buf.store(shuffle(records));
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+ deepEqual(changesToUpload, {}, "Should take all remote deletions");
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Shouldn't store tombstones for remote deletions");
+
+ let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ "unfiled"
+ );
+ deepEqual(
+ localChildRecordIds,
+ [],
+ "Should delete all unfiled children locally"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_corruption.js b/toolkit/components/places/tests/sync/test_bookmark_corruption.js
new file mode 100644
index 0000000000..5f0b0afeef
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js
@@ -0,0 +1,3290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function reparentItem(db, guid, newParentGuid = null) {
+ await db.execute(
+ `
+ UPDATE moz_bookmarks SET
+ parent = IFNULL((SELECT id FROM moz_bookmarks
+ WHERE guid = :newParentGuid), 0)
+ WHERE guid = :guid`,
+ { newParentGuid, guid }
+ );
+}
+
+async function getCountOfBookmarkRows(db) {
+ let queryRows = await db.execute("SELECT COUNT(*) FROM moz_bookmarks");
+ Assert.equal(queryRows.length, 1);
+ return queryRows[0].getResultByIndex(0);
+}
+
+add_task(async function test_multiple_parents() {
+ let buf = await openMirror("multiple_parents");
+ let now = Date.now();
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ modified: now / 1000 - 10,
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ modified: now / 1000 - 5,
+ children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"],
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ modified: now / 1000 - 3,
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ modified: now / 1000,
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "A",
+ modified: now / 1000 - 10,
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "mobile",
+ type: "bookmark",
+ title: "B",
+ modified: now / 1000 - 3,
+ bmkUri: "http://example.com/b",
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ localTimeSeconds: now / 1000,
+ remoteTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave items with new remote structure unmerged"
+ );
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ ]);
+ deepEqual(changesToUpload, {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkAAAA"],
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid),
+ title: BookmarksToolbarTitle,
+ children: [],
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ title: UnfiledBookmarksTitle,
+ children: ["bookmarkBBBB"],
+ },
+ },
+ mobile: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "mobile",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid),
+ title: MobileBookmarksTitle,
+ children: [],
+ },
+ },
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkAAAA"),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ },
+ },
+ bookmarkBBBB: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkBBBB",
+ type: "bookmark",
+ parentid: "unfiled",
+ hasDupe: true,
+ parentName: UnfiledBookmarksTitle,
+ dateAdded: datesAdded.get("bookmarkBBBB"),
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ },
+ });
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should parent (A B) correctly"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+
+ let newChangesToUpload = await buf.apply({
+ localTimeSeconds: now / 1000,
+ remoteTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ newChangesToUpload,
+ {},
+ "Should not upload any changes after updating mirror"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_reupload_replace() {
+ let buf = await openMirror("reupload_replace");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ },
+ ],
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "folderBBBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: [],
+ },
+ ],
+ { needsMerge: false }
+ );
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAAA",
+ "folderBBBBBB",
+ "queryCCCCCCC",
+ "queryDDDDDDD",
+ ],
+ },
+ {
+ // A has an invalid URL, but exists locally, so we should reupload a valid
+ // local copy. This discards _all_ remote changes to A.
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A (remote)",
+ bmkUri: "!@#$%",
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B (remote)",
+ children: ["bookmarkEEEE"],
+ },
+ {
+ // E is a bookmark with an invalid URL that doesn't exist locally, so we'll
+ // delete it.
+ id: "bookmarkEEEE",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "E (remote)",
+ bmkUri: "!@#$%",
+ },
+ {
+ // C is a legacy tag query, so we'll rewrite its URL and reupload it.
+ id: "queryCCCCCCC",
+ parentid: "menu",
+ type: "query",
+ title: "C (remote)",
+ bmkUri: "place:type=7&folder=999",
+ folderName: "taggy",
+ },
+ {
+ // D is a query with an invalid URL, so we'll delete it.
+ id: "queryDDDDDDD",
+ parentid: "menu",
+ type: "query",
+ title: "D",
+ bmkUri: "^&*()",
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkAAAA",
+ "bookmarkEEEE",
+ "folderBBBBBB",
+ PlacesUtils.bookmarks.menuGuid,
+ "queryCCCCCCC",
+ "queryDDDDDDD",
+ ],
+ "Should leave invalid A, E, D; reuploaded C; B, menu with new remote structure unmerged"
+ );
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ "bookmarkAAAA",
+ ]);
+ deepEqual(changesToUpload, {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkAAAA", "folderBBBBBB", "queryCCCCCCC"],
+ },
+ },
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkAAAA"),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ },
+ },
+ folderBBBBBB: {
+ // B is reuploaded because we deleted its child E.
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderBBBBBB",
+ type: "folder",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: undefined,
+ title: "B (remote)",
+ children: [],
+ },
+ },
+ queryCCCCCCC: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "queryCCCCCCC",
+ type: "query",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: undefined,
+ bmkUri: "place:tag=taggy",
+ title: "C (remote)",
+ folderName: "taggy",
+ },
+ },
+ queryDDDDDDD: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "queryDDDDDDD",
+ deleted: true,
+ },
+ },
+ bookmarkEEEE: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkEEEE",
+ deleted: true,
+ },
+ },
+ });
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkEEEE", "queryDDDDDDD"],
+ "Should store local tombstones for (E D)"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_corrupt_local_roots() {
+ let buf = await openMirror("corrupt_roots");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ ]);
+
+ try {
+ info("Move local menu into unfiled");
+ await reparentItem(
+ buf.db,
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.unfiledGuid
+ );
+ await Assert.rejects(
+ buf.apply(),
+ /The Places roots are invalid/,
+ "Should abort merge if local tree has misparented syncable root"
+ );
+
+ info("Move local Places root into toolbar");
+ await buf.db.executeTransaction(async function () {
+ await reparentItem(
+ buf.db,
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+ await reparentItem(
+ buf.db,
+ PlacesUtils.bookmarks.rootGuid,
+ PlacesUtils.bookmarks.toolbarGuid
+ );
+ });
+ await Assert.rejects(
+ buf.apply(),
+ /The Places roots are invalid/,
+ "Should abort merge if local tree has misparented Places root"
+ );
+ } finally {
+ info("Restore local roots");
+ await buf.db.executeTransaction(async function () {
+ await reparentItem(buf.db, PlacesUtils.bookmarks.rootGuid);
+ await reparentItem(
+ buf.db,
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+ });
+ }
+
+ info("Apply remote with restored roots");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ deepEqual(changesToUpload, {}, "Should not reupload any local records");
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should parent (A B) correctly with restored roots"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_corrupt_remote_roots() {
+ let buf = await openMirror("corrupt_remote_roots");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes: Menu > Unfiled");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["unfiled", "bookmarkAAAA"],
+ },
+ {
+ id: "unfiled",
+ parentid: "menu",
+ type: "folder",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "toolbar",
+ deleted: true,
+ },
+ ]);
+
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave deleted roots unmerged"
+ );
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkAAAA"],
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ title: UnfiledBookmarksTitle,
+ children: ["bookmarkBBBB"],
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid),
+ title: BookmarksToolbarTitle,
+ children: [],
+ },
+ },
+ },
+ "Should reupload invalid roots"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should not corrupt local roots"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Should not store local tombstones");
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_missing_children() {
+ let buf = await openMirror("missing_childen");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes: A > ([B] C [D E])");
+ {
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ ],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ bmkUri: "http://example.com/c",
+ title: "C",
+ },
+ ])
+ );
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid],
+ "Should leave menu with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["menu"],
+ deleted: [],
+ },
+ "Should reupload menu without missing children (B D E)"
+ );
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ "Menu children should be (C)"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ info("Add (B E) to remote");
+ {
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "menu",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ ])
+ );
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkBBBB", "bookmarkEEEE"],
+ "Should leave B, E with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkBBBB", "bookmarkEEEE", "menu"],
+ deleted: [],
+ },
+ "Should reupload menu and restored children"
+ );
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ "Menu children should be (C B E)"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ info("Add D to remote");
+ {
+ await storeRecords(buf, [
+ {
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ ]);
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkDDDD"],
+ "Should leave D with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkDDDD", "menu"],
+ deleted: [],
+ },
+ "Should reupload complete menu"
+ );
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "D",
+ url: "http://example.com/d",
+ },
+ ],
+ },
+ "Menu children should be (C B E D)"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_new_orphan_without_local_parent() {
+ let buf = await openMirror("new_orphan_without_local_parent");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // A doesn't exist locally, so we move the bookmarks into "unfiled" without
+ // reuploading. When the partial uploader returns and uploads A, we'll
+ // move the bookmarks to the correct folder.
+ info("Make remote changes: [A] > (B C D)");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B (remote)",
+ bmkUri: "http://example.com/b-remote",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: "http://example.com/c-remote",
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "D (remote)",
+ bmkUri: "http://example.com/d-remote",
+ },
+ ])
+ );
+
+ info("Apply remote with (B C D)");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave orphans B, C, D unmerged"
+ );
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD", "unfiled"],
+ deleted: [],
+ },
+ "Should reupload orphans (B C D)"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.unfiledGuid,
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B (remote)",
+ url: "http://example.com/b-remote",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ ],
+ },
+ "Should move (B C D) to unfiled"
+ );
+
+ // A is an orphan because we don't have E locally, but we should move
+ // (B C D) into A.
+ info("Add [E] > A to remote");
+ await storeRecords(buf, [
+ {
+ id: "folderAAAAAA",
+ parentid: "folderEEEEEE",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkDDDD", "bookmarkCCCC", "bookmarkBBBB"],
+ },
+ ]);
+
+ info("Apply remote with A");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["folderAAAAAA"],
+ "Should leave A with new remote structure unmerged"
+ );
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "folderAAAAAA",
+ "unfiled",
+ ],
+ deleted: [],
+ },
+ "Should reupload A and its children"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.unfiledGuid,
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "B (remote)",
+ url: "http://example.com/b-remote",
+ },
+ ],
+ },
+ ],
+ },
+ "Should move (D C B) into A"
+ );
+
+ info("Add E to remote");
+ await storeRecords(buf, [
+ {
+ id: "folderEEEEEE",
+ parentid: "menu",
+ type: "folder",
+ title: "E",
+ children: ["folderAAAAAA"],
+ },
+ ]);
+
+ info("Apply remote with E");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["folderEEEEEE"],
+ "Should leave E with new remote structure unmerged"
+ );
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["folderAAAAAA", "folderEEEEEE", "menu", "unfiled"],
+ deleted: [],
+ },
+ "Should move E out of unfiled into menu"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "E",
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "B (remote)",
+ url: "http://example.com/b-remote",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ "Should move Menu > E > A"
+ );
+
+ info("Add Menu > E to remote");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderEEEEEE"],
+ },
+ ]);
+
+ info("Apply remote with menu");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not reupload after forming complete tree"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "E",
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "B (remote)",
+ url: "http://example.com/b-remote",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should form complete tree after applying E"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_into_orphaned() {
+ let buf = await openMirror("move_into_orphaned");
+
+ info("Set up mirror: Menu > (A B (C > (D (E > F))))");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ },
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "C",
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ },
+ {
+ guid: "folderEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "E",
+ children: [
+ {
+ guid: "bookmarkFFFF",
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB", "folderCCCCCC"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "menu",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkDDDD", "folderEEEEEE"],
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ {
+ id: "folderEEEEEE",
+ parentid: "folderCCCCCC",
+ type: "folder",
+ title: "E",
+ children: ["bookmarkFFFF"],
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderEEEEEE",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: delete D, add E > I");
+ await PlacesUtils.bookmarks.remove("bookmarkDDDD");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkIIII",
+ parentGuid: "folderEEEEEE",
+ title: "I (local)",
+ url: "http://example.com/i",
+ });
+
+ // G doesn't exist on the server.
+ info("Make remote changes: ([G] > A (C > (D H E))), (C > H)");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkAAAA",
+ parentid: "folderGGGGGG",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "folderGGGGGG",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkDDDD", "bookmarkHHHH", "folderEEEEEE"],
+ },
+ {
+ id: "bookmarkHHHH",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "H (remote)",
+ bmkUri: "http://example.com/h-remote",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAAA", "folderCCCCCC"],
+ "Should leave orphaned A, C with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [
+ "bookmarkAAAA",
+ "bookmarkIIII",
+ "folderCCCCCC",
+ "folderEEEEEE",
+ "menu",
+ ],
+ deleted: ["bookmarkDDDD"],
+ },
+ "Should upload records for (A I C E); tombstone for D"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ // A remains in its original place, since we don't use the `parentid`,
+ // and we don't have a record for G.
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ // C exists on the server, so we take its children and order. D was
+ // deleted locally, and doesn't exist remotely. C is also a child of
+ // G, but we don't have a record for it on the server.
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "C",
+ children: [
+ {
+ guid: "bookmarkHHHH",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "H (remote)",
+ url: "http://example.com/h-remote",
+ },
+ {
+ guid: "folderEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "E",
+ children: [
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ {
+ guid: "bookmarkIIII",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "I (local)",
+ url: "http://example.com/i",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should treat local tree as canonical if server is missing new parent"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkDDDD"],
+ "Should store local tombstone for D"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_new_orphan_with_local_parent() {
+ let buf = await openMirror("new_orphan_with_local_parent");
+
+ info("Set up mirror: A > (B D)");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkEEEE"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // Simulate a partial write by another device that uploaded only B and C. A
+ // exists locally, so we can move B and C into the correct folder, but not
+ // the correct positions.
+ info("Set up remote with orphans: [A] > (C D)");
+ await storeRecords(buf, [
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "D (remote)",
+ bmkUri: "http://example.com/d-remote",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: "http://example.com/c-remote",
+ },
+ ]);
+
+ info("Apply remote with (C D)");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkCCCC", "bookmarkDDDD"],
+ "Should leave orphaned C, D unmerged"
+ );
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkCCCC", "bookmarkDDDD", "folderAAAAAA"],
+ deleted: [],
+ },
+ "Should reupload orphans (C D) and folder A"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should move (C D) to end of A"
+ );
+
+ // The partial uploader returns and uploads A.
+ info("Add A to remote");
+ await storeRecords(buf, [
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: [
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ "bookmarkBBBB",
+ ],
+ },
+ ]);
+
+ info("Apply remote with A");
+ {
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not reupload orphan A"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+ }
+
+ await assertLocalTree(
+ "folderAAAAAA",
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "D (remote)",
+ url: "http://example.com/d-remote",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ "Should update child positions once A exists in mirror"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_tombstone_as_child() {
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let buf = await openMirror("tombstone_as_child");
+ // Setup the mirror such that an incoming folder references a tombstone
+ // as a child.
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkAAAA", "bookmarkTTTT", "bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "Bookmark A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "Bookmark B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkTTTT",
+ deleted: true,
+ },
+ ]),
+ { needsMerge: true }
+ );
+
+ let changesToUpload = await buf.apply();
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(idsToUpload.deleted, [], "no new tombstones were created.");
+ deepEqual(idsToUpload.updated, ["folderAAAAAA"], "parent is re-uploaded");
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/a",
+ index: 0,
+ title: "Bookmark A",
+ },
+ {
+ // Note that this was the 3rd child specified on the server record,
+ // but we we've correctly moved it back to being the second after
+ // ignoring the tombstone.
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/b",
+ index: 1,
+ title: "Bookmark B",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should have ignored tombstone record"
+ );
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Should not store local tombstones");
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_non_syncable_items() {
+ let buf = await openMirror("non_syncable_items");
+
+ info("Insert local orphaned left pane queries");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [
+ {
+ guid: "folderLEFTPQ",
+ url: "place:folder=SOMETHING",
+ title: "Some query",
+ },
+ {
+ guid: "folderLEFTPC",
+ url: "place:folder=SOMETHING_ELSE",
+ title: "A query under 'All Bookmarks'",
+ },
+ ],
+ });
+
+ info(
+ "Insert syncable local items (A > B) that exist in non-syncable remote root H"
+ );
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // A is non-syncable remotely, but B doesn't exist remotely, so we'll
+ // remove A from the merged structure, and move B to the menu.
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ ],
+ });
+
+ info("Insert non-syncable local root C and items (C > (D > E) F)");
+ await insertLocalRoot({
+ guid: "rootCCCCCCCC",
+ title: "C",
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: "rootCCCCCCCC",
+ children: [
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ ],
+ },
+ {
+ guid: "bookmarkFFFF",
+ url: "http://example.com/f",
+ title: "F",
+ },
+ ],
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ // H is a non-syncable root that only exists remotely.
+ id: "rootHHHHHHHH",
+ type: "folder",
+ parentid: "places",
+ title: "H",
+ children: ["folderAAAAAA"],
+ },
+ {
+ // A is a folder with children that's non-syncable remotely, and syncable
+ // locally. We should remove A and its descendants locally, since its parent
+ // H is known to be non-syncable remotely.
+ id: "folderAAAAAA",
+ parentid: "rootHHHHHHHH",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkFFFF", "bookmarkIIII"],
+ },
+ {
+ // F exists in two different non-syncable folders: C locally, and A
+ // remotely.
+ id: "bookmarkFFFF",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ {
+ id: "bookmarkIIII",
+ parentid: "folderAAAAAA",
+ type: "query",
+ title: "I",
+ bmkUri: "http://example.com/i",
+ },
+ {
+ // The complete left pane root. We should remove all left pane queries
+ // locally, even though they're syncable, since the left pane root is
+ // known to be non-syncable.
+ id: "folderLEFTPR",
+ type: "folder",
+ parentid: "places",
+ title: "",
+ children: ["folderLEFTPQ", "folderLEFTPF"],
+ },
+ {
+ id: "folderLEFTPQ",
+ parentid: "folderLEFTPR",
+ type: "query",
+ title: "Some query",
+ bmkUri: "place:folder=SOMETHING",
+ },
+ {
+ id: "folderLEFTPF",
+ parentid: "folderLEFTPR",
+ type: "folder",
+ title: "All Bookmarks",
+ children: ["folderLEFTPC"],
+ },
+ {
+ id: "folderLEFTPC",
+ parentid: "folderLEFTPF",
+ type: "query",
+ title: "A query under 'All Bookmarks'",
+ bmkUri: "place:folder=SOMETHING_ELSE",
+ },
+ {
+ // D, J, and G are syncable remotely, but D is non-syncable locally. Since
+ // J and G don't exist locally, and are syncable remotely, we'll remove D
+ // from the merged structure, and move J and G to unfiled.
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["folderDDDDDD", "bookmarkGGGG"],
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "unfiled",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkJJJJ"],
+ },
+ {
+ id: "bookmarkJJJJ",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "J",
+ bmkUri: "http://example.com/j",
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ },
+ ]);
+
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkFFFF",
+ "bookmarkIIII",
+ "bookmarkJJJJ",
+ "folderAAAAAA",
+ "folderDDDDDD",
+ "folderLEFTPC",
+ "folderLEFTPF",
+ "folderLEFTPQ",
+ "folderLEFTPR",
+ PlacesUtils.bookmarks.menuGuid,
+ "rootHHHHHHHH",
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave non-syncable items and roots with new remote structure unmerged"
+ );
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "bookmarkBBBB",
+ "bookmarkJJJJ",
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ folderAAAAAA: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderAAAAAA",
+ deleted: true,
+ },
+ },
+ folderDDDDDD: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderDDDDDD",
+ deleted: true,
+ },
+ },
+ folderLEFTPQ: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderLEFTPQ",
+ deleted: true,
+ },
+ },
+ folderLEFTPC: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderLEFTPC",
+ deleted: true,
+ },
+ },
+ folderLEFTPR: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderLEFTPR",
+ deleted: true,
+ },
+ },
+ folderLEFTPF: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderLEFTPF",
+ deleted: true,
+ },
+ },
+ rootHHHHHHHH: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "rootHHHHHHHH",
+ deleted: true,
+ },
+ },
+ bookmarkFFFF: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkFFFF",
+ deleted: true,
+ },
+ },
+ bookmarkIIII: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkIIII",
+ deleted: true,
+ },
+ },
+ bookmarkBBBB: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkBBBB",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkBBBB"),
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ },
+ bookmarkJJJJ: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkJJJJ",
+ type: "bookmark",
+ parentid: "unfiled",
+ hasDupe: true,
+ parentName: UnfiledBookmarksTitle,
+ dateAdded: undefined,
+ bmkUri: "http://example.com/j",
+ title: "J",
+ },
+ },
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkBBBB"],
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ title: UnfiledBookmarksTitle,
+ children: ["bookmarkJJJJ", "bookmarkGGGG"],
+ },
+ },
+ },
+ "Should upload new structure and tombstones for non-syncable items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkJJJJ",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "J",
+ url: "http://example.com/j",
+ },
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should exclude non-syncable items from new local structure"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ [
+ "bookmarkFFFF",
+ "bookmarkIIII",
+ "folderAAAAAA",
+ "folderDDDDDD",
+ "folderLEFTPC",
+ "folderLEFTPF",
+ "folderLEFTPQ",
+ "folderLEFTPR",
+ "rootHHHHHHHH",
+ ],
+ "Should store local tombstones for non-syncable items"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// See what happens when a left-pane root and a left-pane query are on the server
+add_task(async function test_left_pane_root() {
+ let buf = await openMirror("lpr");
+
+ let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid);
+
+ // This test is expected to not touch bookmarks at all, and if it did
+ // happen to create a new item that's not under our syncable roots, then
+ // just checking the result of fetchLocalTree wouldn't pick that up - so
+ // as an additional safety check, count how many bookmark rows exist.
+ let numRows = await getCountOfBookmarkRows(buf.db);
+
+ // Add a left pane root, a left-pane query and a left-pane folder to the
+ // mirror, all correctly parented.
+ // Because we can determine this is a complete tree that's outside our
+ // syncable trees, we expect none of them to be applied.
+ await storeRecords(
+ buf,
+ shuffle(
+ [
+ {
+ id: "folderLEFTPR",
+ type: "folder",
+ parentid: "places",
+ title: "",
+ children: ["folderLEFTPQ", "folderLEFTPF"],
+ },
+ {
+ id: "folderLEFTPQ",
+ type: "query",
+ parentid: "folderLEFTPR",
+ title: "Some query",
+ bmkUri: "place:folder=SOMETHING",
+ },
+ {
+ id: "folderLEFTPF",
+ type: "folder",
+ parentid: "folderLEFTPR",
+ title: "All Bookmarks",
+ children: ["folderLEFTPC"],
+ },
+ {
+ id: "folderLEFTPC",
+ type: "query",
+ parentid: "folderLEFTPF",
+ title: "A query under 'All Bookmarks'",
+ bmkUri: "place:folder=SOMETHING_ELSE",
+ },
+ ],
+ { needsMerge: true }
+ )
+ );
+
+ await buf.apply();
+
+ // should have ignored everything.
+ await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree);
+
+ // and a check we didn't write *any* items to the places database, even
+ // outside of our user roots.
+ Assert.equal(await getCountOfBookmarkRows(buf.db), numRows);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// See what happens when a left-pane query (without the left-pane root) is on
+// the server
+add_task(async function test_left_pane_query() {
+ let buf = await openMirror("lpq");
+
+ let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid);
+
+ // This test is expected to not touch bookmarks at all, and if it did
+ // happen to create a new item that's not under our syncable roots, then
+ // just checking the result of fetchLocalTree wouldn't pick that up - so
+ // as an additional safety check, count how many bookmark rows exist.
+ let numRows = await getCountOfBookmarkRows(buf.db);
+
+ // Add the left pane root and left-pane folders to the mirror, correctly parented.
+ // We should not apply it because we made a policy decision to not apply
+ // orphaned queries (bug 1433182)
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "folderLEFTPQ",
+ type: "query",
+ parentid: "folderLEFTPR",
+ title: "Some query",
+ bmkUri: "place:folder=SOMETHING",
+ },
+ ],
+ { needsMerge: true }
+ );
+
+ await buf.apply();
+
+ // should have ignored everything.
+ await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree);
+
+ // and further check we didn't apply it as mis-rooted.
+ Assert.equal(await getCountOfBookmarkRows(buf.db), numRows);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_partial_cycle() {
+ let buf = await openMirror("partial_cycle");
+
+ info("Set up mirror: Menu > A > B > C");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["folderBBBBBB"],
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // Try to create a cycle: move A into B, and B into the menu, but don't upload
+ // a record for the menu.
+ info("Make remote changes: A > C");
+ await storeRecords(buf, [
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A (remote)",
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "B (remote)",
+ children: ["folderAAAAAA"],
+ },
+ ]);
+
+ await Assert.rejects(
+ buf.apply(),
+ /Item <guid: folderBBBBBB> can't contain itself/,
+ "Should abort merge if remote tree parents form `parentid` cycle"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_complete_cycle() {
+ let buf = await openMirror("complete_cycle");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // This test is order-dependent. We shouldn't recurse infinitely, but,
+ // depending on the order of the records, we might ignore the circular
+ // subtree because there's nothing linking it back to the rest of the
+ // tree.
+ info("Make remote changes: Menu > A > B > C > A");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["folderBBBBBB"],
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "B",
+ children: ["folderCCCCCC"],
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "folderBBBBBB",
+ type: "folder",
+ title: "C",
+ children: ["folderDDDDDD"],
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderCCCCCC",
+ type: "folder",
+ title: "D",
+ children: ["folderAAAAAA"],
+ },
+ ]);
+
+ await Assert.rejects(
+ buf.apply(),
+ /Item <guid: folderAAAAAA> can't contain itself/,
+ "Should abort merge if remote tree parents form cycle through `children`"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_invalid_guid() {
+ let now = new Date();
+
+ let buf = await openMirror("invalid_guid");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bad!guid~", "bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bad!guid~",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ ]);
+
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bad!guid~", PlacesUtils.bookmarks.menuGuid],
+ "Should leave bad GUID and menu with new remote structure unmerged"
+ );
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ ]);
+
+ let recordIdsToUpload = Object.keys(changesToUpload);
+ let newGuid = recordIdsToUpload.find(
+ recordId => !["bad!guid~", "menu"].includes(recordId)
+ );
+
+ equal(
+ recordIdsToUpload.length,
+ 3,
+ "Should reupload menu, C, and tombstone for bad GUID"
+ );
+
+ deepEqual(
+ changesToUpload["bad!guid~"],
+ {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bad!guid~",
+ deleted: true,
+ },
+ },
+ "Should upload tombstone for C's invalid GUID"
+ );
+
+ deepEqual(
+ changesToUpload[newGuid],
+ {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: newGuid,
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/c",
+ title: "C",
+ },
+ },
+ "Should reupload C with new GUID"
+ );
+
+ deepEqual(
+ changesToUpload.menu,
+ {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkAAAA", newGuid, "bookmarkBBBB"],
+ },
+ },
+ "Should reupload menu with new child GUID for C"
+ );
+
+ await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: newGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bad!guid~"],
+ "Should store local tombstone for C's invalid GUID"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_sync_status_mismatches() {
+ let dateAdded = new Date();
+
+ let buf = await openMirror("sync_status_mismatches");
+
+ info("Ensure mirror is up-to-date with Places");
+ let initialChangesToUpload = await buf.apply();
+
+ deepEqual(
+ Object.keys(initialChangesToUpload).sort(),
+ ["menu", "mobile", "toolbar", "unfiled"],
+ "Should upload roots on first merge"
+ );
+
+ await storeChangesInMirror(buf, initialChangesToUpload);
+
+ info("Make local changes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ // A is NORMAL in Places, but doesn't exist in the mirror.
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ dateAdded,
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [
+ {
+ // B is NEW in Places and exists in the mirror.
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ dateAdded,
+ },
+ ],
+ });
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "unfiled",
+ type: "bookmark",
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ {
+ // C is flagged as merged in the mirror, but doesn't exist in Places.
+ id: "bookmarkCCCC",
+ parentid: "toolbar",
+ type: "bookmark",
+ bmkUri: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ { needsMerge: false }
+ );
+
+ info("Apply mirror");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ },
+ },
+ bookmarkBBBB: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkBBBB",
+ type: "bookmark",
+ parentid: "unfiled",
+ hasDupe: true,
+ parentName: UnfiledBookmarksTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ },
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkAAAA"],
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ title: UnfiledBookmarksTitle,
+ children: ["bookmarkBBBB"],
+ },
+ },
+ },
+ "Should flag (A B) and their parents for upload"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should parent C correctly"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_invalid_local_urls() {
+ let buf = await openMirror("invalid_local_urls");
+
+ info("Skip uploading local roots on first merge");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Set up local tree");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // A has an invalid URL locally and doesn't exist remotely, so we
+ // should delete it without uploading a tombstone.
+ guid: "bookmarkAAAA",
+ title: "A (local)",
+ url: "http://example.com/a",
+ },
+ {
+ // B has an invalid URL locally and has a valid URL remotely, so
+ // we should replace our local copy with the remote one.
+ guid: "bookmarkBBBB",
+ title: "B (local)",
+ url: "http://example.com/b",
+ },
+ {
+ // C has an invalid URL on both sides, so we should delete it locally
+ // and upload a tombstone.
+ guid: "bookmarkCCCC",
+ title: "A (local)",
+ url: "http://example.com/c",
+ },
+ ],
+ });
+
+ // The public API doesn't let us insert invalid URLs (for good reason!), so
+ // we update them directly in Places.
+ info("Invalidate local URLs");
+ await buf.db.executeTransaction(async function () {
+ const invalidURLs = [
+ {
+ guid: "bookmarkAAAA",
+ invalidURL: "!@#$%",
+ },
+ {
+ guid: "bookmarkBBBB",
+ invalidURL: "^&*(",
+ },
+ {
+ guid: "bookmarkCCCC",
+ invalidURL: ")-+!@",
+ },
+ ];
+ for (let params of invalidURLs) {
+ await buf.db.execute(
+ `UPDATE moz_places SET
+ url = :invalidURL,
+ url_hash = hash(:invalidURL)
+ WHERE id = (SELECT fk FROM moz_bookmarks WHERE guid = :guid)`,
+ params
+ );
+ }
+ });
+
+ info("Set up remote tree");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B (remote)",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ // C should be marked as `VALIDITY_REPLACE` in the mirror database.
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: ")(*&^",
+ },
+ {
+ // D has an invalid URL remotely and doesn't exist locally, so we
+ // should replace it with a tombstone.
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D (remote)",
+ bmkUri: "^%$#@",
+ },
+ ]);
+
+ info("Apply mirror");
+ let changesToUpload = await buf.apply();
+
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ title: BookmarksMenuTitle,
+ children: ["bookmarkBBBB"],
+ },
+ },
+ bookmarkCCCC: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkCCCC",
+ deleted: true,
+ },
+ },
+ bookmarkDDDD: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkDDDD",
+ deleted: true,
+ },
+ },
+ },
+ "Should reupload menu and tombstones for (C D)"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B (remote)",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ "Should replace B with remote and delete (A C)"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [],
+ "Should flag all items as merged after upload"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_deduping.js b/toolkit/components/places/tests/sync/test_bookmark_deduping.js
new file mode 100644
index 0000000000..0c6c79496a
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_deduping.js
@@ -0,0 +1,1290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_duping_local_newer() {
+ let mergeTelemetryCounts;
+ let buf = await openMirror("duping_local_newer", {
+ recordStepTelemetry(name, took, counts) {
+ if (name == "merge") {
+ mergeTelemetryCounts = counts.filter(({ count }) => count > 0);
+ }
+ },
+ });
+ let localModified = new Date();
+
+ info("Start with empty local and mirror with merged items");
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAA5"],
+ dateAdded: localModified.getTime(),
+ },
+ {
+ id: "bookmarkAAA5",
+ parentid: "menu",
+ type: "bookmark",
+ bmkUri: "http://example.com/a",
+ title: "A",
+ dateAdded: localModified.getTime(),
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Add newer local dupes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAA1",
+ title: "A",
+ url: "http://example.com/a",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ {
+ guid: "bookmarkAAA2",
+ title: "A",
+ url: "http://example.com/a",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ {
+ guid: "bookmarkAAA3",
+ title: "A",
+ url: "http://example.com/a",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ ],
+ });
+
+ info("Add older remote dupes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"],
+ modified: localModified / 1000 - 5,
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ bmkUri: "http://example.com/a",
+ title: "A",
+ keyword: "kw",
+ tags: ["remote", "tags"],
+ modified: localModified / 1000 - 5,
+ },
+ {
+ id: "bookmarkAAA4",
+ parentid: "menu",
+ type: "bookmark",
+ bmkUri: "http://example.com/a",
+ title: "A",
+ modified: localModified / 1000 - 5,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ remoteTimeSeconds: localModified / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAA4", "bookmarkAAAA", PlacesUtils.bookmarks.menuGuid],
+ "Should leave A4, A, menu with new remote structure unmerged"
+ );
+ deepEqual(
+ mergeTelemetryCounts,
+ [
+ { name: "items", count: 9 },
+ { name: "dupes", count: 2 },
+ ],
+ "Should record telemetry with dupe counts"
+ );
+
+ let menuInfo = await PlacesUtils.bookmarks.fetch(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ changesToUpload,
+ {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: menuInfo.dateAdded.getTime(),
+ title: menuInfo.title,
+ children: [
+ "bookmarkAAAA",
+ "bookmarkAAA4",
+ "bookmarkAAA3",
+ "bookmarkAAA5",
+ ],
+ },
+ },
+ // Note that we always reupload the deduped local item, because content
+ // matching doesn't account for attributes like keywords, synced annos, or
+ // tags.
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: localModified.getTime(),
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ },
+ // Unchanged from local.
+ bookmarkAAA4: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA4",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: localModified.getTime(),
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ },
+ bookmarkAAA3: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA3",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: localModified.getTime(),
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ },
+ bookmarkAAA5: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA5",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: localModified.getTime(),
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ },
+ },
+ "Should uploaded newer deduped local items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkAAA4",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkAAA3",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkAAA5",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ "Should dedupe local multiple bookmarks with similar contents"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_duping_remote_newer() {
+ let buf = await openMirror("duping_remote_new");
+ let localModified = new Date();
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // Shouldn't dupe to `folderA11111` because its sync status is "NORMAL".
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ // Shouldn't dupe to `bookmarkG111`.
+ guid: "bookmarkGGGG",
+ url: "http://example.com/g",
+ title: "G",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkGGGG"],
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Insert local dupes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // Should dupe to `folderB11111`.
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ dateAdded: localModified,
+ lastModified: localModified,
+ children: [
+ {
+ // Should dupe to `bookmarkC222`.
+ guid: "bookmarkC111",
+ url: "http://example.com/c",
+ title: "C",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ {
+ // Should dupe to `separatorF11` because the positions are the same.
+ guid: "separatorFFF",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ ],
+ },
+ {
+ // Shouldn't dupe to `separatorE11`, because the positions are different.
+ guid: "separatorEEE",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ {
+ // Shouldn't dupe to `bookmarkC222` because the parents are different.
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ {
+ // Should dupe to `queryD111111`.
+ guid: "queryDDDDDDD",
+ url: "place:maxResults=10&sort=8",
+ title: "Most Visited",
+ dateAdded: localModified,
+ lastModified: localModified,
+ },
+ ],
+ });
+
+ // Make sure we still dedupe this even though it doesn't have SYNC_STATUS.NEW
+ PlacesTestUtils.setBookmarkSyncFields({
+ guid: "folderBBBBBB",
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ });
+
+ // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`.
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: "folderAAAAAA",
+ guid: "bookmarkHHHH",
+ url: "http://example.com/h",
+ title: "H",
+ dateAdded: localModified,
+ lastModified: localModified,
+ });
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "folderAAAAAA",
+ "folderB11111",
+ "folderA11111",
+ "separatorE11",
+ "queryD111111",
+ ],
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "folderB11111",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkC222", "separatorF11"],
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "bookmarkC222",
+ parentid: "folderB11111",
+ type: "bookmark",
+ bmkUri: "http://example.com/c",
+ title: "C",
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "separatorF11",
+ parentid: "folderB11111",
+ type: "separator",
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "folderA11111",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkG111"],
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "bookmarkG111",
+ parentid: "folderA11111",
+ type: "bookmark",
+ bmkUri: "http://example.com/g",
+ title: "G",
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "separatorE11",
+ parentid: "menu",
+ type: "separator",
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ {
+ id: "queryD111111",
+ parentid: "menu",
+ type: "query",
+ bmkUri: "place:maxResults=10&sort=8",
+ title: "Most Visited",
+ dateAdded: localModified.getTime(),
+ modified: localModified / 1000 + 5,
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ remoteTimeSeconds: localModified / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid],
+ "Should leave menu with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [
+ "bookmarkCCCC",
+ "bookmarkHHHH",
+ "folderAAAAAA",
+ "menu",
+ "separatorEEE",
+ ],
+ deleted: [],
+ },
+ "Should not upload deduped local records"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ {
+ guid: "bookmarkHHHH",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "H",
+ url: "http://example.com/h",
+ },
+ ],
+ },
+ {
+ guid: "folderB11111",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "B",
+ children: [
+ {
+ guid: "bookmarkC222",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "separatorF11",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: 1,
+ title: "",
+ },
+ ],
+ },
+ {
+ guid: "folderA11111",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkG111",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ ],
+ },
+ {
+ guid: "separatorE11",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: 3,
+ title: "",
+ },
+ {
+ guid: "queryD111111",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 4,
+ title: "Most Visited",
+ url: "place:maxResults=10&sort=8",
+ },
+ {
+ guid: "separatorEEE",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: 5,
+ title: "",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 6,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should dedupe matching NEW bookmarks"
+ );
+
+ ok(
+ (
+ await PlacesTestUtils.fetchBookmarkSyncFields(
+ "menu________",
+ "folderB11111",
+ "bookmarkC222",
+ "separatorF11",
+ "folderA11111",
+ "bookmarkG111",
+ "separatorE11",
+ "queryD111111"
+ )
+ ).every(info => info.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL)
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_duping_both() {
+ let buf = await openMirror("duping_both");
+ let now = Date.now();
+
+ info("Start with empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Add local dupes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // `folderAAAAA1` is older than `folderAAAAAA`, but we should still flag
+ // it for upload because it has a new structure (`bookmarkCCCC`).
+ guid: "folderAAAAA1",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ // Shouldn't upload, since `bookmarkBBBB` is newer.
+ guid: "bookmarkBBB1",
+ title: "B",
+ url: "http://example.com/b",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ },
+ {
+ // Should upload, since `bookmarkCCCC` doesn't exist on the server and
+ // has no content matches.
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ // `folderDDDDD1` should keep complete local structure, but we'll still
+ // flag it for reupload because it's newer than `folderDDDDDD`.
+ guid: "folderDDDDD1",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now + 5000),
+ children: [
+ {
+ guid: "bookmarkEEE1",
+ title: "E",
+ url: "http://example.com/e",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ // `folderFFFFF1` should keep complete remote value and structure, so
+ // we shouldn't upload it or its children.
+ guid: "folderFFFFF1",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "F",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ guid: "bookmarkGGG1",
+ title: "G",
+ url: "http://example.com/g",
+ dateAdded: new Date(now - 10000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ ],
+ });
+
+ info("Add remote dupes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ dateAdded: now - 10000,
+ modified: now / 1000 + 5,
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ bmkUri: "http://example.com/b",
+ title: "B",
+ dateAdded: now - 10000,
+ modified: now / 1000 + 5,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ dateAdded: now - 10000,
+ modified: now / 1000 - 5,
+ children: ["bookmarkEEEE"],
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ bmkUri: "http://example.com/e",
+ title: "E",
+ dateAdded: now - 10000,
+ modified: now / 1000 + 5,
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "menu",
+ type: "folder",
+ title: "F",
+ dateAdded: now - 10000,
+ modified: now / 1000 + 5,
+ children: ["bookmarkGGGG", "bookmarkHHHH"],
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderFFFFFF",
+ type: "bookmark",
+ bmkUri: "http://example.com/g",
+ title: "G",
+ dateAdded: now - 10000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkHHHH",
+ parentid: "folderFFFFFF",
+ type: "bookmark",
+ bmkUri: "http://example.com/h",
+ title: "H",
+ dateAdded: now - 10000,
+ modified: now / 1000 + 5,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ remoteTimeSeconds: now / 1000,
+ });
+
+ let menuInfo = await PlacesUtils.bookmarks.fetch(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ changesToUpload,
+ {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: menuInfo.dateAdded.getTime(),
+ title: menuInfo.title,
+ children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"],
+ },
+ },
+ folderAAAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderAAAAAA",
+ type: "folder",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: now - 10000,
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkCCCC"],
+ },
+ },
+ bookmarkCCCC: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkCCCC",
+ type: "bookmark",
+ parentid: "folderAAAAAA",
+ hasDupe: true,
+ parentName: "A",
+ dateAdded: now - 10000,
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ },
+ folderDDDDDD: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderDDDDDD",
+ type: "folder",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: menuInfo.title,
+ dateAdded: now - 10000,
+ title: "D",
+ children: ["bookmarkEEEE"],
+ },
+ },
+ },
+ "Should upload new and newer locally deduped items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "F",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ {
+ guid: "bookmarkHHHH",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "H",
+ url: "http://example.com/h",
+ },
+ ],
+ },
+ ],
+ },
+ "Should change local GUIDs for mixed older and newer items"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_applying_two_empty_folders_doesnt_smush() {
+ let buf = await openMirror("applying_two_empty_folders_doesnt_smush");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: ["emptyempty01", "emptyempty02"],
+ },
+ {
+ id: "emptyempty01",
+ parentid: "mobile",
+ type: "folder",
+ title: "Empty",
+ },
+ {
+ id: "emptyempty02",
+ parentid: "mobile",
+ type: "folder",
+ title: "Empty",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not upload records for remote-only value changes"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.mobileGuid,
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: "mobile",
+ children: [
+ {
+ guid: "emptyempty01",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "Empty",
+ },
+ {
+ guid: "emptyempty02",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "Empty",
+ },
+ ],
+ },
+ "Should not smush 1 and 2"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_applying_two_empty_folders_matches_only_one() {
+ let buf = await openMirror("applying_two_empty_folders_doesnt_smush");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ children: [
+ {
+ guid: "emptyempty02",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "Empty",
+ },
+ {
+ guid: "emptyemptyL0",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "Empty",
+ },
+ ],
+ });
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: ["emptyempty01", "emptyempty02", "emptyempty03"],
+ },
+ {
+ id: "emptyempty01",
+ parentid: "mobile",
+ type: "folder",
+ title: "Empty",
+ },
+ {
+ id: "emptyempty02",
+ parentid: "mobile",
+ type: "folder",
+ title: "Empty",
+ },
+ {
+ id: "emptyempty03",
+ parentid: "mobile",
+ type: "folder",
+ title: "Empty",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.mobileGuid],
+ "Should leave mobile with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["mobile"],
+ deleted: [],
+ },
+ "Should not upload records after applying empty folders"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.mobileGuid,
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: "mobile",
+ children: [
+ {
+ guid: "emptyempty01",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "Empty",
+ },
+ {
+ guid: "emptyempty02",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "Empty",
+ },
+ {
+ guid: "emptyempty03",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "Empty",
+ },
+ ],
+ },
+ "Should apply 1 and dedupe L0 to 3"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Bug 747699.
+add_task(async function test_duping_mobile_bookmarks() {
+ let buf = await openMirror("duping_mobile_bookmarks");
+
+ info("Set up empty mirror with localized mobile root title");
+ let mobileInfo = await PlacesUtils.bookmarks.fetch(
+ PlacesUtils.bookmarks.mobileGuid
+ );
+ await PlacesUtils.bookmarks.update({
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ title: "Favoritos do celular",
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAA1",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ title: "A",
+ url: "http://example.com/a",
+ });
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "mobile",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.mobileGuid],
+ "Should leave mobile with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["mobile"],
+ deleted: [],
+ },
+ "Should not upload records after applying deduped mobile bookmark"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.mobileGuid,
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: "Favoritos do celular",
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ "Should dedupe A1 to A with different parent title"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ // Restore the original mobile root title.
+ await PlacesUtils.bookmarks.update({
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ title: mobileInfo.title,
+ });
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_duping_invalid() {
+ // To check if invalid items are prevented from deduping
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAA1",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ });
+
+ let buf = await openMirror("duping_invalid");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAA2"],
+ },
+ {
+ id: "bookmarkAAA2",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ ]);
+
+ // Invalidate bookmarkAAA2 so that it does not dedupe to bookmarkAAA1
+ await buf.db.execute(
+ `UPDATE items SET
+ validity = :validity
+ WHERE guid = :guid`,
+ {
+ validity: Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE,
+ guid: "bookmarkAAA2",
+ }
+ );
+
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ changesToUpload.menu.cleartext.children,
+ ["bookmarkAAA1"],
+ "Should upload A1 in menu"
+ );
+ ok(
+ !changesToUpload.bookmarkAAA1.tombstone,
+ "Should not upload tombstone for A1"
+ );
+ ok(changesToUpload.bookmarkAAA2.tombstone, "Should upload tombstone for A2");
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAA1",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ "No deduping of invalid items"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_deletion.js b/toolkit/components/places/tests/sync/test_bookmark_deletion.js
new file mode 100644
index 0000000000..fd29252e74
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_deletion.js
@@ -0,0 +1,1602 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_complex_orphaning() {
+ let now = Date.now();
+
+ let mergeTelemetryCounts;
+ let buf = await openMirror("complex_orphaning", {
+ recordStepTelemetry(name, took, counts) {
+ if (name == "merge") {
+ mergeTelemetryCounts = counts.filter(({ count }) => count > 0);
+ }
+ },
+ });
+
+ // On iOS, the mirror exists as a separate table. On Desktop, we have a
+ // shadow mirror of synced local bookmarks without new changes.
+ info("Set up mirror: ((Toolbar > A > B) (Menu > G > C > D))");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "toolbar",
+ type: "folder",
+ title: "A",
+ children: ["folderBBBBBB"],
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "B",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderGGGGGG",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "G",
+ children: [
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "C",
+ children: [
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderGGGGGG"],
+ },
+ {
+ id: "folderGGGGGG",
+ parentid: "menu",
+ type: "folder",
+ title: "G",
+ children: ["folderCCCCCC"],
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "folderGGGGGG",
+ type: "folder",
+ title: "C",
+ children: ["folderDDDDDD"],
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderCCCCCC",
+ type: "folder",
+ title: "D",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: delete D, add B > E");
+ await PlacesUtils.bookmarks.remove("folderDDDDDD");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkEEEE",
+ parentGuid: "folderBBBBBB",
+ title: "E",
+ url: "http://example.com/e",
+ });
+
+ info("Make remote changes: delete B, add D > F");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "folderBBBBBB",
+ deleted: true,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "toolbar",
+ type: "folder",
+ title: "A",
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderCCCCCC",
+ type: "folder",
+ children: ["bookmarkFFFF"],
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkFFFF", "folderAAAAAA", "folderDDDDDD"],
+ "Should leave deleted D; A and F with new remote structure unmerged"
+ );
+ deepEqual(
+ mergeTelemetryCounts,
+ [
+ { name: "items", count: 10 },
+ { name: "localDeletes", count: 1 },
+ { name: "remoteDeletes", count: 1 },
+ ],
+ "Should record telemetry with structure change counts"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkEEEE", "bookmarkFFFF", "folderAAAAAA", "folderCCCCCC"],
+ deleted: ["folderDDDDDD"],
+ },
+ "Should upload new records for (A > E), (C > F); tombstone for D"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderGGGGGG",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "G",
+ children: [
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "C",
+ children: [
+ {
+ // D was deleted, so F moved to C, the closest surviving parent.
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ // B was deleted, so E moved to A.
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should move orphans to closest surviving parent"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["folderDDDDDD"],
+ "Should store local tombstone for D"
+ );
+ Assert.ok(
+ is_time_ordered(now, tombstones[0].dateRemoved.getTime()),
+ "Tombstone timestamp should be recent"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_locally_modified_remotely_deleted() {
+ let mergeTelemetryCounts;
+ let buf = await openMirror("locally_modified_remotely_deleted", {
+ recordStepTelemetry(name, took, counts) {
+ if (name == "merge") {
+ mergeTelemetryCounts = counts.filter(({ count }) => count > 0);
+ }
+ },
+ });
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "folderBBBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC", "folderDDDDDD"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderBBBBBB",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkEEEE"],
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: change A; B > ((D > F) G)");
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkAAAA",
+ title: "A (local)",
+ url: "http://example.com/a-local",
+ });
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkFFFF",
+ parentGuid: "folderDDDDDD",
+ title: "F (local)",
+ url: "http://example.com/f-local",
+ });
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkGGGG",
+ parentGuid: "folderBBBBBB",
+ title: "G (local)",
+ url: "http://example.com/g-local",
+ });
+
+ info("Make remote changes: delete A, B");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ {
+ id: "bookmarkAAAA",
+ deleted: true,
+ },
+ {
+ id: "folderBBBBBB",
+ deleted: true,
+ },
+ {
+ id: "bookmarkCCCC",
+ deleted: true,
+ },
+ {
+ id: "folderDDDDDD",
+ deleted: true,
+ },
+ {
+ id: "bookmarkEEEE",
+ deleted: true,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAAA", PlacesUtils.bookmarks.menuGuid],
+ "Should leave revived A and menu with new remote structure unmerged"
+ );
+ deepEqual(
+ mergeTelemetryCounts,
+ [
+ { name: "items", count: 8 },
+ { name: "localRevives", count: 1 },
+ { name: "remoteDeletes", count: 2 },
+ ],
+ "Should record telemetry for local item and remote folder deletions"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkAAAA", "bookmarkFFFF", "bookmarkGGGG", "menu"],
+ deleted: [],
+ },
+ "Should upload A, relocated local orphans, and menu"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A (local)",
+ url: "http://example.com/a-local",
+ },
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F (local)",
+ url: "http://example.com/f-local",
+ },
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "G (local)",
+ url: "http://example.com/g-local",
+ },
+ ],
+ },
+ "Should restore A and relocate (F G) to menu"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Should not store local tombstones");
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_locally_deleted_remotely_modified() {
+ let now = Date.now();
+
+ let mergeTelemetryCounts;
+ let buf = await openMirror("locally_deleted_remotely_modified", {
+ recordStepTelemetry(name, took, counts) {
+ if (name == "merge") {
+ mergeTelemetryCounts = counts.filter(({ count }) => count > 0);
+ }
+ },
+ });
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "folderBBBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC", "folderDDDDDD"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderBBBBBB",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkEEEE"],
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: delete A, B");
+ await PlacesUtils.bookmarks.remove("bookmarkAAAA");
+ await PlacesUtils.bookmarks.remove("folderBBBBBB");
+
+ info("Make remote changes: change A; B > ((D > F) G)");
+ await storeRecords(buf, [
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A (remote)",
+ bmkUri: "http://example.com/a-remote",
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B (remote)",
+ children: ["bookmarkCCCC", "folderDDDDDD", "bookmarkGGGG"],
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "folderBBBBBB",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkEEEE", "bookmarkFFFF"],
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "F (remote)",
+ bmkUri: "http://example.com/f-remote",
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "G (remote)",
+ bmkUri: "http://example.com/g-remote",
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkFFFF", "bookmarkGGGG", "folderBBBBBB", "folderDDDDDD"],
+ "Should leave deleted B and D; relocated F and G unmerged"
+ );
+ deepEqual(
+ mergeTelemetryCounts,
+ [
+ { name: "items", count: 8 },
+ { name: "remoteRevives", count: 1 },
+ { name: "localDeletes", count: 2 },
+ ],
+ "Should record telemetry for remote item and local folder deletions"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkFFFF", "bookmarkGGGG", "menu"],
+ deleted: ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"],
+ },
+ "Should upload relocated remote orphans and menu"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A (remote)",
+ url: "http://example.com/a-remote",
+ },
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F (remote)",
+ url: "http://example.com/f-remote",
+ },
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "G (remote)",
+ url: "http://example.com/g-remote",
+ },
+ ],
+ },
+ "Should restore A and relocate (F G) to menu"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"],
+ "Should store local tombstones for deleted items; remove for undeleted"
+ );
+ Assert.ok(
+ tombstones.every(({ dateRemoved }) =>
+ is_time_ordered(now, dateRemoved.getTime())
+ ),
+ "Local tombstone timestamps should be recent"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_to_new_then_delete() {
+ let buf = await openMirror("move_to_new_then_delete");
+
+ info("Set up mirror: A > B > (C D)");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ {
+ guid: "bookmarkDDDD",
+ url: "http://example.com/d",
+ title: "D",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["folderBBBBBB"],
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC", "bookmarkDDDD"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: E > A, delete E");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "folderEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "E",
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: "folderAAAAAA",
+ parentGuid: "folderEEEEEE",
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+ // E isn't synced, so we shouldn't upload a tombstone.
+ await PlacesUtils.bookmarks.remove("folderEEEEEE");
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: "http://example.com/c-remote",
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkCCCC", PlacesUtils.bookmarks.toolbarGuid],
+ "Should leave revived C and toolbar with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkCCCC", "menu", "toolbar"],
+ deleted: ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"],
+ },
+ "Should upload records for Menu > C, Toolbar"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should move C to closest surviving parent"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"],
+ "Should store local tombstones for (D A B)"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_nonexistent_on_one_side() {
+ let buf = await openMirror("nonexistent_on_one_side");
+
+ info("Set up empty mirror");
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // A doesn't exist in the mirror.
+ info("Create local tombstone for nonexistent remote item A");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "A",
+ url: "http://example.com/a",
+ // Pretend a bookmark restore added A, so that we'll write a tombstone when
+ // we remove it.
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE,
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkAAAA");
+
+ // B doesn't exist in Places, and we don't currently persist tombstones (bug
+ // 1343103), so we should ignore it.
+ info("Create remote tombstone for nonexistent local item B");
+ await storeRecords(buf, [
+ {
+ id: "bookmarkBBBB",
+ deleted: true,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid],
+ "Should leave menu with new remote structure unmerged"
+ );
+
+ let menuInfo = await PlacesUtils.bookmarks.fetch(
+ PlacesUtils.bookmarks.menuGuid
+ );
+
+ // We should still upload a record for the menu, since we changed its
+ // children when we added then removed A.
+ deepEqual(changesToUpload, {
+ menu: {
+ tombstone: false,
+ counter: 2,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: menuInfo.dateAdded.getTime(),
+ title: BookmarksMenuTitle,
+ children: [],
+ },
+ },
+ });
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_clear_folder_then_delete() {
+ let buf = await openMirror("clear_folder_then_delete");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ {
+ guid: "bookmarkFFFF",
+ url: "http://example.com/f",
+ title: "F",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderDDDDDD"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkEEEE", "bookmarkFFFF"],
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes: Menu > E, Mobile > F, delete D");
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkEEEE",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkFFFF",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ index: 0,
+ });
+ await PlacesUtils.bookmarks.remove("folderDDDDDD");
+
+ info("Make remote changes: Menu > B, Unfiled > C, delete A");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB", "folderDDDDDD"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "folderAAAAAA",
+ deleted: true,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.mobileGuid],
+ "Should leave menu and mobile with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkEEEE", "bookmarkFFFF", "menu", "mobile"],
+ deleted: ["folderDDDDDD"],
+ },
+ "Should upload locally moved and deleted items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ ],
+ },
+ "Should not orphan moved children of a deleted folder"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["folderDDDDDD"],
+ "Should store local tombstone for D"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_newer_move_to_deleted() {
+ let buf = await openMirror("test_newer_move_to_deleted");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ ],
+ },
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "C",
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ url: "http://example.com/d",
+ title: "D",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderCCCCCC"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "menu",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkDDDD"],
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let now = Date.now();
+
+ // A will have a newer local timestamp. However, we should *not* revert
+ // remotely moving B to the toolbar. (Locally, B exists in A, but we
+ // deleted the now-empty A remotely).
+ info("Make local changes: A > E, Toolbar > D, delete C");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkEEEE",
+ parentGuid: "folderAAAAAA",
+ title: "E",
+ url: "http://example.com/e",
+ dateAdded: new Date(now),
+ lastModified: new Date(now),
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkDDDD",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ lastModified: new Date(now),
+ });
+ await PlacesUtils.bookmarks.remove("folderCCCCCC");
+
+ // C will have a newer remote timestamp. However, we should *not* revert
+ // locally moving D to the toolbar. (Locally, D exists in C, but we
+ // deleted the now-empty C locally).
+ info("Make remote changes: C > F, Toolbar > B, delete A");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderCCCCCC"],
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "menu",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkDDDD", "bookmarkFFFF"],
+ modified: now / 1000 + 5,
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB"],
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderAAAAAA",
+ deleted: true,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ localTimeSeconds: now / 1000,
+ remoteTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkFFFF",
+ "folderCCCCCC",
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ ],
+ "Should leave deleted C; revived F and roots with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ "bookmarkFFFF",
+ "menu",
+ "toolbar",
+ ],
+ deleted: ["folderCCCCCC"],
+ },
+ "Should upload new and moved items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "D",
+ url: "http://example.com/d",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should not decide to keep newly moved items in deleted parents"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["folderCCCCCC"],
+ "Should store local tombstone for C"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_remotely_deleted_also_removes_keyword() {
+ let buf = await openMirror("remotely_deleted_removes_keyword");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ keyword: "keyworda",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ keyword: "keywordb",
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "keyworda",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ keyword: "keywordb",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // Validate the keywords exists
+ let has_keyword_a = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/a",
+ });
+ Assert.equal(has_keyword_a.keyword, "keyworda");
+
+ let has_keyword_b = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/b",
+ });
+ Assert.equal(has_keyword_b.keyword, "keywordb");
+
+ info("Make remote changes: delete A & B");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ {
+ id: "bookmarkAAAA",
+ deleted: true,
+ },
+ {
+ id: "bookmarkBBBB",
+ deleted: true,
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "No local changes done"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ },
+ "Should've remove A & B from menu"
+ );
+
+ // Validate the keyword no longer exists after removing the bookmark
+ let no_keyword_a = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/a",
+ });
+ Assert.equal(no_keyword_a, null);
+
+ // Both keywords should've been removed after the sync
+ let no_keyword_b = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/b",
+ });
+ Assert.equal(no_keyword_b, null);
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Should not store local tombstones");
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_haschanges.js b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js
new file mode 100644
index 0000000000..32cfd050aa
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js
@@ -0,0 +1,228 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_no_changes() {
+ let buf = await openMirror("nochanges");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let controller = new AbortController();
+ const wasMerged = await buf.merge(controller.signal);
+ Assert.ok(!wasMerged);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_changes_remote() {
+ let buf = await openMirror("remote_changes");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "New Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ { needsMerge: true }
+ );
+
+ let controller = new AbortController();
+ const wasMerged = await buf.merge(controller.signal);
+ Assert.ok(wasMerged);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_changes_local() {
+ let buf = await openMirror("local_changes");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await PlacesUtils.bookmarks.update({
+ guid: "mozBmk______",
+ title: "New Mozilla!",
+ });
+
+ let controller = new AbortController();
+ const wasMerged = await buf.merge(controller.signal);
+ Assert.ok(wasMerged);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_changes_deleted_bookmark() {
+ let buf = await openMirror("delete_bookmark");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events =>
+ events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid)
+ );
+ await PlacesUtils.bookmarks.remove("mozBmk______");
+
+ await wait;
+ // Wait for everything to be finished
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ let controller = new AbortController();
+ const wasMerged = await buf.merge(controller.signal);
+ Assert.ok(wasMerged);
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_kinds.js b/toolkit/components/places/tests/sync/test_bookmark_kinds.js
new file mode 100644
index 0000000000..3372757532
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_kinds.js
@@ -0,0 +1,312 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_queries() {
+ let buf = await openMirror("queries");
+
+ info("Set up places");
+
+ // create a tag and grab the local folder ID.
+ let tag = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: "a-tag",
+ });
+
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // this entry has a tag= query param for a tag that exists.
+ guid: "queryAAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "TAG_QUERY query",
+ url: `place:tag=a-tag&&sort=14&maxResults=10`,
+ },
+ {
+ // this entry has a tag= query param for a tag that doesn't exist.
+ guid: "queryBBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "TAG_QUERY query but invalid folder id",
+ url: `place:tag=b-tag&sort=14&maxResults=10`,
+ },
+ {
+ // this entry has no tag= query param.
+ guid: "queryCCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "TAG_QUERY without a folder at all",
+ url: "place:sort=14&maxResults=10",
+ },
+ {
+ // this entry has only a tag= query.
+ guid: "queryDDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "TAG_QUERY without a folder at all",
+ url: "place:tag=a-tag",
+ },
+ ],
+ });
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "queryEEEEEEE",
+ "queryFFFFFFF",
+ "queryGGGGGGG",
+ "queryHHHHHHH",
+ "queryIIIIIII",
+ ],
+ },
+ {
+ // Legacy tag query.
+ id: "queryEEEEEEE",
+ parentid: "toolbar",
+ type: "query",
+ title: "E",
+ bmkUri: "place:type=7&folder=999",
+ folderName: "taggy",
+ },
+ {
+ // New tag query.
+ id: "queryFFFFFFF",
+ parentid: "toolbar",
+ type: "query",
+ title: "F",
+ bmkUri: "place:tag=a-tag",
+ folderName: "a-tag",
+ },
+ {
+ // Legacy tag query referencing the same tag as the new query.
+ id: "queryGGGGGGG",
+ parentid: "toolbar",
+ type: "query",
+ title: "G",
+ bmkUri: "place:type=7&folder=111&something=else",
+ folderName: "a-tag",
+ },
+ {
+ // Legacy folder lookup query.
+ id: "queryHHHHHHH",
+ parentid: "toolbar",
+ type: "query",
+ title: "H",
+ bmkUri: "place:folder=1",
+ },
+ {
+ // Legacy tag query with invalid tag folder name.
+ id: "queryIIIIIII",
+ parentid: "toolbar",
+ type: "query",
+ title: "I",
+ bmkUri: "place:type=7&folder=222",
+ folderName: " ",
+ },
+ ])
+ );
+
+ info("Create records to upload");
+ let changes = await buf.apply();
+ deepEqual(
+ Object.keys(changes),
+ [
+ "menu",
+ "toolbar",
+ "queryAAAAAAA",
+ "queryBBBBBBB",
+ "queryCCCCCCC",
+ "queryDDDDDDD",
+ "queryEEEEEEE",
+ "queryGGGGGGG",
+ "queryHHHHHHH",
+ "queryIIIIIII",
+ ],
+ "Should upload roots, new queries, and rewritten queries"
+ );
+ Assert.strictEqual(changes.queryAAAAAAA.cleartext.folderName, tag.title);
+ Assert.strictEqual(changes.queryBBBBBBB.cleartext.folderName, "b-tag");
+ Assert.strictEqual(changes.queryCCCCCCC.cleartext.folderName, undefined);
+ Assert.strictEqual(changes.queryDDDDDDD.cleartext.folderName, tag.title);
+ Assert.strictEqual(changes.queryIIIIIII.tombstone, true);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.toolbarGuid,
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "queryEEEEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "place:tag=taggy",
+ },
+ {
+ guid: "queryFFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F",
+ url: "place:tag=a-tag",
+ },
+ {
+ guid: "queryGGGGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "G",
+ url: "place:tag=a-tag",
+ },
+ {
+ guid: "queryHHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "H",
+ url: "place:folder=1&excludeItems=1",
+ },
+ ],
+ },
+ "Should rewrite legacy remote queries"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_different_but_compatible_bookmark_types() {
+ let buf = await openMirror("partial_queries");
+ try {
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "not yet a query",
+ url: "about:blank",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "a query",
+ url: "place:foo",
+ },
+ ],
+ });
+
+ let changes = await buf.apply();
+ // We should have an outgoing record for bookmarkA with type=bookmark
+ // and bookmarkB with type=query.
+ Assert.equal(changes.bookmarkAAAA.cleartext.type, "bookmark");
+ Assert.equal(changes.bookmarkBBBB.cleartext.type, "query");
+
+ // Now pretend that same records are already on the server.
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "not yet a query",
+ bmkUri: "about:blank",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "query",
+ title: "a query",
+ bmkUri: "place:foo",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // change the url of bookmarkA to be a "real" query and of bookmarkB to
+ // no longer be a query.
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkAAAA",
+ url: "place:type=6&sort=14&maxResults=10",
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkBBBB",
+ url: "about:robots",
+ });
+
+ changes = await buf.apply();
+ // We should have an outgoing record for bookmarkA with type=query and
+ // for bookmarkB with type=bookmark
+ Assert.equal(changes.bookmarkAAAA.cleartext.type, "query");
+ Assert.equal(changes.bookmarkBBBB.cleartext.type, "bookmark");
+ } finally {
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+ }
+});
+
+add_task(async function test_incompatible_types() {
+ try {
+ let buf = await openMirror("incompatible_types");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "AAAAAAAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: "a bookmark",
+ url: "about:blank",
+ },
+ ],
+ });
+
+ await buf.apply();
+
+ // Now pretend that same records are already on the server with incompatible
+ // types.
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["AAAAAAAAAAAA"],
+ },
+ {
+ id: "AAAAAAAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "conflicting folder",
+ },
+ ],
+ { needsMerge: true }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await Assert.rejects(
+ buf.apply(),
+ /Can't merge local Bookmark <guid: AAAAAAAAAAAA> and remote Folder <guid: AAAAAAAAAAAA>/
+ );
+ } finally {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+ }
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js
new file mode 100644
index 0000000000..6c475daab6
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js
@@ -0,0 +1,193 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_highWaterMark() {
+ let buf = await openMirror("highWaterMark");
+
+ strictEqual(
+ await buf.getCollectionHighWaterMark(),
+ 0,
+ "High water mark should be 0 without items"
+ );
+
+ await buf.setCollectionLastModified(123.45);
+ equal(
+ await buf.getCollectionHighWaterMark(),
+ 123.45,
+ "High water mark should be last modified time without items"
+ );
+
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ modified: 50,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ modified: 123.95,
+ },
+ ]);
+ equal(
+ await buf.getCollectionHighWaterMark(),
+ 123.45,
+ "High water mark should be last modified time if items are older"
+ );
+
+ await storeRecords(buf, [
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ modified: 125.45,
+ },
+ ]);
+ equal(
+ await buf.getCollectionHighWaterMark(),
+ 124.45,
+ "High water mark should be modified time - 1s of newest record if exists"
+ );
+
+ await buf.finalize();
+});
+
+add_task(async function test_ensureCurrentSyncId() {
+ let buf = await openMirror("ensureCurrentSyncId");
+
+ await buf.ensureCurrentSyncId("syncIdAAAAAA");
+ equal(
+ await buf.getCollectionHighWaterMark(),
+ 0,
+ "High water mark should be 0 after setting sync ID"
+ );
+
+ info("Insert items and set collection last modified");
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ modified: 125.45,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ children: [],
+ },
+ ],
+ { needsMerge: false }
+ );
+ await buf.setCollectionLastModified(123.45);
+
+ info("Set matching sync ID");
+ await buf.ensureCurrentSyncId("syncIdAAAAAA");
+ {
+ equal(
+ await buf.getSyncId(),
+ "syncIdAAAAAA",
+ "Should return existing sync ID"
+ );
+ strictEqual(
+ await buf.getCollectionHighWaterMark(),
+ 124.45,
+ "Different sync ID should reset high water mark"
+ );
+
+ let itemRows = await buf.db.execute(`
+ SELECT guid, needsMerge FROM items
+ ORDER BY guid`);
+ let itemInfos = itemRows.map(row => ({
+ guid: row.getResultByName("guid"),
+ needsMerge: !!row.getResultByName("needsMerge"),
+ }));
+ deepEqual(
+ itemInfos,
+ [
+ {
+ guid: "folderAAAAAA",
+ needsMerge: false,
+ },
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ needsMerge: false,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ needsMerge: true,
+ },
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ needsMerge: false,
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ needsMerge: true,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ needsMerge: true,
+ },
+ ],
+ "Matching sync ID should not reset items"
+ );
+ }
+
+ info("Set different sync ID");
+ await buf.ensureCurrentSyncId("syncIdBBBBBB");
+ {
+ equal(
+ await buf.getSyncId(),
+ "syncIdBBBBBB",
+ "Should replace existing sync ID"
+ );
+ strictEqual(
+ await buf.getCollectionHighWaterMark(),
+ 0,
+ "Different sync ID should reset high water mark"
+ );
+
+ let itemRows = await buf.db.execute(`
+ SELECT guid, needsMerge FROM items
+ ORDER BY guid`);
+ let itemInfos = itemRows.map(row => ({
+ guid: row.getResultByName("guid"),
+ needsMerge: !!row.getResultByName("needsMerge"),
+ }));
+ deepEqual(
+ itemInfos,
+ [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ needsMerge: true,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ needsMerge: true,
+ },
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ needsMerge: false,
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ needsMerge: true,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ needsMerge: true,
+ },
+ ],
+ "Different sync ID should reset items"
+ );
+ }
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js
new file mode 100644
index 0000000000..86cf45eb0f
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Keep in sync with `SyncedBookmarksMirror.jsm`.
+const CURRENT_MIRROR_SCHEMA_VERSION = 9;
+
+// The oldest schema version that we support. Any databases with schemas older
+// than this will be dropped and recreated.
+const OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION = 5;
+
+async function getIndexNames(db, table, schema = "mirror") {
+ let rows = await db.execute(`PRAGMA ${schema}.index_list(${table})`);
+ let names = [];
+ for (let row of rows) {
+ // Column 4 is `c` if the index was created via `CREATE INDEX`, `u` if
+ // via `UNIQUE`, and `pk` if via `PRIMARY KEY`.
+ let wasCreated = row.getResultByIndex(3) == "c";
+ if (wasCreated) {
+ // Column 2 is the name of the index.
+ names.push(row.getResultByIndex(1));
+ }
+ }
+ return names.sort();
+}
+
+add_task(async function test_migrate_after_downgrade() {
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let dbFile = await setupFixtureFile("mirror_v5.sqlite");
+ let oldBuf = await SyncedBookmarksMirror.open({
+ path: dbFile.path,
+ recordStepTelemetry() {},
+ recordValidationTelemetry() {},
+ });
+
+ info("Downgrade schema version to oldest supported");
+ await oldBuf.db.setSchemaVersion(
+ OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION,
+ "mirror"
+ );
+ await oldBuf.finalize();
+
+ let buf = await SyncedBookmarksMirror.open({
+ path: dbFile.path,
+ recordStepTelemetry() {},
+ recordValidationTelemetry() {},
+ });
+
+ // All migrations between `OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION` should
+ // be idempotent. When we downgrade, we roll back the schema version, but
+ // leave the schema changes in place, since we can't anticipate what a
+ // future version will change.
+ let schemaVersion = await buf.db.getSchemaVersion("mirror");
+ equal(
+ schemaVersion,
+ CURRENT_MIRROR_SCHEMA_VERSION,
+ "Should upgrade downgraded mirror schema"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Migrations between 5 and 7 add three indexes.
+add_task(async function test_migrate_from_5_to_current() {
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let dbFile = await setupFixtureFile("mirror_v5.sqlite");
+ let buf = await SyncedBookmarksMirror.open({
+ path: dbFile.path,
+ recordStepTelemetry() {},
+ recordValidationTelemetry() {},
+ });
+
+ let schemaVersion = await buf.db.getSchemaVersion("mirror");
+ equal(
+ schemaVersion,
+ CURRENT_MIRROR_SCHEMA_VERSION,
+ "Should upgrade mirror schema to current version"
+ );
+
+ let itemsIndexNames = await getIndexNames(buf.db, "items");
+ deepEqual(
+ itemsIndexNames,
+ ["itemKeywords", "itemURLs"],
+ "Should add two indexes on items"
+ );
+
+ let structureIndexNames = await getIndexNames(buf.db, "structure");
+ deepEqual(
+ structureIndexNames,
+ ["structurePositions"],
+ "Should add an index on structure"
+ );
+
+ let changesToUpload = await buf.apply();
+ deepEqual(changesToUpload, {}, "Shouldn't flag any items for reupload");
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ keyword: "hi",
+ },
+ ],
+ },
+ "Should apply mirror tree after migrating"
+ );
+
+ let keywordEntry = await PlacesUtils.keywords.fetch("hi");
+ equal(
+ keywordEntry.url.href,
+ "http://example.com/b",
+ "Should apply keyword from migrated mirror"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Migrations between 1 and 2 discard the entire database.
+add_task(async function test_migrate_from_1_to_2() {
+ let dbFile = await setupFixtureFile("mirror_v1.sqlite");
+ let buf = await SyncedBookmarksMirror.open({
+ path: dbFile.path,
+ });
+ ok(
+ buf.wasCorrupt,
+ "Migrating from unsupported version should mark database as corrupt"
+ );
+ await buf.finalize();
+});
+
+add_task(async function test_database_corrupt() {
+ let corruptFile = await setupFixtureFile("mirror_corrupt.sqlite");
+ let buf = await SyncedBookmarksMirror.open({
+ path: corruptFile.path,
+ });
+ ok(buf.wasCorrupt, "Opening corrupt database should mark it as such");
+ await buf.finalize();
+});
+
+add_task(async function test_migrate_v7_v9() {
+ let buf = await openMirror("test_migrate_v7_v9");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ },
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ ],
+ });
+
+ await buf.db.execute(
+ `UPDATE moz_bookmarks
+ SET syncChangeCounter = 0,
+ syncStatus = ${PlacesUtils.bookmarks.SYNC_STATUS.NEW}`
+ );
+
+ // setup the mirror.
+ await storeRecords(buf, [
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ },
+ ]);
+
+ await buf.db.setSchemaVersion(7, "mirror");
+ await buf.finalize();
+
+ // reopen it.
+ buf = await openMirror("test_migrate_v7_v9");
+ Assert.equal(await buf.db.getSchemaVersion("mirror"), 9, "did upgrade");
+
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ PlacesUtils.bookmarks.menuGuid
+ );
+ let [fieldsA, fieldsB, fieldsMenu] = fields;
+
+ // 'A' was in the mirror - should now be _NORMAL
+ Assert.equal(fieldsA.guid, "bookmarkAAAA");
+ Assert.equal(fieldsA.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL);
+ // 'B' was not in the mirror so should be untouched.
+ Assert.equal(fieldsB.guid, "bookmarkBBBB");
+ Assert.equal(fieldsB.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NEW);
+ // 'menu' was in the mirror - should now be _NORMAL
+ Assert.equal(fieldsMenu.guid, PlacesUtils.bookmarks.menuGuid);
+ Assert.equal(fieldsMenu.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL);
+ await buf.finalize();
+});
+
+add_task(async function test_migrate_v8_v9() {
+ let dbFile = await setupFixtureFile("mirror_v8.sqlite");
+ let buf = await SyncedBookmarksMirror.open({
+ path: dbFile.path,
+ recordStepTelemetry() {},
+ recordValidationTelemetry() {},
+ });
+
+ Assert.equal(await buf.db.getSchemaVersion("mirror"), 9, "did upgrade");
+
+ // Verify the new column is there
+ Assert.ok(await buf.db.execute("SELECT unknownFields FROM items"));
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js
new file mode 100644
index 0000000000..16d8ed746c
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js
@@ -0,0 +1,670 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function promiseAllURLFrecencies() {
+ let frecencies = new Map();
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(`
+ SELECT url, frecency, recalc_frecency
+ FROM moz_places
+ WHERE url_hash BETWEEN hash('http', 'prefix_lo') AND
+ hash('http', 'prefix_hi')`);
+ for (let row of rows) {
+ frecencies.set(row.getResultByName("url"), {
+ frecency: row.getResultByName("frecency"),
+ recalc: row.getResultByName("recalc_frecency"),
+ });
+ }
+ return frecencies;
+}
+
+function mapFilterIterator(iter, fn) {
+ let results = [];
+ for (let value of iter) {
+ let newValue = fn(value);
+ if (newValue) {
+ results.push(newValue);
+ }
+ }
+ return results;
+}
+
+add_task(async function test_update_frecencies() {
+ let buf = await openMirror("update_frecencies");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // Not modified in mirror; shouldn't recalculate frecency.
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ // URL changed to B1 in mirror; should recalculate frecency for B
+ // and B1, using existing frecency to determine order.
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ // URL changed to new URL in mirror, should recalculate frecency
+ // for new URL first, before B1.
+ guid: "bookmarkBBB1",
+ title: "B1",
+ url: "http://example.com/b1",
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkBBB1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B1",
+ bmkUri: "http://example.com/b1",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local changes");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // Query; shouldn't recalculate frecency.
+ guid: "queryCCCCCCC",
+ title: "C",
+ url: "place:type=6&sort=14&maxResults=10",
+ },
+ ],
+ });
+
+ info("Calculate frecencies for all local URLs");
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"],
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkBBB2",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ "queryFFFFFFF",
+ ],
+ },
+ {
+ // Existing bookmark changed to existing URL.
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b1",
+ },
+ {
+ // Existing bookmark with new URL; should recalculate frecency first.
+ id: "bookmarkBBB1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B1",
+ bmkUri: "http://example.com/b11",
+ },
+ {
+ id: "bookmarkBBB2",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "B2",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ // New bookmark with new URL; should recalculate frecency first.
+ id: "bookmarkDDDD",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: null,
+ bmkUri: "http://example.com/d",
+ },
+ {
+ // New bookmark with new URL.
+ id: "bookmarkEEEE",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ {
+ // New query; shouldn't count against limit.
+ id: "queryFFFFFFF",
+ parentid: "unfiled",
+ type: "query",
+ title: "F",
+ bmkUri: `place:parent=${PlacesUtils.bookmarks.menuGuid}`,
+ },
+ ]);
+
+ info("Apply new items and recalculate 3 frecencies");
+ await buf.apply();
+ await PlacesFrecencyRecalculator.recalculateSomeFrecencies({ chunkSize: 3 });
+
+ {
+ let frecencies = await promiseAllURLFrecencies();
+ let urlsWithFrecency = mapFilterIterator(
+ frecencies.entries(),
+ ([href, { frecency, recalc }]) => (recalc == 0 ? href : null)
+ );
+
+ // A is unchanged, and we should recalculate frecency for three more
+ // random URLs.
+ equal(
+ urlsWithFrecency.length,
+ 4,
+ "Should keep unchanged frecency and recalculate 3"
+ );
+ let unexpectedURLs = CommonUtils.difference(
+ urlsWithFrecency,
+ new Set([
+ // A is unchanged.
+ "http://example.com/a",
+
+ // B11, D, and E are new URLs.
+ "http://example.com/b11",
+ "http://example.com/d",
+ "http://example.com/e",
+
+ // B and B1 are existing, changed URLs.
+ "http://example.com/b",
+ "http://example.com/b1",
+ ])
+ );
+ ok(
+ !unexpectedURLs.size,
+ "Should recalculate frecency for new and changed URLs only"
+ );
+ }
+
+ info("Change non-URL property of D");
+ await storeRecords(buf, [
+ {
+ id: "bookmarkDDDD",
+ parentid: "unfiled",
+ type: "bookmark",
+ title: "D (remote)",
+ bmkUri: "http://example.com/d",
+ },
+ ]);
+
+ info("Apply new item and recalculate remaining frecencies");
+ await buf.apply();
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ {
+ let frecencies = await promiseAllURLFrecencies();
+ let urlsWithoutFrecency = mapFilterIterator(
+ frecencies.entries(),
+ ([href, { frecency, recalc }]) => (recalc == 1 ? href : null)
+ );
+ deepEqual(
+ urlsWithoutFrecency,
+ [],
+ "Should finish calculating remaining frecencies"
+ );
+ }
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+async function setupLocalTree(localTimeSeconds) {
+ let dateAdded = new Date(localTimeSeconds * 1000);
+ let lastModified = new Date(localTimeSeconds * 1000);
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ dateAdded,
+ lastModified,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ dateAdded,
+ lastModified,
+ },
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ dateAdded,
+ lastModified,
+ },
+ ],
+ },
+ {
+ guid: "bookmarkDDDD",
+ title: null,
+ url: "http://example.com/d",
+ dateAdded,
+ lastModified,
+ },
+ ],
+ });
+}
+
+// This test ensures we clean up the temp tables between merges, and don't throw
+// constraint errors recording observer notifications.
+add_task(async function test_apply_then_revert() {
+ let buf = await openMirror("apply_then_revert");
+
+ let now = Date.now() / 1000;
+ let localTimeSeconds = now - 180;
+
+ info("Set up initial local tree and mirror");
+ await setupLocalTree(localTimeSeconds);
+ let recordsToUpload = await buf.apply({
+ localTimeSeconds,
+ remoteTimeSeconds: now,
+ });
+ await storeChangesInMirror(buf, recordsToUpload);
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkEEE1",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "E",
+ url: "http://example.com/e",
+ dateAdded: new Date(localTimeSeconds * 1000),
+ lastModified: new Date(localTimeSeconds * 1000),
+ });
+
+ info("Make remote changes");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkEEEE", "bookmarkFFFF"],
+ modified: now,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ modified: now,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "toolbar",
+ type: "folder",
+ title: "A (remote)",
+ children: ["bookmarkCCCC", "bookmarkBBBB"],
+ modified: now,
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b-remote",
+ modified: now,
+ },
+ {
+ id: "bookmarkDDDD",
+ deleted: true,
+ modified: now,
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "menu",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ modified: now,
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "menu",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ modified: now,
+ },
+ ]);
+
+ info("Apply remote changes, first time");
+ let firstTimeRecords = await buf.apply({
+ localTimeSeconds,
+ remoteTimeSeconds: now,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid],
+ "Should leave menu with new remote structure unmerged after first time"
+ );
+
+ info("Revert local tree");
+ let dateAdded = new Date(localTimeSeconds * 1000);
+ await PlacesSyncUtils.bookmarks.wipe();
+ await setupLocalTree(localTimeSeconds);
+ await PlacesTestUtils.markBookmarksAsSynced();
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkEEE1",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "E",
+ url: "http://example.com/e",
+ dateAdded,
+ lastModified: new Date(localTimeSeconds * 1000),
+ });
+ let localIdForD = await PlacesTestUtils.promiseItemId("bookmarkDDDD");
+
+ info("Apply remote changes, second time");
+ await buf.db.execute(
+ `
+ UPDATE items SET
+ needsMerge = 1
+ WHERE guid <> :rootGuid`,
+ { rootGuid: PlacesUtils.bookmarks.rootGuid }
+ );
+ let observer = expectBookmarkChangeNotifications();
+ let secondTimeRecords = await buf.apply({
+ localTimeSeconds,
+ remoteTimeSeconds: now,
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid],
+ "Should leave menu with new remote structure unmerged after second time"
+ );
+ deepEqual(
+ secondTimeRecords,
+ firstTimeRecords,
+ "Should stage identical records to upload, first and second time"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "bookmarkFFFF",
+ "bookmarkEEEE",
+ "folderAAAAAA",
+ "bookmarkCCCC",
+ "bookmarkBBBB",
+ PlacesUtils.bookmarks.menuGuid,
+ ]);
+ observer.check([
+ {
+ name: "bookmark-removed",
+ params: {
+ itemId: localIdForD,
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/d",
+ title: "", // null titles get turned into empty strings.
+ guid: "bookmarkDDDD",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ },
+ },
+ {
+ name: "bookmark-guid-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkEEEE"),
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "",
+ guid: "bookmarkEEEE",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging: false,
+ },
+ },
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("bookmarkFFFF"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/f",
+ title: "F",
+ guid: "bookmarkFFFF",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkEEEE"),
+ oldIndex: 2,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkEEEE",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/e",
+ isTagging: false,
+ title: "E",
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("folderAAAAAA"),
+ oldIndex: 0,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ guid: "folderAAAAAA",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "",
+ isTagging: false,
+ title: "A (remote)",
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkCCCC",
+ oldParentGuid: "folderAAAAAA",
+ newParentGuid: "folderAAAAAA",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/c",
+ isTagging: false,
+ title: "C",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ oldIndex: 0,
+ newIndex: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkBBBB",
+ oldParentGuid: "folderAAAAAA",
+ newParentGuid: "folderAAAAAA",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/b-remote",
+ isTagging: false,
+ title: "B",
+ tags: "",
+ frecency: -1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("folderAAAAAA"),
+ title: "A (remote)",
+ guid: "folderAAAAAA",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ },
+ },
+ {
+ name: "bookmark-url-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/b-remote",
+ guid: "bookmarkBBBB",
+ parentGuid: "folderAAAAAA",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging: false,
+ },
+ },
+ ]);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A (remote)",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b-remote",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should apply new structure, second time"
+ );
+
+ await storeChangesInMirror(buf, secondTimeRecords);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_reconcile.js b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js
new file mode 100644
index 0000000000..218e84beb6
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js
@@ -0,0 +1,191 @@
+// Get bookmarks which aren't marked as normally syncing and with no pending
+// changes.
+async function getBookmarksNotMarkedAsSynced() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ `
+ SELECT guid, syncStatus, syncChangeCounter FROM moz_bookmarks
+ WHERE syncChangeCounter > 1 OR syncStatus != :syncStatus
+ ORDER BY guid
+ `,
+ { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
+ );
+ return rows.map(row => {
+ return {
+ guid: row.getResultByName("guid"),
+ syncStatus: row.getResultByName("syncStatus"),
+ syncChangeCounter: row.getResultByName("syncChangeCounter"),
+ };
+ });
+}
+
+add_task(async function test_reconcile_metadata() {
+ let buf = await openMirror("test_reconcile_metadata");
+
+ let olderDate = new Date(Date.now() - 100000);
+ info("Set up local tree");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ // this folder is going to reconcile exactly
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ ],
+ },
+ {
+ // this folder's existing child isn't on the server (so will be
+ // outgoing) and also will take a new child from the server.
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "C",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ ],
+ },
+ {
+ // This bookmark is going to take the remote title.
+ guid: "bookmarkFFFF",
+ url: "http://example.com/f",
+ title: "f",
+ dateAdded: olderDate,
+ lastModified: olderDate,
+ },
+ ],
+ });
+ // And a single, local-only bookmark in the toolbar.
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bookmarkTTTT",
+ url: "http://example.com/t",
+ title: "in the toolbar",
+ dateAdded: olderDate,
+ lastModified: olderDate,
+ },
+ ],
+ });
+ // Reset to prepare for our reconciled sync.
+ await PlacesSyncUtils.bookmarks.reset();
+ // setup the mirror.
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "menu",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkDDDD"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "menu",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ dateAdded: olderDate,
+ modified: Date.now() / 1000 + 60,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ index: 1,
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: [],
+ index: 3,
+ },
+ ])
+ );
+ info("Applying");
+ let changesToUpload = await buf.apply();
+ // We need to upload a bookmark and the parent as they didn't exist on the
+ // server. Since we always use the local state for roots (bug 1472241), we'll
+ // reupload them too.
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [
+ "bookmarkEEEE",
+ "bookmarkTTTT",
+ "folderCCCCCC",
+ "menu",
+ "mobile",
+ "toolbar",
+ "unfiled",
+ ],
+ deleted: [],
+ },
+ "Should upload the 2 local-only bookmarks and their parents"
+ );
+ // Check it took the remote thing we were expecting.
+ Assert.equal((await PlacesUtils.bookmarks.fetch("bookmarkFFFF")).title, "F");
+ // Most things should be synced and have no change counter.
+ let badGuids = await getBookmarksNotMarkedAsSynced();
+ Assert.deepEqual(badGuids, [
+ {
+ // The bookmark that was only on the server. Still have SYNC_STATUS_NEW
+ // as it's yet to be uploaded.
+ guid: "bookmarkEEEE",
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ syncChangeCounter: 1,
+ },
+ {
+ // This bookmark is local only so is yet to be uploaded.
+ guid: "bookmarkTTTT",
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ syncChangeCounter: 1,
+ },
+ ]);
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
new file mode 100644
index 0000000000..cde4d5e751
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
@@ -0,0 +1,2966 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_value_structure_conflict() {
+ let buf = await openMirror("value_structure_conflict");
+
+ info("Set up mirror");
+ let dateAdded = new Date();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ dateAdded,
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ dateAdded,
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ dateAdded,
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderDDDDDD"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkCCCC"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkEEEE"],
+ modified: Date.now() / 1000 - 60,
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ modified: Date.now() / 1000 - 60,
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local value change");
+ await PlacesUtils.bookmarks.update({
+ guid: "folderAAAAAA",
+ title: "A (local)",
+ });
+
+ info("Make local structure change");
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkBBBB",
+ parentGuid: "folderDDDDDD",
+ index: 0,
+ });
+
+ info("Make remote value change");
+ await storeRecords(buf, [
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D (remote)",
+ children: ["bookmarkEEEE"],
+ modified: Date.now() / 1000 + 60,
+ },
+ ]);
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ remoteTimeSeconds: Date.now() / 1000,
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["folderDDDDDD"],
+ "Should leave D with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkBBBB", "folderAAAAAA", "folderDDDDDD"],
+ deleted: [],
+ },
+ "Should upload records for merged and new local items"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "folderAAAAAA",
+ "bookmarkEEEE",
+ "bookmarkBBBB",
+ "folderDDDDDD",
+ ]);
+ observer.check([
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkEEEE"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkEEEE",
+ oldParentGuid: "folderDDDDDD",
+ newParentGuid: "folderDDDDDD",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/e",
+ isTagging: false,
+ title: "E",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ oldIndex: 0,
+ newIndex: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkBBBB",
+ oldParentGuid: "folderDDDDDD",
+ newParentGuid: "folderDDDDDD",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/b",
+ isTagging: false,
+ title: "B",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("folderDDDDDD"),
+ title: "D (remote)",
+ guid: "folderDDDDDD",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ ]);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A (local)",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "D (remote)",
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ ],
+ },
+ "Should reconcile structure and value changes"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move() {
+ let buf = await openMirror("move");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "devFolder___",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "Dev",
+ children: [
+ {
+ guid: "mdnBmk______",
+ title: "MDN",
+ url: "https://developer.mozilla.org",
+ },
+ {
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ guid: "mozFolder___",
+ title: "Mozilla",
+ children: [
+ {
+ guid: "fxBmk_______",
+ title: "Get Firefox!",
+ url: "http://getfirefox.com/",
+ },
+ {
+ guid: "nightlyBmk__",
+ title: "Nightly",
+ url: "https://nightly.mozilla.org",
+ },
+ ],
+ },
+ {
+ guid: "wmBmk_______",
+ title: "Webmaker",
+ url: "https://webmaker.org",
+ },
+ ],
+ },
+ {
+ guid: "bzBmk_______",
+ title: "Bugzilla",
+ url: "https://bugzilla.mozilla.org",
+ },
+ ],
+ });
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["mozFolder___"],
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["devFolder___"],
+ },
+ {
+ // Moving to toolbar.
+ id: "devFolder___",
+ parentid: "toolbar",
+ type: "folder",
+ title: "Dev",
+ children: ["bzBmk_______", "wmBmk_______"],
+ },
+ {
+ // Moving to "Mozilla".
+ id: "mdnBmk______",
+ parentid: "mozFolder___",
+ type: "bookmark",
+ title: "MDN",
+ bmkUri: "https://developer.mozilla.org",
+ },
+ {
+ // Rearranging children and moving to unfiled.
+ id: "mozFolder___",
+ parentid: "unfiled",
+ type: "folder",
+ title: "Mozilla",
+ children: ["nightlyBmk__", "mdnBmk______", "fxBmk_______"],
+ },
+ {
+ id: "fxBmk_______",
+ parentid: "mozFolder___",
+ type: "bookmark",
+ title: "Get Firefox!",
+ bmkUri: "http://getfirefox.com/",
+ },
+ {
+ id: "nightlyBmk__",
+ parentid: "mozFolder___",
+ type: "bookmark",
+ title: "Nightly",
+ bmkUri: "https://nightly.mozilla.org",
+ },
+ {
+ id: "wmBmk_______",
+ parentid: "devFolder___",
+ type: "bookmark",
+ title: "Webmaker",
+ bmkUri: "https://webmaker.org",
+ },
+ {
+ id: "bzBmk_______",
+ parentid: "devFolder___",
+ type: "bookmark",
+ title: "Bugzilla",
+ bmkUri: "https://bugzilla.mozilla.org",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not upload records for remotely moved items"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "devFolder___",
+ "mozFolder___",
+ "bzBmk_______",
+ "wmBmk_______",
+ "nightlyBmk__",
+ "mdnBmk______",
+ "fxBmk_______",
+ ]);
+ observer.check([
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("devFolder___"),
+ oldIndex: 0,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ guid: "devFolder___",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "",
+ isTagging: false,
+ title: "Dev",
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("mozFolder___"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ guid: "mozFolder___",
+ oldParentGuid: "devFolder___",
+ newParentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "",
+ isTagging: false,
+ title: "Mozilla",
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bzBmk_______"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bzBmk_______",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: "devFolder___",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "https://bugzilla.mozilla.org/",
+ isTagging: false,
+ title: "Bugzilla",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("wmBmk_______"),
+ oldIndex: 2,
+ newIndex: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "wmBmk_______",
+ oldParentGuid: "devFolder___",
+ newParentGuid: "devFolder___",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "https://webmaker.org/",
+ isTagging: false,
+ title: "Webmaker",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("nightlyBmk__"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "nightlyBmk__",
+ oldParentGuid: "mozFolder___",
+ newParentGuid: "mozFolder___",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "https://nightly.mozilla.org/",
+ isTagging: false,
+ title: "Nightly",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("mdnBmk______"),
+ oldIndex: 0,
+ newIndex: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "mdnBmk______",
+ oldParentGuid: "devFolder___",
+ newParentGuid: "mozFolder___",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "https://developer.mozilla.org/",
+ isTagging: false,
+ title: "MDN",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("fxBmk_______"),
+ oldIndex: 0,
+ newIndex: 2,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "fxBmk_______",
+ oldParentGuid: "mozFolder___",
+ newParentGuid: "mozFolder___",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://getfirefox.com/",
+ isTagging: false,
+ title: "Get Firefox!",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ ]);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "devFolder___",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "Dev",
+ children: [
+ {
+ guid: "bzBmk_______",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "Bugzilla",
+ url: "https://bugzilla.mozilla.org/",
+ },
+ {
+ guid: "wmBmk_______",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "Webmaker",
+ url: "https://webmaker.org/",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "mozFolder___",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "Mozilla",
+ children: [
+ {
+ guid: "nightlyBmk__",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "Nightly",
+ url: "https://nightly.mozilla.org/",
+ },
+ {
+ guid: "mdnBmk______",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "MDN",
+ url: "https://developer.mozilla.org/",
+ },
+ {
+ guid: "fxBmk_______",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "Get Firefox!",
+ url: "http://getfirefox.com/",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should move and reorder bookmarks to match remote"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_into_parent_sibling() {
+ // This test moves a bookmark that exists locally into a new folder that only
+ // exists remotely, and is a later sibling of the local parent. This ensures
+ // we set up the local structure before applying structure changes.
+ let buf = await openMirror("move_into_parent_sibling");
+
+ info("Set up mirror: Menu > A > B");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes: Menu > (A (B > C))");
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderCCCCCC"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "menu",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ ]);
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not upload records for remote-only structure changes"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "folderCCCCCC",
+ "bookmarkBBBB",
+ "folderAAAAAA",
+ PlacesUtils.bookmarks.menuGuid,
+ ]);
+ observer.check([
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("folderCCCCCC"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ urlHref: "",
+ title: "C",
+ guid: "folderCCCCCC",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ oldIndex: 0,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkBBBB",
+ oldParentGuid: "folderAAAAAA",
+ newParentGuid: "folderCCCCCC",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/b",
+ isTagging: false,
+ title: "B",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ ]);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ },
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "C",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ },
+ ],
+ },
+ "Should set up local structure correctly"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_complex_move_with_additions() {
+ let mergeTelemetryCounts;
+ let buf = await openMirror("complex_move_with_additions", {
+ recordStepTelemetry(name, took, counts) {
+ if (name == "merge") {
+ mergeTelemetryCounts = counts;
+ }
+ },
+ });
+
+ info("Set up mirror: Menu > A > (B C)");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make local change: Menu > A > (B C D)");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkDDDD",
+ parentGuid: "folderAAAAAA",
+ title: "D (local)",
+ url: "http://example.com/d-local",
+ });
+
+ info("Make remote change: ((Menu > C) (Toolbar > A > (B E)))");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkCCCC"],
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "toolbar",
+ type: "folder",
+ title: "A",
+ children: ["bookmarkBBBB", "bookmarkEEEE"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["folderAAAAAA"],
+ "Should leave A with new remote structure unmerged"
+ );
+ deepEqual(
+ mergeTelemetryCounts,
+ [{ name: "items", count: 10 }],
+ "Should record telemetry with structure change counts"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkDDDD", "folderAAAAAA"],
+ deleted: [],
+ },
+ "Should upload new records for (A D)"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "bookmarkEEEE",
+ "folderAAAAAA",
+ "bookmarkCCCC",
+ ]);
+ observer.check([
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("bookmarkEEEE"),
+ parentId: localItemIds.get("folderAAAAAA"),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/e",
+ title: "E",
+ guid: "bookmarkEEEE",
+ parentGuid: "folderAAAAAA",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ oldIndex: 1,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkCCCC",
+ oldParentGuid: "folderAAAAAA",
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/c",
+ isTagging: false,
+ title: "C",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("folderAAAAAA"),
+ oldIndex: 0,
+ newIndex: 0,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ guid: "folderAAAAAA",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "",
+ isTagging: false,
+ title: "A",
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: 0,
+ lastVisitDate: null,
+ },
+ },
+ ]);
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ // We can guarantee child order (B E D), since we always walk remote
+ // children first, and the remote folder A record is newer than the
+ // local folder. If the local folder were newer, the order would be
+ // (D B E).
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "D (local)",
+ url: "http://example.com/d-local",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should take remote order and preserve local children"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_reorder_and_insert() {
+ let buf = await openMirror("reorder_and_insert");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ },
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ url: "http://example.com/d",
+ title: "D",
+ },
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ {
+ guid: "bookmarkFFFF",
+ url: "http://example.com/f",
+ title: "F",
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkDDDD", "bookmarkEEEE", "bookmarkFFFF"],
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let now = Date.now();
+
+ info("Make local changes: Reorder Menu, Toolbar > (G H)");
+ await PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, [
+ "bookmarkCCCC",
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ ]);
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ url: "http://example.com/g",
+ title: "G",
+ dateAdded: new Date(now),
+ lastModified: new Date(now),
+ },
+ {
+ guid: "bookmarkHHHH",
+ url: "http://example.com/h",
+ title: "H",
+ dateAdded: new Date(now),
+ lastModified: new Date(now),
+ },
+ ],
+ });
+
+ info("Make remote changes: Reorder Toolbar, Menu > (I J)");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ // The server has a newer toolbar, so we should use the remote order (F D E)
+ // as the base, then append (G H).
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkFFFF", "bookmarkDDDD", "bookmarkEEEE"],
+ modified: now / 1000 + 5,
+ },
+ {
+ // The server has an older menu, so we should use the local order (C A B)
+ // as the base, then append (I J).
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkIIII",
+ "bookmarkJJJJ",
+ ],
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkIIII",
+ parentid: "menu",
+ type: "bookmark",
+ title: "I",
+ bmkUri: "http://example.com/i",
+ },
+ {
+ id: "bookmarkJJJJ",
+ parentid: "menu",
+ type: "bookmark",
+ title: "J",
+ bmkUri: "http://example.com/j",
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ remoteTimeSeconds: now / 1000,
+ localTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.toolbarGuid],
+ "Should leave roots with new remote structure unmerged"
+ );
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkGGGG", "bookmarkHHHH", "menu", "toolbar"],
+ deleted: [],
+ },
+ "Should upload records for merged and new local items"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ url: "http://example.com/c",
+ title: "C",
+ },
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ url: "http://example.com/a",
+ title: "A",
+ },
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkIIII",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ url: "http://example.com/i",
+ title: "I",
+ },
+ {
+ guid: "bookmarkJJJJ",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 4,
+ url: "http://example.com/j",
+ title: "J",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ url: "http://example.com/f",
+ title: "F",
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ url: "http://example.com/d",
+ title: "D",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ url: "http://example.com/e",
+ title: "E",
+ },
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ url: "http://example.com/g",
+ title: "G",
+ },
+ {
+ guid: "bookmarkHHHH",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 4,
+ url: "http://example.com/h",
+ title: "H",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should use timestamps to decide base folder order"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_newer_remote_moves() {
+ let now = Date.now();
+ let buf = await openMirror("newer_remote_moves");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "F",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ url: "http://example.com/g",
+ title: "G",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "H",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "toolbar",
+ type: "folder",
+ title: "F",
+ children: ["bookmarkGGGG"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderFFFFFF",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderHHHHHH",
+ parentid: "toolbar",
+ type: "folder",
+ title: "H",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info(
+ "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G"
+ );
+ let localMoves = [
+ {
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ },
+ {
+ guid: "folderBBBBBB",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ },
+ {
+ guid: "bookmarkCCCC",
+ parentGuid: "folderDDDDDD",
+ },
+ {
+ guid: "bookmarkGGGG",
+ parentGuid: "folderHHHHHH",
+ },
+ ];
+ for (let { guid, parentGuid } of localMoves) {
+ await PlacesUtils.bookmarks.update({
+ guid,
+ parentGuid,
+ index: 0,
+ lastModified: new Date(now - 2500),
+ });
+ }
+ await PlacesUtils.bookmarks.reorder(
+ PlacesUtils.bookmarks.toolbarGuid,
+ ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"],
+ { lastModified: new Date(now - 2500) }
+ );
+
+ info(
+ "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C"
+ );
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderDDDDDD"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ // This is similar to H > C, explained below, except we'll always reupload
+ // the mobile root, because we always prefer the local state for roots.
+ id: "bookmarkAAAA",
+ parentid: "mobile",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["folderBBBBBB"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "unfiled",
+ type: "folder",
+ title: "B",
+ children: [],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "folderHHHHHH",
+ parentid: "toolbar",
+ type: "folder",
+ title: "H",
+ children: ["bookmarkCCCC"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ // Reparenting an item uploads records for the item and its parent.
+ // The merger would still work if we only marked H as unmerged; we'd
+ // then use the remote state for H, and local state for C. Since C was
+ // changed locally, we'll reupload it, even though it didn't actually
+ // change.
+ id: "bookmarkCCCC",
+ parentid: "folderHHHHHH",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkGGGG"],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ children: ["bookmarkGGGG"],
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "toolbar",
+ type: "folder",
+ title: "F",
+ children: [],
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ {
+ // Same as C above.
+ id: "bookmarkGGGG",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ dateAdded: now - 5000,
+ modified: now / 1000,
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ localTimeSeconds: now / 1000,
+ remoteTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave roots with new remote structure unmerged"
+ );
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ // We took the remote structure for the roots, but they're still flagged as
+ // changed locally. Since we always use the local state for roots
+ // (bug 1472241), and can't distinguish between value and structure changes
+ // in Places (see the comment for F below), we'll reupload them.
+ menu: {
+ tombstone: false,
+ counter: 2,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ children: ["folderDDDDDD"],
+ title: BookmarksMenuTitle,
+ },
+ },
+ mobile: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "mobile",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid),
+ children: ["bookmarkAAAA"],
+ title: MobileBookmarksTitle,
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ children: ["folderBBBBBB"],
+ title: UnfiledBookmarksTitle,
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid),
+ children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"],
+ title: BookmarksToolbarTitle,
+ },
+ },
+ },
+ "Should only reupload local roots"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "F",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "H",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "B",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ ],
+ },
+ "Should use newer remote parents and order"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_newer_local_moves() {
+ let now = Date.now();
+ let buf = await openMirror("newer_local_moves");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "B",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "D",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "F",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ url: "http://example.com/g",
+ title: "G",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ },
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "H",
+ dateAdded: new Date(now - 5000),
+ lastModified: new Date(now - 5000),
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "menu",
+ type: "folder",
+ title: "B",
+ children: ["bookmarkCCCC"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderBBBBBB",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "toolbar",
+ type: "folder",
+ title: "F",
+ children: ["bookmarkGGGG"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderFFFFFF",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ {
+ id: "folderHHHHHH",
+ parentid: "toolbar",
+ type: "folder",
+ title: "H",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 5,
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info(
+ "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G"
+ );
+ let localMoves = [
+ {
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ },
+ {
+ guid: "folderBBBBBB",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ },
+ {
+ guid: "bookmarkCCCC",
+ parentGuid: "folderDDDDDD",
+ },
+ {
+ guid: "bookmarkGGGG",
+ parentGuid: "folderHHHHHH",
+ },
+ ];
+ for (let { guid, parentGuid } of localMoves) {
+ await PlacesUtils.bookmarks.update({
+ guid,
+ parentGuid,
+ index: 0,
+ lastModified: new Date(now),
+ });
+ }
+ await PlacesUtils.bookmarks.reorder(
+ PlacesUtils.bookmarks.toolbarGuid,
+ ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"],
+ { lastModified: new Date(now) }
+ );
+
+ info(
+ "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C"
+ );
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderDDDDDD"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "mobile",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "mobile",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "unfiled",
+ parentid: "places",
+ type: "folder",
+ children: ["folderBBBBBB"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "folderBBBBBB",
+ parentid: "unfiled",
+ type: "folder",
+ title: "B",
+ children: [],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "folderHHHHHH",
+ parentid: "toolbar",
+ type: "folder",
+ title: "H",
+ children: ["bookmarkCCCC"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderHHHHHH",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "folderDDDDDD",
+ parentid: "menu",
+ type: "folder",
+ title: "D",
+ children: ["bookmarkGGGG"],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "toolbar",
+ type: "folder",
+ title: "F",
+ children: [],
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderDDDDDD",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ dateAdded: now - 5000,
+ modified: now / 1000 - 2.5,
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ localTimeSeconds: now / 1000,
+ remoteTimeSeconds: now / 1000,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ "bookmarkAAAA",
+ "bookmarkCCCC",
+ "bookmarkGGGG",
+ "folderBBBBBB",
+ "folderDDDDDD",
+ "folderFFFFFF",
+ "folderHHHHHH",
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave items with new remote structure unmerged"
+ );
+ let datesAdded = await promiseManyDatesAdded([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ ]);
+ deepEqual(
+ changesToUpload,
+ {
+ // Reupload roots with new children.
+ menu: {
+ tombstone: false,
+ counter: 2,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid),
+ children: ["folderDDDDDD"],
+ title: BookmarksMenuTitle,
+ },
+ },
+ mobile: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "mobile",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid),
+ children: ["folderBBBBBB"],
+ title: MobileBookmarksTitle,
+ },
+ },
+ unfiled: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "unfiled",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid),
+ children: ["bookmarkAAAA"],
+ title: UnfiledBookmarksTitle,
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid),
+ children: ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"],
+ title: BookmarksToolbarTitle,
+ },
+ },
+ // G moved to H from F, so F and H have new children, and we need
+ // to upload G for the new `parentid`.
+ folderFFFFFF: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderFFFFFF",
+ type: "folder",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: now - 5000,
+ children: [],
+ title: "F",
+ },
+ },
+ folderHHHHHH: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderHHHHHH",
+ type: "folder",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: now - 5000,
+ children: ["bookmarkGGGG"],
+ title: "H",
+ },
+ },
+ bookmarkGGGG: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkGGGG",
+ type: "bookmark",
+ parentid: "folderHHHHHH",
+ hasDupe: true,
+ parentName: "H",
+ dateAdded: now - 5000,
+ bmkUri: "http://example.com/g",
+ title: "G",
+ },
+ },
+ // C moved to D, so we need to reupload D (for `children`) and C
+ // (for `parentid`).
+ folderDDDDDD: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderDDDDDD",
+ type: "folder",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now - 5000,
+ children: ["bookmarkCCCC"],
+ title: "D",
+ },
+ },
+ bookmarkCCCC: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkCCCC",
+ type: "bookmark",
+ parentid: "folderDDDDDD",
+ hasDupe: true,
+ parentName: "D",
+ dateAdded: now - 5000,
+ bmkUri: "http://example.com/c",
+ title: "C",
+ },
+ },
+ // Reupload A with the new `parentid`. B moved to mobile *and* has
+ // new children` so we should upload it, anyway.
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "unfiled",
+ hasDupe: true,
+ parentName: UnfiledBookmarksTitle,
+ dateAdded: now - 5000,
+ bmkUri: "http://example.com/a",
+ title: "A",
+ },
+ },
+ folderBBBBBB: {
+ tombstone: false,
+ counter: 2,
+ synced: false,
+ cleartext: {
+ id: "folderBBBBBB",
+ type: "folder",
+ parentid: "mobile",
+ hasDupe: true,
+ parentName: MobileBookmarksTitle,
+ dateAdded: now - 5000,
+ children: [],
+ title: "B",
+ },
+ },
+ },
+ "Should reupload new local structure"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderDDDDDD",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "D",
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "H",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ ],
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "F",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ children: [
+ {
+ guid: "folderBBBBBB",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "B",
+ },
+ ],
+ },
+ ],
+ },
+ "Should use newer local parents and order"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_unchanged_newer_changed_older() {
+ let buf = await openMirror("unchanged_newer_changed_older");
+ let modified = new Date(Date.now() - 5000);
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.update({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ dateAdded: new Date(modified.getTime() - 5000),
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ dateAdded: new Date(modified.getTime() - 5000),
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ dateAdded: new Date(modified.getTime() - 5000),
+ lastModified: modified,
+ },
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ dateAdded: new Date(modified.getTime() - 5000),
+ lastModified: modified,
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "folderCCCCCC",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "C",
+ dateAdded: new Date(modified.getTime() - 5000),
+ lastModified: modified,
+ },
+ {
+ guid: "bookmarkDDDD",
+ url: "http://example.com/d",
+ title: "D",
+ dateAdded: new Date(modified.getTime() - 5000),
+ lastModified: modified,
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "bookmarkBBBB"],
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["folderCCCCCC", "bookmarkDDDD"],
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ {
+ id: "folderCCCCCC",
+ parentid: "toolbar",
+ type: "folder",
+ title: "C",
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000,
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ // Even though the local menu is newer (local = 5s, remote = 9s; adding E
+ // updated the modified times of A and the menu), it's not *changed* locally,
+ // so we should merge remote children first.
+ info("Add A > E locally with newer time; delete A remotely with older time");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkEEEE",
+ parentGuid: "folderAAAAAA",
+ url: "http://example.com/e",
+ title: "E",
+ index: 0,
+ dateAdded: new Date(modified.getTime() + 5000),
+ lastModified: new Date(modified.getTime() + 5000),
+ });
+ await storeRecords(buf, [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkBBBB"],
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000 + 1,
+ },
+ {
+ id: "folderAAAAAA",
+ deleted: true,
+ },
+ ]);
+
+ // Even though the remote toolbar is newer (local = 15s, remote = 10s), it's
+ // not changed remotely, so we should merge local children first.
+ info("Add C > F remotely with newer time; delete C locally with older time");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "folderCCCCCC",
+ parentid: "toolbar",
+ type: "folder",
+ title: "C",
+ children: ["bookmarkFFFF"],
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000 + 5,
+ },
+ {
+ id: "bookmarkFFFF",
+ parentid: "folderCCCCCC",
+ type: "bookmark",
+ title: "F",
+ bmkUri: "http://example.com/f",
+ dateAdded: modified.getTime() - 5000,
+ modified: modified.getTime() / 1000 + 5,
+ },
+ ])
+ );
+ await PlacesUtils.bookmarks.remove("folderCCCCCC");
+ await PlacesUtils.bookmarks.update({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ lastModified: new Date(modified.getTime() - 5000),
+ // Use `SOURCES.SYNC` to avoid bumping the change counter and flagging the
+ // local toolbar as modified.
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ });
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply({
+ localTimeSeconds: modified.getTime() / 1000 + 10,
+ remoteTimeSeconds: modified.getTime() / 1000 + 10,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkFFFF", "folderCCCCCC", PlacesUtils.bookmarks.menuGuid],
+ "Should leave deleted C; F and menu with new remote structure unmerged"
+ );
+
+ deepEqual(
+ changesToUpload,
+ {
+ menu: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "menu",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: modified.getTime() - 5000,
+ children: ["bookmarkBBBB", "bookmarkEEEE"],
+ title: BookmarksMenuTitle,
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: modified.getTime() - 5000,
+ children: ["bookmarkDDDD", "bookmarkFFFF"],
+ title: BookmarksToolbarTitle,
+ },
+ },
+ // Upload E and F with new `parentid`.
+ bookmarkEEEE: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkEEEE",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: modified.getTime() + 5000,
+ bmkUri: "http://example.com/e",
+ title: "E",
+ },
+ },
+ bookmarkFFFF: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkFFFF",
+ type: "bookmark",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: modified.getTime() - 5000,
+ bmkUri: "http://example.com/f",
+ title: "F",
+ },
+ },
+ folderCCCCCC: {
+ tombstone: true,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "folderCCCCCC",
+ deleted: true,
+ },
+ },
+ },
+ "Should reupload menu, toolbar, E, F with new structure; tombstone for C"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "E",
+ url: "http://example.com/e",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "D",
+ url: "http://example.com/d",
+ },
+ {
+ guid: "bookmarkFFFF",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "F",
+ url: "http://example.com/f",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should merge children of changed side first, even if they're older"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["folderCCCCCC"],
+ "Should store local tombstone for C"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js b/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js
new file mode 100644
index 0000000000..e5e1d4e078
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function test_bookmark_unknown_fields() {
+ let buf = await openMirror("unknown_fields");
+
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ unknownStr: "an unknown field",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "New Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ unknownStr: "a new unknown field",
+ },
+ ],
+ { needsMerge: true }
+ );
+
+ let controller = new AbortController();
+ const wasMerged = await buf.merge(controller.signal);
+ Assert.ok(wasMerged);
+
+ let itemRows = await buf.db.execute(`SELECT guid, unknownFields FROM items`);
+
+ let updatedBookmark = itemRows.find(
+ row => row.getResultByName("guid") == "mozBmk______"
+ );
+ deepEqual(JSON.parse(updatedBookmark.getResultByName("unknownFields")), {
+ unknownStr: "a new unknown field",
+ });
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_changes_unknown_fields_all_types() {
+ let buf = await openMirror("unknown_fields_all");
+
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ title: "menu",
+ children: ["bookmarkAAAA", "separatorAAA", "queryAAAAAAA"],
+ unknownFolderField: "an unknown folder field",
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla2",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ unknownStrField: "an unknown bookmark field",
+ unknownStrObj: { newField: "unknown pt deux" },
+ },
+ {
+ id: "separatorAAA",
+ parentid: "menu",
+ type: "separator",
+ unknownSepField: "an unknown separator field",
+ },
+ {
+ id: "queryAAAAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "a query",
+ bmkUri: "place:foo",
+ unknownQueryField: "an unknown query field",
+ },
+ ],
+ { needsMerge: true }
+ );
+
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ let changesToUpload = await buf.apply();
+ // Should be no local changes needing to be uploaded
+ deepEqual(changesToUpload, {});
+
+ // Make updates to all the type of bookmarks
+ await PlacesUtils.bookmarks.update({
+ guid: "menu________",
+ title: "updated menu",
+ });
+ await PlacesUtils.bookmarks.update({
+ guid: "bookmarkAAAA",
+ title: "Mozilla3",
+ });
+ await PlacesUtils.bookmarks.update({ guid: "separatorAAA", index: 2 });
+ await PlacesUtils.bookmarks.update({
+ guid: "queryAAAAAAA",
+ title: "an updated query",
+ });
+
+ // We should now have a bunch of changes to upload
+ changesToUpload = await buf.apply();
+ const { menu, bookmarkAAAA, separatorAAA, queryAAAAAAA } = changesToUpload;
+
+ // Validate we have the updated title as well as the unknown fields
+ Assert.equal(menu.cleartext.title, "updated menu");
+ Assert.equal(menu.cleartext.unknownFolderField, "an unknown folder field");
+
+ // Test bookmark unknown fields
+ Assert.equal(bookmarkAAAA.cleartext.title, "Mozilla3");
+ Assert.equal(
+ bookmarkAAAA.cleartext.unknownStrField,
+ "an unknown bookmark field"
+ );
+ deepEqual(bookmarkAAAA.cleartext.unknownStrObj, {
+ newField: "unknown pt deux",
+ });
+
+ // Test separator unknown fields
+ Assert.equal(
+ separatorAAA.cleartext.unknownSepField,
+ "an unknown separator field"
+ );
+
+ // Test query unknown fields
+ Assert.equal(queryAAAAAAA.cleartext.title, "an updated query");
+ Assert.equal(
+ queryAAAAAAA.cleartext.unknownQueryField,
+ "an unknown query field"
+ );
+
+ let itemRows = await buf.db.execute(`SELECT guid, unknownFields FROM items`);
+
+ // Test bookmark correctly JSON'd in the mirror
+ let remoteBookmark = itemRows.find(
+ row => row.getResultByName("guid") == "bookmarkAAAA"
+ );
+ deepEqual(JSON.parse(remoteBookmark.getResultByName("unknownFields")), {
+ unknownStrField: "an unknown bookmark field",
+ unknownStrObj: { newField: "unknown pt deux" },
+ });
+
+ // Test folder correctly JSON'd in the mirror
+ let remoteFolder = itemRows.find(
+ row => row.getResultByName("guid") == "menu________"
+ );
+ deepEqual(JSON.parse(remoteFolder.getResultByName("unknownFields")), {
+ unknownFolderField: "an unknown folder field",
+ });
+ // Test query correctly JSON'd in the mirror
+ let remoteQuery = itemRows.find(
+ row => row.getResultByName("guid") == "queryAAAAAAA"
+ );
+ deepEqual(JSON.parse(remoteQuery.getResultByName("unknownFields")), {
+ unknownQueryField: "an unknown query field",
+ });
+ // Test separator correctly JSON'd in the mirror
+ let remoteSeparator = itemRows.find(
+ row => row.getResultByName("guid") == "separatorAAA"
+ );
+ deepEqual(JSON.parse(remoteSeparator.getResultByName("unknownFields")), {
+ unknownSepField: "an unknown separator field",
+ });
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_bookmark_value_changes.js b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js
new file mode 100644
index 0000000000..be20a59c68
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js
@@ -0,0 +1,2639 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_value_combo() {
+ let buf = await openMirror("value_combo");
+ let now = Date.now();
+
+ info("Set up mirror with existing bookmark to update");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "mozBmk______",
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ tags: ["moz", "dot", "org"],
+ dateAdded: new Date(now),
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["mozBmk______"],
+ },
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla",
+ bmkUri: "https://mozilla.org",
+ tags: ["moz", "dot", "org"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Insert new local bookmark to upload");
+ let [bzBmk] = await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ guid: "bzBmk_______",
+ url: "https://bugzilla.mozilla.org",
+ title: "Bugzilla",
+ tags: ["new", "tag"],
+ },
+ ],
+ });
+
+ info("Insert remote bookmarks and folder to apply");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "mozBmk______",
+ parentid: "menu",
+ type: "bookmark",
+ title: "Mozilla home page",
+ bmkUri: "https://mozilla.org",
+ tags: ["browsers"],
+ },
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["fxBmk_______", "tFolder_____"],
+ },
+ {
+ id: "fxBmk_______",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "Get Firefox",
+ bmkUri: "http://getfirefox.com",
+ tags: ["taggy", "browsers"],
+ dateAdded: now,
+ },
+ {
+ id: "tFolder_____",
+ parentid: "toolbar",
+ type: "folder",
+ title: "Mail",
+ children: ["tbBmk_______"],
+ dateAdded: now,
+ },
+ {
+ id: "tbBmk_______",
+ parentid: "tFolder_____",
+ type: "bookmark",
+ title: "Get Thunderbird",
+ bmkUri: "http://getthunderbird.com",
+ keyword: "tb",
+ dateAdded: now,
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications({
+ skipTags: true,
+ ignoreDates: false,
+ });
+ let localTimeSeconds = Math.floor(now / 1000);
+ let changesToUpload = await buf.apply({
+ localTimeSeconds,
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [PlacesUtils.bookmarks.toolbarGuid],
+ "Should leave toolbar with new remote structure unmerged"
+ );
+
+ let menuInfo = await PlacesUtils.bookmarks.fetch(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ changesToUpload,
+ {
+ bzBmk_______: {
+ tombstone: false,
+ counter: 3,
+ synced: false,
+ cleartext: {
+ id: "bzBmk_______",
+ type: "bookmark",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: bzBmk.dateAdded.getTime(),
+ bmkUri: "https://bugzilla.mozilla.org/",
+ title: "Bugzilla",
+ tags: ["new", "tag"],
+ },
+ },
+ toolbar: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "toolbar",
+ type: "folder",
+ parentid: "places",
+ hasDupe: true,
+ parentName: "",
+ dateAdded: menuInfo.dateAdded.getTime(),
+ title: BookmarksToolbarTitle,
+ children: ["fxBmk_______", "tFolder_____", "bzBmk_______"],
+ },
+ },
+ },
+ "Should upload new local bookmarks and parents"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "fxBmk_______",
+ "tFolder_____",
+ "tbBmk_______",
+ "bzBmk_______",
+ "mozBmk______",
+ PlacesUtils.bookmarks.toolbarGuid,
+ ]);
+
+ observer.check([
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("fxBmk_______"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid),
+ index: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://getfirefox.com/",
+ title: "Get Firefox",
+ guid: "fxBmk_______",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ dateAdded: now,
+ tags: "browsers,taggy",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("tFolder_____"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ urlHref: "",
+ title: "Mail",
+ guid: "tFolder_____",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ dateAdded: now,
+ tags: "",
+ frecency: 0,
+ hidden: false,
+ visitCount: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("tbBmk_______"),
+ parentId: localItemIds.get("tFolder_____"),
+ index: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://getthunderbird.com/",
+ title: "Get Thunderbird",
+ guid: "tbBmk_______",
+ parentGuid: "tFolder_____",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ dateAdded: now,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bzBmk_______"),
+ oldIndex: 0,
+ newIndex: 2,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bzBmk_______",
+ oldParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "https://bugzilla.mozilla.org/",
+ isTagging: false,
+ title: "Bugzilla",
+ tags: "new,tag",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: bzBmk.dateAdded.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("mozBmk______"),
+ title: "Mozilla home page",
+ guid: "mozBmk______",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ ]);
+
+ let fxBmk = await PlacesUtils.bookmarks.fetch("fxBmk_______");
+ ok(fxBmk, "New Firefox bookmark should exist");
+ equal(
+ fxBmk.parentGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ "Should add Firefox bookmark to toolbar"
+ );
+ let fxTags = PlacesUtils.tagging.getTagsForURI(
+ Services.io.newURI("http://getfirefox.com")
+ );
+ deepEqual(fxTags, ["browsers", "taggy"], "Should tag new Firefox bookmark");
+
+ let folder = await PlacesUtils.bookmarks.fetch("tFolder_____");
+ ok(folder, "New folder should exist");
+ equal(
+ folder.parentGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ "Should add new folder to toolbar"
+ );
+
+ let tbBmk = await PlacesUtils.bookmarks.fetch("tbBmk_______");
+ ok(tbBmk, "Should insert Thunderbird child bookmark");
+ equal(
+ tbBmk.parentGuid,
+ folder.guid,
+ "Should add Thunderbird bookmark to new folder"
+ );
+ let keywordInfo = await PlacesUtils.keywords.fetch("tb");
+ equal(
+ keywordInfo.url.href,
+ "http://getthunderbird.com/",
+ "Should set keyword for Thunderbird bookmark"
+ );
+
+ let updatedBmk = await PlacesUtils.bookmarks.fetch("mozBmk______");
+ equal(
+ updatedBmk.title,
+ "Mozilla home page",
+ "Should rename Mozilla bookmark"
+ );
+ equal(
+ updatedBmk.parentGuid,
+ PlacesUtils.bookmarks.menuGuid,
+ "Should not move Mozilla bookmark"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_value_only_changes() {
+ let buf = await openMirror("value_only_changes");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ {
+ guid: "folderJJJJJJ",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "J",
+ children: [
+ {
+ guid: "bookmarkKKKK",
+ url: "http://example.com/k",
+ title: "K",
+ },
+ ],
+ },
+ {
+ guid: "bookmarkDDDD",
+ url: "http://example.com/d",
+ title: "D",
+ },
+ {
+ guid: "bookmarkEEEE",
+ url: "http://example.com/e",
+ title: "E",
+ },
+ ],
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "F",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ url: "http://example.com/g",
+ title: "G",
+ },
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "H",
+ children: [
+ {
+ guid: "bookmarkIIII",
+ url: "http://example.com/i",
+ title: "I",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["folderAAAAAA", "folderFFFFFF"],
+ },
+ {
+ id: "folderAAAAAA",
+ parentid: "menu",
+ type: "folder",
+ title: "A",
+ children: [
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "folderJJJJJJ",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ ],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "folderJJJJJJ",
+ parentid: "folderAAAAAA",
+ type: "folder",
+ title: "J",
+ children: ["bookmarkKKKK"],
+ },
+ {
+ id: "bookmarkKKKK",
+ parentid: "folderJJJJJJ",
+ type: "bookmark",
+ title: "K",
+ bmkUri: "http://example.com/k",
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "menu",
+ type: "folder",
+ title: "F",
+ children: ["bookmarkGGGG", "folderHHHHHH"],
+ },
+ {
+ id: "bookmarkGGGG",
+ parentid: "folderFFFFFF",
+ type: "bookmark",
+ title: "G",
+ bmkUri: "http://example.com/g",
+ },
+ {
+ id: "folderHHHHHH",
+ parentid: "folderFFFFFF",
+ type: "folder",
+ title: "H",
+ children: ["bookmarkIIII"],
+ },
+ {
+ id: "bookmarkIIII",
+ parentid: "folderHHHHHH",
+ type: "bookmark",
+ title: "I",
+ bmkUri: "http://example.com/i",
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkCCCC",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: "http://example.com/c-remote",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "folderAAAAAA",
+ type: "bookmark",
+ title: "E (remote)",
+ bmkUri: "http://example.com/e-remote",
+ },
+ {
+ id: "bookmarkIIII",
+ parentid: "folderHHHHHH",
+ type: "bookmark",
+ title: "I (remote)",
+ bmkUri: "http://example.com/i-remote",
+ },
+ {
+ id: "folderFFFFFF",
+ parentid: "menu",
+ type: "folder",
+ title: "F (remote)",
+ children: ["bookmarkGGGG", "folderHHHHHH"],
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: [],
+ deleted: [],
+ },
+ "Should not upload records for remote-only value changes"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "folderAAAAAA",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "A",
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "C (remote)",
+ url: "http://example.com/c-remote",
+ },
+ {
+ guid: "folderJJJJJJ",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 2,
+ title: "J",
+ children: [
+ {
+ guid: "bookmarkKKKK",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "K",
+ url: "http://example.com/k",
+ },
+ ],
+ },
+ {
+ guid: "bookmarkDDDD",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "D",
+ url: "http://example.com/d",
+ },
+ {
+ guid: "bookmarkEEEE",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 4,
+ title: "E (remote)",
+ url: "http://example.com/e-remote",
+ },
+ ],
+ },
+ {
+ guid: "folderFFFFFF",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "F (remote)",
+ children: [
+ {
+ guid: "bookmarkGGGG",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "G",
+ url: "http://example.com/g",
+ },
+ {
+ guid: "folderHHHHHH",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: "H",
+ children: [
+ {
+ guid: "bookmarkIIII",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "I (remote)",
+ url: "http://example.com/i-remote",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should not change structure for value-only changes"
+ );
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_conflicting_keywords() {
+ let buf = await openMirror("conflicting_keywords");
+ let dateAdded = new Date();
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ keyword: "one",
+ dateAdded,
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "one",
+ dateAdded: dateAdded.getTime(),
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ {
+ let entryByKeyword = await PlacesUtils.keywords.fetch("one");
+ equal(
+ entryByKeyword.url.href,
+ "http://example.com/a",
+ "Should return new keyword entry by URL"
+ );
+ let entryByURL = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/a",
+ });
+ equal(entryByURL.keyword, "one", "Should return new entry by keyword");
+ }
+
+ info("Insert new bookmark with same URL and different keyword");
+ {
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAA1"],
+ },
+ {
+ id: "bookmarkAAA1",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "A1",
+ bmkUri: "http://example.com/a",
+ keyword: "two",
+ dateAdded: dateAdded.getTime(),
+ },
+ ])
+ );
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAA1"],
+ "Should leave A1 with conflicting keyword unmerged"
+ );
+ deepEqual(
+ changesToUpload,
+ {
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ keyword: "two",
+ },
+ },
+ bookmarkAAA1: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA1",
+ type: "bookmark",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A1",
+ keyword: "two",
+ },
+ },
+ },
+ "Should reupload bookmarks with different keyword"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+
+ let entryByOldKeyword = await PlacesUtils.keywords.fetch("one");
+ ok(
+ !entryByOldKeyword,
+ "Should remove old entry when inserting bookmark with different keyword"
+ );
+ let entryByNewKeyword = await PlacesUtils.keywords.fetch("two");
+ equal(
+ entryByNewKeyword.url.href,
+ "http://example.com/a",
+ "Should return new keyword entry by URL"
+ );
+ let entryByURL = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/a",
+ });
+ equal(entryByURL.keyword, "two", "Should return new entry by URL");
+ }
+
+ info("Update bookmark with different keyword");
+ {
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "three",
+ dateAdded: dateAdded.getTime(),
+ },
+ ])
+ );
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAAA"],
+ "Should leave A with conflicting keyword unmerged"
+ );
+ deepEqual(
+ changesToUpload,
+ {
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ keyword: "three",
+ },
+ },
+ bookmarkAAA1: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA1",
+ type: "bookmark",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: dateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A1",
+ keyword: "three",
+ },
+ },
+ },
+ "Should reupload A and A1 with updated keyword"
+ );
+ await storeChangesInMirror(buf, changesToUpload);
+
+ let entryByOldKeyword = await PlacesUtils.keywords.fetch("two");
+ ok(
+ !entryByOldKeyword,
+ "Should remove old entry when updating bookmark keyword"
+ );
+ let entryByNewKeyword = await PlacesUtils.keywords.fetch("three");
+ equal(
+ entryByNewKeyword.url.href,
+ "http://example.com/a",
+ "Should return updated keyword entry by URL"
+ );
+ let entryByURL = await PlacesUtils.keywords.fetch({
+ url: "http://example.com/a",
+ });
+ equal(entryByURL.keyword, "three", "Should return updated entry by URL");
+ }
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_keywords() {
+ let buf = await openMirror("keywords");
+ let now = new Date();
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ keyword: "one",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ keyword: "two",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ keyword: "three",
+ dateAdded: now,
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ ],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "one",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ keyword: "two",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ keyword: "three",
+ dateAdded: now.getTime(),
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Change keywords remotely");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "two",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ dateAdded: now.getTime(),
+ },
+ ])
+ );
+
+ info("Change keywords locally");
+ await PlacesUtils.keywords.insert({
+ keyword: "four",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.keywords.remove("three");
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ deepEqual(
+ changesToUpload,
+ {
+ bookmarkCCCC: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkCCCC",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/c",
+ title: "C",
+ keyword: "four",
+ },
+ },
+ bookmarkDDDD: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkDDDD",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/d",
+ title: "D",
+ },
+ },
+ },
+ "Should upload C with new keyword, D with keyword removed"
+ );
+
+ let entryForOne = await PlacesUtils.keywords.fetch("one");
+ ok(!entryForOne, "Should remove existing keyword from A");
+
+ let entriesForTwo = await fetchAllKeywords("two");
+ deepEqual(
+ entriesForTwo.map(entry => ({
+ keyword: entry.keyword,
+ url: entry.url.href,
+ })),
+ [
+ {
+ keyword: "two",
+ url: "http://example.com/a",
+ },
+ ],
+ "Should move keyword for B to A"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_keywords_complex() {
+ let buf = await openMirror("keywords_complex");
+ let now = new Date();
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ keyword: "four",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ keyword: "five",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ dateAdded: now,
+ },
+ {
+ guid: "bookmarkEEEE",
+ title: "E",
+ url: "http://example.com/e",
+ keyword: "three",
+ dateAdded: now,
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ ],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ keyword: "four",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ keyword: "five",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "menu",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ keyword: "three",
+ dateAdded: now.getTime(),
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAAA",
+ "bookmarkAAA1",
+ "bookmarkBBB1",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ ],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ keyword: "one",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkAAA1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A (copy)",
+ bmkUri: "http://example.com/a",
+ keyword: "two",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkBBB1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ dateAdded: now.getTime(),
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C (remote)",
+ bmkUri: "http://example.com/c-remote",
+ keyword: "six",
+ dateAdded: now.getTime(),
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["bookmarkAAA1", "bookmarkAAAA", "bookmarkBBB1"],
+ "Should leave A1, A, B with conflicting keywords unmerged"
+ );
+
+ let expectedChangesToUpload = {
+ bookmarkBBBB: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkBBBB",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ },
+ bookmarkBBB1: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkBBB1",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/b",
+ title: "B",
+ },
+ },
+ bookmarkAAAA: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ },
+ },
+ bookmarkAAA1: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "bookmarkAAA1",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A (copy)",
+ },
+ },
+ };
+
+ // We'll take the keyword of either "bookmarkAAAA" or "bookmarkAAA1",
+ // depending on which we see first, and reupload the other.
+ let entriesForOne = await fetchAllKeywords("one");
+ let entriesForTwo = await fetchAllKeywords("two");
+ if (entriesForOne.length) {
+ ok(!entriesForTwo.length, "Should drop conflicting keyword from A1");
+ deepEqual(
+ entriesForOne.map(keyword => keyword.url.href),
+ ["http://example.com/a"],
+ "Should use A keyword for A and A1"
+ );
+ expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "one";
+ expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "one";
+ } else {
+ ok(!entriesForOne.length, "Should drop conflicting keyword from A");
+ deepEqual(
+ entriesForTwo.map(keyword => keyword.url.href),
+ ["http://example.com/a"],
+ "Should use A1 keyword for A and A1"
+ );
+ expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "two";
+ expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "two";
+ }
+ deepEqual(
+ changesToUpload,
+ expectedChangesToUpload,
+ "Should reupload all local records with corrected keywords"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "bookmarkAAAA",
+ "bookmarkAAA1",
+ "bookmarkBBB1",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ "bookmarkEEEE",
+ PlacesUtils.bookmarks.menuGuid,
+ ]);
+ let expectedNotifications = [
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("bookmarkAAAA"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 0,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/a",
+ title: "A",
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("bookmarkAAA1"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 1,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/a",
+ title: "A (copy)",
+ guid: "bookmarkAAA1",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ name: "bookmark-added",
+ params: {
+ itemId: localItemIds.get("bookmarkBBB1"),
+ parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid),
+ index: 2,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/b",
+ title: "B",
+ guid: "bookmarkBBB1",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ },
+ },
+ {
+ // These `bookmark-moved` notifications aren't necessary: we only moved
+ // (B C D E) to accomodate (A A1 B1), and Places doesn't usually fire move
+ // notifications for repositioned siblings. However, detecting and filtering
+ // these out complicates `noteObserverChanges`, so, for simplicity, we
+ // record and fire the extra notifications.
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ oldIndex: 0,
+ newIndex: 3,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkBBBB",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/b",
+ isTagging: false,
+ title: "B",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: now.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ oldIndex: 1,
+ newIndex: 4,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkCCCC",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/c-remote",
+ isTagging: false,
+ title: "C (remote)",
+ tags: "",
+ frecency: -1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: now.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkDDDD"),
+ oldIndex: 2,
+ newIndex: 5,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkDDDD",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/d",
+ isTagging: false,
+ title: "D",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: now.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-moved",
+ params: {
+ itemId: localItemIds.get("bookmarkEEEE"),
+ oldIndex: 3,
+ newIndex: 6,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ guid: "bookmarkEEEE",
+ oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+ newParentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ urlHref: "http://example.com/e",
+ isTagging: false,
+ title: "E",
+ tags: "",
+ frecency: 1,
+ hidden: false,
+ visitCount: 0,
+ dateAdded: now.getTime(),
+ lastVisitDate: null,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ title: "C (remote)",
+ guid: "bookmarkCCCC",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ {
+ name: "bookmark-url-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ urlHref: "http://example.com/c-remote",
+ guid: "bookmarkCCCC",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging: false,
+ },
+ },
+ ];
+ observer.check(expectedNotifications);
+
+ let entriesForFour = await fetchAllKeywords("four");
+ ok(!entriesForFour.length, "Should remove all keywords for B");
+
+ let entriesForOldC = await fetchAllKeywords({
+ url: "http://example.com/c",
+ });
+ ok(!entriesForOldC.length, "Should remove all keywords from old C URL");
+ let entriesForNewC = await fetchAllKeywords({
+ url: "http://example.com/c-remote",
+ });
+ deepEqual(
+ entriesForNewC.map(entry => entry.keyword),
+ ["six"],
+ "Should add new keyword to new C URL"
+ );
+
+ let entriesForD = await fetchAllKeywords("http://example.com/d");
+ ok(!entriesForD.length, "Should not add keywords to D");
+
+ let entriesForThree = await fetchAllKeywords("three");
+ deepEqual(
+ entriesForThree.map(keyword => keyword.url.href),
+ ["http://example.com/e"],
+ "Should not change keywords for E"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_tags_complex() {
+ let buf = await openMirror("tags_complex");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAA1",
+ title: "A1",
+ url: "http://example.com/a",
+ tags: ["one", "two"],
+ },
+ {
+ guid: "bookmarkAAA2",
+ title: "A2",
+ url: "http://example.com/a",
+ tags: ["one", "two"],
+ },
+ {
+ guid: "bookmarkBBB1",
+ title: "B1",
+ url: "http://example.com/b",
+ tags: ["one"],
+ },
+ {
+ guid: "bookmarkBBB2",
+ title: "B2",
+ url: "http://example.com/b",
+ tags: ["one"],
+ },
+ {
+ guid: "bookmarkCCC1",
+ title: "C1",
+ url: "http://example.com/c",
+ tags: ["two", "three"],
+ },
+ {
+ guid: "bookmarkCCC2",
+ title: "C2",
+ url: "http://example.com/c",
+ tags: ["two", "three"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAA1",
+ "bookmarkAAA2",
+ "bookmarkBBB1",
+ "bookmarkBBB2",
+ "bookmarkCCC1",
+ "bookmarkCCC2",
+ ],
+ },
+ {
+ id: "bookmarkAAA1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two"],
+ },
+ {
+ id: "bookmarkAAA2",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two"],
+ },
+ {
+ id: "bookmarkBBB1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B1",
+ bmkUri: "http://example.com/b",
+ tags: ["one"],
+ },
+ {
+ id: "bookmarkBBB2",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B2",
+ bmkUri: "http://example.com/b",
+ tags: ["one"],
+ },
+ {
+ id: "bookmarkCCC1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C1",
+ bmkUri: "http://example.com/c",
+ tags: ["two", "three"],
+ },
+ {
+ id: "bookmarkCCC2",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C2",
+ bmkUri: "http://example.com/c",
+ tags: ["two", "three"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Add tags for B locally");
+ PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/b"), [
+ "four",
+ "five",
+ ]);
+
+ info("Remove tag from C locally");
+ PlacesUtils.tagging.untagURI(Services.io.newURI("http://example.com/c"), [
+ "two",
+ ]);
+
+ info("Update tags for A remotely");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkAAA1",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A1",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two", "four", "six"],
+ },
+ {
+ id: "bookmarkAAA2",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A2",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two", "four", "six"],
+ },
+ ])
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+
+ let datesAdded = await promiseManyDatesAdded([
+ "bookmarkBBB1",
+ "bookmarkBBB2",
+ "bookmarkCCC1",
+ "bookmarkCCC2",
+ ]);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+ deepEqual(
+ changesToUpload,
+ {
+ bookmarkBBB1: {
+ counter: 2,
+ synced: false,
+ tombstone: false,
+ cleartext: {
+ id: "bookmarkBBB1",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkBBB1"),
+ bmkUri: "http://example.com/b",
+ title: "B1",
+ tags: ["five", "four", "one"],
+ },
+ },
+ bookmarkBBB2: {
+ counter: 2,
+ synced: false,
+ tombstone: false,
+ cleartext: {
+ id: "bookmarkBBB2",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkBBB2"),
+ bmkUri: "http://example.com/b",
+ title: "B2",
+ tags: ["five", "four", "one"],
+ },
+ },
+ bookmarkCCC1: {
+ counter: 1,
+ synced: false,
+ tombstone: false,
+ cleartext: {
+ id: "bookmarkCCC1",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkCCC1"),
+ bmkUri: "http://example.com/c",
+ title: "C1",
+ tags: ["three"],
+ },
+ },
+ bookmarkCCC2: {
+ counter: 1,
+ synced: false,
+ tombstone: false,
+ cleartext: {
+ id: "bookmarkCCC2",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: datesAdded.get("bookmarkCCC2"),
+ bmkUri: "http://example.com/c",
+ title: "C2",
+ tags: ["three"],
+ },
+ },
+ },
+ "Should upload local records with new tags"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAA1",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A1",
+ url: "http://example.com/a",
+ tags: ["four", "one", "six", "two"],
+ },
+ {
+ guid: "bookmarkAAA2",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 1,
+ title: "A2",
+ url: "http://example.com/a",
+ tags: ["four", "one", "six", "two"],
+ },
+ {
+ guid: "bookmarkBBB1",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 2,
+ title: "B1",
+ url: "http://example.com/b",
+ tags: ["five", "four", "one"],
+ },
+ {
+ guid: "bookmarkBBB2",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 3,
+ title: "B2",
+ url: "http://example.com/b",
+ tags: ["five", "four", "one"],
+ },
+ {
+ guid: "bookmarkCCC1",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 4,
+ title: "C1",
+ url: "http://example.com/c",
+ tags: ["three"],
+ },
+ {
+ guid: "bookmarkCCC2",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 5,
+ title: "C2",
+ url: "http://example.com/c",
+ tags: ["three"],
+ },
+ ],
+ },
+ "Should update local items with new tags"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_tags() {
+ let buf = await openMirror("tags");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ tags: ["one", "two", "three", "four"],
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ tags: ["five", "six"],
+ },
+ {
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ },
+ {
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ tags: ["seven", "eight", "nine"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: [
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ "bookmarkDDDD",
+ ],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two", "three", "four"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ tags: ["five", "six"],
+ },
+ {
+ id: "bookmarkCCCC",
+ parentid: "menu",
+ type: "bookmark",
+ title: "C",
+ bmkUri: "http://example.com/c",
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ tags: ["seven", "eight", "nine"],
+ },
+ ]),
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Change tags remotely");
+ await storeRecords(
+ buf,
+ shuffle([
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ tags: ["one", "two", "ten"],
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ bmkUri: "http://example.com/b",
+ tags: [],
+ },
+ ])
+ );
+
+ info("Change tags locally");
+ PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/c"), [
+ "eleven",
+ "twelve",
+ ]);
+
+ let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events =>
+ events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid)
+ );
+
+ PlacesUtils.tagging.untagURI(
+ Services.io.newURI("http://example.com/d"),
+ null
+ );
+
+ await wait;
+
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkCCCC", "bookmarkDDDD"],
+ deleted: [],
+ },
+ "Should upload local records with new tags"
+ );
+
+ deepEqual(
+ changesToUpload.bookmarkCCCC.cleartext.tags.sort(),
+ ["eleven", "twelve"],
+ "Should upload record with new tags for C"
+ );
+ ok(
+ !changesToUpload.bookmarkDDDD.cleartext.tags,
+ "Should upload record for D with tags removed"
+ );
+
+ let tagsForA = PlacesUtils.tagging.getTagsForURI(
+ Services.io.newURI("http://example.com/a")
+ );
+ deepEqual(tagsForA, ["one", "ten", "two"], "Should change tags for A");
+
+ let tagsForB = PlacesUtils.tagging.getTagsForURI(
+ Services.io.newURI("http://example.com/b")
+ );
+ deepEqual(tagsForB, [], "Should remove all tags from B");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_rewrite_tag_queries() {
+ let buf = await openMirror("rewrite_tag_queries");
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ tags: ["kitty"],
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkDDDD"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkDDDD",
+ parentid: "menu",
+ type: "bookmark",
+ title: "D",
+ bmkUri: "http://example.com/d",
+ tags: ["kitty"],
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Add tag queries for new and existing tags");
+ await storeRecords(buf, [
+ {
+ id: "toolbar",
+ parentid: "places",
+ type: "folder",
+ children: ["queryBBBBBBB", "queryCCCCCCC", "bookmarkEEEE"],
+ },
+ {
+ id: "queryBBBBBBB",
+ parentid: "toolbar",
+ type: "query",
+ title: "Tagged stuff",
+ bmkUri: "place:type=7&folder=999",
+ folderName: "taggy",
+ },
+ {
+ id: "queryCCCCCCC",
+ parentid: "toolbar",
+ type: "query",
+ title: "Cats",
+ bmkUri: "place:type=7&folder=888",
+ folderName: "kitty",
+ },
+ {
+ id: "bookmarkEEEE",
+ parentid: "toolbar",
+ type: "bookmark",
+ title: "E",
+ bmkUri: "http://example.com/e",
+ tags: ["taggy"],
+ },
+ ]);
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ ["queryBBBBBBB", "queryCCCCCCC"],
+ "Should leave rewritten queries unmerged"
+ );
+
+ deepEqual(
+ changesToUpload,
+ {
+ queryBBBBBBB: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "queryBBBBBBB",
+ type: "query",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: undefined,
+ bmkUri: "place:tag=taggy",
+ title: "Tagged stuff",
+ folderName: "taggy",
+ },
+ },
+ queryCCCCCCC: {
+ tombstone: false,
+ counter: 1,
+ synced: false,
+ cleartext: {
+ id: "queryCCCCCCC",
+ type: "query",
+ parentid: "toolbar",
+ hasDupe: true,
+ parentName: BookmarksToolbarTitle,
+ dateAdded: undefined,
+ bmkUri: "place:tag=kitty",
+ title: "Cats",
+ folderName: "kitty",
+ },
+ },
+ },
+ "Should reupload (E C) with rewritten URLs"
+ );
+
+ let bmWithTaggy = await PlacesUtils.bookmarks.fetch({ tags: ["taggy"] });
+ equal(
+ bmWithTaggy.url.href,
+ "http://example.com/e",
+ "Should insert bookmark with new tag"
+ );
+
+ let bmWithKitty = await PlacesUtils.bookmarks.fetch({ tags: ["kitty"] });
+ equal(
+ bmWithKitty.url.href,
+ "http://example.com/d",
+ "Should retain existing tag"
+ );
+
+ let { root: toolbarContainer } = PlacesUtils.getFolderContents(
+ PlacesUtils.bookmarks.toolbarGuid,
+ false,
+ true
+ );
+ equal(
+ toolbarContainer.childCount,
+ 3,
+ "Should add queries and bookmark to toolbar"
+ );
+
+ let containerForB = PlacesUtils.asContainer(toolbarContainer.getChild(0));
+ containerForB.containerOpen = true;
+ for (let i = 0; i < containerForB.childCount; ++i) {
+ let child = containerForB.getChild(i);
+ equal(
+ child.uri,
+ "http://example.com/e",
+ `Rewritten tag query B should have tagged child node at ${i}`
+ );
+ }
+ containerForB.containerOpen = false;
+
+ let containerForC = PlacesUtils.asContainer(toolbarContainer.getChild(1));
+ containerForC.containerOpen = true;
+ for (let i = 0; i < containerForC.childCount; ++i) {
+ let child = containerForC.getChild(i);
+ equal(
+ child.uri,
+ "http://example.com/d",
+ `Rewritten tag query C should have tagged child node at ${i}`
+ );
+ }
+ containerForC.containerOpen = false;
+
+ toolbarContainer.containerOpen = false;
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_date_added() {
+ let buf = await openMirror("date_added");
+
+ let aDateAdded = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
+ let bDateAdded = new Date();
+
+ info("Set up mirror");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ dateAdded: aDateAdded,
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ dateAdded: bDateAdded,
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await storeRecords(
+ buf,
+ [
+ {
+ id: "menu",
+ parentid: "places",
+ type: "folder",
+ children: ["bookmarkAAAA", "bookmarkBBBB"],
+ },
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A",
+ dateAdded: aDateAdded.getTime(),
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B",
+ dateAdded: bDateAdded.getTime(),
+ bmkUri: "http://example.com/b",
+ },
+ ],
+ { needsMerge: false }
+ );
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("Make remote changes");
+ let bNewDateAdded = new Date(bDateAdded.getTime() - 1 * 60 * 60 * 1000);
+ await storeRecords(buf, [
+ {
+ id: "bookmarkAAAA",
+ parentid: "menu",
+ type: "bookmark",
+ title: "A (remote)",
+ dateAdded: Date.now(),
+ bmkUri: "http://example.com/a",
+ },
+ {
+ id: "bookmarkBBBB",
+ parentid: "menu",
+ type: "bookmark",
+ title: "B (remote)",
+ dateAdded: bNewDateAdded.getTime(),
+ bmkUri: "http://example.com/b",
+ },
+ ]);
+
+ info("Apply remote");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ let idsToUpload = inspectChangeRecords(changesToUpload);
+ deepEqual(
+ idsToUpload,
+ {
+ updated: ["bookmarkAAAA"],
+ deleted: [],
+ },
+ "Should flag A for weak reupload"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ ]);
+ observer.check([
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkAAAA"),
+ title: "A (remote)",
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ title: "B (remote)",
+ guid: "bookmarkBBBB",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ ]);
+
+ let changeCounter = changesToUpload.bookmarkAAAA.counter;
+ strictEqual(changeCounter, 0, "Should not bump change counter for A");
+
+ let aInfo = await PlacesUtils.bookmarks.fetch("bookmarkAAAA");
+ equal(aInfo.title, "A (remote)", "Should change local title for A");
+ deepEqual(
+ aInfo.dateAdded,
+ aDateAdded,
+ "Should not change date added for A to newer remote date"
+ );
+
+ let bInfo = await PlacesUtils.bookmarks.fetch("bookmarkBBBB");
+ equal(bInfo.title, "B (remote)", "Should change local title for B");
+ deepEqual(
+ bInfo.dateAdded,
+ bNewDateAdded,
+ "Should take older date added for B"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Bug 1472435.
+add_task(async function test_duplicate_url_rows() {
+ let buf = await openMirror("test_duplicate_url_rows");
+
+ let placesToInsert = [
+ {
+ guid: "placeAAAAAAA",
+ href: "http://example.com",
+ },
+ {
+ guid: "placeBBBBBBB",
+ href: "http://example.com",
+ },
+ {
+ guid: "placeCCCCCCC",
+ href: "http://example.com/c",
+ },
+ ];
+
+ let itemsToInsert = [
+ {
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ placeGuid: "placeAAAAAAA",
+ localTitle: "A",
+ remoteTitle: "A (remote)",
+ },
+ {
+ guid: "bookmarkBBBB",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ placeGuid: "placeBBBBBBB",
+ localTitle: "B",
+ remoteTitle: "B (remote)",
+ },
+ {
+ guid: "bookmarkCCCC",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ placeGuid: "placeCCCCCCC",
+ localTitle: "C",
+ remoteTitle: "C (remote)",
+ },
+ ];
+
+ info("Manually insert local and remote items with duplicate URLs");
+ await buf.db.executeTransaction(async function () {
+ for (let { guid, href } of placesToInsert) {
+ let url = new URL(href);
+ await buf.db.executeCached(
+ `
+ INSERT INTO moz_places(url, url_hash, rev_host, hidden, frecency, guid)
+ VALUES(:url, hash(:url), :revHost, 0, -1, :guid)`,
+ { url: url.href, revHost: PlacesUtils.getReversedHost(url), guid }
+ );
+
+ await buf.db.executeCached(
+ `
+ INSERT INTO urls(guid, url, hash, revHost)
+ VALUES(:guid, :url, hash(:url), :revHost)`,
+ { guid, url: url.href, revHost: PlacesUtils.getReversedHost(url) }
+ );
+ }
+
+ for (let {
+ guid,
+ parentGuid,
+ placeGuid,
+ localTitle,
+ remoteTitle,
+ } of itemsToInsert) {
+ await buf.db.executeCached(
+ `
+ INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title,
+ syncStatus, syncChangeCounter)
+ VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid),
+ (SELECT id FROM moz_places WHERE guid = :placeGuid),
+ (SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE p.guid = :parentGuid), :type, :localTitle,
+ :syncStatus, 1)`,
+ {
+ guid,
+ parentGuid,
+ placeGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ localTitle,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ }
+ );
+
+ await buf.db.executeCached(
+ `
+ INSERT INTO items(guid, parentGuid, needsMerge, kind, title, urlId)
+ VALUES(:guid, :parentGuid, 1, :kind, :remoteTitle,
+ (SELECT id FROM urls WHERE guid = :placeGuid))`,
+ {
+ guid,
+ parentGuid,
+ placeGuid,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_BOOKMARK,
+ remoteTitle,
+ }
+ );
+
+ await buf.db.executeCached(
+ `
+ INSERT INTO structure(guid, parentGuid, position)
+ VALUES(:guid, :parentGuid,
+ IFNULL((SELECT count(*) FROM structure
+ WHERE parentGuid = :parentGuid), 0))`,
+ { guid, parentGuid }
+ );
+ }
+ });
+
+ info("Apply mirror");
+ let observer = expectBookmarkChangeNotifications();
+ let changesToUpload = await buf.apply({
+ notifyInStableOrder: true,
+ });
+ deepEqual(
+ await buf.fetchUnmergedGuids(),
+ [
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ ],
+ "Should leave roots unmerged"
+ );
+ deepEqual(
+ Object.keys(changesToUpload).sort(),
+ ["menu", "mobile", "toolbar", "unfiled"],
+ "Should upload roots"
+ );
+
+ await assertLocalTree(
+ PlacesUtils.bookmarks.rootGuid,
+ {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: "",
+ children: [
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 0,
+ title: BookmarksMenuTitle,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "A (remote)",
+ url: "http://example.com/",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 1,
+ title: BookmarksToolbarTitle,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "B (remote)",
+ url: "http://example.com/",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 3,
+ title: UnfiledBookmarksTitle,
+ children: [
+ {
+ guid: "bookmarkCCCC",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ index: 0,
+ title: "C (remote)",
+ url: "http://example.com/c",
+ },
+ ],
+ },
+ {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ index: 4,
+ title: MobileBookmarksTitle,
+ },
+ ],
+ },
+ "Should update titles for items with duplicate URLs"
+ );
+
+ let localItemIds = await PlacesTestUtils.promiseManyItemIds([
+ "bookmarkAAAA",
+ "bookmarkBBBB",
+ "bookmarkCCCC",
+ ]);
+ observer.check([
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkAAAA"),
+ title: "A (remote)",
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkBBBB"),
+ title: "B (remote)",
+ guid: "bookmarkBBBB",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ },
+ },
+ {
+ name: "bookmark-title-changed",
+ params: {
+ itemId: localItemIds.get("bookmarkCCCC"),
+ title: "C (remote)",
+ guid: "bookmarkCCCC",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ },
+ },
+ ]);
+
+ info("Remove duplicate URLs from Places to avoid tripping debug asserts");
+ await buf.db.executeTransaction(async function () {
+ for (let { guid } of placesToInsert) {
+ await buf.db.executeCached(
+ `
+ DELETE FROM moz_places WHERE guid = :guid`,
+ { guid }
+ );
+ }
+ });
+
+ await storeChangesInMirror(buf, changesToUpload);
+ deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_duplicate_local_tags() {
+ let buf = await openMirror("duplicate_local_tags");
+ let now = new Date();
+
+ info("Insert A");
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "A",
+ url: "http://example.com/a",
+ dateAdded: now,
+ });
+
+ // Each tag folder should have unique tag entries, but the tagging service
+ // doesn't enforce this. We should still sync the correct set of tags,
+ // though, even if there are duplicates for the same URL.
+ info("Manually insert local tags for A");
+ for (let [tag, dupes] of [
+ ["one", 2],
+ ["two", 1],
+ ["three", 2],
+ ]) {
+ let tagFolderInfo = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: tag,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ });
+ for (let i = 0; i < dupes; ++i) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: tagFolderInfo.guid,
+ url: "http://example.com/a",
+ });
+ }
+ }
+
+ let tagsForA = PlacesUtils.tagging.getTagsForURI(
+ Services.io.newURI("http://example.com/a")
+ );
+ deepEqual(
+ tagsForA,
+ ["one", "one", "three", "three", "two"],
+ "Tagging service should return duplicate tags"
+ );
+
+ info("Apply remote");
+ let changesToUpload = await buf.apply();
+ deepEqual(
+ changesToUpload.bookmarkAAAA.cleartext,
+ {
+ id: "bookmarkAAAA",
+ type: "bookmark",
+ parentid: "menu",
+ hasDupe: true,
+ parentName: BookmarksMenuTitle,
+ dateAdded: now.getTime(),
+ bmkUri: "http://example.com/a",
+ title: "A",
+ tags: ["one", "three", "two"],
+ },
+ "Should upload A with tags"
+ );
+
+ await buf.finalize();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
diff --git a/toolkit/components/places/tests/sync/test_sync_utils.js b/toolkit/components/places/tests/sync/test_sync_utils.js
new file mode 100644
index 0000000000..8396ac2f0d
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_sync_utils.js
@@ -0,0 +1,3130 @@
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+var makeGuid = PlacesUtils.history.makeGuid;
+
+function shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+}
+
+async function assertTagForURLs(tag, urls, message) {
+ let taggedURLs = new Set();
+ await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b =>
+ taggedURLs.add(b.url.href)
+ );
+ deepEqual(
+ Array.from(taggedURLs).sort(compareAscending),
+ urls.sort(compareAscending),
+ message
+ );
+}
+
+function assertURLHasTags(url, tags, message) {
+ let actualTags = PlacesUtils.tagging.getTagsForURI(uri(url));
+ deepEqual(actualTags.sort(compareAscending), tags, message);
+}
+
+var populateTree = async function populate(parentGuid, ...items) {
+ let guids = {};
+
+ for (let index = 0; index < items.length; index++) {
+ let item = items[index];
+ let guid = makeGuid();
+
+ switch (item.kind) {
+ case "bookmark":
+ case "query":
+ await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: item.url,
+ title: item.title,
+ parentGuid,
+ guid,
+ index,
+ });
+ break;
+
+ case "separator":
+ await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid,
+ guid,
+ });
+ break;
+
+ case "folder":
+ await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: item.title,
+ parentGuid,
+ guid,
+ });
+ if (item.children) {
+ Object.assign(guids, await populate(guid, ...item.children));
+ }
+ break;
+
+ default:
+ throw new Error(`Unsupported item type: ${item.type}`);
+ }
+
+ guids[item.title] = guid;
+ }
+
+ return guids;
+};
+
+var moveSyncedBookmarksToUnsyncedParent = async function () {
+ info("Insert synced bookmarks");
+ let syncedGuids = await populateTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ kind: "folder",
+ title: "folder",
+ children: [
+ {
+ kind: "bookmark",
+ title: "childBmk",
+ url: "https://example.org",
+ },
+ ],
+ },
+ {
+ kind: "bookmark",
+ title: "topBmk",
+ url: "https://example.com",
+ }
+ );
+ // Pretend we've synced each bookmark at least once.
+ await PlacesTestUtils.setBookmarkSyncFields(
+ ...Object.values(syncedGuids).map(guid => ({
+ guid,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ }))
+ );
+
+ info("Make new folder");
+ let unsyncedFolder = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "unsyncedFolder",
+ });
+
+ info("Move synced bookmarks into unsynced new folder");
+ for (let guid of Object.values(syncedGuids)) {
+ await PlacesUtils.bookmarks.update({
+ guid,
+ parentGuid: unsyncedFolder.guid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+ }
+
+ return { syncedGuids, unsyncedFolder };
+};
+
+var setChangesSynced = async function (changes) {
+ for (let recordId in changes) {
+ changes[recordId].synced = true;
+ }
+ await PlacesSyncUtils.bookmarks.pushChanges(changes);
+};
+
+var ignoreChangedRoots = async function () {
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ let expectedRoots = ["menu", "mobile", "toolbar", "unfiled"];
+ if (!ObjectUtils.deepEqual(Object.keys(changes).sort(), expectedRoots)) {
+ // Make sure the previous test cleaned up.
+ throw new Error(
+ `Unexpected changes at start of test: ${JSON.stringify(changes)}`
+ );
+ }
+ await setChangesSynced(changes);
+};
+
+add_task(async function test_fetchURLFrecency() {
+ // Add visits to the following URLs and then check if frecency for those URLs is not -1.
+ let arrayOfURLsToVisit = [
+ "https://www.mozilla.org/en-US/",
+ "http://getfirefox.com",
+ "http://getthunderbird.com",
+ ];
+ for (let url of arrayOfURLsToVisit) {
+ await PlacesTestUtils.addVisits(url);
+ }
+ for (let url of arrayOfURLsToVisit) {
+ let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url);
+ equal(typeof frecency, "number", "The frecency should be of type: number");
+ notEqual(
+ frecency,
+ -1,
+ "The frecency of this url should be different than -1"
+ );
+ }
+ // Do not add visits to the following URLs, and then check if frecency for those URLs is -1.
+ let arrayOfURLsNotVisited = ["https://bugzilla.org", "https://example.org"];
+ for (let url of arrayOfURLsNotVisited) {
+ let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url);
+ equal(frecency, -1, "The frecency of this url should be -1");
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_determineNonSyncableGuids() {
+ // Add visits to the following URLs with different transition types.
+ let arrayOfVisits = [
+ { uri: "https://www.mozilla.org/en-US/", transition: TRANSITION_TYPED },
+ { uri: "http://getfirefox.com/", transition: TRANSITION_LINK },
+ { uri: "http://getthunderbird.com/", transition: TRANSITION_FRAMED_LINK },
+ { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD },
+ ];
+ for (let visit of arrayOfVisits) {
+ await PlacesTestUtils.addVisits(visit);
+ }
+
+ // Fetch the guid for each visit.
+ let guids = [];
+ let dictURLGuid = {};
+ for (let visit of arrayOfVisits) {
+ let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri);
+ guids.push(guid);
+ dictURLGuid[visit.uri] = guid;
+ }
+
+ // Filter the visits.
+ let filteredGuids = await PlacesSyncUtils.history.determineNonSyncableGuids(
+ guids
+ );
+
+ let filtered = [TRANSITION_FRAMED_LINK, TRANSITION_DOWNLOAD];
+ // Check if the filtered visits are of type TRANSITION_FRAMED_LINK.
+ for (let visit of arrayOfVisits) {
+ if (filtered.includes(visit.transition)) {
+ ok(
+ filteredGuids.includes(dictURLGuid[visit.uri]),
+ "This url should be one of the filtered guids."
+ );
+ } else {
+ ok(
+ !filteredGuids.includes(dictURLGuid[visit.uri]),
+ "This url should not be one of the filtered guids."
+ );
+ }
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_changeGuid() {
+ // Add some visits of the following URLs.
+ let arrayOfURLsToVisit = [
+ "https://www.mozilla.org/en-US/",
+ "http://getfirefox.com/",
+ "http://getthunderbird.com/",
+ ];
+ for (let url of arrayOfURLsToVisit) {
+ await PlacesTestUtils.addVisits(url);
+ }
+
+ for (let url of arrayOfURLsToVisit) {
+ let originalGuid = await PlacesSyncUtils.history.fetchGuidForURL(url);
+ let newGuid = makeGuid();
+
+ // Change the original GUID for the new GUID.
+ await PlacesSyncUtils.history.changeGuid(url, newGuid);
+
+ // Fetch the GUID for this URL.
+ let newGuidFetched = await PlacesSyncUtils.history.fetchGuidForURL(url);
+
+ // Check that the URL has the new GUID as its GUID and not the original one.
+ equal(
+ newGuid,
+ newGuidFetched,
+ "These should be equal since we changed the guid for the visit."
+ );
+ notEqual(
+ originalGuid,
+ newGuidFetched,
+ "These should be different since we changed the guid for the visit."
+ );
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_fetchVisitsForURL() {
+ // Get the date for this moment and a date for a minute ago.
+ let now = new Date();
+ let aMinuteAgo = new Date(now.getTime() - 1 * 60000);
+
+ // Add some visits of the following URLs, specifying the transition and the visit date.
+ let arrayOfVisits = [
+ {
+ uri: "https://www.mozilla.org/en-US/",
+ transition: TRANSITION_TYPED,
+ visitDate: aMinuteAgo,
+ },
+ {
+ uri: "http://getfirefox.com/",
+ transition: TRANSITION_LINK,
+ visitDate: aMinuteAgo,
+ },
+ {
+ uri: "http://getthunderbird.com/",
+ transition: TRANSITION_LINK,
+ visitDate: aMinuteAgo,
+ },
+ ];
+ for (let elem of arrayOfVisits) {
+ await PlacesTestUtils.addVisits(elem);
+ }
+
+ for (let elem of arrayOfVisits) {
+ // Fetch all the visits for this URL.
+ let visits = await PlacesSyncUtils.history.fetchVisitsForURL(elem.uri);
+ // Since the visit we added will be the last one in the collection of visits, we get the index of it.
+ let iLast = visits.length - 1;
+
+ // The date is saved in _micro_seconds, here we change it to milliseconds.
+ let dateInMilliseconds = visits[iLast].date * 0.001;
+
+ // Check that the info we provided for this URL is the same one retrieved.
+ equal(
+ dateInMilliseconds,
+ elem.visitDate.getTime(),
+ "The date we provided should be the same we retrieved."
+ );
+ equal(
+ visits[iLast].type,
+ elem.transition,
+ "The transition type we provided should be the same we retrieved."
+ );
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_fetchGuidForURL() {
+ // Add some visits of the following URLs.
+ let arrayOfURLsToVisit = [
+ "https://www.mozilla.org/en-US/",
+ "http://getfirefox.com/",
+ "http://getthunderbird.com/",
+ ];
+ for (let url of arrayOfURLsToVisit) {
+ await PlacesTestUtils.addVisits(url);
+ }
+
+ // This tries to test fetchGuidForURL in two ways:
+ // 1- By fetching the GUID, and then using that GUID to retrieve the info of the visit.
+ // It then compares the URL with the URL that is on the visits info.
+ // 2- By creating a new GUID, changing the GUID for the visit, fetching the GUID and comparing them.
+ for (let url of arrayOfURLsToVisit) {
+ let guid = await PlacesSyncUtils.history.fetchGuidForURL(url);
+ let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid);
+
+ let newGuid = makeGuid();
+ await PlacesSyncUtils.history.changeGuid(url, newGuid);
+ let newGuid2 = await PlacesSyncUtils.history.fetchGuidForURL(url);
+
+ equal(
+ url,
+ info.url,
+ "The url provided and the url retrieved should be the same."
+ );
+ equal(
+ newGuid,
+ newGuid2,
+ "The changed guid and the retrieved guid should be the same."
+ );
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_fetchURLInfoForGuid() {
+ // Add some visits of the following URLs. specifying the title.
+ let visits = [
+ { uri: "https://www.mozilla.org/en-US/", title: "mozilla" },
+ { uri: "http://getfirefox.com/", title: "firefox" },
+ { uri: "http://getthunderbird.com/", title: "thunderbird" },
+ { uri: "http://quantum.mozilla.com/", title: null },
+ ];
+ for (let visit of visits) {
+ await PlacesTestUtils.addVisits(visit);
+ }
+
+ for (let visit of visits) {
+ let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri);
+ let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid);
+
+ // Compare the info returned by fetchURLInfoForGuid,
+ // URL and title should match while frecency must be different than -1.
+ equal(
+ info.url,
+ visit.uri,
+ "The url provided should be the same as the url retrieved."
+ );
+ equal(
+ info.title,
+ visit.title || "",
+ "The title provided should be the same as the title retrieved."
+ );
+ notEqual(
+ info.frecency,
+ -1,
+ "The frecency of the visit should be different than -1."
+ );
+ }
+
+ // Create a "fake" GUID and check that the result of fetchURLInfoForGuid is null.
+ let guid = makeGuid();
+ let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid);
+
+ equal(
+ info,
+ null,
+ "The information object of a non-existent guid should be null."
+ );
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_getAllURLs() {
+ // Add some visits of the following URLs.
+ let arrayOfURLsToVisit = [
+ "https://www.mozilla.org/en-US/",
+ "http://getfirefox.com/",
+ "http://getthunderbird.com/",
+ ];
+ for (let url of arrayOfURLsToVisit) {
+ await PlacesTestUtils.addVisits(url);
+ }
+
+ // Get all URLs.
+ let allURLs = await PlacesSyncUtils.history.getAllURLs({
+ since: new Date(Date.now() - 2592000000),
+ limit: 5000,
+ });
+
+ // The amount of URLs must be the same in both collections.
+ equal(
+ allURLs.length,
+ arrayOfURLsToVisit.length,
+ "The amount of urls retrived should match the amount of urls provided."
+ );
+
+ // Check that the correct URLs were retrived.
+ for (let url of arrayOfURLsToVisit) {
+ ok(
+ allURLs.includes(url),
+ "The urls retrieved should match the ones used in this test."
+ );
+ }
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_getAllURLs_skips_downloads() {
+ // Add some visits of the following URLs.
+ let arrayOfURLsToVisit = [
+ "https://www.mozilla.org/en-US/",
+ { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD },
+ ];
+ for (let url of arrayOfURLsToVisit) {
+ await PlacesTestUtils.addVisits(url);
+ }
+
+ // Get all URLs.
+ let allURLs = await PlacesSyncUtils.history.getAllURLs({
+ since: new Date(Date.now() - 2592000000),
+ limit: 5000,
+ });
+
+ // Should be only the non-download
+ equal(allURLs.length, 1, "Should only get one URL back.");
+
+ // Check that the correct URLs were retrived.
+ equal(allURLs[0], arrayOfURLsToVisit[0], "Should get back our non-download.");
+
+ // Remove the visits added during this test.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_order() {
+ info("Insert some bookmarks");
+ let guids = await populateTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ kind: "bookmark",
+ title: "childBmk",
+ url: "http://getfirefox.com",
+ },
+ {
+ kind: "bookmark",
+ title: "siblingBmk",
+ url: "http://getthunderbird.com",
+ },
+ {
+ kind: "folder",
+ title: "siblingFolder",
+ },
+ {
+ kind: "separator",
+ title: "siblingSep",
+ }
+ );
+
+ info("Reorder inserted bookmarks");
+ {
+ let order = [
+ guids.siblingFolder,
+ guids.siblingSep,
+ guids.childBmk,
+ guids.siblingBmk,
+ ];
+ await PlacesSyncUtils.bookmarks.order(
+ PlacesUtils.bookmarks.menuGuid,
+ order
+ );
+ let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ childRecordIds,
+ order,
+ "New bookmarks should be reordered according to array"
+ );
+ }
+
+ info("Same order with unspecified children");
+ {
+ await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [
+ guids.siblingSep,
+ guids.siblingBmk,
+ ]);
+ let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ childRecordIds,
+ [guids.siblingFolder, guids.siblingSep, guids.childBmk, guids.siblingBmk],
+ "Current order should be respected if possible"
+ );
+ }
+
+ info("New order with unspecified children");
+ {
+ await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [
+ guids.siblingBmk,
+ guids.siblingSep,
+ ]);
+ let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ childRecordIds,
+ [guids.siblingBmk, guids.siblingSep, guids.siblingFolder, guids.childBmk],
+ "Unordered children should be moved to end if current order can't be respected"
+ );
+ }
+
+ info("Reorder with nonexistent children");
+ {
+ await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [
+ guids.childBmk,
+ makeGuid(),
+ guids.siblingBmk,
+ guids.siblingSep,
+ makeGuid(),
+ guids.siblingFolder,
+ makeGuid(),
+ ]);
+ let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ childRecordIds,
+ [guids.childBmk, guids.siblingBmk, guids.siblingSep, guids.siblingFolder],
+ "Nonexistent children should be ignored"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_order_roots() {
+ let oldOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.rootGuid
+ );
+ await PlacesSyncUtils.bookmarks.order(
+ PlacesUtils.bookmarks.rootGuid,
+ shuffle(oldOrder)
+ );
+ let newOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.rootGuid
+ );
+ deepEqual(oldOrder, newOrder, "Should ignore attempts to reorder roots");
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_tags() {
+ await ignoreChangedRoots();
+
+ info("Insert untagged items with same URL");
+ let firstItem = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "https://example.org",
+ });
+ let secondItem = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "https://example.org",
+ });
+ let untaggedItem = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "https://bugzilla.org",
+ });
+ let taggedItem = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "https://mozilla.org",
+ });
+
+ info("Create tag");
+ PlacesUtils.tagging.tagURI(uri("https://example.org"), ["taggy"]);
+
+ let tagBm = await PlacesUtils.bookmarks.fetch({
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ index: 0,
+ });
+ let tagFolderGuid = tagBm.guid;
+ let tagFolderId = await PlacesTestUtils.promiseItemId(tagFolderGuid);
+
+ info("Tagged bookmarks should be in changeset");
+ {
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId].sort(),
+ "Should include tagged bookmarks in changeset"
+ );
+ await setChangesSynced(changes);
+ }
+
+ info("Change tag case");
+ {
+ PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["TaGgY"]);
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId, taggedItem.recordId].sort(),
+ "Should include tagged bookmarks after changing case"
+ );
+ await assertTagForURLs(
+ "TaGgY",
+ ["https://example.org/", "https://mozilla.org/"],
+ "Should add tag for new URL"
+ );
+ await setChangesSynced(changes);
+ }
+
+ // These tests change a tag item directly, without going through the tagging
+ // service. This behavior isn't supported, but the tagging service registers
+ // an observer to handle these cases, so we make sure we handle them
+ // correctly.
+
+ info("Rename tag folder using Bookmarks.setItemTitle");
+ {
+ PlacesUtils.bookmarks.setItemTitle(tagFolderId, "sneaky");
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ ["sneaky"],
+ "Tagging service should update cache with new title"
+ );
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId].sort(),
+ "Should include tagged bookmarks after renaming tag folder"
+ );
+ await setChangesSynced(changes);
+ }
+
+ info("Rename tag folder using Bookmarks.update");
+ {
+ await PlacesUtils.bookmarks.update({
+ guid: tagFolderGuid,
+ title: "tricky",
+ });
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ ["tricky"],
+ "Tagging service should update cache after updating tag folder"
+ );
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId].sort(),
+ "Should include tagged bookmarks after updating tag folder"
+ );
+ await setChangesSynced(changes);
+ }
+
+ info("Change tag entry URL using Bookmarks.update");
+ {
+ let bm = await PlacesUtils.bookmarks.fetch({
+ parentGuid: tagFolderGuid,
+ index: 0,
+ });
+ bm.url = "https://bugzilla.org/";
+ await PlacesUtils.bookmarks.update(bm);
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(),
+ "Should include tagged bookmarks after changing tag entry URI"
+ );
+ await assertTagForURLs(
+ "tricky",
+ ["https://bugzilla.org/", "https://mozilla.org/"],
+ "Should remove tag entry for old URI"
+ );
+ await setChangesSynced(changes);
+
+ bm.url = "https://example.org/";
+ await PlacesUtils.bookmarks.update(bm);
+ changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(),
+ "Should include tagged bookmarks after changing tag entry URL"
+ );
+ await assertTagForURLs(
+ "tricky",
+ ["https://example.org/", "https://mozilla.org/"],
+ "Should remove tag entry for old URL"
+ );
+ await setChangesSynced(changes);
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_conflicting_keywords() {
+ await ignoreChangedRoots();
+
+ info("Insert bookmark with new keyword");
+ let tbBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "unfiled",
+ url: "http://getthunderbird.com",
+ keyword: "tbird",
+ });
+ {
+ let entryByKeyword = await PlacesUtils.keywords.fetch("tbird");
+ equal(
+ entryByKeyword.url.href,
+ "http://getthunderbird.com/",
+ "Should return new keyword entry by URL"
+ );
+ let entryByURL = await PlacesUtils.keywords.fetch({
+ url: "http://getthunderbird.com",
+ });
+ equal(entryByURL.keyword, "tbird", "Should return new entry by keyword");
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ changes,
+ {},
+ "Should not bump change counter for new keyword entry"
+ );
+ }
+
+ info("Insert bookmark with same URL and different keyword");
+ let dupeTbBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ url: "http://getthunderbird.com",
+ keyword: "tb",
+ });
+ {
+ let oldKeywordByURL = await PlacesUtils.keywords.fetch("tbird");
+ ok(
+ !oldKeywordByURL,
+ "Should remove old entry when inserting bookmark with different keyword"
+ );
+ let entryByKeyword = await PlacesUtils.keywords.fetch("tb");
+ equal(
+ entryByKeyword.url.href,
+ "http://getthunderbird.com/",
+ "Should return different keyword entry by URL"
+ );
+ let entryByURL = await PlacesUtils.keywords.fetch({
+ url: "http://getthunderbird.com",
+ });
+ equal(entryByURL.keyword, "tb", "Should return different entry by keyword");
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [tbBmk.recordId, dupeTbBmk.recordId].sort(),
+ "Should bump change counter for bookmarks with different keyword"
+ );
+ await setChangesSynced(changes);
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_insert() {
+ info("Insert bookmark");
+ {
+ let item = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "https://example.org",
+ });
+ let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId });
+ equal(
+ type,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "Bookmark should have correct type"
+ );
+ }
+
+ info("Insert query");
+ {
+ let item = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "query",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url: "place:terms=term&folder=TOOLBAR&queryType=1",
+ folder: "Saved search",
+ });
+ let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId });
+ equal(
+ type,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "Queries should be stored as bookmarks"
+ );
+ }
+
+ info("Insert folder");
+ {
+ let item = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ title: "New folder",
+ });
+ let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId });
+ equal(
+ type,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ "Folder should have correct type"
+ );
+ }
+
+ info("Insert separator");
+ {
+ let item = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "separator",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ });
+ let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId });
+ equal(
+ type,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ "Separator should have correct type"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_insert_tags() {
+ await Promise.all(
+ [
+ {
+ kind: "bookmark",
+ url: "https://example.com",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ tags: ["foo", "bar"],
+ },
+ {
+ kind: "bookmark",
+ url: "https://example.org",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ tags: ["foo", "baz"],
+ },
+ {
+ kind: "query",
+ url: "place:queryType=1&sort=12&maxResults=10",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ folder: "bar",
+ tags: ["baz", "qux"],
+ title: "bar",
+ },
+ ].map(info => PlacesSyncUtils.test.bookmarks.insert(info))
+ );
+
+ await assertTagForURLs(
+ "foo",
+ ["https://example.com/", "https://example.org/"],
+ "2 URLs with new tag"
+ );
+ await assertTagForURLs(
+ "bar",
+ ["https://example.com/"],
+ "1 URL with existing tag"
+ );
+ await assertTagForURLs(
+ "baz",
+ ["https://example.org/", "place:queryType=1&sort=12&maxResults=10"],
+ "Should support tagging URLs and tag queries"
+ );
+ await assertTagForURLs(
+ "qux",
+ ["place:queryType=1&sort=12&maxResults=10"],
+ "Should support tagging tag queries"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_insert_tags_whitespace() {
+ info("Untrimmed and blank tags");
+ let taggedBlanks = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.org",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ tags: [" untrimmed ", " ", "taggy"],
+ });
+ deepEqual(
+ taggedBlanks.tags,
+ ["untrimmed", "taggy"],
+ "Should not return empty tags"
+ );
+ assertURLHasTags(
+ "https://example.org/",
+ ["taggy", "untrimmed"],
+ "Should set trimmed tags and ignore dupes"
+ );
+
+ info("Dupe tags");
+ let taggedDupes = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.net",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ tags: [" taggy", "taggy ", " taggy ", "taggy"],
+ });
+ deepEqual(
+ taggedDupes.tags,
+ ["taggy", "taggy", "taggy", "taggy"],
+ "Should return trimmed and dupe tags"
+ );
+ assertURLHasTags(
+ "https://example.net/",
+ ["taggy"],
+ "Should ignore dupes when setting tags"
+ );
+
+ await assertTagForURLs(
+ "taggy",
+ ["https://example.net/", "https://example.org/"],
+ "Should exclude falsy tags"
+ );
+
+ PlacesUtils.tagging.untagURI(uri("https://example.org"), [
+ "untrimmed",
+ "taggy",
+ ]);
+ PlacesUtils.tagging.untagURI(uri("https://example.net"), ["taggy"]);
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ [],
+ "Should clean up all tags"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_insert_keyword() {
+ info("Insert item with new keyword");
+ {
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ url: "https://example.com",
+ keyword: "moz",
+ recordId: makeGuid(),
+ });
+ let entry = await PlacesUtils.keywords.fetch("moz");
+ equal(
+ entry.url.href,
+ "https://example.com/",
+ "Should add keyword for item"
+ );
+ }
+
+ info("Insert item with existing keyword");
+ {
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ url: "https://mozilla.org",
+ keyword: "moz",
+ recordId: makeGuid(),
+ });
+ let entry = await PlacesUtils.keywords.fetch("moz");
+ equal(
+ entry.url.href,
+ "https://mozilla.org/",
+ "Should reassign keyword to new item"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_insert_tag_query() {
+ info("Use the public tagging API to ensure we added the tag correctly");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ });
+ PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["taggy"]);
+ assertURLHasTags(
+ "https://mozilla.org/",
+ ["taggy"],
+ "Should set tags using the tagging API"
+ );
+
+ info("Insert tag query for non existing tag");
+ {
+ let query = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "query",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ url: "place:type=7&folder=90",
+ folder: "nonexisting",
+ title: "Tagged stuff",
+ });
+ let params = new URLSearchParams(query.url.pathname);
+ ok(!params.has("type"), "Should not preserve query type");
+ ok(!params.has("folder"), "Should not preserve folder");
+ equal(params.get("tag"), "nonexisting", "Should add tag");
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ ["taggy"],
+ "The nonexisting tag should not be added"
+ );
+ }
+
+ info("Insert tag query for existing tag");
+ {
+ let url = "place:type=7&folder=90&maxResults=15";
+ let query = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "query",
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ url,
+ folder: "taggy",
+ title: "Sorted and tagged",
+ });
+ let params = new URLSearchParams(query.url.pathname);
+ ok(!params.get("type"), "Should not preserve query type");
+ ok(!params.has("folder"), "Should not preserve folder");
+ equal(params.get("maxResults"), "15", "Should preserve additional params");
+ equal(params.get("tag"), "taggy", "Should add tag");
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ ["taggy"],
+ "Should not duplicate existing tags"
+ );
+ }
+
+ info("Removing the tag should clean up the tag folder");
+ PlacesUtils.tagging.untagURI(uri("https://mozilla.org"), null);
+ deepEqual(
+ (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name),
+ [],
+ "Should remove tag folder once last item is untagged"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_fetch() {
+ let folder = await PlacesSyncUtils.test.bookmarks.insert({
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ kind: "folder",
+ });
+ let bmk = await PlacesSyncUtils.test.bookmarks.insert({
+ recordId: makeGuid(),
+ parentRecordId: "menu",
+ kind: "bookmark",
+ url: "https://example.com",
+ tags: ["taggy"],
+ });
+ let folderBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ recordId: makeGuid(),
+ parentRecordId: folder.recordId,
+ kind: "bookmark",
+ url: "https://example.org",
+ keyword: "kw",
+ });
+ let folderSep = await PlacesSyncUtils.test.bookmarks.insert({
+ recordId: makeGuid(),
+ parentRecordId: folder.recordId,
+ kind: "separator",
+ });
+ let tagQuery = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "query",
+ recordId: makeGuid(),
+ parentRecordId: "toolbar",
+ url: "place:tag=taggy",
+ folder: "taggy",
+ title: "Tagged stuff",
+ });
+
+ info("Fetch empty folder");
+ {
+ let item = await PlacesSyncUtils.bookmarks.fetch(folder.recordId);
+ deepEqual(
+ item,
+ {
+ recordId: folder.recordId,
+ kind: "folder",
+ parentRecordId: "menu",
+ childRecordIds: [folderBmk.recordId, folderSep.recordId],
+ parentTitle: "menu",
+ dateAdded: item.dateAdded,
+ title: "",
+ },
+ "Should include children, title, and parent title in folder"
+ );
+ }
+
+ info("Fetch bookmark with tags");
+ {
+ let item = await PlacesSyncUtils.bookmarks.fetch(bmk.recordId);
+ deepEqual(
+ Object.keys(item).sort(),
+ [
+ "recordId",
+ "kind",
+ "parentRecordId",
+ "url",
+ "tags",
+ "parentTitle",
+ "title",
+ "dateAdded",
+ ].sort(),
+ "Should include bookmark-specific properties"
+ );
+ equal(item.recordId, bmk.recordId, "Sync ID should match");
+ equal(item.url.href, "https://example.com/", "Should return URL");
+ equal(item.parentRecordId, "menu", "Should return parent sync ID");
+ deepEqual(item.tags, ["taggy"], "Should return tags");
+ equal(item.parentTitle, "menu", "Should return parent title");
+ strictEqual(item.title, "", "Should return empty title");
+ }
+
+ info("Fetch bookmark with keyword; without parent title");
+ {
+ let item = await PlacesSyncUtils.bookmarks.fetch(folderBmk.recordId);
+ deepEqual(
+ Object.keys(item).sort(),
+ [
+ "recordId",
+ "kind",
+ "parentRecordId",
+ "url",
+ "keyword",
+ "tags",
+ "parentTitle",
+ "title",
+ "dateAdded",
+ ].sort(),
+ "Should omit blank bookmark-specific properties"
+ );
+ deepEqual(item.tags, [], "Tags should be empty");
+ equal(item.keyword, "kw", "Should return keyword");
+ strictEqual(
+ item.parentTitle,
+ "",
+ "Should include parent title even if empty"
+ );
+ strictEqual(item.title, "", "Should include bookmark title even if empty");
+ }
+
+ info("Fetch separator");
+ {
+ let item = await PlacesSyncUtils.bookmarks.fetch(folderSep.recordId);
+ strictEqual(item.index, 1, "Should return separator position");
+ }
+
+ info("Fetch tag query");
+ {
+ let item = await PlacesSyncUtils.bookmarks.fetch(tagQuery.recordId);
+ deepEqual(
+ Object.keys(item).sort(),
+ [
+ "recordId",
+ "kind",
+ "parentRecordId",
+ "url",
+ "title",
+ "folder",
+ "parentTitle",
+ "dateAdded",
+ ].sort(),
+ "Should include query-specific properties"
+ );
+ equal(
+ item.url.href,
+ `place:tag=taggy`,
+ "Should not rewrite outgoing tag queries"
+ );
+ equal(item.folder, "taggy", "Should return tag name for tag queries");
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_new_parent() {
+ await ignoreChangedRoots();
+
+ let { syncedGuids, unsyncedFolder } =
+ await moveSyncedBookmarksToUnsyncedParent();
+
+ info("Unsynced parent and synced items should be tracked");
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [
+ syncedGuids.folder,
+ syncedGuids.topBmk,
+ syncedGuids.childBmk,
+ unsyncedFolder.guid,
+ "menu",
+ ].sort(),
+ "Should return change records for moved items and new parent"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_deleted_folder() {
+ await ignoreChangedRoots();
+
+ let { syncedGuids, unsyncedFolder } =
+ await moveSyncedBookmarksToUnsyncedParent();
+
+ info("Remove unsynced new folder");
+ await PlacesUtils.bookmarks.remove(unsyncedFolder.guid);
+
+ info("Deleted synced items should be tracked; unsynced folder should not");
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [
+ syncedGuids.folder,
+ syncedGuids.topBmk,
+ syncedGuids.childBmk,
+ "menu",
+ ].sort(),
+ "Should return change records for all deleted items"
+ );
+ for (let guid of Object.values(syncedGuids)) {
+ strictEqual(
+ changes[guid].tombstone,
+ true,
+ `Tombstone flag should be set for deleted item ${guid}`
+ );
+ equal(
+ changes[guid].counter,
+ 1,
+ `Change counter should be 1 for deleted item ${guid}`
+ );
+ equal(
+ changes[guid].status,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ `Sync status should be normal for deleted item ${guid}`
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_import_html() {
+ await ignoreChangedRoots();
+
+ info("Add unsynced bookmark");
+ let unsyncedBmk = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "https://example.com",
+ });
+
+ {
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ unsyncedBmk.guid
+ );
+ ok(
+ fields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Unsynced bookmark statuses should match"
+ );
+ }
+
+ info("Import new bookmarks from HTML");
+ let { path } = do_get_file("./sync_utils_bookmarks.html");
+ await BookmarkHTMLUtils.importFromFile(path);
+
+ // Bookmarks.html doesn't store IDs, so we need to look these up.
+ let mozBmk = await PlacesUtils.bookmarks.fetch({
+ url: "https://www.mozilla.org/",
+ });
+ let fxBmk = await PlacesUtils.bookmarks.fetch({
+ url: "https://www.mozilla.org/en-US/firefox/",
+ });
+ // All Bookmarks.html bookmarks are stored under the menu. For toolbar
+ // bookmarks, this means they're imported into a "Bookmarks Toolbar"
+ // subfolder under the menu, instead of the real toolbar root.
+ let toolbarSubfolder = (
+ await PlacesUtils.bookmarks.search({
+ title: "Bookmarks Toolbar",
+ })
+ ).find(item => item.guid != PlacesUtils.bookmarks.toolbarGuid);
+ let importedFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ mozBmk.guid,
+ fxBmk.guid,
+ toolbarSubfolder.guid
+ );
+ ok(
+ importedFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Sync statuses should match for HTML imports"
+ );
+
+ info("Fetch new HTML imports");
+ let newChanges = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(newChanges).sort(),
+ [
+ mozBmk.guid,
+ fxBmk.guid,
+ toolbarSubfolder.guid,
+ "menu",
+ unsyncedBmk.guid,
+ ].sort(),
+ "Should return new IDs imported from HTML file"
+ );
+ let newFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ unsyncedBmk.guid,
+ mozBmk.guid,
+ fxBmk.guid,
+ toolbarSubfolder.guid
+ );
+ ok(
+ newFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Pulling new HTML imports should not mark them as syncing"
+ );
+
+ info("Mark new HTML imports as syncing");
+ await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges);
+ let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ unsyncedBmk.guid,
+ mozBmk.guid,
+ fxBmk.guid,
+ toolbarSubfolder.guid
+ );
+ ok(
+ normalFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ ),
+ "Marking new HTML imports as syncing should update their statuses"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_import_json() {
+ await ignoreChangedRoots();
+
+ info("Add synced folder");
+ let syncedFolder = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "syncedFolder",
+ });
+ await PlacesTestUtils.setBookmarkSyncFields({
+ guid: syncedFolder.guid,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ });
+
+ info("Import new bookmarks from JSON");
+ let { path } = do_get_file("./sync_utils_bookmarks.json");
+ await BookmarkJSONUtils.importFromFile(path);
+ {
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ syncedFolder.guid,
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ deepEqual(
+ fields.map(field => field.syncStatus),
+ [
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ ],
+ "Sync statuses should match for JSON imports"
+ );
+ }
+
+ info("Fetch new JSON imports");
+ let newChanges = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(newChanges).sort(),
+ [
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l",
+ "menu",
+ "toolbar",
+ syncedFolder.guid,
+ ].sort(),
+ "Should return items imported from JSON backup"
+ );
+ let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ syncedFolder.guid,
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ deepEqual(
+ existingFields.map(field => field.syncStatus),
+ [
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ ],
+ "Pulling new JSON imports should not mark them as syncing"
+ );
+
+ info("Mark new JSON imports as syncing");
+ await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges);
+ let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ syncedFolder.guid,
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ ok(
+ normalFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ ),
+ "Marking new JSON imports as syncing should update their statuses"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_restore_json_tracked() {
+ await ignoreChangedRoots();
+
+ let unsyncedBmk = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "https://example.com",
+ });
+ info(`Unsynced bookmark GUID: ${unsyncedBmk.guid}`);
+ let syncedFolder = await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "syncedFolder",
+ });
+ info(`Synced folder GUID: ${syncedFolder.guid}`);
+ await PlacesTestUtils.setBookmarkSyncFields({
+ guid: syncedFolder.guid,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ });
+ {
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ unsyncedBmk.guid,
+ syncedFolder.guid
+ );
+ deepEqual(
+ fields.map(field => field.syncStatus),
+ [
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ ],
+ "Sync statuses should match before restoring from JSON"
+ );
+ }
+
+ info("Restore from JSON, replacing existing items");
+ let { path } = do_get_file("./sync_utils_bookmarks.json");
+ await BookmarkJSONUtils.importFromFile(path, { replace: true });
+ {
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ ok(
+ fields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "All bookmarks should be NEW after restoring from JSON"
+ );
+ }
+
+ info("Fetch new items restored from JSON");
+ {
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [
+ "menu",
+ "toolbar",
+ "unfiled",
+ "mobile",
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l",
+ ].sort(),
+ "Should restore items from JSON backup"
+ );
+
+ let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.mobileGuid,
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ ok(
+ existingFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Items restored from JSON backup should not be marked as syncing"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones,
+ [],
+ "Tombstones should not exist after restoring from JSON backup"
+ );
+
+ await PlacesSyncUtils.bookmarks.markChangesAsSyncing(changes);
+ let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "NnvGl3CRA4hC",
+ "APzP8MupzA8l"
+ );
+ ok(
+ normalFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ ),
+ "Roots and NEW items restored from JSON backup should be marked as NORMAL"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pullChanges_tombstones() {
+ await ignoreChangedRoots();
+
+ info("Insert new bookmarks");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ url: "http://example.com/a",
+ title: "A",
+ },
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ ],
+ });
+
+ info("Manually insert conflicting tombstone for new bookmark");
+ await PlacesUtils.withConnectionWrapper(
+ "test_pullChanges_tombstones",
+ async function (db) {
+ await db.executeCached(
+ `
+ INSERT INTO moz_bookmarks_deleted(guid)
+ VALUES(:guid)`,
+ { guid: "bookmarkAAAA" }
+ );
+ }
+ );
+
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ ["bookmarkAAAA", "bookmarkBBBB", "menu"],
+ "Should handle undeleted items when returning changes"
+ );
+ strictEqual(
+ changes.bookmarkAAAA.tombstone,
+ false,
+ "Should replace tombstone for A with undeleted item"
+ );
+ strictEqual(
+ changes.bookmarkBBBB.tombstone,
+ false,
+ "Should not report B as deleted"
+ );
+
+ await setChangesSynced(changes);
+
+ let newChanges = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ newChanges,
+ {},
+ "Should not return changes after marking undeleted items as synced"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_pushChanges() {
+ await ignoreChangedRoots();
+
+ info("Populate test bookmarks");
+ let guids = await populateTree(
+ PlacesUtils.bookmarks.menuGuid,
+ {
+ kind: "bookmark",
+ title: "unknownBmk",
+ url: "https://example.org",
+ },
+ {
+ kind: "bookmark",
+ title: "syncedBmk",
+ url: "https://example.com",
+ },
+ {
+ kind: "bookmark",
+ title: "newBmk",
+ url: "https://example.info",
+ },
+ {
+ kind: "bookmark",
+ title: "deletedBmk",
+ url: "https://example.edu",
+ },
+ {
+ kind: "bookmark",
+ title: "unchangedBmk",
+ url: "https://example.systems",
+ }
+ );
+
+ info("Update sync statuses");
+ await PlacesTestUtils.setBookmarkSyncFields(
+ {
+ guid: guids.syncedBmk,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ },
+ {
+ guid: guids.unknownBmk,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ },
+ {
+ guid: guids.deletedBmk,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ },
+ {
+ guid: guids.unchangedBmk,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ syncChangeCounter: 0,
+ }
+ );
+
+ info("Change synced bookmark; should bump change counter");
+ await PlacesUtils.bookmarks.update({
+ guid: guids.syncedBmk,
+ url: "https://example.ninja",
+ });
+
+ info("Remove synced bookmark");
+ {
+ await PlacesUtils.bookmarks.remove(guids.deletedBmk);
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ ok(
+ tombstones.some(({ guid }) => guid == guids.deletedBmk),
+ "Should write tombstone for deleted synced bookmark"
+ );
+ }
+
+ info("Pull changes");
+ let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges;
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ {
+ let actualChanges = Object.entries(changes).map(([recordId, change]) => ({
+ recordId,
+ syncChangeCounter: change.counter,
+ }));
+ let expectedChanges = [
+ {
+ recordId: guids.unknownBmk,
+ syncChangeCounter: 1,
+ },
+ {
+ // Parent of changed bookmarks.
+ recordId: "menu",
+ syncChangeCounter: 6,
+ },
+ {
+ recordId: guids.syncedBmk,
+ syncChangeCounter: 2,
+ },
+ {
+ recordId: guids.newBmk,
+ syncChangeCounter: 1,
+ },
+ {
+ recordId: guids.deletedBmk,
+ syncChangeCounter: 1,
+ },
+ ];
+ deepEqual(
+ sortBy(actualChanges, "recordId"),
+ sortBy(expectedChanges, "recordId"),
+ "Should return deleted, new, and unknown bookmarks"
+ );
+ }
+
+ info("Modify changed bookmark to bump its counter");
+ await PlacesUtils.bookmarks.update({
+ guid: guids.newBmk,
+ url: "https://example.club",
+ });
+
+ info("Mark some bookmarks as synced");
+ for (let title of ["unknownBmk", "newBmk", "deletedBmk"]) {
+ let guid = guids[title];
+ strictEqual(
+ changes[guid].synced,
+ false,
+ "All bookmarks should not be marked as synced yet"
+ );
+ changes[guid].synced = true;
+ }
+
+ await PlacesSyncUtils.bookmarks.pushChanges(changes);
+ equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 4);
+
+ {
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ guids.newBmk,
+ guids.unknownBmk
+ );
+ ok(
+ fields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ ),
+ "Should update sync statuses for synced bookmarks"
+ );
+ }
+
+ {
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ ok(
+ !tombstones.some(({ guid }) => guid == guids.deletedBmk),
+ "Should remove tombstone after syncing"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ guids.unknownBmk,
+ guids.syncedBmk,
+ guids.newBmk
+ );
+ {
+ let info = syncFields.find(field => field.guid == guids.unknownBmk);
+ equal(
+ info.syncStatus,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ "Syncing an UNKNOWN bookmark should set its sync status to NORMAL"
+ );
+ strictEqual(
+ info.syncChangeCounter,
+ 0,
+ "Syncing an UNKNOWN bookmark should reduce its change counter"
+ );
+ }
+ {
+ let info = syncFields.find(field => field.guid == guids.syncedBmk);
+ equal(
+ info.syncStatus,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ "Syncing a NORMAL bookmark should not update its sync status"
+ );
+ equal(
+ info.syncChangeCounter,
+ 2,
+ "Should not reduce counter for NORMAL bookmark not marked as synced"
+ );
+ }
+ {
+ let info = syncFields.find(field => field.guid == guids.newBmk);
+ equal(
+ info.syncStatus,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ "Syncing a NEW bookmark should update its sync status"
+ );
+ strictEqual(
+ info.syncChangeCounter,
+ 1,
+ "Updating new bookmark after pulling changes should bump change counter"
+ );
+ }
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_changes_between_pull_and_push() {
+ await ignoreChangedRoots();
+
+ info("Populate test bookmarks");
+ let guids = await populateTree(PlacesUtils.bookmarks.menuGuid, {
+ kind: "bookmark",
+ title: "bmk",
+ url: "https://example.info",
+ });
+
+ info("Update sync statuses");
+ await PlacesTestUtils.setBookmarkSyncFields({
+ guid: guids.bmk,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ syncChangeCounter: 1,
+ });
+
+ info("Pull changes");
+ let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges;
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ Assert.equal(changes[guids.bmk].counter, 1);
+ Assert.equal(changes[guids.bmk].tombstone, false);
+
+ // delete the bookmark.
+ await PlacesUtils.bookmarks.remove(guids.bmk);
+
+ info("Push changes");
+ await PlacesSyncUtils.bookmarks.pushChanges(changes);
+ equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2);
+
+ // we should have a tombstone.
+ let ts = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.equal(ts.length, 1);
+ Assert.equal(ts[0].guid, guids.bmk);
+
+ // there should be no record for the item we deleted.
+ Assert.strictEqual(await PlacesUtils.bookmarks.fetch(guids.bmk), null);
+
+ // and re-fetching changes should list it as a tombstone.
+ changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ Assert.equal(changes[guids.bmk].counter, 1);
+ Assert.equal(changes[guids.bmk].tombstone, true);
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_separator() {
+ await ignoreChangedRoots();
+
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ recordId: makeGuid(),
+ url: "https://example.com",
+ });
+ let childBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ recordId: makeGuid(),
+ url: "https://foo.bar",
+ });
+ let separatorRecordId = makeGuid();
+ let separator = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "separator",
+ parentRecordId: "menu",
+ recordId: separatorRecordId,
+ });
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ recordId: makeGuid(),
+ url: "https://bar.foo",
+ });
+
+ let child2Guid = await PlacesSyncUtils.bookmarks.recordIdToGuid(
+ childBmk.recordId
+ );
+ let parentGuid = await await PlacesSyncUtils.bookmarks.recordIdToGuid("menu");
+ let separatorGuid =
+ PlacesSyncUtils.bookmarks.recordIdToGuid(separatorRecordId);
+
+ info("Move a bookmark around the separator");
+ await PlacesUtils.bookmarks.update({
+ guid: child2Guid,
+ parentGuid,
+ index: 2,
+ });
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort());
+
+ await setChangesSynced(changes);
+
+ info("Move a separator around directly");
+ await PlacesUtils.bookmarks.update({
+ guid: separatorGuid,
+ parentGuid,
+ index: 0,
+ });
+
+ changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort());
+
+ await setChangesSynced(changes);
+
+ info("Move a separator around directly using update");
+ await PlacesUtils.bookmarks.update({ guid: separatorGuid, index: 2 });
+ changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort());
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_remove() {
+ await ignoreChangedRoots();
+
+ info("Insert subtree for removal");
+ let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ parentRecordId: "menu",
+ recordId: makeGuid(),
+ });
+ let childBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.com",
+ });
+ let childFolder = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ });
+ let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: childFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.edu",
+ });
+
+ info("Remove entire subtree");
+ await PlacesSyncUtils.bookmarks.remove([
+ parentFolder.recordId,
+ childFolder.recordId,
+ childBmk.recordId,
+ grandChildBmk.recordId,
+ ]);
+
+ /**
+ * Even though we've removed the entire subtree, we still track the menu
+ * because we 1) removed `parentFolder`, 2) reparented `childFolder` to
+ * `menu`, and 3) removed `childFolder`.
+ *
+ * This depends on the order of the folders passed to `remove`. If we
+ * removed `childFolder` *before* `parentFolder`, we wouldn't reparent
+ * anything to `menu`.
+ *
+ * `deleteSyncedFolder` could check if it's reparenting an item that will
+ * eventually be removed, and avoid bumping the new parent's change counter.
+ * Unfortunately, that introduces inconsistencies if `deleteSyncedFolder` is
+ * interrupted by shutdown. If the server changes before the next sync,
+ * we'll never upload records for the reparented item or the new parent.
+ *
+ * Another alternative: we can try to remove folders in level order, instead
+ * of the order passed to `remove`. But that means we need a recursive query
+ * to determine the order. This is already enough of an edge case that
+ * occasionally reuploading the closest living ancestor is the simplest
+ * solution.
+ */
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes),
+ ["menu"],
+ "Should track closest living ancestor of removed subtree"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_remove_partial() {
+ await ignoreChangedRoots();
+
+ info("Insert subtree for partial removal");
+ let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ parentRecordId: PlacesUtils.bookmarks.menuGuid,
+ recordId: makeGuid(),
+ });
+ let prevSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.net",
+ });
+ let childBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.com",
+ });
+ let nextSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.org",
+ });
+ let childFolder = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ });
+ let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://example.edu",
+ });
+ let grandChildSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: parentFolder.recordId,
+ recordId: makeGuid(),
+ url: "https://mozilla.org",
+ });
+ let grandChildFolder = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "folder",
+ parentRecordId: childFolder.recordId,
+ recordId: makeGuid(),
+ });
+ let greatGrandChildPrevSiblingBmk =
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: grandChildFolder.recordId,
+ recordId: makeGuid(),
+ url: "http://getfirefox.com",
+ });
+ let greatGrandChildNextSiblingBmk =
+ await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: grandChildFolder.recordId,
+ recordId: makeGuid(),
+ url: "http://getthunderbird.com",
+ });
+ let menuBmk = await PlacesSyncUtils.test.bookmarks.insert({
+ kind: "bookmark",
+ parentRecordId: "menu",
+ recordId: makeGuid(),
+ url: "https://example.info",
+ });
+
+ info("Remove subset of folders and items in subtree");
+ let changes = await PlacesSyncUtils.bookmarks.remove([
+ parentFolder.recordId,
+ childBmk.recordId,
+ grandChildFolder.recordId,
+ grandChildBmk.recordId,
+ childFolder.recordId,
+ ]);
+ deepEqual(
+ Object.keys(changes).sort(),
+ [
+ // Closest living ancestor.
+ "menu",
+ // Reparented bookmarks.
+ prevSiblingBmk.recordId,
+ nextSiblingBmk.recordId,
+ grandChildSiblingBmk.recordId,
+ greatGrandChildPrevSiblingBmk.recordId,
+ greatGrandChildNextSiblingBmk.recordId,
+ ].sort(),
+ "Should track reparented bookmarks and their closest living ancestor"
+ );
+
+ /**
+ * Reparented bookmarks should maintain their order relative to their
+ * siblings: `prevSiblingBmk` (0) should precede `nextSiblingBmk` (2) in the
+ * menu, and `greatGrandChildPrevSiblingBmk` (0) should precede
+ * `greatGrandChildNextSiblingBmk` (1).
+ */
+ let menuChildren = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ deepEqual(
+ menuChildren,
+ [
+ // Existing bookmark.
+ menuBmk.recordId,
+ // 1) Moved out of `parentFolder` to `menu`.
+ prevSiblingBmk.recordId,
+ nextSiblingBmk.recordId,
+ // 3) Moved out of `childFolder` to `menu`. After this step, `childFolder`
+ // is deleted.
+ grandChildSiblingBmk.recordId,
+ // 2) Moved out of `grandChildFolder` to `childFolder`, because we remove
+ // `grandChildFolder` *before* `childFolder`. After this step,
+ // `grandChildFolder` is deleted and `childFolder`'s children are
+ // `[grandChildSiblingBmk, greatGrandChildPrevSiblingBmk,
+ // greatGrandChildNextSiblingBmk]`.
+ greatGrandChildPrevSiblingBmk.recordId,
+ greatGrandChildNextSiblingBmk.recordId,
+ ],
+ "Should move descendants to closest living ancestor"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_migrateOldTrackerEntries() {
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ let unknownBmk = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "http://getfirefox.com",
+ title: "Get Firefox!",
+ });
+ let newBmk = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "http://getthunderbird.com",
+ title: "Get Thunderbird!",
+ });
+ let normalBmk = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ });
+
+ await PlacesTestUtils.setBookmarkSyncFields(
+ {
+ guid: unknownBmk.guid,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ syncChangeCounter: 0,
+ },
+ {
+ guid: normalBmk.guid,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ }
+ );
+ PlacesUtils.tagging.tagURI(uri("http://getfirefox.com"), ["taggy"]);
+
+ let tombstoneRecordId = makeGuid();
+ await PlacesSyncUtils.bookmarks.migrateOldTrackerEntries([
+ {
+ recordId: normalBmk.guid,
+ modified: Date.now(),
+ },
+ {
+ recordId: tombstoneRecordId,
+ modified: 1479162463976,
+ },
+ ]);
+
+ let changes = await PlacesSyncUtils.bookmarks.pullChanges();
+ deepEqual(
+ Object.keys(changes).sort(),
+ [normalBmk.guid, tombstoneRecordId].sort(),
+ "Should return change records for migrated bookmark and tombstone"
+ );
+
+ let fields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ unknownBmk.guid,
+ newBmk.guid,
+ normalBmk.guid
+ );
+ for (let field of fields) {
+ if (field.guid == normalBmk.guid) {
+ Assert.greater(
+ field.lastModified,
+ normalBmk.lastModified,
+ `Should bump last modified date for migrated bookmark ${field.guid}`
+ );
+ equal(
+ field.syncChangeCounter,
+ 1,
+ `Should bump change counter for migrated bookmark ${field.guid}`
+ );
+ } else {
+ strictEqual(
+ field.syncChangeCounter,
+ 0,
+ `Should not bump change counter for ${field.guid}`
+ );
+ }
+ equal(
+ field.syncStatus,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ `Should set sync status for ${field.guid} to NORMAL`
+ );
+ }
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones,
+ [
+ {
+ guid: tombstoneRecordId,
+ dateRemoved: new Date(1479162463976),
+ },
+ ],
+ "Should write tombstone for nonexistent migrated item"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_ensureMobileQuery() {
+ info("Ensure we correctly set the showMobileBookmarks preference");
+ const mobilePref = "browser.bookmarks.showMobileBookmarks";
+ Services.prefs.clearUserPref(mobilePref);
+
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ url: "http://example.com/a",
+ title: "A",
+ });
+
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkBBBB",
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ url: "http://example.com/b",
+ title: "B",
+ });
+
+ await PlacesSyncUtils.bookmarks.ensureMobileQuery();
+
+ Assert.ok(
+ Services.prefs.getBoolPref(mobilePref),
+ "Pref should be true where there are bookmarks in the folder."
+ );
+
+ await PlacesUtils.bookmarks.remove("bookmarkAAAA");
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+
+ await PlacesSyncUtils.bookmarks.ensureMobileQuery();
+
+ Assert.ok(
+ !Services.prefs.getBoolPref(mobilePref),
+ "Pref should be false where there are no bookmarks in the folder."
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_remove_stale_tombstones() {
+ info("Insert and delete synced bookmark");
+ {
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: "http://example.com/a",
+ title: "A",
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkAAAA");
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkAAAA"],
+ "Should store tombstone for deleted synced bookmark"
+ );
+ }
+
+ info("Reinsert deleted bookmark");
+ {
+ // Different parent, URL, and title, but same GUID.
+ await PlacesUtils.bookmarks.insert({
+ guid: "bookmarkAAAA",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/a-restored",
+ title: "A (Restored)",
+ });
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones,
+ [],
+ "Should remove tombstone for reinserted bookmark"
+ );
+ }
+
+ info("Insert tree and erase everything");
+ {
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.eraseEverything();
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid).sort(),
+ ["bookmarkBBBB", "bookmarkCCCC"],
+ "Should store tombstones after erasing everything"
+ );
+ }
+
+ info("Reinsert tree");
+ {
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ children: [
+ {
+ guid: "bookmarkBBBB",
+ url: "http://example.com/b",
+ title: "B",
+ },
+ {
+ guid: "bookmarkCCCC",
+ url: "http://example.com/c",
+ title: "C",
+ },
+ ],
+ });
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid).sort(),
+ [],
+ "Should remove tombstones after reinserting tree"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_resetSyncId() {
+ let syncId = await PlacesSyncUtils.bookmarks.getSyncId();
+ strictEqual(syncId, "", "Should start with empty bookmarks sync ID");
+
+ // Add a tree with a NORMAL bookmark (A), tombstone (B), NEW bookmark (C),
+ // and UNKNOWN bookmark (D).
+ info("Set up local tree before resetting bookmarks sync ID");
+ await ignoreChangedRoots();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ info("Assign new bookmarks sync ID for first time");
+ let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId();
+ syncId = await PlacesSyncUtils.bookmarks.getSyncId();
+ equal(
+ newSyncId,
+ syncId,
+ "Should assign new bookmarks sync ID for first time"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "bookmarkAAAA",
+ "bookmarkCCCC",
+ "bookmarkDDDD"
+ );
+ ok(
+ syncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Should change all sync statuses to NEW after resetting bookmarks sync ID"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones,
+ [],
+ "Should remove all tombstones after resetting bookmarks sync ID"
+ );
+
+ info("Set bookmarks last sync time");
+ let lastSync = Date.now() / 1000;
+ await PlacesSyncUtils.bookmarks.setLastSync(lastSync);
+ equal(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ lastSync,
+ "Should record bookmarks last sync time"
+ );
+
+ newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId();
+ notEqual(
+ newSyncId,
+ syncId,
+ "Should set new bookmarks sync ID if one already exists"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync time after resetting sync ID"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_wipe() {
+ info("Add Sync metadata before wipe");
+ let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId();
+ await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000);
+
+ let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId();
+ equal(
+ existingSyncId,
+ newSyncId,
+ "Ensure bookmarks sync ID was recorded before wipe"
+ );
+
+ info("Set up local tree before wipe");
+ await ignoreChangedRoots();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ info("Wipe bookmarks");
+ await PlacesSyncUtils.bookmarks.wipe();
+
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "",
+ "Should reset bookmarks sync ID after wipe"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync after wipe"
+ );
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Wiping bookmarks locally should not wipe server"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(tombstones, [], "Should drop tombstones after wipe");
+
+ deepEqual(
+ await PlacesSyncUtils.bookmarks.fetchChildRecordIds("menu"),
+ [],
+ "Should wipe menu children"
+ );
+ deepEqual(
+ await PlacesSyncUtils.bookmarks.fetchChildRecordIds("toolbar"),
+ [],
+ "Should wipe toolbar children"
+ );
+
+ let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid
+ );
+ ok(
+ rootSyncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Should reset all sync statuses to NEW after wipe"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_meta_eraseEverything() {
+ info("Add Sync metadata before erase");
+ let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId();
+ let lastSync = Date.now() / 1000;
+ await PlacesSyncUtils.bookmarks.setLastSync(lastSync);
+
+ info("Set up local tree before reset");
+ await ignoreChangedRoots();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ info("Erase all bookmarks");
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ newSyncId,
+ "Should not reset bookmarks sync ID after erase"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ lastSync,
+ "Should not reset bookmarks last sync after erase"
+ );
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Erasing everything should not wipe server"
+ );
+
+ deepEqual(
+ (await PlacesTestUtils.fetchSyncTombstones()).map(info => info.guid),
+ ["bookmarkAAAA", "bookmarkBBBB"],
+ "Should keep tombstones after erasing everything"
+ );
+
+ let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.mobileGuid
+ );
+ ok(
+ rootSyncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ ),
+ "Should not reset sync statuses after erasing everything"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_reset() {
+ info("Add Sync metadata before reset");
+ await PlacesSyncUtils.bookmarks.resetSyncId();
+ await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000);
+
+ info("Set up local tree before reset");
+ await ignoreChangedRoots();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ info("Reset Sync metadata for bookmarks");
+ await PlacesSyncUtils.bookmarks.reset();
+
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "",
+ "Should reset bookmarks sync ID after reset"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync after reset"
+ );
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Resetting Sync metadata should not wipe server"
+ );
+
+ deepEqual(
+ await PlacesTestUtils.fetchSyncTombstones(),
+ [],
+ "Should drop tombstones after reset"
+ );
+
+ let itemSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ "bookmarkAAAA",
+ "bookmarkCCCC"
+ );
+ ok(
+ itemSyncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Should reset sync statuses for existing items to NEW after reset"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_meta_restore() {
+ info("Add Sync metadata before manual restore");
+ await PlacesSyncUtils.bookmarks.resetSyncId();
+ await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000);
+
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Should not wipe server before manual restore"
+ );
+
+ info("Manually restore");
+ let { path } = do_get_file("./sync_utils_bookmarks.json");
+ await BookmarkJSONUtils.importFromFile(path, { replace: true });
+
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "",
+ "Should reset bookmarks sync ID after manual restore"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync after manual restore"
+ );
+ ok(
+ await PlacesSyncUtils.bookmarks.shouldWipeRemote(),
+ "Should wipe server after manual restore"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ "NnvGl3CRA4hC",
+ PlacesUtils.bookmarks.toolbarGuid,
+ "APzP8MupzA8l"
+ );
+ ok(
+ syncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW
+ ),
+ "Should reset all sync stauses to NEW after manual restore"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_meta_restore_on_startup() {
+ info("Add Sync metadata before simulated automatic restore");
+ await PlacesSyncUtils.bookmarks.resetSyncId();
+ await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000);
+
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Should not wipe server before automatic restore"
+ );
+
+ info("Simulate automatic restore on startup");
+ let { path } = do_get_file("./sync_utils_bookmarks.json");
+ await BookmarkJSONUtils.importFromFile(path, {
+ replace: true,
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "",
+ "Should reset bookmarks sync ID after automatic restore"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync after automatic restore"
+ );
+ ok(
+ !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()),
+ "Should not wipe server after manual restore"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ "NnvGl3CRA4hC",
+ PlacesUtils.bookmarks.toolbarGuid,
+ "APzP8MupzA8l"
+ );
+ ok(
+ syncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
+ ),
+ "Should reset all sync stauses to UNKNOWN after automatic restore"
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_bookmarks_ensureCurrentSyncId() {
+ info("Set up local tree");
+ await ignoreChangedRoots();
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ children: [
+ {
+ guid: "bookmarkAAAA",
+ title: "A",
+ url: "http://example.com/a",
+ },
+ {
+ guid: "bookmarkBBBB",
+ title: "B",
+ url: "http://example.com/b",
+ },
+ ],
+ });
+ await PlacesUtils.bookmarks.remove("bookmarkBBBB");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ guid: "bookmarkCCCC",
+ title: "C",
+ url: "http://example.com/c",
+ });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ guid: "bookmarkDDDD",
+ title: "D",
+ url: "http://example.com/d",
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ });
+
+ let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId();
+ strictEqual(existingSyncId, "", "Should start without bookmarks sync ID");
+
+ info("Assign new bookmarks sync ID");
+ {
+ await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA");
+
+ let newSyncId = await PlacesSyncUtils.bookmarks.getSyncId();
+ equal(
+ newSyncId,
+ "syncIdAAAAAA",
+ "Should assign bookmarks sync ID if one doesn't exist"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkBBBB"],
+ "Should keep tombstones after assigning new bookmarks sync ID"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "bookmarkAAAA",
+ "bookmarkCCCC",
+ "bookmarkDDDD"
+ );
+ deepEqual(
+ syncFields.map(field => field.syncStatus),
+ [
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ ],
+ "Should not reset sync statuses after assigning new bookmarks sync ID"
+ );
+ }
+
+ info("Ensure existing bookmarks sync ID matches");
+ {
+ let lastSync = Date.now() / 1000;
+ await PlacesSyncUtils.bookmarks.setLastSync(lastSync);
+ await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA");
+
+ equal(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "syncIdAAAAAA",
+ "Should keep existing bookmarks sync ID on match"
+ );
+ equal(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ lastSync,
+ "Should keep existing bookmarks last sync time on sync ID match"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones.map(({ guid }) => guid),
+ ["bookmarkBBBB"],
+ "Should keep tombstones if bookmarks sync IDs match"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "bookmarkAAAA",
+ "bookmarkCCCC",
+ "bookmarkDDDD"
+ );
+ deepEqual(
+ syncFields.map(field => field.syncStatus),
+ [
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ ],
+ "Should not reset sync statuses if bookmarks sync IDs match"
+ );
+ }
+
+ info("Replace existing bookmarks sync ID with new ID");
+ {
+ await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdBBBBBB");
+
+ equal(
+ await PlacesSyncUtils.bookmarks.getSyncId(),
+ "syncIdBBBBBB",
+ "Should replace existing bookmarks sync ID on mismatch"
+ );
+ strictEqual(
+ await PlacesSyncUtils.bookmarks.getLastSync(),
+ 0,
+ "Should reset bookmarks last sync time on sync ID mismatch"
+ );
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ deepEqual(
+ tombstones,
+ [],
+ "Should drop tombstones after bookmarks sync ID mismatch"
+ );
+
+ let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ "bookmarkAAAA",
+ "bookmarkCCCC",
+ "bookmarkDDDD"
+ );
+ ok(
+ syncFields.every(
+ field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
+ ),
+ "Should reset all sync statuses to UNKNOWN after bookmarks sync ID mismatch"
+ );
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_history_resetSyncId() {
+ let syncId = await PlacesSyncUtils.history.getSyncId();
+ strictEqual(syncId, "", "Should start with empty history sync ID");
+
+ info("Assign new history sync ID for first time");
+ let newSyncId = await PlacesSyncUtils.history.resetSyncId();
+ syncId = await PlacesSyncUtils.history.getSyncId();
+ equal(newSyncId, syncId, "Should assign new history sync ID for first time");
+
+ info("Set history last sync time");
+ let lastSync = Date.now() / 1000;
+ await PlacesSyncUtils.history.setLastSync(lastSync);
+ equal(
+ await PlacesSyncUtils.history.getLastSync(),
+ lastSync,
+ "Should record history last sync time"
+ );
+
+ newSyncId = await PlacesSyncUtils.history.resetSyncId();
+ notEqual(
+ newSyncId,
+ syncId,
+ "Should set new history sync ID if one already exists"
+ );
+ strictEqual(
+ await PlacesSyncUtils.history.getLastSync(),
+ 0,
+ "Should reset history last sync time after resetting sync ID"
+ );
+
+ await PlacesSyncUtils.history.reset();
+});
+
+add_task(async function test_history_ensureCurrentSyncId() {
+ info("Assign new history sync ID");
+ await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA");
+ equal(
+ await PlacesSyncUtils.history.getSyncId(),
+ "syncIdAAAAAA",
+ "Should assign history sync ID if one doesn't exist"
+ );
+
+ info("Ensure existing history sync ID matches");
+ let lastSync = Date.now() / 1000;
+ await PlacesSyncUtils.history.setLastSync(lastSync);
+ await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA");
+
+ equal(
+ await PlacesSyncUtils.history.getSyncId(),
+ "syncIdAAAAAA",
+ "Should keep existing history sync ID on match"
+ );
+ equal(
+ await PlacesSyncUtils.history.getLastSync(),
+ lastSync,
+ "Should keep existing history last sync time on sync ID match"
+ );
+
+ info("Replace existing history sync ID with new ID");
+ await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdBBBBBB");
+
+ equal(
+ await PlacesSyncUtils.history.getSyncId(),
+ "syncIdBBBBBB",
+ "Should replace existing history sync ID on mismatch"
+ );
+ strictEqual(
+ await PlacesSyncUtils.history.getLastSync(),
+ 0,
+ "Should reset history last sync time on sync ID mismatch"
+ );
+
+ await PlacesSyncUtils.history.reset();
+});
+
+add_task(async function test_updateUnknownFieldsBatch() {
+ // We're just validating we have something where placeId = 1, mainly as a sanity
+ // since moz_places_extra needs a valid foreign key
+ let placeId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", {
+ id: 1,
+ });
+
+ // an example of json with multiple fields in it to test updateUnknownFields
+ // will update ONLY unknown_sync_fields and not override any others
+ const test_json = JSON.stringify({
+ unknown_sync_fields: { unknownStrField: "an old str field " },
+ extra_str_field: "another field within the json",
+ extra_obj_field: { inner: "hi" },
+ });
+
+ // Manually put the inital json in the DB
+ await PlacesUtils.withConnectionWrapper(
+ "test_update_moz_places_extra",
+ async function (db) {
+ await db.executeCached(
+ `
+ INSERT INTO moz_places_extra(place_id, sync_json)
+ VALUES(:placeId, :sync_json)`,
+ { placeId, sync_json: test_json }
+ );
+ }
+ );
+
+ // call updateUnknownFieldsBatch to validate it ONLY updates
+ // the unknown_sync_fields in the sync_json
+ let update = {
+ placeId,
+ unknownFields: JSON.stringify({ unknownStrField: "a new unknownStrField" }),
+ };
+ await PlacesSyncUtils.history.updateUnknownFieldsBatch([update]);
+
+ let updated_sync_json = await PlacesTestUtils.getDatabaseValue(
+ "moz_places_extra",
+ "sync_json",
+ {
+ place_id: placeId,
+ }
+ );
+
+ let updated_data = JSON.parse(updated_sync_json);
+
+ // unknown_sync_fields has been updated
+ deepEqual(JSON.parse(updated_data.unknown_sync_fields), {
+ unknownStrField: "a new unknownStrField",
+ });
+
+ // we didn't override any other fields within
+ deepEqual(updated_data.extra_str_field, "another field within the json");
+});
diff --git a/toolkit/components/places/tests/sync/xpcshell.toml b/toolkit/components/places/tests/sync/xpcshell.toml
new file mode 100644
index 0000000000..9d04b8aaad
--- /dev/null
+++ b/toolkit/components/places/tests/sync/xpcshell.toml
@@ -0,0 +1,40 @@
+[DEFAULT]
+head = "head_sync.js"
+support-files = [
+ "sync_utils_bookmarks.html",
+ "sync_utils_bookmarks.json",
+ "mirror_corrupt.sqlite",
+ "mirror_v1.sqlite",
+ "mirror_v5.sqlite",
+ "mirror_v8.sqlite",
+]
+
+["test_bookmark_abort_merging.js"]
+
+["test_bookmark_chunking.js"]
+
+["test_bookmark_corruption.js"]
+
+["test_bookmark_deduping.js"]
+
+["test_bookmark_deletion.js"]
+
+["test_bookmark_haschanges.js"]
+
+["test_bookmark_kinds.js"]
+
+["test_bookmark_mirror_meta.js"]
+
+["test_bookmark_mirror_migration.js"]
+
+["test_bookmark_observer_recorder.js"]
+
+["test_bookmark_reconcile.js"]
+
+["test_bookmark_structure_changes.js"]
+
+["test_bookmark_unknown_fields.js"]
+
+["test_bookmark_value_changes.js"]
+
+["test_sync_utils.js"]