/* 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 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 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(); });