new file mode 100644
--- /dev/null
+++ b/services/sync/modules/bookmark_utils.js
@@ -0,0 +1,94 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["BookmarkSpecialIds", "BookmarkAnnos"];
+
+const { utils: Cu, interfaces: Ci, classes: Cc } = Components;
+
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+let BookmarkAnnos = {
+ ALLBOOKMARKS_ANNO: "AllBookmarks",
+ DESCRIPTION_ANNO: "bookmarkProperties/description",
+ SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar",
+ MOBILEROOT_ANNO: "mobile/bookmarksRoot",
+ MOBILE_ANNO: "MobileBookmarks",
+ EXCLUDEBACKUP_ANNO: "places/excludeFromBackup",
+ SMART_BOOKMARKS_ANNO: "Places/SmartBookmark",
+ PARENT_ANNO: "sync/parent",
+ ORGANIZERQUERY_ANNO: "PlacesOrganizer/OrganizerQuery",
+};
+
+let BookmarkSpecialIds = {
+
+ // Special IDs. Note that mobile can attempt to create a record on
+ // dereference; special accessors are provided to prevent recursion within
+ // observers.
+ guids: ["menu", "places", "tags", "toolbar", "unfiled", "mobile"],
+
+ // Create the special mobile folder to store mobile bookmarks.
+ createMobileRoot: function createMobileRoot() {
+ let root = PlacesUtils.placesRootId;
+ let mRoot = PlacesUtils.bookmarks.createFolder(root, "mobile", -1);
+ PlacesUtils.annotations.setItemAnnotation(
+ mRoot, BookmarkAnnos.MOBILEROOT_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
+ PlacesUtils.annotations.setItemAnnotation(
+ mRoot, BookmarkAnnos.EXCLUDEBACKUP_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
+ return mRoot;
+ },
+
+ findMobileRoot: function findMobileRoot(create) {
+ // Use the (one) mobile root if it already exists.
+ let root = PlacesUtils.annotations.getItemsWithAnnotation(
+ BookmarkAnnos.MOBILEROOT_ANNO, {});
+ if (root.length != 0)
+ return root[0];
+
+ if (create)
+ return this.createMobileRoot();
+
+ return null;
+ },
+
+ // Accessors for IDs.
+ isSpecialGUID: function isSpecialGUID(g) {
+ return this.guids.indexOf(g) != -1;
+ },
+
+ specialIdForGUID: function specialIdForGUID(guid, create) {
+ if (guid == "mobile") {
+ return this.findMobileRoot(create);
+ }
+ return this[guid];
+ },
+
+ // Don't bother creating mobile: if it doesn't exist, this ID can't be it!
+ specialGUIDForId: function specialGUIDForId(id) {
+ for (let guid of this.guids)
+ if (this.specialIdForGUID(guid, false) == id)
+ return guid;
+ return null;
+ },
+
+ get menu() {
+ return PlacesUtils.bookmarksMenuFolderId;
+ },
+ get places() {
+ return PlacesUtils.placesRootId;
+ },
+ get tags() {
+ return PlacesUtils.tagsFolderId;
+ },
+ get toolbar() {
+ return PlacesUtils.toolbarFolderId;
+ },
+ get unfiled() {
+ return PlacesUtils.unfiledBookmarksFolderId;
+ },
+ get mobile() {
+ return this.findMobileRoot(true);
+ },
+};
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/bookmark_validator.js
@@ -0,0 +1,462 @@
+/* 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/. */
+
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://services-sync/bookmark_utils.js");
+
+this.EXPORTED_SYMBOLS = ["BookmarkValidator"];
+
+
+class BookmarkValidator {
+
+ createClientRecordsFromTree(clientTree) {
+ // Iterate over the treeNode, converting it to something more similar to what
+ // the server stores.
+ let records = [];
+ function traverse(treeNode) {
+ let guid = BookmarkSpecialIds.specialGUIDForId(treeNode.id) || treeNode.guid;
+ let itemType = 'item';
+ treeNode.id = guid;
+ switch (treeNode.type) {
+ case PlacesUtils.TYPE_X_MOZ_PLACE:
+ let query = null;
+ if (treeNode.annos && treeNode.uri.startsWith("place:")) {
+ query = treeNode.annos.find(({name}) =>
+ name === BookmarkAnnos.SMART_BOOKMARKS_ANNO);
+ }
+ if (query && query.value) {
+ itemType = 'query';
+ } else {
+ itemType = 'bookmark';
+ }
+ break;
+ case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER:
+ itemType = 'folder';
+ break;
+ case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR:
+ itemType = 'separator';
+ break;
+ }
+
+ treeNode.type = itemType;
+ treeNode.pos = treeNode.index;
+ treeNode.bmkUri = treeNode.uri;
+ records.push(treeNode);
+ if (treeNode.type === 'folder') {
+ treeNode.childGUIDs = [];
+ if (!treeNode.children) {
+ treeNode.children = [];
+ }
+ for (let child of treeNode.children) {
+ traverse(child);
+ child.parent = treeNode;
+ child.parentid = guid;
+ child.parentName = treeNode.title;
+ treeNode.childGUIDs.push(child.guid);
+ }
+ }
+ }
+ traverse(clientTree);
+ clientTree.id = 'places';
+ return records;
+ }
+
+ /**
+ * Process the server-side list. Mainly this builds the records into a tree,
+ * but it also records information about problems, and produces arrays of the
+ * deleted and non-deleted nodes.
+ *
+ * Returns an object containing:
+ * - records:Array of non-deleted records. Each record contains the following
+ * properties
+ * - childGUIDs (array of strings, only present if type is 'folder'): the
+ * list of child GUIDs stored on the server.
+ * - children (array of records, only present if type is 'folder'):
+ * each record has these same properties. This may differ in content
+ * from what you may expect from the childGUIDs list, as it won't
+ * contain any records that could not be found.
+ * - parent (record): The parent to this record.
+ * - Unchanged properties send down from the server: id, title, type,
+ * parentName, parentid, bmkURI, keyword, tags, pos, queryId, loadInSidebar
+ * - root: Root of the server-side bookmark tree. Has the same properties as
+ * above.
+ * - deletedRecords: As above, but only contains items that the server sent
+ * where it also sent indication that the item should be deleted.
+ * - problemData: Object containing info about problems recorded.
+ * - missingIDs (number): # of objects with missing ids
+ * - duplicates (array of ids): ids seen more than once
+ * - parentChildMismatches (array of {parent: parentid, child: childid}):
+ * instances where the child's parentid and the parent's children array
+ * do not match
+ * - cycles (array of array of ids). List of cycles found in the "tree".
+ * - orphans (array of {id: string, parent: string}): List of nodes with
+ * either no parentid, or where the parent could not be found.
+ * - missingChildren (array of {parent: id, child: id}):
+ * List of parent/children where the child id couldn't be found
+ * - multipleParents (array of {child: id, parents: array of ids}):
+ * List of children that were part of multiple parent arrays
+ * - deletedParents (array of ids) : List of records that aren't deleted but
+ * had deleted parents
+ * - childrenOnNonFolder (array of ids): list of non-folders that still have
+ * children arrays
+ * - duplicateChildren (array of ids): list of records who have the same
+ * child listed multiple times in their children array
+ * - parentNotFolder (array of ids): list of records that have parents that
+ * aren't folders
+ * - wrongParentName (array of ids): list of records whose parentName does
+ * not match the parent's actual title
+ * - rootOnServer (boolean): true if the root came from the server
+ */
+ inspectServerRecords(serverRecords) {
+ let deletedItemIds = new Set();
+ let idToRecord = new Map();
+ let deletedRecords = [];
+
+ let folders = [];
+ let problems = [];
+
+ let problemData = {
+ missingIDs: 0,
+ duplicates: [],
+ parentChildMismatches: [],
+ cycles: [],
+ orphans: [],
+ missingChildren: [],
+ multipleParents: [],
+ deletedParents: [],
+ childrenOnNonFolder: [],
+ duplicateChildren: [],
+ parentNotFolder: [],
+ wrongParentName: [],
+ rootOnServer: false
+ };
+
+ let resultRecords = [];
+
+ for (let record of serverRecords) {
+ if (!record.id) {
+ ++problemData.missingIDs;
+ continue;
+ }
+ if (record.deleted) {
+ deletedItemIds.add(record.id);
+ } else {
+ if (idToRecord.has(record.id)) {
+ problemData.duplicates.push(record.id);
+ continue;
+ }
+ idToRecord.set(record.id, record);
+ }
+ if (record.children) {
+ if (record.type !== 'folder') {
+ problemData.childrenOnNonFolder.push(record.id);
+ }
+ folders.push(record);
+
+ if (new Set(record.children).size !== record.children.length) {
+ problemData.duplicateChildren.push(record.id)
+ }
+
+ // This whole next part is a huge hack.
+ // The children array stores special guids as their local guid values,
+ // e.g. 'menu________' instead of 'menu', but all other parts of the
+ // serverside bookmark info stores it as the special value ('menu').
+ //
+ // Since doing a sql query for every entry would be extremely slow, and
+ // wouldn't even be necessarially accurate (since these values are only
+ // the local values for whichever client created the records) We just
+ // strip off the trailing _ and see if that results in a special id.
+ //
+ // To make things worse, this doesn't even work for root________, which has
+ // the special id 'places'.
+ record.childGUIDs = record.children;
+ record.children = record.children.map(childID => {
+ let match = childID.match(/_+$/);
+ if (!match) {
+ return childID;
+ }
+ let possibleSpecialID = childID.slice(0, match.index);
+ if (possibleSpecialID === 'root') {
+ possibleSpecialID = 'places';
+ }
+ if (BookmarkSpecialIds.isSpecialGUID(possibleSpecialID)) {
+ return possibleSpecialID;
+ }
+ return childID;
+ });
+ }
+ }
+
+ for (let deletedId of deletedItemIds) {
+ let record = idToRecord.get(deletedId);
+ if (record && !record.isDeleted) {
+ deletedRecords.push(record);
+ record.isDeleted = true;
+ }
+ }
+
+ let root = idToRecord.get('places');
+
+ if (!root) {
+ // Fabricate a root. We want to remember that it's fake so that we can
+ // avoid complaining about stuff like it missing it's childGUIDs later.
+ root = { id: 'places', children: [], type: 'folder', title: '' };
+ resultRecords.push(root);
+ idToRecord.set('places', root);
+ } else {
+ problemData.rootOnServer = true;
+ }
+
+ // Build the tree, find orphans, and record most problems having to do with
+ // the tree structure.
+ for (let [id, record] of idToRecord) {
+ if (record === root) {
+ continue;
+ }
+
+ let parentID = record.parentid;
+ if (!parentID) {
+ problemData.orphans.push({id: record.id, parent: parentID});
+ continue;
+ }
+
+ let parent = idToRecord.get(parentID);
+ if (!parent) {
+ problemData.orphans.push({id: record.id, parent: parentID});
+ continue;
+ }
+
+ if (parent.type !== 'folder') {
+ problemData.parentNotFolder.push(record.id);
+ }
+
+ if (!record.isDeleted) {
+ resultRecords.push(record);
+ }
+
+ record.parent = parent;
+ if (parent !== root) {
+ let childIndex = parent.children.indexOf(id);
+ if (childIndex < 0) {
+ problemData.parentChildMismatches.push({parent: parent.id, child: record.id});
+ } else {
+ parent.children[childIndex] = record;
+ }
+ } else {
+ parent.children.push(record);
+ }
+
+ if (parent.isDeleted && !record.isDeleted) {
+ problemData.deletedParents.push(record.id);
+ }
+
+ if (record.parentName !== parent.title && parent.id !== 'unfiled') {
+ problemData.wrongParentName.push(record.id);
+ }
+ }
+
+ // Check that we aren't missing any children.
+ for (let folder of folders) {
+ for (let ci = 0; ci < folder.children.length; ++ci) {
+ let child = folder.children[ci];
+ if (typeof child === 'string') {
+ let childObject = idToRecord.get(child);
+ if (!childObject) {
+ problemData.missingChildren.push({parent: folder.id, child});
+ } else {
+ if (childObject.parentid === folder.id) {
+ // Probably impossible, would have been caught in the loop above.
+ continue;
+ }
+
+ // The child is in multiple `children` arrays.
+ let currentProblemRecord = problemData.multipleParents.find(pr =>
+ pr.child === child);
+
+ if (currentProblemRecord) {
+ currentProblemRecord.parents.push(folder.id);
+ } else {
+ problemData.multipleParents.push({ child, parents: [childObject.parentid, folder.id] });
+ }
+ }
+ // Remove it from the array to avoid needing to special case this later.
+ folder.children.splice(ci, 1);
+ --ci;
+ }
+ }
+ }
+
+ problemData.cycles = this._detectCycles(resultRecords);
+
+ return {
+ deletedRecords,
+ records: resultRecords,
+ problemData,
+ root,
+ };
+ }
+
+ // helper for inspectServerRecords
+ _detectCycles(records) {
+ // currentPath and pathLookup contain the same data. pathLookup is faster to
+ // query, but currentPath gives is the order of traversal that we need in
+ // order to report the members of the cycles.
+ let pathLookup = new Set();
+ let currentPath = [];
+ let cycles = [];
+ let seenEver = new Set();
+ const traverse = node => {
+ if (pathLookup.has(node)) {
+ let cycleStart = currentPath.lastIndexOf(node);
+ let cyclePath = currentPath.slice(cycleStart).map(n => n.id);
+ cycles.push(cyclePath);
+ return;
+ } else if (seenEver.has(node)) {
+ // This is a problem, but we catch it earlier (multipleParents)
+ return;
+ }
+ seenEver.add(node);
+
+ if (node.children) {
+ pathLookup.add(node);
+ currentPath.push(node);
+ for (let child of node.children) {
+ traverse(child);
+ }
+ currentPath.pop();
+ pathLookup.delete(node);
+ }
+ };
+ for (let record of records) {
+ if (!seenEver.has(record)) {
+ traverse(record);
+ }
+ }
+
+ return cycles;
+ }
+
+ /**
+ * Compare the list of server records with the client tree.
+ *
+ * Returns the problemData described in the inspectServerRecords comment,
+ * with the following additional fields.
+ * - clientMissing: Array of ids on the server missing from the client
+ * - serverMissing: Array of ids on the client missing from the server
+ * - differences: Array of {id: string, differences: string array} recording
+ * the properties that are differente between the client and server
+ */
+ compareServerWithClient(serverRecords, clientTree) {
+
+ let clientRecords = this.createClientRecordsFromTree(clientTree);
+ let inspectionInfo = this.inspectServerRecords(serverRecords);
+
+ // Mainly do this to remove deleted items and normalize child guids.
+ serverRecords = inspectionInfo.records;
+ let problemData = inspectionInfo.problemData;
+
+ problemData.clientMissing = [];
+ problemData.serverMissing = [];
+ problemData.differences = [];
+ problemData.good = [];
+
+ let matches = [];
+
+ let allRecords = new Map();
+
+ for (let sr of serverRecords) {
+ allRecords.set(sr.id, {client: null, server: sr});
+ }
+
+ for (let cr of clientRecords) {
+ let unified = allRecords.get(cr.id);
+ if (!unified) {
+ allRecords.set(cr.id, {client: cr, server: null});
+ } else {
+ unified.client = cr;
+ }
+ }
+
+
+ for (let [id, {client, server}] of allRecords) {
+ if (!client && server) {
+ problemData.clientMissing.push(id);
+ continue;
+ }
+ if (!server && client) {
+ problemData.serverMissing.push(id);
+ continue;
+ }
+ let differences = [];
+ if (client.title !== server.title) {
+ differences.push('title');
+ }
+
+ if (client.parentid || server.parentid) {
+ if (client.parentid !== server.parentid) {
+ differences.push('parentid');
+ }
+ // Need to special case 'unfiled' due to it's recent name change
+ // ("Other Bookmarks" vs "Unsorted Bookmarks"), otherwise this has a lot
+ // of false positives.
+ if (client.parentName !== server.parentName && server.parentid !== 'unfiled') {
+ differences.push('parentName');
+ }
+ }
+
+ if (client.tags || server.tags) {
+ let cl = client.tags || [];
+ let sl = server.tags || [];
+ if (cl.length !== sl.length || !cl.every((tag, i) => sl.indexOf(tag) >= 0)) {
+ differences.push('tags');
+ }
+ }
+
+ if (client.type !== server.type) {
+ differences.push('type');
+ } else {
+ switch (server.type) {
+ case 'bookmark':
+ case 'query':
+ if (server.bmkUri !== client.bmkUri) {
+ differences.push('bmkUri');
+ }
+ break;
+ case 'separator':
+ if (server.pos !== client.pos) {
+ differences.push('pos');
+ }
+ break;
+ case 'folder':
+ if (server.id === 'places' && !problemData.rootOnServer) {
+ // It's the fabricated places root. It won't have the GUIDs, but
+ // it doesn't matter.
+ break;
+ }
+ if (client.childGUIDs || server.childGUIDs) {
+ let cl = client.childGUIDs || [];
+ let sl = server.childGUIDs || [];
+ if (cl.length !== sl.length || !cl.every((id, i) => sl[i] === id)) {
+ differences.push('childGUIDs');
+ }
+ }
+ break;
+ }
+ }
+
+ if (differences.length) {
+ problemData.differences.push({id, differences});
+ }
+ }
+ return problemData;
+ }
+};
+
+
+
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -12,29 +12,21 @@ var Cu = Components.utils;
Cu.import("resource://gre/modules/PlacesUtils.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://services-common/async.js");
Cu.import("resource://services-sync/constants.js");
Cu.import("resource://services-sync/engines.js");
Cu.import("resource://services-sync/record.js");
Cu.import("resource://services-sync/util.js");
+Cu.import("resource://services-sync/bookmark_utils.js");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/PlacesBackups.jsm");
-const ALLBOOKMARKS_ANNO = "AllBookmarks";
-const DESCRIPTION_ANNO = "bookmarkProperties/description";
-const SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
-const MOBILEROOT_ANNO = "mobile/bookmarksRoot";
-const MOBILE_ANNO = "MobileBookmarks";
-const EXCLUDEBACKUP_ANNO = "places/excludeFromBackup";
-const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark";
-const PARENT_ANNO = "sync/parent";
-const ORGANIZERQUERY_ANNO = "PlacesOrganizer/OrganizerQuery";
-const ANNOS_TO_TRACK = [DESCRIPTION_ANNO, SIDEBAR_ANNO,
+const ANNOS_TO_TRACK = [BookmarkAnnos.DESCRIPTION_ANNO, BookmarkAnnos.SIDEBAR_ANNO,
PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI];
const SERVICE_NOT_SUPPORTED = "Service not supported on this platform";
const FOLDER_SORTINDEX = 1000000;
this.PlacesItem = function PlacesItem(collection, id, type) {
CryptoWrapper.call(this, collection, id);
this.type = type || "item";
@@ -129,87 +121,16 @@ this.BookmarkSeparator = function Bookma
}
BookmarkSeparator.prototype = {
__proto__: PlacesItem.prototype,
_logName: "Sync.Record.Separator",
};
Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos");
-
-var kSpecialIds = {
-
- // Special IDs. Note that mobile can attempt to create a record on
- // dereference; special accessors are provided to prevent recursion within
- // observers.
- guids: ["menu", "places", "tags", "toolbar", "unfiled", "mobile"],
-
- // Create the special mobile folder to store mobile bookmarks.
- createMobileRoot: function createMobileRoot() {
- let root = PlacesUtils.placesRootId;
- let mRoot = PlacesUtils.bookmarks.createFolder(root, "mobile", -1);
- PlacesUtils.annotations.setItemAnnotation(
- mRoot, MOBILEROOT_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
- PlacesUtils.annotations.setItemAnnotation(
- mRoot, EXCLUDEBACKUP_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
- return mRoot;
- },
-
- findMobileRoot: function findMobileRoot(create) {
- // Use the (one) mobile root if it already exists.
- let root = PlacesUtils.annotations.getItemsWithAnnotation(MOBILEROOT_ANNO, {});
- if (root.length != 0)
- return root[0];
-
- if (create)
- return this.createMobileRoot();
-
- return null;
- },
-
- // Accessors for IDs.
- isSpecialGUID: function isSpecialGUID(g) {
- return this.guids.indexOf(g) != -1;
- },
-
- specialIdForGUID: function specialIdForGUID(guid, create) {
- if (guid == "mobile") {
- return this.findMobileRoot(create);
- }
- return this[guid];
- },
-
- // Don't bother creating mobile: if it doesn't exist, this ID can't be it!
- specialGUIDForId: function specialGUIDForId(id) {
- for (let guid of this.guids)
- if (this.specialIdForGUID(guid, false) == id)
- return guid;
- return null;
- },
-
- get menu() {
- return PlacesUtils.bookmarksMenuFolderId;
- },
- get places() {
- return PlacesUtils.placesRootId;
- },
- get tags() {
- return PlacesUtils.tagsFolderId;
- },
- get toolbar() {
- return PlacesUtils.toolbarFolderId;
- },
- get unfiled() {
- return PlacesUtils.unfiledBookmarksFolderId;
- },
- get mobile() {
- return this.findMobileRoot(true);
- },
-};
-
this.BookmarksEngine = function BookmarksEngine(service) {
SyncEngine.call(this, "Bookmarks", service);
}
BookmarksEngine.prototype = {
__proto__: SyncEngine.prototype,
_recordObj: PlacesItem,
_storeObj: BookmarksStore,
_trackerObj: BookmarksTracker,
@@ -286,39 +207,39 @@ BookmarksEngine.prototype = {
yield* walkBookmarksTree(child, tree);
}
}
}
}
function* walkBookmarksRoots(tree, rootGUIDs) {
for (let guid of rootGUIDs) {
- let id = kSpecialIds.specialIdForGUID(guid, false);
+ let id = BookmarkSpecialIds.specialIdForGUID(guid, false);
let bookmarkRoot = id === null ? null :
tree.children.find(child => child.id === id);
if (bookmarkRoot === null) {
continue;
}
yield* walkBookmarksTree(bookmarkRoot, tree);
}
}
- let rootsToWalk = kSpecialIds.guids.filter(guid =>
+ let rootsToWalk = BookmarkSpecialIds.guids.filter(guid =>
guid !== 'places' && guid !== 'tags');
for (let [node, parent] of walkBookmarksRoots(tree, rootsToWalk)) {
let {guid, id, type: placeType} = node;
- guid = kSpecialIds.specialGUIDForId(id) || guid;
+ guid = BookmarkSpecialIds.specialGUIDForId(id) || guid;
let key;
switch (placeType) {
case PlacesUtils.TYPE_X_MOZ_PLACE:
// Bookmark
let query = null;
if (node.annos && node.uri.startsWith("place:")) {
- query = node.annos.find(({name}) => name === SMART_BOOKMARKS_ANNO);
+ query = node.annos.find(({name}) => name === BookmarkAnnos.SMART_BOOKMARKS_ANNO);
}
if (query && query.value) {
key = "q" + query.value;
} else {
key = "b" + node.uri + ":" + node.title;
}
break;
case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER:
@@ -579,17 +500,17 @@ BookmarksStore.prototype = {
}
finally {
tags.containerOpen = false;
}
},
applyIncoming: function BStore_applyIncoming(record) {
this._log.debug("Applying record " + record.id);
- let isSpecial = record.id in kSpecialIds;
+ let isSpecial = record.id in BookmarkSpecialIds;
if (record.deleted) {
if (isSpecial) {
this._log.warn("Ignoring deletion for special record " + record.id);
return;
}
// Don't bother with pre and post-processing for deletions.
@@ -646,17 +567,17 @@ BookmarksStore.prototype = {
// Reorder children later
if (record.children)
this._childrenToOrder[record.id] = record.children;
}
// Create an annotation to remember that it needs reparenting.
if (record._orphan) {
PlacesUtils.annotations.setItemAnnotation(
- itemId, PARENT_ANNO, parentGUID, 0,
+ itemId, BookmarkAnnos.PARENT_ANNO, parentGUID, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
}
},
/**
* Find all ids of items that have a given value for an annotation
*/
@@ -668,23 +589,23 @@ BookmarksStore.prototype = {
},
/**
* For the provided parent item, attach its children to it
*/
_reparentOrphans: function _reparentOrphans(parentId) {
// Find orphans and reunite with this folder parent
let parentGUID = this.GUIDForId(parentId);
- let orphans = this._findAnnoItems(PARENT_ANNO, parentGUID);
+ let orphans = this._findAnnoItems(BookmarkAnnos.PARENT_ANNO, parentGUID);
this._log.debug("Reparenting orphans " + orphans + " to " + parentId);
orphans.forEach(function(orphan) {
// Move the orphan to the parent and drop the missing parent annotation
if (this._reparentItem(orphan, parentId)) {
- PlacesUtils.annotations.removeItemAnnotation(orphan, PARENT_ANNO);
+ PlacesUtils.annotations.removeItemAnnotation(orphan, BookmarkAnnos.PARENT_ANNO);
}
}, this);
},
_reparentItem: function _reparentItem(itemId, parentId) {
this._log.trace("Attempting to move item " + itemId + " to new parent " +
parentId);
try {
@@ -732,64 +653,64 @@ BookmarksStore.prototype = {
// Default to unfiled if we don't have the parent yet.
// Valid parent IDs are all positive integers. Other values -- undefined,
// null, -1 -- all compare false for > 0, so this catches them all. We
// don't just use <= without the !, because undefined and null compare
// false for that, too!
if (!(record._parent > 0)) {
this._log.debug("Parent is " + record._parent + "; reparenting to unfiled.");
- record._parent = kSpecialIds.unfiled;
+ record._parent = BookmarkSpecialIds.unfiled;
}
switch (record.type) {
case "bookmark":
case "query":
case "microsummary": {
let uri = Utils.makeURI(record.bmkUri);
let newId = PlacesUtils.bookmarks.insertBookmark(
record._parent, uri, PlacesUtils.bookmarks.DEFAULT_INDEX, record.title,
record.id);
this._log.debug("created bookmark " + newId + " under " + record._parent
+ " as " + record.title + " " + record.bmkUri);
// Smart bookmark annotations are strings.
if (record.queryId) {
PlacesUtils.annotations.setItemAnnotation(
- newId, SMART_BOOKMARKS_ANNO, record.queryId, 0,
+ newId, BookmarkAnnos.SMART_BOOKMARKS_ANNO, record.queryId, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
if (Array.isArray(record.tags)) {
this._tagURI(uri, record.tags);
}
PlacesUtils.bookmarks.setKeywordForBookmark(newId, record.keyword);
if (record.description) {
PlacesUtils.annotations.setItemAnnotation(
- newId, DESCRIPTION_ANNO, record.description, 0,
+ newId, BookmarkAnnos.DESCRIPTION_ANNO, record.description, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
if (record.loadInSidebar) {
PlacesUtils.annotations.setItemAnnotation(
- newId, SIDEBAR_ANNO, true, 0,
+ newId, BookmarkAnnos.SIDEBAR_ANNO, true, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
} break;
case "folder": {
let newId = PlacesUtils.bookmarks.createFolder(
record._parent, record.title, PlacesUtils.bookmarks.DEFAULT_INDEX,
record.id);
this._log.debug("created folder " + newId + " under " + record._parent
+ " as " + record.title);
if (record.description) {
PlacesUtils.annotations.setItemAnnotation(
- newId, DESCRIPTION_ANNO, record.description, 0,
+ newId, BookmarkAnnos.DESCRIPTION_ANNO, record.description, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
// record.children will be dealt with in _orderChildren.
} break;
case "livemark":
let siteURI = null;
if (!record.feedUri) {
@@ -869,17 +790,17 @@ BookmarksStore.prototype = {
break;
default:
this._log.error("remove: Unknown item type: " + type);
break;
}
},
remove: function BStore_remove(record) {
- if (kSpecialIds.isSpecialGUID(record.id)) {
+ if (BookmarkSpecialIds.isSpecialGUID(record.id)) {
this._log.warn("Refusing to remove special folder " + record.id);
return;
}
let itemId = this.idForGUID(record.id);
if (itemId <= 0) {
this._log.debug("Item " + record.id + " already removed");
return;
@@ -945,34 +866,34 @@ BookmarksStore.prototype = {
}
break;
case "keyword":
PlacesUtils.bookmarks.setKeywordForBookmark(itemId, val);
break;
case "description":
if (val) {
PlacesUtils.annotations.setItemAnnotation(
- itemId, DESCRIPTION_ANNO, val, 0,
+ itemId, BookmarkAnnos.DESCRIPTION_ANNO, val, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
} else {
- PlacesUtils.annotations.removeItemAnnotation(itemId, DESCRIPTION_ANNO);
+ PlacesUtils.annotations.removeItemAnnotation(itemId, BookmarkAnnos.DESCRIPTION_ANNO);
}
break;
case "loadInSidebar":
if (val) {
PlacesUtils.annotations.setItemAnnotation(
- itemId, SIDEBAR_ANNO, true, 0,
+ itemId, BookmarkAnnos.SIDEBAR_ANNO, true, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
} else {
- PlacesUtils.annotations.removeItemAnnotation(itemId, SIDEBAR_ANNO);
+ PlacesUtils.annotations.removeItemAnnotation(itemId, BookmarkAnnos.SIDEBAR_ANNO);
}
break;
case "queryId":
PlacesUtils.annotations.setItemAnnotation(
- itemId, SMART_BOOKMARKS_ANNO, val, 0,
+ itemId, BookmarkAnnos.SMART_BOOKMARKS_ANNO, val, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
break;
}
}
},
_orderChildren: function _orderChildren() {
for (let [guid, children] in Iterator(this._childrenToOrder)) {
@@ -1027,24 +948,24 @@ BookmarksStore.prototype = {
} catch(e) {
this._log.warn("Could not parse URI \"" + uri + "\": " + e);
}
return PlacesUtils.tagging.getTagsForURI(uri, {});
},
_getDescription: function BStore__getDescription(id) {
try {
- return PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO);
+ return PlacesUtils.annotations.getItemAnnotation(id, BookmarkAnnos.DESCRIPTION_ANNO);
} catch (e) {
return null;
}
},
_isLoadInSidebar: function BStore__isLoadInSidebar(id) {
- return PlacesUtils.annotations.itemHasAnnotation(id, SIDEBAR_ANNO);
+ return PlacesUtils.annotations.itemHasAnnotation(id, BookmarkAnnos.SIDEBAR_ANNO);
},
get _childGUIDsStm() {
return this._getStmt(
"SELECT id AS item_id, guid " +
"FROM moz_bookmarks " +
"WHERE parent = :parent " +
"ORDER BY position");
@@ -1090,19 +1011,19 @@ BookmarksStore.prototype = {
record.folderName = PlacesUtils.bookmarks.getItemTitle(folder);
this._log.trace("query id: " + folder + " = " + record.folderName);
}
}
catch(ex) {}
// Persist the Smart Bookmark anno, if found.
try {
- let anno = PlacesUtils.annotations.getItemAnnotation(placeId, SMART_BOOKMARKS_ANNO);
+ let anno = PlacesUtils.annotations.getItemAnnotation(placeId, BookmarkAnnos.SMART_BOOKMARKS_ANNO);
if (anno != null) {
- this._log.trace("query anno: " + SMART_BOOKMARKS_ANNO +
+ this._log.trace("query anno: " + BookmarkAnnos.SMART_BOOKMARKS_ANNO +
" = " + anno);
record.queryId = anno;
}
}
catch(ex) {}
}
else {
record = new Bookmark(collection, id);
@@ -1202,17 +1123,17 @@ BookmarksStore.prototype = {
return this._getStmt(
"SELECT guid " +
"FROM moz_bookmarks " +
"WHERE id = :item_id");
},
_guidForIdCols: ["guid"],
GUIDForId: function GUIDForId(id) {
- let special = kSpecialIds.specialGUIDForId(id);
+ let special = BookmarkSpecialIds.specialGUIDForId(id);
if (special)
return special;
let stmt = this._guidForIdStm;
stmt.params.item_id = id;
// Use the existing GUID if it exists
let result = Async.querySpinningly(stmt, this._guidForIdCols)[0];
@@ -1229,18 +1150,18 @@ BookmarksStore.prototype = {
"FROM moz_bookmarks " +
"WHERE guid = :guid");
},
_idForGUIDCols: ["item_id"],
// noCreate is provided as an optional argument to prevent the creation of
// non-existent special records, such as "mobile".
idForGUID: function idForGUID(guid, noCreate) {
- if (kSpecialIds.isSpecialGUID(guid))
- return kSpecialIds.specialIdForGUID(guid, !noCreate);
+ if (BookmarkSpecialIds.isSpecialGUID(guid))
+ return BookmarkSpecialIds.specialIdForGUID(guid, !noCreate);
let stmt = this._idForGUIDStm;
// guid might be a String object rather than a string.
stmt.params.guid = guid.toString();
let results = Async.querySpinningly(stmt, this._idForGUIDCols);
this._log.trace("Number of rows matching GUID " + guid + ": "
+ results.length);
@@ -1351,34 +1272,34 @@ BookmarksStore.prototype = {
getAllIDs: function BStore_getAllIDs() {
let items = {"menu": true,
"toolbar": true,
"unfiled": true,
};
// We also want "mobile" but only if a local mobile folder already exists
// (otherwise we'll later end up creating it, which we want to avoid until
// we actually need it.)
- if (kSpecialIds.findMobileRoot(false)) {
+ if (BookmarkSpecialIds.findMobileRoot(false)) {
items["mobile"] = true;
}
- for (let guid of kSpecialIds.guids) {
+ for (let guid of BookmarkSpecialIds.guids) {
if (guid != "places" && guid != "tags")
this._getChildren(guid, items);
}
return items;
},
wipe: function BStore_wipe() {
let cb = Async.makeSpinningCallback();
Task.spawn(function* () {
// Save a backup before clearing out all bookmarks.
yield PlacesBackups.create(null, true);
- for (let guid of kSpecialIds.guids)
+ for (let guid of BookmarkSpecialIds.guids)
if (guid != "places") {
- let id = kSpecialIds.specialIdForGUID(guid);
+ let id = BookmarkSpecialIds.specialIdForGUID(guid);
if (id)
PlacesUtils.bookmarks.removeFolderChildren(id);
}
cb();
});
cb.wait();
}
};
@@ -1437,17 +1358,17 @@ BookmarksTracker.prototype = {
/**
* Add a bookmark GUID to be uploaded and bump up the sync score.
*
* @param itemGuid
* GUID of the bookmark to upload.
*/
_add: function BMT__add(itemId, guid) {
- guid = kSpecialIds.specialGUIDForId(itemId) || guid;
+ guid = BookmarkSpecialIds.specialGUIDForId(itemId) || guid;
if (this.addChangedID(guid))
this._upScore();
},
/* Every add/remove/change will trigger a sync for MULTI_DEVICE. */
_upScore: function BMT__upScore() {
this.score += SCORE_INCREMENT_XLARGE;
},
@@ -1475,26 +1396,26 @@ BookmarksTracker.prototype = {
// I'm guessing that gFIFI can throw, and perhaps that's why
// _ensureMobileQuery is here at all. Try not to call it.
this._ensureMobileQuery();
folder = PlacesUtils.bookmarks.getFolderIdForItem(itemId);
}
}
// Ignore changes to tags (folders under the tags folder).
- let tags = kSpecialIds.tags;
+ let tags = BookmarkSpecialIds.tags;
if (folder == tags)
return true;
// Ignore tag items (the actual instance of a tag for a bookmark).
if (PlacesUtils.bookmarks.getFolderIdForItem(folder) == tags)
return true;
// Make sure to remove items that have the exclude annotation.
- if (PlacesUtils.annotations.itemHasAnnotation(itemId, EXCLUDEBACKUP_ANNO)) {
+ if (PlacesUtils.annotations.itemHasAnnotation(itemId, BookmarkAnnos.EXCLUDEBACKUP_ANNO)) {
this.removeChangedID(guid);
return true;
}
return false;
},
onItemAdded: function BMT_onItemAdded(itemId, folder, index,
@@ -1516,43 +1437,43 @@ BookmarksTracker.prototype = {
this._log.trace("onItemRemoved: " + itemId);
this._add(itemId, guid);
this._add(parentId, parentGuid);
},
_ensureMobileQuery: function _ensureMobileQuery() {
let find = val =>
- PlacesUtils.annotations.getItemsWithAnnotation(ORGANIZERQUERY_ANNO, {}).filter(
- id => PlacesUtils.annotations.getItemAnnotation(id, ORGANIZERQUERY_ANNO) == val
+ PlacesUtils.annotations.getItemsWithAnnotation(BookmarkAnnos.ORGANIZERQUERY_ANNO, {}).filter(
+ id => PlacesUtils.annotations.getItemAnnotation(id, BookmarkAnnos.ORGANIZERQUERY_ANNO) == val
);
// Don't continue if the Library isn't ready
- let all = find(ALLBOOKMARKS_ANNO);
+ let all = find(BookmarkAnnos.ALLBOOKMARKS_ANNO);
if (all.length == 0)
return;
// Disable handling of notifications while changing the mobile query
this.ignoreAll = true;
- let mobile = find(MOBILE_ANNO);
- let queryURI = Utils.makeURI("place:folder=" + kSpecialIds.mobile);
+ let mobile = find(BookmarkAnnos.MOBILE_ANNO);
+ let queryURI = Utils.makeURI("place:folder=" + BookmarkSpecialIds.mobile);
let title = Str.sync.get("mobile.label");
// Don't add OR remove the mobile bookmarks if there's nothing.
- if (PlacesUtils.bookmarks.getIdForItemAt(kSpecialIds.mobile, 0) == -1) {
+ if (PlacesUtils.bookmarks.getIdForItemAt(BookmarkSpecialIds.mobile, 0) == -1) {
if (mobile.length != 0)
PlacesUtils.bookmarks.removeItem(mobile[0]);
}
// Add the mobile bookmarks query if it doesn't exist
else if (mobile.length == 0) {
let query = PlacesUtils.bookmarks.insertBookmark(all[0], queryURI, -1, title);
- PlacesUtils.annotations.setItemAnnotation(query, ORGANIZERQUERY_ANNO, MOBILE_ANNO, 0,
+ PlacesUtils.annotations.setItemAnnotation(query, BookmarkAnnos.ORGANIZERQUERY_ANNO, BookmarkAnnos.MOBILE_ANNO, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
- PlacesUtils.annotations.setItemAnnotation(query, EXCLUDEBACKUP_ANNO, 1, 0,
+ PlacesUtils.annotations.setItemAnnotation(query, BookmarkAnnos.EXCLUDEBACKUP_ANNO, 1, 0,
PlacesUtils.annotations.EXPIRE_NEVER);
}
// Make sure the existing title is correct
else if (PlacesUtils.bookmarks.getItemTitle(mobile[0]) != title) {
PlacesUtils.bookmarks.setItemTitle(mobile[0], title);
}
this.ignoreAll = false;
@@ -1594,15 +1515,15 @@ BookmarksTracker.prototype = {
this._log.trace("onItemMoved: " + itemId);
this._add(oldParent, oldParentGuid);
if (oldParent != newParent) {
this._add(itemId, guid);
this._add(newParent, newParentGuid);
}
// Remove any position annotations now that the user moved the item
- PlacesUtils.annotations.removeItemAnnotation(itemId, PARENT_ANNO);
+ PlacesUtils.annotations.removeItemAnnotation(itemId, BookmarkAnnos.PARENT_ANNO);
},
onBeginUpdateBatch: function () {},
onEndUpdateBatch: function () {},
onItemVisited: function () {}
};
--- a/services/sync/moz.build
+++ b/services/sync/moz.build
@@ -14,16 +14,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/unit
EXTRA_COMPONENTS += [
'SyncComponents.manifest',
'Weave.js',
]
EXTRA_JS_MODULES['services-sync'] += [
'modules/addonsreconciler.js',
'modules/addonutils.js',
+ 'modules/bookmark_utils.js',
+ 'modules/bookmark_validator.js',
'modules/browserid_identity.js',
'modules/engines.js',
'modules/FxaMigrator.jsm',
'modules/identity.js',
'modules/jpakeclient.js',
'modules/keys.js',
'modules/main.js',
'modules/policies.js',
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_bookmark_validator.js
@@ -0,0 +1,242 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Components.utils.import("resource://services-sync/bookmark_validator.js");
+
+function inspectServerRecords(data) {
+ return new BookmarkValidator().inspectServerRecords(data);
+}
+
+add_test(function test_isr_rootOnServer() {
+ let c = inspectServerRecords([{
+ id: 'places',
+ type: 'folder',
+ children: [],
+ }]);
+ ok(c.problemData.rootOnServer);
+ run_next_test();
+});
+
+add_test(function test_isr_empty() {
+ let c = inspectServerRecords([]);
+ ok(!c.problemData.rootOnServer);
+ notEqual(c.root, null);
+ run_next_test();
+});
+
+add_test(function test_isr_cycles() {
+ let c = inspectServerRecords([
+ {id: 'C', type: 'folder', children: ['A', 'B'], parentid: 'places'},
+ {id: 'A', type: 'folder', children: ['B'], parentid: 'B'},
+ {id: 'B', type: 'folder', children: ['A'], parentid: 'A'},
+ ]).problemData;
+
+ equal(c.cycles.length, 1);
+ ok(c.cycles[0].indexOf('A') >= 0);
+ ok(c.cycles[0].indexOf('B') >= 0);
+ run_next_test();
+});
+
+add_test(function test_isr_orphansMultiParents() {
+ let c = inspectServerRecords([
+ { id: 'A', type: 'bookmark', parentid: 'D' },
+ { id: 'B', type: 'folder', parentid: 'places', children: ['A']},
+ { id: 'C', type: 'folder', parentid: 'places', children: ['A']},
+ ]).problemData;
+ equal(c.orphans.length, 1);
+ equal(c.orphans[0].id, 'A');
+ equal(c.multipleParents.length, 1);
+ equal(c.multipleParents[0].child, 'A');
+ ok(c.multipleParents[0].parents.indexOf('B') >= 0);
+ ok(c.multipleParents[0].parents.indexOf('C') >= 0);
+ run_next_test();
+});
+
+add_test(function test_isr_deletedParents() {
+ let c = inspectServerRecords([
+ { id: 'A', type: 'bookmark', parentid: 'B' },
+ { id: 'B', type: 'folder', parentid: 'places', children: ['A']},
+ { id: 'B', type: 'item', deleted: true},
+ ]).problemData;
+ deepEqual(c.deletedParents, ['A'])
+ run_next_test();
+});
+
+add_test(function test_isr_badChildren() {
+ let c = inspectServerRecords([
+ { id: 'A', type: 'bookmark', parentid: 'places', children: ['B', 'C'] },
+ { id: 'C', type: 'bookmark', parentid: 'A' }
+ ]).problemData;
+ deepEqual(c.childrenOnNonFolder, ['A'])
+ deepEqual(c.missingChildren, [{parent: 'A', child: 'B'}]);
+ deepEqual(c.parentNotFolder, ['C']);
+ run_next_test();
+});
+
+
+add_test(function test_isr_parentChildMismatches() {
+ let c = inspectServerRecords([
+ { id: 'A', type: 'folder', parentid: 'places', children: [] },
+ { id: 'B', type: 'bookmark', parentid: 'A' }
+ ]).problemData;
+ deepEqual(c.parentChildMismatches, [{parent: 'A', child: 'B'}]);
+ run_next_test();
+});
+
+add_test(function test_isr_duplicatesAndMissingIDs() {
+ let c = inspectServerRecords([
+ {id: 'A', type: 'folder', parentid: 'places', children: []},
+ {id: 'A', type: 'folder', parentid: 'places', children: []},
+ {type: 'folder', parentid: 'places', children: []}
+ ]).problemData;
+ equal(c.missingIDs, 1);
+ deepEqual(c.duplicates, ['A']);
+ run_next_test();
+});
+
+add_test(function test_isr_wrongParentName() {
+ let c = inspectServerRecords([
+ {id: 'A', type: 'folder', title: 'My Amazing Bookmarks', parentName: '', parentid: 'places', children: ['B']},
+ {id: 'B', type: 'bookmark', title: '', parentName: 'My Awesome Bookmarks', parentid: 'A'},
+ ]).problemData;
+ deepEqual(c.wrongParentName, ['B'])
+ run_next_test();
+});
+
+add_test(function test_isr_duplicateChildren() {
+ let c = inspectServerRecords([
+ {id: 'A', type: 'folder', parentid: 'places', children: ['B', 'B']},
+ {id: 'B', type: 'bookmark', parentid: 'A'},
+ ]).problemData;
+ deepEqual(c.duplicateChildren, ['A']);
+ run_next_test();
+});
+
+// Each compareServerWithClient test mutates these, so we can't just keep them
+// global
+function getDummyServerAndClient() {
+ let server = [
+ {
+ id: 'aaaaaaaaaaaa',
+ parentid: 'places',
+ type: 'folder',
+ parentName: '',
+ title: 'foo',
+ children: ['bbbbbbbbbbbb', 'cccccccccccc']
+ },
+ {
+ id: 'bbbbbbbbbbbb',
+ type: 'bookmark',
+ parentid: 'aaaaaaaaaaaa',
+ parentName: 'foo',
+ title: 'bar',
+ bmkUri: 'http://baz.com'
+ },
+ {
+ id: 'cccccccccccc',
+ parentid: 'aaaaaaaaaaaa',
+ parentName: 'foo',
+ title: '',
+ type: 'query',
+ bmkUri: 'place:type=6&sort=14&maxResults=10'
+ }
+ ];
+
+ let client = {
+ "guid": "root________",
+ "title": "",
+ "id": 1,
+ "type": "text/x-moz-place-container",
+ "children": [
+ {
+ "guid": "aaaaaaaaaaaa",
+ "title": "foo",
+ "id": 1000,
+ "type": "text/x-moz-place-container",
+ "children": [
+ {
+ "guid": "bbbbbbbbbbbb",
+ "title": "bar",
+ "id": 1001,
+ "type": "text/x-moz-place",
+ "uri": "http://baz.com"
+ },
+ {
+ "guid": "cccccccccccc",
+ "title": "",
+ "id": 1002,
+ "annos": [{
+ "name": "Places/SmartBookmark",
+ "flags": 0,
+ "expires": 4,
+ "value": "RecentTags"
+ }],
+ "type": "text/x-moz-place",
+ "uri": "place:type=6&sort=14&maxResults=10"
+ }
+ ]
+ }
+ ]
+ };
+ return {server, client};
+}
+
+
+add_test(function test_cswc_valid() {
+ let {server, client} = getDummyServerAndClient();
+
+ let c = new BookmarkValidator().compareServerWithClient(server, client);
+ equal(c.clientMissing.length, 0);
+ equal(c.serverMissing.length, 0);
+ equal(c.differences.length, 0);
+ run_next_test();
+});
+
+add_test(function test_cswc_serverMissing() {
+ let {server, client} = getDummyServerAndClient();
+ // remove c
+ server.pop();
+ server[0].children.pop();
+
+ let c = new BookmarkValidator().compareServerWithClient(server, client);
+ deepEqual(c.serverMissing, ['cccccccccccc']);
+ equal(c.clientMissing.length, 0);
+ deepEqual(c.differences, [{id: 'aaaaaaaaaaaa', differences: ['childGUIDs']}]);
+ run_next_test();
+});
+
+add_test(function test_cswc_clientMissing() {
+ let {server, client} = getDummyServerAndClient();
+ client.children[0].children.pop();
+
+ let c = new BookmarkValidator().compareServerWithClient(server, client);
+ deepEqual(c.clientMissing, ['cccccccccccc']);
+ equal(c.serverMissing.length, 0);
+ deepEqual(c.differences, [{id: 'aaaaaaaaaaaa', differences: ['childGUIDs']}]);
+ run_next_test();
+});
+
+add_test(function test_cswc_differences() {
+ {
+ let {server, client} = getDummyServerAndClient();
+ client.children[0].children[0].title = 'asdf';
+ let c = new BookmarkValidator().compareServerWithClient(server, client);
+ equal(c.clientMissing.length, 0);
+ equal(c.serverMissing.length, 0);
+ deepEqual(c.differences, [{id: 'bbbbbbbbbbbb', differences: ['title']}]);
+ }
+
+ {
+ let {server, client} = getDummyServerAndClient();
+ server[2].type = 'bookmark';
+ let c = new BookmarkValidator().compareServerWithClient(server, client);
+ equal(c.clientMissing.length, 0);
+ equal(c.serverMissing.length, 0);
+ deepEqual(c.differences, [{id: 'cccccccccccc', differences: ['type']}]);
+ }
+ run_next_test();
+});
+
+function run_test() {
+ run_next_test();
+}
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -149,16 +149,17 @@ tags = addons
[test_bookmark_order.js]
[test_bookmark_places_query_rewriting.js]
[test_bookmark_record.js]
[test_bookmark_smart_bookmarks.js]
[test_bookmark_store.js]
# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
skip-if = debug
[test_bookmark_tracker.js]
+[test_bookmark_validator.js]
[test_clients_engine.js]
[test_clients_escape.js]
[test_forms_store.js]
[test_forms_tracker.js]
# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
skip-if = debug
[test_history_engine.js]
[test_history_store.js]