// Tracks a set of bookmark guids and their syncChangeCounter field and // provides a simple way for the test to check the correct fields had the // counter incremented. class CounterTracker { constructor() { this.tracked = new Map(); } async _getCounter(guid) { let fields = await PlacesTestUtils.fetchBookmarkSyncFields(guid); if (!fields.length) { throw new Error(`Item ${guid} does not exist`); } return fields[0].syncChangeCounter; } // Call this after creating a new bookmark. async track(guid, name, expectedInitial = 1) { if (this.tracked.has(guid)) { throw new Error(`Already tracking item ${guid}`); } let initial = await this._getCounter(guid); Assert.equal( initial, expectedInitial, `Initial value of item '${name}' is correct` ); this.tracked.set(guid, { name, value: expectedInitial }); } // Call this to check *only* the specified IDs had a change increment, and // that none of the other "tracked" ones did. async check(...expectedToIncrement) { info(`Checking counter for items ${JSON.stringify(expectedToIncrement)}`); for (let [guid, entry] of this.tracked) { let { name, value } = entry; let newValue = await this._getCounter(guid); let desc = `record '${name}' (guid=${guid})`; if (expectedToIncrement.includes(guid)) { // Note we don't check specifically for +1, as some changes will // increment the counter by more than 1 (which is OK). Assert.ok( newValue > value, `${desc} was expected to increment - was ${value}, now ${newValue}` ); this.tracked.set(guid, { name, value: newValue }); } else { Assert.equal(newValue, value, `${desc} was NOT expected to increment`); } } } } async function checkSyncFields(guid, expected) { let results = await PlacesTestUtils.fetchBookmarkSyncFields(guid); if (!results.length) { throw new Error(`Missing sync fields for ${guid}`); } for (let name in expected) { let expectedValue = expected[name]; Assert.equal( results[0][name], expectedValue, `field ${name} matches item ${guid}` ); } } // Common test cases for sync field changes. class TestCases { async run() { info("Test 1: inserts, updates, tags, and keywords"); try { await this.testChanges(); } finally { info("Reset sync fields after test 1"); await PlacesTestUtils.markBookmarksAsSynced(); } if ("moveItem" in this && "reorder" in this) { info("Test 2: reparenting"); try { await this.testReparenting(); } finally { info("Reset sync fields after test 2"); await PlacesTestUtils.markBookmarksAsSynced(); } } if ("insertSeparator" in this) { info("Test 3: separators"); try { await this.testSeparators(); } finally { info("Reset sync fields after test 3"); await PlacesTestUtils.markBookmarksAsSynced(); } } } async testChanges() { let testUri = NetUtil.newURI("http://test.mozilla.org"); let guid = await this.insertBookmark( PlacesUtils.bookmarks.unfiledGuid, testUri, PlacesUtils.bookmarks.DEFAULT_INDEX, "bookmark title" ); info(`Inserted bookmark ${guid}`); await checkSyncFields(guid, { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, syncChangeCounter: 1, }); // Pretend Sync just did whatever it does await PlacesTestUtils.setBookmarkSyncFields({ guid, syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, }); info(`Updated sync status of ${guid}`); await checkSyncFields(guid, { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, syncChangeCounter: 1, }); // update it - it should increment the change counter await this.setTitle(guid, "new title"); info(`Changed title of ${guid}`); await checkSyncFields(guid, { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, syncChangeCounter: 2, }); // Tagging a bookmark should update its change counter. await this.tagURI(testUri, ["test-tag"]); info(`Tagged bookmark ${guid}`); await checkSyncFields(guid, { syncChangeCounter: 3 }); if ("setKeyword" in this) { await this.setKeyword(guid, "keyword"); info(`Set keyword for bookmark ${guid}`); await checkSyncFields(guid, { syncChangeCounter: 4 }); } if ("removeKeyword" in this) { await this.removeKeyword(guid, "keyword"); info(`Removed keyword from bookmark ${guid}`); await checkSyncFields(guid, { syncChangeCounter: 5 }); } } async testSeparators() { let insertSyncedBookmark = uri => { return this.insertBookmark( PlacesUtils.bookmarks.unfiledGuid, NetUtil.newURI(uri), PlacesUtils.bookmarks.DEFAULT_INDEX, "A bookmark name" ); }; await insertSyncedBookmark("http://foo.bar"); let secondBmk = await insertSyncedBookmark("http://bar.foo"); let sepGuid = await this.insertSeparator( PlacesUtils.bookmarks.unfiledGuid, PlacesUtils.bookmarks.DEFAULT_INDEX ); await insertSyncedBookmark("http://barbar.foo"); info("Move a bookmark around the separator"); await this.moveItem(secondBmk, PlacesUtils.bookmarks.unfiledGuid, 4); await checkSyncFields(sepGuid, { syncChangeCounter: 2 }); info("Move a separator around directly"); await this.moveItem(sepGuid, PlacesUtils.bookmarks.unfiledGuid, 0); await checkSyncFields(sepGuid, { syncChangeCounter: 3 }); } async testReparenting() { let counterTracker = new CounterTracker(); let folder1 = await this.createFolder( PlacesUtils.bookmarks.unfiledGuid, "folder1", PlacesUtils.bookmarks.DEFAULT_INDEX ); info(`Created the first folder, guid is ${folder1}`); // New folder should have a change recorded. await counterTracker.track(folder1, "folder 1"); // Put a new bookmark in the folder. let testUri = NetUtil.newURI("http://test2.mozilla.org"); let child1 = await this.insertBookmark( folder1, testUri, PlacesUtils.bookmarks.DEFAULT_INDEX, "bookmark 1" ); info(`Created a new bookmark into ${folder1}, guid is ${child1}`); // both the folder and the child should have a change recorded. await counterTracker.track(child1, "child 1"); await counterTracker.check(folder1); // A new child in the folder at index 0 - even though the existing child // was bumped down the list, it should *not* have a change recorded. let child2 = await this.insertBookmark(folder1, testUri, 0, "bookmark 2"); info( `Created a second new bookmark into folder ${folder1}, guid is ${child2}` ); await counterTracker.track(child2, "child 2"); await counterTracker.check(folder1); // Move the items within the same folder - this should result in just a // change for the parent, but for neither of the children. // child0 is currently at index 0, so move child1 there. await this.moveItem(child1, folder1, 0); await counterTracker.check(folder1); // Another folder to play with. let folder2 = await this.createFolder( PlacesUtils.bookmarks.unfiledGuid, "folder2", PlacesUtils.bookmarks.DEFAULT_INDEX ); info(`Created a second new folder, guid is ${folder2}`); await counterTracker.track(folder2, "folder 2"); // nothing else has changed. await counterTracker.check(); // Move one of the children to the new folder. info( `Moving bookmark ${child2} from folder ${folder1} to folder ${folder2}` ); await this.moveItem(child2, folder2, PlacesUtils.bookmarks.DEFAULT_INDEX); // child1 should have no change, everything should have a new change. await counterTracker.check(folder1, folder2, child2); // Move the new folder to another root. await this.moveItem( folder2, PlacesUtils.bookmarks.toolbarGuid, PlacesUtils.bookmarks.DEFAULT_INDEX ); info(`Moving folder ${folder2} to toolbar`); await counterTracker.check( folder2, PlacesUtils.bookmarks.toolbarGuid, PlacesUtils.bookmarks.unfiledGuid ); let child3 = await this.insertBookmark(folder2, testUri, 0, "bookmark 3"); info(`Prepended child ${child3} to folder ${folder2}`); await counterTracker.check(folder2, child3); // Reordering should only track the parent. await this.reorder(folder2, [child2, child3]); info(`Reorder children of ${folder2}`); await counterTracker.check(folder2); // All fields still have a syncStatus of SYNC_STATUS_NEW - so deleting them // should *not* cause any deleted items to be written. await this.removeItem(folder1); Assert.equal((await PlacesTestUtils.fetchSyncTombstones()).length, 0); // Set folder2 and child2 to have a state of SYNC_STATUS_NORMAL and deleting // them will cause both GUIDs to be written to moz_bookmarks_deleted. await PlacesTestUtils.setBookmarkSyncFields({ guid: folder2, syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, }); await PlacesTestUtils.setBookmarkSyncFields({ guid: child2, syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, }); await this.removeItem(folder2); let tombstones = await PlacesTestUtils.fetchSyncTombstones(); let tombstoneGuids = sortBy(tombstones, "guid").map(({ guid }) => guid); Assert.equal(tombstoneGuids.length, 2); Assert.deepEqual(tombstoneGuids, [folder2, child2].sort(compareAscending)); } } // Exercises the legacy, synchronous `nsINavBookmarksService` calls implemented // in C++. class SyncTestCases extends TestCases { async createFolder(parentGuid, title, index) { let parentId = await PlacesUtils.promiseItemId(parentGuid); let id = PlacesUtils.bookmarks.createFolder(parentId, title, index); return PlacesUtils.promiseItemGuid(id); } async insertBookmark(parentGuid, uri, index, title) { let parentId = await PlacesUtils.promiseItemId(parentGuid); let id = PlacesUtils.bookmarks.insertBookmark(parentId, uri, index, title); return PlacesUtils.promiseItemGuid(id); } async removeItem(guid) { let id = await PlacesUtils.promiseItemId(guid); PlacesUtils.bookmarks.removeItem(id); } async setTitle(guid, title) { let id = await PlacesUtils.promiseItemId(guid); PlacesUtils.bookmarks.setItemTitle(id, title); } async tagURI(uri, tags) { PlacesUtils.tagging.tagURI(uri, tags); } } async function findTagFolder(tag) { let db = await PlacesUtils.promiseDBConnection(); let results = await db.executeCached( ` SELECT guid FROM moz_bookmarks WHERE type = :type AND parent = :tagsFolderId AND title = :tag`, { type: PlacesUtils.bookmarks.TYPE_FOLDER, tagsFolderId: PlacesUtils.tagsFolderId, tag, } ); return results.length ? results[0].getResultByName("guid") : null; } // Exercises the new, async calls implemented in `Bookmarks.jsm`. class AsyncTestCases extends TestCases { async createFolder(parentGuid, title, index) { let item = await PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER, parentGuid, title, index, }); return item.guid; } async insertBookmark(parentGuid, uri, index, title) { let item = await PlacesUtils.bookmarks.insert({ parentGuid, url: uri, index, title, }); return item.guid; } async insertSeparator(parentGuid, index) { let item = await PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR, parentGuid, index, }); return item.guid; } async moveItem(guid, newParentGuid, index) { await PlacesUtils.bookmarks.update({ guid, parentGuid: newParentGuid, index, }); } async removeItem(guid) { await PlacesUtils.bookmarks.remove(guid); } async setTitle(guid, title) { await PlacesUtils.bookmarks.update({ guid, title }); } async setKeyword(guid, keyword) { let item = await PlacesUtils.bookmarks.fetch(guid); if (!item) { throw new Error( `Cannot set keyword ${keyword} on nonexistent bookmark ${guid}` ); } await PlacesUtils.keywords.insert({ keyword, url: item.url }); } async removeKeyword(guid, keyword) { let item = await PlacesUtils.bookmarks.fetch(guid); if (!item) { throw new Error( `Cannot remove keyword ${keyword} from nonexistent bookmark ${guid}` ); } let entry = await PlacesUtils.keywords.fetch({ keyword, url: item.url }); if (!entry) { throw new Error(`Keyword ${keyword} not set on bookmark ${guid}`); } await PlacesUtils.keywords.remove(entry); } // There's no async API for tags, but the `PlacesUtils.bookmarks` methods are // tag-aware, and should bump the change counters for tagged bookmarks when // called directly. async tagURI(uri, tags) { for (let tag of tags) { let tagFolderGuid = await findTagFolder(tag); if (!tagFolderGuid) { let tagFolder = await PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER, parentGuid: PlacesUtils.bookmarks.tagsGuid, title: tag, }); tagFolderGuid = tagFolder.guid; } await PlacesUtils.bookmarks.insert({ url: uri, parentGuid: tagFolderGuid, }); } } async reorder(parentGuid, childGuids) { await PlacesUtils.bookmarks.reorder(parentGuid, childGuids); } } add_task(async function test_sync_api() { let tests = new SyncTestCases(); await tests.run(); }); add_task(async function test_async_api() { let tests = new AsyncTestCases(); await tests.run(); });