diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/places/tests | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/places/tests')
409 files changed, 72095 insertions, 0 deletions
diff --git a/toolkit/components/places/tests/PlacesTestUtils.sys.mjs b/toolkit/components/places/tests/PlacesTestUtils.sys.mjs new file mode 100644 index 0000000000..e2e792c24d --- /dev/null +++ b/toolkit/components/places/tests/PlacesTestUtils.sys.mjs @@ -0,0 +1,616 @@ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +export var PlacesTestUtils = Object.freeze({ + /** + * Asynchronously adds visits to a page. + * + * @param {*} aPlaceInfo + * A string URL, nsIURI, Window.URL object, info object (explained + * below), or an array of any of those. Info objects describe the + * visits to add more fully than URLs/URIs alone and look like this: + * + * { + * uri|url: href, URL or nsIURI of the page, + * [optional] transition: one of the TRANSITION_* from nsINavHistoryService, + * [optional] title: title of the page, + * [optional] visitDate: visit date, either in microseconds from the epoch or as a date object + * [optional] referrer: nsIURI of the referrer for this visit + * } + * + * @return {Promise} + * @resolves When all visits have been added successfully. + * @rejects JavaScript exception. + */ + async addVisits(placeInfo) { + let places = []; + let infos = []; + + if (Array.isArray(placeInfo)) { + places.push(...placeInfo); + } else { + places.push(placeInfo); + } + + // Create a PageInfo for each entry. + let lastStoredVisit; + for (let obj of places) { + let place; + if ( + obj instanceof Ci.nsIURI || + URL.isInstance(obj) || + typeof obj == "string" + ) { + place = { uri: obj }; + } else if (typeof obj == "object" && (obj.uri || obj.url)) { + place = obj; + } else { + throw new Error("Unsupported type passed to addVisits"); + } + + let referrer = place.referrer + ? lazy.PlacesUtils.toURI(place.referrer) + : null; + let info = { url: place.uri || place.url }; + let spec = + info.url instanceof Ci.nsIURI ? info.url.spec : new URL(info.url).href; + info.title = "title" in place ? place.title : "test visit for " + spec; + let visitDate = place.visitDate; + if (visitDate) { + if (visitDate.constructor.name != "Date") { + // visitDate should be in microseconds. It's easy to do the wrong thing + // and pass milliseconds, so we lazily check for that. + // While it's not easily distinguishable, since both are integers, we + // can check if the value is very far in the past, and assume it's + // probably a mistake. + if (visitDate <= Date.now()) { + throw new Error( + "AddVisits expects a Date object or _micro_seconds!" + ); + } + visitDate = lazy.PlacesUtils.toDate(visitDate); + } + } else { + visitDate = new Date(); + } + info.visits = [ + { + transition: place.transition, + date: visitDate, + referrer, + }, + ]; + infos.push(info); + if ( + !place.transition || + place.transition != lazy.PlacesUtils.history.TRANSITIONS.EMBED + ) { + lastStoredVisit = info; + } + } + await lazy.PlacesUtils.history.insertMany(infos); + if (lastStoredVisit) { + await lazy.TestUtils.waitForCondition( + () => lazy.PlacesUtils.history.fetch(lastStoredVisit.url), + "Ensure history has been updated and is visible to read-only connections" + ); + } + }, + + /* + * Add Favicons + * + * @param {Map} faviconURLs keys are page URLs, values are their + * associated favicon URLs. + */ + + async addFavicons(faviconURLs) { + let faviconPromises = []; + + // If no favicons were provided, we do not want to continue on + if (!faviconURLs) { + throw new Error("No favicon URLs were provided"); + } + for (let [key, val] of faviconURLs) { + if (!val) { + throw new Error("URL does not exist"); + } + faviconPromises.push( + new Promise((resolve, reject) => { + let uri = Services.io.newURI(key); + let faviconURI = Services.io.newURI(val); + try { + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + uri, + faviconURI, + false, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } catch (ex) { + reject(ex); + } + }) + ); + } + await Promise.all(faviconPromises); + }, + + /** + * Clears any favicons stored in the database. + */ + async clearFavicons() { + return new Promise(resolve => { + Services.obs.addObserver(function observer() { + Services.obs.removeObserver(observer, "places-favicons-expired"); + resolve(); + }, "places-favicons-expired"); + lazy.PlacesUtils.favicons.expireAllFavicons(); + }); + }, + + /** + * Adds a bookmark to the database. This should only be used when you need to + * add keywords. Otherwise, use `PlacesUtils.bookmarks.insert()`. + * @param {string} aBookmarkObj.uri + * @param {string} [aBookmarkObj.title] + * @param {string} [aBookmarkObj.keyword] + */ + async addBookmarkWithDetails(aBookmarkObj) { + await lazy.PlacesUtils.bookmarks.insert({ + parentGuid: lazy.PlacesUtils.bookmarks.unfiledGuid, + title: aBookmarkObj.title || "A bookmark", + url: aBookmarkObj.uri, + }); + + if (aBookmarkObj.keyword) { + await lazy.PlacesUtils.keywords.insert({ + keyword: aBookmarkObj.keyword, + url: + aBookmarkObj.uri instanceof Ci.nsIURI + ? aBookmarkObj.uri.spec + : aBookmarkObj.uri, + postData: aBookmarkObj.postData, + }); + } + + if (aBookmarkObj.tags) { + let uri = + aBookmarkObj.uri instanceof Ci.nsIURI + ? aBookmarkObj.uri + : Services.io.newURI(aBookmarkObj.uri); + lazy.PlacesUtils.tagging.tagURI(uri, aBookmarkObj.tags); + } + }, + + /** + * Waits for all pending async statements on the default connection. + * + * @return {Promise} + * @resolves When all pending async statements finished. + * @rejects Never. + * + * @note The result is achieved by asynchronously executing a query requiring + * a write lock. Since all statements on the same connection are + * serialized, the end of this write operation means that all writes are + * complete. Note that WAL makes so that writers don't block readers, but + * this is a problem only across different connections. + */ + promiseAsyncUpdates() { + return lazy.PlacesUtils.withConnectionWrapper( + "promiseAsyncUpdates", + async function (db) { + try { + await db.executeCached("BEGIN EXCLUSIVE"); + await db.executeCached("COMMIT"); + } catch (ex) { + // If we fail to start a transaction, it's because there is already one. + // In such a case we should not try to commit the existing transaction. + } + } + ); + }, + + /** + * Asynchronously checks if an address is found in the database. + * @param aURI + * nsIURI or address to look for. + * + * @return {Promise} + * @resolves Returns true if the page is found. + * @rejects JavaScript exception. + */ + async isPageInDB(aURI) { + return ( + (await this.getDatabaseValue("moz_places", "id", { url: aURI })) !== + undefined + ); + }, + + /** + * Asynchronously checks how many visits exist for a specified page. + * @param aURI + * nsIURI or address to look for. + * + * @return {Promise} + * @resolves Returns the number of visits found. + * @rejects JavaScript exception. + */ + async visitsInDB(aURI) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let db = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT count(*) FROM moz_historyvisits v + JOIN moz_places h ON h.id = v.place_id + WHERE url_hash = hash(:url) AND url = :url`, + { url } + ); + return rows[0].getResultByIndex(0); + }, + + /** + * Marks all syncable bookmarks as synced by setting their sync statuses to + * "NORMAL", resetting their change counters, and removing all tombstones. + * Used by tests to avoid calling `PlacesSyncUtils.bookmarks.pullChanges` + * and `PlacesSyncUtils.bookmarks.pushChanges`. + * + * @resolves When all bookmarks have been updated. + * @rejects JavaScript exception. + */ + markBookmarksAsSynced() { + return lazy.PlacesUtils.withConnectionWrapper( + "PlacesTestUtils: markBookmarksAsSynced", + function (db) { + return db.executeTransaction(async function () { + await db.executeCached( + `WITH RECURSIVE + syncedItems(id) AS ( + SELECT b.id FROM moz_bookmarks b + WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', + 'mobile______') + UNION ALL + SELECT b.id FROM moz_bookmarks b + JOIN syncedItems s ON b.parent = s.id + ) + UPDATE moz_bookmarks + SET syncChangeCounter = 0, + syncStatus = :syncStatus + WHERE id IN syncedItems`, + { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } + ); + await db.executeCached("DELETE FROM moz_bookmarks_deleted"); + }); + } + ); + }, + + /** + * Sets sync fields for multiple bookmarks. + * @param aStatusInfos + * One or more objects with the following properties: + * { [required] guid: The bookmark's GUID, + * syncStatus: An `nsINavBookmarksService::SYNC_STATUS_*` constant, + * syncChangeCounter: The sync change counter value, + * lastModified: The last modified time, + * dateAdded: The date added time. + * } + * + * @resolves When all bookmarks have been updated. + * @rejects JavaScript exception. + */ + setBookmarkSyncFields(...aFieldInfos) { + return lazy.PlacesUtils.withConnectionWrapper( + "PlacesTestUtils: setBookmarkSyncFields", + function (db) { + return db.executeTransaction(async function () { + for (let info of aFieldInfos) { + if (!lazy.PlacesUtils.isValidGuid(info.guid)) { + throw new Error(`Invalid GUID: ${info.guid}`); + } + await db.executeCached( + `UPDATE moz_bookmarks + SET syncStatus = IFNULL(:syncStatus, syncStatus), + syncChangeCounter = IFNULL(:syncChangeCounter, syncChangeCounter), + lastModified = IFNULL(:lastModified, lastModified), + dateAdded = IFNULL(:dateAdded, dateAdded) + WHERE guid = :guid`, + { + guid: info.guid, + syncChangeCounter: info.syncChangeCounter, + syncStatus: "syncStatus" in info ? info.syncStatus : null, + lastModified: + "lastModified" in info + ? lazy.PlacesUtils.toPRTime(info.lastModified) + : null, + dateAdded: + "dateAdded" in info + ? lazy.PlacesUtils.toPRTime(info.dateAdded) + : null, + } + ); + } + }); + } + ); + }, + + async fetchBookmarkSyncFields(...aGuids) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + let results = []; + for (let guid of aGuids) { + let rows = await db.executeCached( + ` + SELECT syncStatus, syncChangeCounter, lastModified, dateAdded + FROM moz_bookmarks + WHERE guid = :guid`, + { guid } + ); + if (!rows.length) { + throw new Error(`Bookmark ${guid} does not exist`); + } + results.push({ + guid, + syncStatus: rows[0].getResultByName("syncStatus"), + syncChangeCounter: rows[0].getResultByName("syncChangeCounter"), + lastModified: lazy.PlacesUtils.toDate( + rows[0].getResultByName("lastModified") + ), + dateAdded: lazy.PlacesUtils.toDate( + rows[0].getResultByName("dateAdded") + ), + }); + } + return results; + }, + + async fetchSyncTombstones() { + let db = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached(` + SELECT guid, dateRemoved + FROM moz_bookmarks_deleted + ORDER BY guid`); + return rows.map(row => ({ + guid: row.getResultByName("guid"), + dateRemoved: lazy.PlacesUtils.toDate(row.getResultByName("dateRemoved")), + })); + }, + + /** + * Returns a promise that waits until happening Places events specified by + * notification parameter. + * + * @param {string} notification + * Available values are: + * bookmark-added + * bookmark-removed + * bookmark-moved + * bookmark-guid_changed + * bookmark-keyword_changed + * bookmark-tags_changed + * bookmark-time_changed + * bookmark-title_changed + * bookmark-url_changed + * favicon-changed + * history-cleared + * page-removed + * page-title-changed + * page-visited + * pages-rank-changed + * purge-caches + * @param {Function} conditionFn [optional] + * If need some more condition to wait, please use conditionFn. + * This is an optional, but if set, should returns true when the wait + * condition is met. + * @return {Promise} + * A promise that resolved if the wait condition is met. + * The resolved value is an array of PlacesEvent object. + */ + waitForNotification(notification, conditionFn) { + return new Promise(resolve => { + function listener(events) { + if (!conditionFn || conditionFn(events)) { + PlacesObservers.removeListener([notification], listener); + resolve(events); + } + } + PlacesObservers.addListener([notification], listener); + }); + }, + + /** + * A debugging helper that dumps the contents of an SQLite table. + * + * @param {String} table + * The table name. + * @param {Sqlite.OpenedConnection} [db] + * The mirror database connection. + * @param {String[]} [columns] + * Clumns to be printed, defaults to all. + */ + async dumpTable({ table, db, columns }) { + if (!table) { + throw new Error("Must pass a `table` name"); + } + if (!db) { + db = await lazy.PlacesUtils.promiseDBConnection(); + } + if (!columns) { + columns = (await db.execute(`PRAGMA table_info('${table}')`)).map(r => + r.getResultByName("name") + ); + } + let results = [columns.join("\t")]; + + let rows = await db.execute(`SELECT ${columns.join()} FROM ${table}`); + dump(`>> Table ${table} contains ${rows.length} rows\n`); + + for (let row of rows) { + let numColumns = row.numEntries; + let rowValues = []; + for (let i = 0; i < numColumns; ++i) { + let value = "N/A"; + switch (row.getTypeOfIndex(i)) { + case Ci.mozIStorageValueArray.VALUE_TYPE_NULL: + value = "NULL"; + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER: + value = row.getInt64(i); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT: + value = row.getDouble(i); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT: + value = JSON.stringify(row.getString(i)); + break; + } + rowValues.push(value.toString().padStart(columns[i].length, " ")); + } + results.push(rowValues.join("\t")); + } + results.push("\n"); + dump(results.join("\n")); + }, + + /** + * Removes all stored metadata. + */ + clearMetadata() { + return lazy.PlacesUtils.withConnectionWrapper( + "PlacesTestUtils: clearMetadata", + async db => { + await db.execute(`DELETE FROM moz_meta`); + lazy.PlacesUtils.metadata.cache.clear(); + } + ); + }, + + /** + * Clear moz_inputhistory table. + */ + async clearInputHistory() { + await lazy.PlacesUtils.withConnectionWrapper( + "test:clearInputHistory", + db => { + return db.executeCached("DELETE FROM moz_inputhistory"); + } + ); + }, + + /** + * Clear moz_historyvisits table. + */ + async clearHistoryVisits() { + await lazy.PlacesUtils.withConnectionWrapper( + "test:clearHistoryVisits", + db => { + return db.executeCached("DELETE FROM moz_historyvisits"); + } + ); + }, + + /** + * Compares 2 place: URLs ignoring the order of their params. + * @param url1 First URL to compare + * @param url2 Second URL to compare + * @return whether the URLs are the same + */ + ComparePlacesURIs(url1, url2) { + url1 = url1 instanceof Ci.nsIURI ? url1.spec : new URL(url1); + if (url1.protocol != "place:") { + throw new Error("Expected a place: uri, got " + url1.href); + } + url2 = url2 instanceof Ci.nsIURI ? url2.spec : new URL(url2); + if (url2.protocol != "place:") { + throw new Error("Expected a place: uri, got " + url2.href); + } + let tokens1 = url1.pathname.split("&").sort().join("&"); + let tokens2 = url2.pathname.split("&").sort().join("&"); + if (tokens1 != tokens2) { + dump(`Failed comparison between:\n${tokens1}\n${tokens2}\n`); + return false; + } + return true; + }, + + /** + * Retrieves a single value from a specified field in a database table, based + * on the given conditions. + * @param {string} table - The name of the database table to query. + * @param {string} field - The name of the field to retrieve a value from. + * @param {Object} conditions - An object containing the conditions to filter + * the query results. The keys represent the names of the columns to filter + * by, and the values represent the filter values. + * @return {Promise} A Promise that resolves to the value of the specified + * field from the database table, or null if the query returns no results. + * @throws If more than one result is found for the given conditions. + */ + async getDatabaseValue(table, field, conditions) { + let { fragment: where, params } = this._buildWhereClause(table, conditions); + let query = `SELECT ${field} FROM ${table} ${where}`; + let conn = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await conn.executeCached(query, params); + if (rows.length > 1) { + throw new Error( + "getDatabaseValue doesn't support returning multiple results" + ); + } + return rows[0]?.getResultByName(field); + }, + + /** + * Updates specified fields in a database table, based on the given + * conditions. + * @param {string} table - The name of the database table to add to. + * @param {string} fields - an object with field, value pairs + * @param {Object} [conditions] - An object containing the conditions to filter + * the query results. The keys represent the names of the columns to filter + * by, and the values represent the filter values. + * @return {Promise} A Promise that resolves to the number of affected rows. + * @throws If no rows were affected. + */ + async updateDatabaseValues(table, fields, conditions = {}) { + let { fragment: where, params } = this._buildWhereClause(table, conditions); + let query = `UPDATE ${table} SET ${Object.keys(fields) + .map(f => f + " = :" + f) + .join()} ${where} RETURNING rowid`; + params = Object.assign(fields, params); + return lazy.PlacesUtils.withConnectionWrapper( + "setDatabaseValue", + async conn => { + let rows = await conn.executeCached(query, params); + if (!rows.length) { + throw new Error("setDatabaseValue didn't update any value"); + } + return rows.length; + } + ); + }, + + _buildWhereClause(table, conditions) { + let fragments = []; + let params = {}; + for (let [column, value] of Object.entries(conditions)) { + if (column == "url") { + if (value instanceof Ci.nsIURI) { + value = value.spec; + } else if (URL.isInstance(value)) { + value = value.href; + } + } + if (column == "url" && table == "moz_places") { + fragments.push("url_hash = hash(:url) AND url = :url"); + } else { + fragments.push(`${column} = :${column}`); + } + params[column] = value; + } + return { + fragment: fragments.length ? `WHERE ${fragments.join(" AND ")}` : "", + params, + }; + }, +}); diff --git a/toolkit/components/places/tests/bookmarks/bookmarks_long_tag.json b/toolkit/components/places/tests/bookmarks/bookmarks_long_tag.json new file mode 100644 index 0000000000..25fef61eb2 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/bookmarks_long_tag.json @@ -0,0 +1,55 @@ +{ + "guid": "root________", + "index": 0, + "id": 1, + "type": "text/x-moz-place-container", + "dateAdded": 1554906792778, + "lastModified": 1554906792778, + "root": "placesRoot", + "children": [ + { + "guid": "unfiled_____", + "index": 0, + "id": 2, + "type": "text/x-moz-place-container", + "dateAdded": 1554906792778, + "lastModified": 1554906792778, + "root": "unfiledBookmarksFolder", + "children": [ + { + "guid": "___guid1____", + "index": 0, + "id": 3, + "charset": "UTF-16", + "tags": "tag0", + "type": "text/x-moz-place", + "dateAdded": 1554906792778, + "lastModified": 1554906792778, + "uri": "http://test0.com/" + }, + { + "guid": "___guid2____", + "index": 1, + "id": 4, + "charset": "UTF-16", + "tags": "tag1,a0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789", + "type": "text/x-moz-place", + "dateAdded": 1554906792778, + "lastModified": 1554906792778, + "uri": "http://test1.com/" + }, + { + "guid": "___guid3____", + "index": 2, + "id": 5, + "charset": "UTF-16", + "tags": "tag2", + "type": "text/x-moz-place", + "dateAdded": 1554906792778, + "lastModified": 1554906792778, + "uri": "http://test2.com/" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/bookmarks/head_bookmarks.js b/toolkit/components/places/tests/bookmarks/head_bookmarks.js new file mode 100644 index 0000000000..e306b4080d --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/head_bookmarks.js @@ -0,0 +1,150 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +function expectPlacesObserverNotifications( + types, + checkAllArgs = true, + skipDescendants = false +) { + let notifications = []; + let listener = events => { + for (let event of events) { + switch (event.type) { + case "bookmark-added": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + parentId: event.parentId, + index: event.index, + url: event.url || undefined, + title: event.title, + dateAdded: new Date(event.dateAdded), + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }); + break; + case "bookmark-removed": + if ( + !( + skipDescendants && + event.isDescendantRemoval && + !PlacesUtils.bookmarks.userContentRoots.includes(event.parentGuid) + ) + ) { + if (checkAllArgs) { + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + parentId: event.parentId, + index: event.index, + url: event.url || null, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }); + } else { + notifications.push({ + type: event.type, + guid: event.guid, + }); + } + } + break; + case "bookmark-moved": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + index: event.index, + oldParentGuid: event.oldParentGuid, + oldIndex: event.oldIndex, + isTagging: event.isTagging, + }); + break; + case "bookmark-tags-changed": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + tags: event.tags, + lastModified: new Date(event.lastModified), + source: event.source, + isTagging: event.isTagging, + }); + break; + case "bookmark-time-changed": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + dateAdded: new Date(event.dateAdded), + lastModified: new Date(event.lastModified), + source: event.source, + isTagging: event.isTagging, + }); + break; + case "bookmark-title-changed": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + title: event.title, + lastModified: new Date(event.lastModified), + source: event.source, + isTagging: event.isTagging, + }); + break; + case "bookmark-url-changed": + notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + lastModified: new Date(event.lastModified), + }); + break; + } + } + }; + PlacesUtils.observers.addListener(types, listener); + return { + check(expectedNotifications) { + PlacesUtils.observers.removeListener(types, listener); + Assert.deepEqual(notifications, expectedNotifications); + }, + }; +} diff --git a/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js b/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js new file mode 100644 index 0000000000..3f74430296 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js @@ -0,0 +1,119 @@ +/* Bug 1016953 - When a previous bookmark backup exists with the same hash +regardless of date, an automatic backup should attempt to either rename it to +today's date if the backup was for an old date or leave it alone if it was for +the same date. However if the file ext was json it will accidentally rename it +to jsonlz4 while keeping the json contents +*/ + +add_task(async function test_same_date_same_hash() { + // If old file has been created on the same date and has the same hash + // the file should be left alone + let backupFolder = await PlacesBackups.getBackupFolder(); + // Save to profile dir to obtain hash and nodeCount to append to filename + let tempPath = PathUtils.join( + PathUtils.profileDir, + "bug10169583_bookmarks.json" + ); + let { count, hash } = await BookmarkJSONUtils.exportToFile(tempPath); + + // Save JSON file in backup folder with hash appended + let dateObj = new Date(); + let filename = + "bookmarks-" + + PlacesBackups.toISODateString(dateObj) + + "_" + + count + + "_" + + hash + + ".json"; + let backupFile = PathUtils.join(backupFolder, filename); + await IOUtils.move(tempPath, backupFile); + + // Force a compressed backup which fallbacks to rename + await PlacesBackups.create(); + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + // check to ensure not renamed to jsonlz4 + Assert.equal(mostRecentBackupFile, backupFile); + // inspect contents and check if valid json + info("Check is valid JSON"); + // We initially wrote an uncompressed file, and although a backup was triggered + // it did not rewrite the file, so this is uncompressed. + await IOUtils.readJSON(mostRecentBackupFile); + + // Cleanup + await IOUtils.remove(backupFile); + await IOUtils.remove(tempPath); + PlacesBackups._backupFiles = null; // To force re-cache of backupFiles +}); + +add_task(async function test_same_date_diff_hash() { + // If the old file has been created on the same date, but has a different hash + // the existing file should be overwritten with the newer compressed version + let backupFolder = await PlacesBackups.getBackupFolder(); + let tempPath = PathUtils.join( + PathUtils.profileDir, + "bug10169583_bookmarks.json" + ); + let { count } = await BookmarkJSONUtils.exportToFile(tempPath); + let dateObj = new Date(); + let filename = + "bookmarks-" + + PlacesBackups.toISODateString(dateObj) + + "_" + + count + + "_differentHash==.json"; + let backupFile = PathUtils.join(backupFolder, filename); + await IOUtils.move(tempPath, backupFile); + await PlacesBackups.create(); // Force compressed backup + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + + // Decode lz4 compressed file to json and check if json is valid + info("Check is valid JSON"); + await IOUtils.readJSON(mostRecentBackupFile, { decompress: true }); + + // Cleanup + await IOUtils.remove(mostRecentBackupFile); + await IOUtils.remove(tempPath); + PlacesBackups._backupFiles = null; // To force re-cache of backupFiles +}); + +add_task(async function test_diff_date_same_hash() { + // If the old file has been created on an older day but has the same hash + // it should be renamed with today's date without altering the contents. + let backupFolder = await PlacesBackups.getBackupFolder(); + let tempPath = PathUtils.join( + PathUtils.profileDir, + "bug10169583_bookmarks.json" + ); + let { count, hash } = await BookmarkJSONUtils.exportToFile(tempPath); + let oldDate = new Date(2014, 1, 1); + let curDate = new Date(); + let oldFilename = + "bookmarks-" + + PlacesBackups.toISODateString(oldDate) + + "_" + + count + + "_" + + hash + + ".json"; + let newFilename = + "bookmarks-" + + PlacesBackups.toISODateString(curDate) + + "_" + + count + + "_" + + hash + + ".json"; + let backupFile = PathUtils.join(backupFolder, oldFilename); + let newBackupFile = PathUtils.join(backupFolder, newFilename); + await IOUtils.move(tempPath, backupFile); + + // Ensure file has been renamed correctly + await PlacesBackups.create(); + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + Assert.equal(mostRecentBackupFile, newBackupFile); + + // Cleanup + await IOUtils.remove(mostRecentBackupFile); + await IOUtils.remove(tempPath); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js b/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js new file mode 100644 index 0000000000..47955a4ea4 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js @@ -0,0 +1,117 @@ +/* -*- tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* Bug 1017502 - Add a foreign_count column to moz_places +This tests, tests the triggers that adjust the foreign_count when a bookmark is +added or removed and also the maintenance task to fix wrong counts. +*/ + +const T_URI = Services.io.newURI( + "https://www.mozilla.org/firefox/nightly/firstrun/" +); + +async function getForeignCountForURL(conn, url) { + await PlacesTestUtils.promiseAsyncUpdates(); + url = url instanceof Ci.nsIURI ? url.spec : url; + let rows = await conn.executeCached( + `SELECT foreign_count FROM moz_places WHERE url_hash = hash(:t_url) + AND url = :t_url`, + { t_url: url } + ); + return rows[0].getResultByName("foreign_count"); +} + +add_task(async function add_remove_change_bookmark_test() { + let conn = await PlacesUtils.promiseDBConnection(); + + // Simulate a visit to the url + await PlacesTestUtils.addVisits(T_URI); + Assert.equal(await getForeignCountForURL(conn, T_URI), 0); + + // Add 1st bookmark which should increment foreign_count by 1 + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "First Run", + url: T_URI, + }); + Assert.equal(await getForeignCountForURL(conn, T_URI), 1); + + // Add 2nd bookmark + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "First Run", + url: T_URI, + }); + Assert.equal(await getForeignCountForURL(conn, T_URI), 2); + + // Remove 2nd bookmark which should decrement foreign_count by 1 + await PlacesUtils.bookmarks.remove(bm2); + Assert.equal(await getForeignCountForURL(conn, T_URI), 1); + + // Change first bookmark's URI + const URI2 = Services.io.newURI("http://www.mozilla.org"); + bm1.url = URI2; + bm1 = await PlacesUtils.bookmarks.update(bm1); + // Check foreign count for original URI + Assert.equal(await getForeignCountForURL(conn, T_URI), 0); + // Check foreign count for new URI + Assert.equal(await getForeignCountForURL(conn, URI2), 1); + + // Cleanup - Remove changed bookmark + await PlacesUtils.bookmarks.remove(bm1); + Assert.equal(await getForeignCountForURL(conn, URI2), 0); +}); + +add_task(async function maintenance_foreign_count_test() { + let conn = await PlacesUtils.promiseDBConnection(); + + // Simulate a visit to the url + await PlacesTestUtils.addVisits(T_URI); + + // Adjust the foreign_count for the added entry to an incorrect value + await new Promise(resolve => { + let stmt = DBConn().createAsyncStatement( + `UPDATE moz_places SET foreign_count = 10 WHERE url_hash = hash(:t_url) + AND url = :t_url ` + ); + stmt.params.t_url = T_URI.spec; + stmt.executeAsync({ + handleCompletion() { + resolve(); + }, + }); + stmt.finalize(); + }); + Assert.equal(await getForeignCountForURL(conn, T_URI), 10); + + // Run maintenance + const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" + ); + await PlacesDBUtils.maintenanceOnIdle(); + + // Check if the foreign_count has been adjusted to the correct value + Assert.equal(await getForeignCountForURL(conn, T_URI), 0); +}); + +add_task(async function add_remove_tags_test() { + let conn = await PlacesUtils.promiseDBConnection(); + + await PlacesTestUtils.addVisits(T_URI); + Assert.equal(await getForeignCountForURL(conn, T_URI), 0); + + // Check foreign count incremented by 1 for a single tag + PlacesUtils.tagging.tagURI(T_URI, ["test tag"]); + Assert.equal(await getForeignCountForURL(conn, T_URI), 1); + + // Check foreign count is incremented by 2 for two tags + PlacesUtils.tagging.tagURI(T_URI, ["one", "two"]); + Assert.equal(await getForeignCountForURL(conn, T_URI), 3); + + // Check foreign count is set to 0 when all tags are removed + PlacesUtils.tagging.untagURI(T_URI, ["test tag", "one", "two"]); + Assert.equal(await getForeignCountForURL(conn, T_URI), 0); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_1129529.js b/toolkit/components/places/tests/bookmarks/test_1129529.js new file mode 100644 index 0000000000..8fa4731823 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_1129529.js @@ -0,0 +1,24 @@ +// Test that importing bookmark data where a bookmark has a tag longer than 100 +// chars imports everything except the tags for that bookmark. +add_task(async function () { + let bookmarksFile = PathUtils.join( + do_get_cwd().path, + "bookmarks_long_tag.json" + ); + let bookmarksUrl = PathUtils.toFileURI(bookmarksFile); + + await BookmarkJSONUtils.importFromURL(bookmarksUrl); + + let [bookmarks] = await PlacesBackups.getBookmarksTree(); + let unsortedBookmarks = bookmarks.children[2].children; + Assert.equal(unsortedBookmarks.length, 3); + + for (let i = 0; i < unsortedBookmarks.length; ++i) { + let bookmark = unsortedBookmarks[i]; + Assert.equal(bookmark.charset, "UTF-16"); + Assert.equal(bookmark.dateAdded, 1554906792000); + Assert.equal(bookmark.lastModified, 1554906792000); + Assert.equal(bookmark.uri, `http://test${i}.com/`); + Assert.equal(bookmark.tags, `tag${i}`); + } +}); diff --git a/toolkit/components/places/tests/bookmarks/test_384228.js b/toolkit/components/places/tests/bookmarks/test_384228.js new file mode 100644 index 0000000000..0db46353b7 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_384228.js @@ -0,0 +1,93 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * test querying for bookmarks in multiple folders. + */ +add_task(async function search_bookmark_in_folder() { + let testFolder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bug 384228 test folder 1", + }); + Assert.equal(testFolder1.index, 0); + + let testFolder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bug 384228 test folder 2", + }); + Assert.equal(testFolder2.index, 1); + + let testFolder3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bug 384228 test folder 3", + }); + Assert.equal(testFolder3.index, 2); + + let b1 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder1.guid, + url: "http://foo.tld/", + title: "title b1 (folder 1)", + }); + Assert.equal(b1.index, 0); + + let b2 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder1.guid, + url: "http://foo.tld/", + title: "title b2 (folder 1)", + }); + Assert.equal(b2.index, 1); + + let b3 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder2.guid, + url: "http://foo.tld/", + title: "title b3 (folder 2)", + }); + Assert.equal(b3.index, 0); + + let b4 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder3.guid, + url: "http://foo.tld/", + title: "title b4 (folder 3)", + }); + Assert.equal(b4.index, 0); + + // also test recursive search + let testFolder1_1 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder1.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bug 384228 test folder 1.1", + }); + Assert.equal(testFolder1_1.index, 2); + + let b5 = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder1_1.guid, + url: "http://foo.tld/", + title: "title b5 (folder 1.1)", + }); + Assert.equal(b5.index, 0); + + // query folder 1, folder 2 and get 4 bookmarks + let hs = PlacesUtils.history; + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + query.searchTerms = "title"; + options.queryType = options.QUERY_TYPE_BOOKMARKS; + query.setParents([testFolder1.guid, testFolder2.guid]); + let rootNode = hs.executeQuery(query, options).root; + rootNode.containerOpen = true; + + // should not match item from folder 3 + Assert.equal(rootNode.childCount, 4); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid); + Assert.equal(rootNode.getChild(3).bookmarkGuid, b5.guid); + + rootNode.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/bookmarks/test_385829.js b/toolkit/components/places/tests/bookmarks/test_385829.js new file mode 100644 index 0000000000..9de7c6da17 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_385829.js @@ -0,0 +1,180 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function search_bookmark_by_lastModified_dateDated() { + // test search on folder with various sorts and max results + // see bug #385829 for more details + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bug 385829 test", + }); + + let now = new Date(); + // ensure some unique values for date added and last modified + // for date added: b1 < b2 < b3 < b4 + // for last modified: b1 > b2 > b3 > b4 + let b1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://a1.com/", + title: "1 title", + dateAdded: new Date(now.getTime() + 1000), + }); + let b2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://a2.com/", + title: "2 title", + dateAdded: new Date(now.getTime() + 2000), + }); + let b3 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://a3.com/", + title: "3 title", + dateAdded: new Date(now.getTime() + 3000), + }); + let b4 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://a4.com/", + title: "4 title", + dateAdded: new Date(now.getTime() + 4000), + }); + + // make sure lastModified is larger than dateAdded + let modifiedTime = new Date(now.getTime() + 5000); + await PlacesUtils.bookmarks.update({ + guid: b1.guid, + lastModified: new Date(modifiedTime.getTime() + 4000), + }); + await PlacesUtils.bookmarks.update({ + guid: b2.guid, + lastModified: new Date(modifiedTime.getTime() + 3000), + }); + await PlacesUtils.bookmarks.update({ + guid: b3.guid, + lastModified: new Date(modifiedTime.getTime() + 2000), + }); + await PlacesUtils.bookmarks.update({ + guid: b4.guid, + lastModified: new Date(modifiedTime.getTime() + 1000), + }); + + let hs = PlacesUtils.history; + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + options.maxResults = 3; + query.setParents([folder.guid]); + + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + + // test SORT_BY_DATEADDED_ASCENDING (live update) + result.sortingMode = options.SORT_BY_DATEADDED_ASCENDING; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid); + Assert.ok(rootNode.getChild(0).dateAdded < rootNode.getChild(1).dateAdded); + Assert.ok(rootNode.getChild(1).dateAdded < rootNode.getChild(2).dateAdded); + + // test SORT_BY_DATEADDED_DESCENDING (live update) + result.sortingMode = options.SORT_BY_DATEADDED_DESCENDING; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b3.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b1.guid); + Assert.ok(rootNode.getChild(0).dateAdded > rootNode.getChild(1).dateAdded); + Assert.ok(rootNode.getChild(1).dateAdded > rootNode.getChild(2).dateAdded); + + // test SORT_BY_LASTMODIFIED_ASCENDING (live update) + result.sortingMode = options.SORT_BY_LASTMODIFIED_ASCENDING; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b3.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b1.guid); + Assert.ok( + rootNode.getChild(0).lastModified < rootNode.getChild(1).lastModified + ); + Assert.ok( + rootNode.getChild(1).lastModified < rootNode.getChild(2).lastModified + ); + + // test SORT_BY_LASTMODIFIED_DESCENDING (live update) + result.sortingMode = options.SORT_BY_LASTMODIFIED_DESCENDING; + + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid); + Assert.ok( + rootNode.getChild(0).lastModified > rootNode.getChild(1).lastModified + ); + Assert.ok( + rootNode.getChild(1).lastModified > rootNode.getChild(2).lastModified + ); + rootNode.containerOpen = false; + + // test SORT_BY_DATEADDED_ASCENDING + options.sortingMode = options.SORT_BY_DATEADDED_ASCENDING; + result = hs.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid); + Assert.ok(rootNode.getChild(0).dateAdded < rootNode.getChild(1).dateAdded); + Assert.ok(rootNode.getChild(1).dateAdded < rootNode.getChild(2).dateAdded); + rootNode.containerOpen = false; + + // test SORT_BY_DATEADDED_DESCENDING + options.sortingMode = options.SORT_BY_DATEADDED_DESCENDING; + result = hs.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b4.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b3.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b2.guid); + Assert.ok(rootNode.getChild(0).dateAdded > rootNode.getChild(1).dateAdded); + Assert.ok(rootNode.getChild(1).dateAdded > rootNode.getChild(2).dateAdded); + rootNode.containerOpen = false; + + // test SORT_BY_LASTMODIFIED_ASCENDING + options.sortingMode = options.SORT_BY_LASTMODIFIED_ASCENDING; + result = hs.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b4.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b3.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b2.guid); + Assert.ok( + rootNode.getChild(0).lastModified < rootNode.getChild(1).lastModified + ); + Assert.ok( + rootNode.getChild(1).lastModified < rootNode.getChild(2).lastModified + ); + rootNode.containerOpen = false; + + // test SORT_BY_LASTMODIFIED_DESCENDING + options.sortingMode = options.SORT_BY_LASTMODIFIED_DESCENDING; + result = hs.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 3); + Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid); + Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid); + Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid); + Assert.ok( + rootNode.getChild(0).lastModified > rootNode.getChild(1).lastModified + ); + Assert.ok( + rootNode.getChild(1).lastModified > rootNode.getChild(2).lastModified + ); + rootNode.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/bookmarks/test_388695.js b/toolkit/components/places/tests/bookmarks/test_388695.js new file mode 100644 index 0000000000..337d8176bd --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_388695.js @@ -0,0 +1,45 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get bookmark service +let bm = PlacesUtils.bookmarks; + +// Test that Bookmarks fetch properly orders its results based on +// the last modified value. Note we cannot rely on dateAdded due to +// the low PR_Now() resolution. + +add_task(async function sort_bookmark_by_relevance() { + let now = new Date(); + let modifiedTime = new Date(now.setHours(now.getHours() - 2)); + + let url = "http://foo.tld.com/"; + let parentGuid = ( + await bm.insert({ + type: bm.TYPE_FOLDER, + title: "test folder", + parentGuid: bm.unfiledGuid, + }) + ).guid; + let item1Guid = (await bm.insert({ url, parentGuid })).guid; + let item2Guid = ( + await bm.insert({ + url, + parentGuid, + dateAdded: modifiedTime, + lastModified: modifiedTime, + }) + ).guid; + let bms = []; + await bm.fetch({ url }, bm1 => bms.push(bm1)); + Assert.equal(bms[0].guid, item1Guid); + Assert.equal(bms[1].guid, item2Guid); + await bm.update({ guid: item2Guid, title: "modified" }); + + let bms1 = []; + await bm.fetch({ url }, bm2 => bms1.push(bm2)); + Assert.equal(bms1[0].guid, item2Guid); + Assert.equal(bms1[1].guid, item1Guid); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_393498.js b/toolkit/components/places/tests/bookmarks/test_393498.js new file mode 100644 index 0000000000..ab7cf57f61 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_393498.js @@ -0,0 +1,161 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var observer = { + handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "bookmark-added": { + this._itemAddedId = event.id; + this._itemAddedParent = event.parentId; + this._itemAddedIndex = event.index; + break; + } + case "bookmark-time-changed": { + this._itemTimeChangedGuid = event.guid; + this._itemTimeChangedDateAdded = event.dateAdded; + this._itemTimeChangedLastModified = event.lastModified; + break; + } + case "bookmark-title-changed": { + this._itemTitleChangedId = event.id; + this._itemTitleChangedTitle = event.title; + break; + } + } + } + }, +}; + +observer.handlePlacesEvents = observer.handlePlacesEvents.bind(observer); +PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-time-changed", "bookmark-title-changed"], + observer.handlePlacesEvents +); + +registerCleanupFunction(function () { + PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-time-changed", "bookmark-title-changed"], + observer.handlePlacesEvents + ); +}); + +// Returns do_check_eq with .getTime() added onto parameters +function do_check_date_eq(t1, t2) { + return Assert.equal(t1.getTime(), t2.getTime()); +} + +add_task(async function test_bookmark_update_notifications() { + // We set times in the past to workaround a timing bug due to virtual + // machines and the skew between PR_Now() and Date.now(), see bug 427142 and + // bug 858377 for details. + const PAST_DATE = new Date(Date.now() - 86400000); + + // Insert a new bookmark. + let testFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "test Folder", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: testFolder.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://google.com/", + title: "a bookmark", + }); + + // Sanity check. + Assert.ok(observer.itemChangedProperty === undefined); + + // Set dateAdded in the past and verify the changes. + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + dateAdded: PAST_DATE, + }); + + Assert.equal(observer._itemTimeChangedGuid, bookmark.guid); + Assert.equal(observer._itemTimeChangedDateAdded, PAST_DATE.getTime()); + + // After just inserting, modified should be the same as dateAdded. + do_check_date_eq(bookmark.lastModified, bookmark.dateAdded); + + let updatedBookmark = await PlacesUtils.bookmarks.fetch({ + guid: bookmark.guid, + }); + + do_check_date_eq(updatedBookmark.dateAdded, PAST_DATE); + + // Set lastModified in the past and verify the changes. + updatedBookmark = await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + lastModified: PAST_DATE, + }); + + Assert.equal(observer._itemTimeChangedGuid, bookmark.guid); + Assert.equal(observer._itemTimeChangedLastModified, PAST_DATE.getTime()); + do_check_date_eq(updatedBookmark.lastModified, PAST_DATE); + + // Set bookmark title + updatedBookmark = await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + title: "Google", + }); + + // Test notifications. + Assert.equal( + observer._itemTitleChangedId, + await PlacesUtils.promiseItemId(bookmark.guid) + ); + Assert.equal(observer._itemTitleChangedTitle, "Google"); + + // Check lastModified has been updated. + Assert.ok(is_time_ordered(PAST_DATE, updatedBookmark.lastModified.getTime())); + + // Check that node properties are updated. + let root = PlacesUtils.getFolderContents(testFolder.guid).root; + Assert.equal(root.childCount, 1); + let childNode = root.getChild(0); + + // confirm current dates match node properties + Assert.equal( + PlacesUtils.toPRTime(updatedBookmark.dateAdded), + childNode.dateAdded + ); + Assert.equal( + PlacesUtils.toPRTime(updatedBookmark.lastModified), + childNode.lastModified + ); + + // Test live update of lastModified when setting title. + updatedBookmark = await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + lastModified: PAST_DATE, + title: "Google", + }); + + // Check lastModified has been updated. + Assert.ok(is_time_ordered(PAST_DATE, childNode.lastModified)); + // Test that node value matches db value. + Assert.equal( + PlacesUtils.toPRTime(updatedBookmark.lastModified), + childNode.lastModified + ); + + // Test live update of the exposed date apis. + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + dateAdded: PAST_DATE, + }); + Assert.equal(childNode.dateAdded, PlacesUtils.toPRTime(PAST_DATE)); + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + lastModified: PAST_DATE, + }); + Assert.equal(childNode.lastModified, PlacesUtils.toPRTime(PAST_DATE)); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js b/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js new file mode 100644 index 0000000000..c893f1db3f --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js @@ -0,0 +1,253 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var tests = []; + +/* + +Backup/restore tests example: + +var myTest = { + populate: function () { ... add bookmarks ... }, + validate: function () { ... query for your bookmarks ... } +} + +this.push(myTest); + +*/ + +/* + +test summary: +- create folders with content +- create a query bookmark for those folders +- backs up bookmarks +- restores bookmarks +- confirms that the query has the new ids for the same folders + +scenarios: +- 1 folder (folder shortcut) +- n folders (single query) +- n folders (multiple queries) + +*/ + +var test = { + _testRootId: null, + _testRootTitle: "test root", + _folderGuids: [], + _bookmarkURIs: [], + _count: 3, + _extraBookmarksCount: 10, + + populate: async function populate() { + // folder to hold this test + await PlacesUtils.bookmarks.eraseEverything(); + + let testFolderItems = []; + // Set a date 60 seconds ago, so that we can set newer bookmarks later. + let dateAdded = new Date(new Date() - 60000); + + // create test folders each with a bookmark + for (let i = 0; i < this._count; i++) { + this._folderGuids.push(PlacesUtils.history.makeGuid()); + testFolderItems.push({ + guid: this._folderGuids[i], + title: `folder${i}`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded, + children: [ + { + dateAdded, + url: `http://${i}`, + title: `bookmark${i}`, + }, + ], + }); + } + + let bookmarksTree = { + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + dateAdded, + title: this._testRootTitle, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: testFolderItems, + }, + ], + }; + + let insertedBookmarks = await PlacesUtils.bookmarks.insertTree( + bookmarksTree + ); + + // create a query URI with 1 folder (ie: folder shortcut) + this._queryURI1 = `place:parent=${this._folderGuids[0]}&queryType=1`; + this._queryTitle1 = "query1"; + await PlacesUtils.bookmarks.insert({ + parentGuid: insertedBookmarks[0].guid, + dateAdded, + url: this._queryURI1, + title: this._queryTitle1, + }); + + // create a query URI with _count folders + this._queryURI2 = `place:parent=${this._folderGuids.join( + "&parent=" + )}&queryType=1`; + this._queryTitle2 = "query2"; + await PlacesUtils.bookmarks.insert({ + parentGuid: insertedBookmarks[0].guid, + dateAdded, + url: this._queryURI2, + title: this._queryTitle2, + }); + + // Create a query URI for most recent bookmarks with NO folders specified. + this._queryURI3 = + "place:queryType=1&sort=12&maxResults=10&excludeQueries=1"; + this._queryTitle3 = "query3"; + await PlacesUtils.bookmarks.insert({ + parentGuid: insertedBookmarks[0].guid, + dateAdded, + url: this._queryURI3, + title: this._queryTitle3, + }); + }, + + clean() {}, + + validate: async function validate(addExtras) { + if (addExtras) { + // Throw a wrench in the works by inserting some new bookmarks, + // ensuring folder ids won't be the same, when restoring. + let date = new Date() - this._extraBookmarksCount * 1000; + for (let i = 0; i < this._extraBookmarksCount; i++) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri("http://aaaa" + i), + dateAdded: new Date(date + (this._extraBookmarksCount - i) * 1000), + }); + } + } + + var toolbar = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid, + false, + true + ).root; + Assert.equal(toolbar.childCount, 1); + + var folderNode = toolbar.getChild(0); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER); + Assert.equal(folderNode.title, this._testRootTitle); + folderNode.QueryInterface(Ci.nsINavHistoryQueryResultNode); + folderNode.containerOpen = true; + + // |_count| folders + the query nodes + Assert.equal(folderNode.childCount, this._count + 3); + + for (let i = 0; i < this._count; i++) { + var subFolder = folderNode.getChild(i); + Assert.equal(subFolder.title, "folder" + i); + subFolder.QueryInterface(Ci.nsINavHistoryContainerResultNode); + subFolder.containerOpen = true; + Assert.equal(subFolder.childCount, 1); + var child = subFolder.getChild(0); + Assert.equal(child.title, "bookmark" + i); + Assert.ok(uri(child.uri).equals(uri("http://" + i))); + } + + // validate folder shortcut + this.validateQueryNode1(folderNode.getChild(this._count)); + + // validate folders query + this.validateQueryNode2(folderNode.getChild(this._count + 1)); + + // validate recent folders query + this.validateQueryNode3(folderNode.getChild(this._count + 2)); + + // clean up + folderNode.containerOpen = false; + toolbar.containerOpen = false; + }, + + validateQueryNode1: function validateQueryNode1(aNode) { + Assert.equal(aNode.title, this._queryTitle1); + Assert.ok(PlacesUtils.nodeIsFolder(aNode)); + + aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + aNode.containerOpen = true; + Assert.equal(aNode.childCount, 1); + var child = aNode.getChild(0); + Assert.ok(uri(child.uri).equals(uri("http://0"))); + Assert.equal(child.title, "bookmark0"); + aNode.containerOpen = false; + }, + + validateQueryNode2: function validateQueryNode2(aNode) { + Assert.equal(aNode.title, this._queryTitle2); + Assert.ok(PlacesUtils.nodeIsQuery(aNode)); + + aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + aNode.containerOpen = true; + Assert.equal(aNode.childCount, this._count); + for (var i = 0; i < aNode.childCount; i++) { + var child = aNode.getChild(i); + Assert.ok(uri(child.uri).equals(uri("http://" + i))); + Assert.equal(child.title, "bookmark" + i); + } + aNode.containerOpen = false; + }, + + validateQueryNode3(aNode) { + Assert.equal(aNode.title, this._queryTitle3); + Assert.ok(PlacesUtils.nodeIsQuery(aNode)); + + aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + aNode.containerOpen = true; + // The query will list the extra bookmarks added at the start of validate. + Assert.equal(aNode.childCount, this._extraBookmarksCount); + for (var i = 0; i < aNode.childCount; i++) { + var child = aNode.getChild(i); + Assert.equal(child.uri, `http://aaaa${i}/`); + } + aNode.containerOpen = false; + }, +}; +tests.push(test); + +add_task(async function () { + // make json file + let jsonFile = PathUtils.join(PathUtils.profileDir, "bookmarks.json"); + + // populate db + for (let singleTest of tests) { + await singleTest.populate(); + // sanity + await singleTest.validate(true); + } + + // export json to file + await BookmarkJSONUtils.exportToFile(jsonFile); + + // clean + for (let singleTest of tests) { + singleTest.clean(); + } + + // restore json file + await BookmarkJSONUtils.importFromFile(jsonFile, { replace: true }); + + // validate + for (let singleTest of tests) { + await singleTest.validate(false); + } + + // clean up + await IOUtils.remove(jsonFile); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js b/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js new file mode 100644 index 0000000000..2a7ce3f003 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js @@ -0,0 +1,48 @@ +/* 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 FOLDER_TITLE = '"quoted folder"'; + +function checkQuotedFolder() { + let toolbar = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + + // test for our quoted folder + Assert.equal(toolbar.childCount, 1); + var folderNode = toolbar.getChild(0); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER); + Assert.equal(folderNode.title, FOLDER_TITLE); + + // clean up + toolbar.containerOpen = false; +} + +add_task(async function () { + // make json file + let jsonFile = PathUtils.join(PathUtils.profileDir, "bookmarks.json"); + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: FOLDER_TITLE, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + checkQuotedFolder(); + + // export json to file + await BookmarkJSONUtils.exportToFile(jsonFile); + + await PlacesUtils.bookmarks.remove(folder.guid); + + // restore json file + await BookmarkJSONUtils.importFromFile(jsonFile, { replace: true }); + + checkQuotedFolder(); + + // clean up + await IOUtils.remove(jsonFile); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_448584.js b/toolkit/components/places/tests/bookmarks/test_448584.js new file mode 100644 index 0000000000..07665327b1 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_448584.js @@ -0,0 +1,90 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get database connection +try { + var mDBConn = PlacesUtils.history.DBConnection; +} catch (ex) { + do_throw("Could not get database connection\n"); +} + +/* + This test is: + - don't try to add invalid uri nodes to a JSON backup +*/ + +const ITEM_TITLE = "invalid uri"; +const ITEM_URL = "http://test.mozilla.org"; + +function validateResults(expectedValidItemsCount) { + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = PlacesUtils.history.getNewQueryOptions(); + var result = PlacesUtils.history.executeQuery(query, options); + + var toolbar = result.root; + toolbar.containerOpen = true; + + // test for our bookmark + Assert.equal(toolbar.childCount, expectedValidItemsCount); + for (var i = 0; i < toolbar.childCount; i++) { + var folderNode = toolbar.getChild(0); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_URI); + Assert.equal(folderNode.title, ITEM_TITLE); + } + + // clean up + toolbar.containerOpen = false; +} + +add_task(async function () { + // make json file + let jsonFile = PathUtils.join(PathUtils.profileDir, "bookmarks.json"); + + // populate db + // add a valid bookmark + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: ITEM_TITLE, + url: ITEM_URL, + }); + + let badBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: ITEM_TITLE, + url: ITEM_URL, + }); + // sanity + validateResults(2); + // Something in the code went wrong and we finish up losing the place, so + // the bookmark uri becomes null. + var sql = "UPDATE moz_bookmarks SET fk = 1337 WHERE guid = ?1"; + var stmt = mDBConn.createStatement(sql); + stmt.bindByIndex(0, badBookmark.guid); + try { + stmt.execute(); + } finally { + stmt.finalize(); + } + + await BookmarkJSONUtils.exportToFile(jsonFile); + + // clean + await PlacesUtils.bookmarks.remove(badBookmark); + + // restore json file + try { + await BookmarkJSONUtils.importFromFile(jsonFile, { replace: true }); + } catch (ex) { + do_throw("couldn't import the exported file: " + ex); + } + + // validate + validateResults(1); + + // clean up + await IOUtils.remove(jsonFile); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_458683.js b/toolkit/components/places/tests/bookmarks/test_458683.js new file mode 100644 index 0000000000..d31fca66e1 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_458683.js @@ -0,0 +1,111 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + This test is: + - don't block while doing backup and restore if tag containers contain + bogus items (separators, folders) +*/ + +const ITEM_TITLE = "invalid uri"; +const ITEM_URL = "http://test.mozilla.org/"; +const TAG_NAME = "testTag"; + +function validateResults() { + let toolbar = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + // test for our bookmark + Assert.equal(toolbar.childCount, 1); + for (var i = 0; i < toolbar.childCount; i++) { + var folderNode = toolbar.getChild(0); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_URI); + Assert.equal(folderNode.title, ITEM_TITLE); + } + toolbar.containerOpen = false; + + // test for our tag + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(ITEM_URL)); + Assert.equal(tags.length, 1); + Assert.equal(tags[0], TAG_NAME); +} + +add_task(async function () { + let jsonFile = PathUtils.join(PathUtils.profileDir, "bookmarks.json"); + + // add a valid bookmark + let item = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: ITEM_TITLE, + url: ITEM_URL, + }); + + // create a tag + PlacesUtils.tagging.tagURI(Services.io.newURI(ITEM_URL), [TAG_NAME]); + // get tag folder id + let tagRoot = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.tagsGuid + ).root; + Assert.equal(tagRoot.childCount, 1); + let tagItemGuid = PlacesUtils.asContainer(tagRoot.getChild(0)).bookmarkGuid; + tagRoot.containerOpen = false; + + function insert({ type, parentGuid }) { + return PlacesUtils.withConnectionWrapper( + "test_458683: insert", + async db => { + await db.executeCached( + `INSERT INTO moz_bookmarks (type, parent, position, guid) + VALUES (:type, + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)), + GENERATE_GUID())`, + { type, parentGuid } + ); + } + ); + } + + // add a separator and a folder inside tag folder + // We must insert these manually, because the new bookmarking API doesn't + // support inserting invalid items into the tag folder. + await insert({ + parentGuid: tagItemGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + await insert({ + parentGuid: tagItemGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // add a separator and a folder inside tag root + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "test tags root folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // sanity + validateResults(); + + await BookmarkJSONUtils.exportToFile(jsonFile); + + // clean + PlacesUtils.tagging.untagURI(Services.io.newURI(ITEM_URL), [TAG_NAME]); + await PlacesUtils.bookmarks.remove(item); + + // restore json file + await BookmarkJSONUtils.importFromFile(jsonFile, { replace: true }); + + validateResults(); + + // clean up + await IOUtils.remove(jsonFile); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js b/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js new file mode 100644 index 0000000000..4596ed93b2 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js @@ -0,0 +1,86 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Since PlacesBackups.getbackupFiles() is a lazy getter, these tests must +// run in the given order, to avoid making it out-of-sync. + +async function countChildren(path) { + let children = await IOUtils.getChildren(path); + let count = 0; + let lastBackupPath = null; + for (let entry of children) { + count++; + if (PlacesBackups.filenamesRegex.test(PathUtils.filename(entry))) { + lastBackupPath = entry; + } + } + return { count, lastBackupPath }; +} + +add_task(async function check_max_backups_is_respected() { + // Get bookmarkBackups directory + let backupFolder = await PlacesBackups.getBackupFolder(); + + // Create 2 json dummy backups in the past. + let oldJsonPath = PathUtils.join(backupFolder, "bookmarks-2008-01-01.json"); + await IOUtils.writeUTF8(oldJsonPath, ""); + Assert.ok(await IOUtils.exists(oldJsonPath)); + + let jsonPath = PathUtils.join(backupFolder, "bookmarks-2008-01-31.json"); + await IOUtils.writeUTF8(jsonPath, ""); + Assert.ok(await IOUtils.exists(jsonPath)); + + // Export bookmarks to JSON. + // Allow 2 backups, the older one should be removed. + await PlacesBackups.create(2); + + let { count, lastBackupPath } = await countChildren(backupFolder); + Assert.equal(count, 2); + Assert.notEqual(lastBackupPath, null); + Assert.equal(false, await IOUtils.exists(oldJsonPath)); + Assert.ok(await IOUtils.exists(jsonPath)); +}); + +add_task(async function check_max_backups_greater_than_backups() { + // Get bookmarkBackups directory + let backupFolder = await PlacesBackups.getBackupFolder(); + + // Export bookmarks to JSON. + // Allow 3 backups, none should be removed. + await PlacesBackups.create(3); + + let { count, lastBackupPath } = await countChildren(backupFolder); + Assert.equal(count, 2); + Assert.notEqual(lastBackupPath, null); +}); + +add_task(async function check_max_backups_null() { + // Get bookmarkBackups directory + let backupFolder = await PlacesBackups.getBackupFolder(); + + // Export bookmarks to JSON. + // Allow infinite backups, none should be removed, a new one is not created + // since one for today already exists. + await PlacesBackups.create(null); + + let { count, lastBackupPath } = await countChildren(backupFolder); + Assert.equal(count, 2); + Assert.notEqual(lastBackupPath, null); +}); + +add_task(async function check_max_backups_undefined() { + // Get bookmarkBackups directory + let backupFolder = await PlacesBackups.getBackupFolder(); + + // Export bookmarks to JSON. + // Allow infinite backups, none should be removed, a new one is not created + // since one for today already exists. + await PlacesBackups.create(); + + let { count, lastBackupPath } = await countChildren(backupFolder); + Assert.equal(count, 2); + Assert.notEqual(lastBackupPath, null); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js b/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js new file mode 100644 index 0000000000..ab4f4a02d5 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js @@ -0,0 +1,56 @@ +/* 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"; + +add_task(async function test_json_backup_in_future() { + let backupFolder = await PlacesBackups.getBackupFolder(); + let bookmarksBackupDir = new FileUtils.File(backupFolder); + // Remove all files from backups folder. + let files = bookmarksBackupDir.directoryEntries; + while (files.hasMoreElements()) { + let entry = files.nextFile; + entry.remove(false); + } + + // Create a json dummy backup in the future. + let dateObj = new Date(); + dateObj.setYear(dateObj.getFullYear() + 1); + let name = PlacesBackups.getFilenameForDate(dateObj); + Assert.equal( + name, + "bookmarks-" + PlacesBackups.toISODateString(dateObj) + ".json" + ); + files = bookmarksBackupDir.directoryEntries; + while (files.hasMoreElements()) { + let entry = files.nextFile; + if (PlacesBackups.filenamesRegex.test(entry.leafName)) { + entry.remove(false); + } + } + + let futureBackupFile = bookmarksBackupDir.clone(); + futureBackupFile.append(name); + futureBackupFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + Assert.ok(futureBackupFile.exists()); + + Assert.equal((await PlacesBackups.getBackupFiles()).length, 0); + + await PlacesBackups.create(); + // Check that a backup for today has been created. + Assert.equal((await PlacesBackups.getBackupFiles()).length, 1); + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + Assert.notEqual(mostRecentBackupFile, null); + Assert.ok( + PlacesBackups.filenamesRegex.test(PathUtils.filename(mostRecentBackupFile)) + ); + + // Check that future backup has been removed. + Assert.ok(!futureBackupFile.exists()); + + // Cleanup. + mostRecentBackupFile = new FileUtils.File(mostRecentBackupFile); + mostRecentBackupFile.remove(false); + Assert.ok(!mostRecentBackupFile.exists()); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js b/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js new file mode 100644 index 0000000000..be5d53f8c6 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js @@ -0,0 +1,66 @@ +/* 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/. */ + +/** + * Checks that automatically created bookmark backups are discarded if they are + * duplicate of an existing ones. + */ +add_task(async function () { + // Create a backup for yesterday in the backups folder. + let backupFolder = await PlacesBackups.getBackupFolder(); + let dateObj = new Date(); + dateObj.setDate(dateObj.getDate() - 1); + let oldBackupName = PlacesBackups.getFilenameForDate(dateObj); + let oldBackup = PathUtils.join(backupFolder, oldBackupName); + let { count: count, hash: hash } = await BookmarkJSONUtils.exportToFile( + oldBackup + ); + Assert.ok(count > 0); + Assert.equal(hash.length, 24); + oldBackupName = oldBackupName.replace( + /\.json/, + "_" + count + "_" + hash + ".json" + ); + await IOUtils.move(oldBackup, PathUtils.join(backupFolder, oldBackupName)); + + // Create a backup. + // This should just rename the existing backup, so in the end there should be + // only one backup with today's date. + await PlacesBackups.create(); + + // Get the hash of the generated backup + let backupFiles = await PlacesBackups.getBackupFiles(); + Assert.equal(backupFiles.length, 1); + + let matches = PathUtils.filename(backupFiles[0]).match( + PlacesBackups.filenamesRegex + ); + Assert.equal(matches[1], PlacesBackups.toISODateString(new Date())); + Assert.equal(matches[2], count); + Assert.equal(matches[3], hash); + + // Add a bookmark and create another backup. + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "foo", + url: "http://foo.com", + }); + + // We must enforce a backup since one for today already exists. The forced + // backup will replace the existing one. + await PlacesBackups.create(undefined, true); + Assert.equal(backupFiles.length, 1); + let recentBackup = await PlacesBackups.getMostRecentBackup(); + Assert.notEqual(recentBackup, PathUtils.join(backupFolder, oldBackupName)); + matches = PathUtils.filename(recentBackup).match( + PlacesBackups.filenamesRegex + ); + Assert.equal(matches[1], PlacesBackups.toISODateString(new Date())); + Assert.equal(matches[2], count + 1); + Assert.notEqual(matches[3], hash); + + // Clean up + await PlacesUtils.bookmarks.remove(bookmark); + await PlacesBackups.create(0); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js b/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js new file mode 100644 index 0000000000..91e0c50f7e --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js @@ -0,0 +1,61 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function compress_bookmark_backups_test() { + // Check for jsonlz4 extension + let todayFilename = PlacesBackups.getFilenameForDate( + new Date(2014, 4, 15), + true + ); + Assert.equal(todayFilename, "bookmarks-2014-05-15.jsonlz4"); + + await PlacesBackups.create(); + + // Check that a backup for today has been created and the regex works fine for lz4. + Assert.equal((await PlacesBackups.getBackupFiles()).length, 1); + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + Assert.notEqual(mostRecentBackupFile, null); + Assert.ok( + PlacesBackups.filenamesRegex.test(PathUtils.filename(mostRecentBackupFile)) + ); + + // The most recent backup file has to be removed since saveBookmarksToJSONFile + // will otherwise over-write the current backup, since it will be made on the + // same date + await IOUtils.remove(mostRecentBackupFile); + Assert.equal(false, await IOUtils.exists(mostRecentBackupFile)); + + // Check that, if the user created a custom backup out of the default + // backups folder, it gets copied (compressed) into it. + let jsonFile = PathUtils.join(PathUtils.profileDir, "bookmarks.json"); + await PlacesBackups.saveBookmarksToJSONFile(jsonFile); + Assert.equal((await PlacesBackups.getBackupFiles()).length, 1); + + // Check if import works from lz4 compressed json + let url = "http://www.mozilla.org/en-US/"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + url, + }); + + // Force create a compressed backup, Remove the bookmark, the restore the backup + await PlacesBackups.create(undefined, true); + let recentBackup = await PlacesBackups.getMostRecentBackup(); + await PlacesUtils.bookmarks.remove(bm); + await BookmarkJSONUtils.importFromFile(recentBackup, { replace: true }); + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let node = root.getChild(0); + Assert.equal(node.uri, url); + + root.containerOpen = false; + await PlacesUtils.bookmarks.eraseEverything(); + + // Cleanup. + await IOUtils.remove(jsonFile); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js b/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js new file mode 100644 index 0000000000..6d280e8cad --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js @@ -0,0 +1,53 @@ +/* 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/. */ + +/** + * To confirm that metadata i.e. bookmark count is set and retrieved for + * automatic backups. + */ +add_task(async function test_saveBookmarksToJSONFile_and_create() { + // Add a bookmark + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Get Firefox!", + url: "http://getfirefox.com/", + }); + + // Test saveBookmarksToJSONFile() + let backupFile = PathUtils.join(PathUtils.tempDir, "bookmarks.json"); + + let nodeCount = await PlacesBackups.saveBookmarksToJSONFile(backupFile, true); + Assert.ok(nodeCount > 0); + Assert.ok(await IOUtils.exists(backupFile)); + + // Ensure the backup would be copied to our backups folder when the original + // backup is saved somewhere else. + let recentBackup = await PlacesBackups.getMostRecentBackup(); + let matches = PathUtils.filename(recentBackup).match( + PlacesBackups.filenamesRegex + ); + Assert.equal(matches[2], nodeCount); + Assert.equal(matches[3].length, 24); + + // Clear all backups in our backups folder. + await PlacesBackups.create(0); + Assert.equal((await PlacesBackups.getBackupFiles()).length, 0); + + // Test create() which saves bookmarks with metadata on the filename. + await PlacesBackups.create(); + Assert.equal((await PlacesBackups.getBackupFiles()).length, 1); + + let mostRecentBackupFile = await PlacesBackups.getMostRecentBackup(); + Assert.notEqual(mostRecentBackupFile, null); + matches = PathUtils.filename(recentBackup).match( + PlacesBackups.filenamesRegex + ); + Assert.equal(matches[2], nodeCount); + Assert.equal(matches[3].length, 24); + + // Cleanup + await IOUtils.remove(backupFile); + await PlacesBackups.create(0); + await PlacesUtils.bookmarks.remove(bookmark); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js b/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js new file mode 100644 index 0000000000..c835a3bd09 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/** + * Checks that backups properly include all of the bookmarks if the hierarchy + * in the database is unordered so that a hierarchy is defined before its + * ancestor in the bookmarks table. + */ +add_task(async function () { + let bms = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bookmark", + url: "http://mozilla.org", + }, + { + title: "f2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "f1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + let bookmark = bms[0]; + let folder2 = bms[1]; + let folder1 = bms[2]; + bookmark.parentGuid = folder2.guid; + await PlacesUtils.bookmarks.update(bookmark); + + folder2.parentGuid = folder1.guid; + await PlacesUtils.bookmarks.update(folder2); + + // Create a backup. + await PlacesBackups.create(); + + // Remove the bookmarks, then restore the backup. + await PlacesUtils.bookmarks.remove(folder1); + await BookmarkJSONUtils.importFromFile( + await PlacesBackups.getMostRecentBackup(), + { replace: true } + ); + + info("Checking first level"); + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let level1 = root.getChild(0); + Assert.equal(level1.title, "f1"); + info("Checking second level"); + PlacesUtils.asContainer(level1).containerOpen = true; + let level2 = level1.getChild(0); + Assert.equal(level2.title, "f2"); + info("Checking bookmark"); + PlacesUtils.asContainer(level2).containerOpen = true; + bookmark = level2.getChild(0); + Assert.equal(bookmark.title, "bookmark"); + level2.containerOpen = false; + level1.containerOpen = false; + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js b/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js new file mode 100644 index 0000000000..6f3132b275 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js @@ -0,0 +1,37 @@ +/* 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/. */ + +/** + * Checks that we don't encodeURI twice when creating bookmarks.html. + */ +add_task(async function () { + let url = + "http://bt.ktxp.com/search.php?keyword=%E5%A6%84%E6%83%B3%E5%AD%A6%E7%94%9F%E4%BC%9A"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + url, + }); + + let file = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.997030.html" + ); + await IOUtils.remove(file, { ignoreAbsent: true }); + await BookmarkHTMLUtils.exportToFile(file); + + // Remove the bookmarks, then restore the backup. + await PlacesUtils.bookmarks.remove(bm); + await BookmarkHTMLUtils.importFromFile(file, { replace: true }); + + info("Checking first level"); + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let node = root.getChild(0); + Assert.equal(node.uri, url); + + root.containerOpen = false; + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_async_observers.js b/toolkit/components/places/tests/bookmarks/test_async_observers.js new file mode 100644 index 0000000000..504f489339 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_async_observers.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test checks that bookmarks service is correctly forwarding async + * events like visit or favicon additions. */ + +let gBookmarkGuids = []; + +add_task(async function setup() { + // Add multiple bookmarks to the same uri. + gBookmarkGuids.push( + ( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://book.ma.rk/", + }) + ).guid + ); + gBookmarkGuids.push( + ( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://book.ma.rk/", + }) + ).guid + ); + Assert.equal(gBookmarkGuids.length, 2); +}); + +add_task(async function test_add_icon() { + // Add a visit to the bookmark and wait for the observer. + let guids = new Set(gBookmarkGuids); + Assert.equal(guids.size, 2); + let promiseNotifications = PlacesTestUtils.waitForNotification( + "favicon-changed", + events => + events.some( + event => + event.url == "http://book.ma.rk/" && + event.faviconUrl.startsWith("data:image/png;base64") + ) + ); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + NetUtil.newURI("http://book.ma.rk/"), + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await promiseNotifications; +}); + +add_task(async function test_remove_page() { + // Add a visit to the bookmark and wait for the observer. + let guids = new Set(gBookmarkGuids); + Assert.equal(guids.size, 2); + let promiseNotifications = PlacesTestUtils.waitForNotification( + "page-removed", + events => + events.some( + event => + event.url === "http://book.ma.rk/" && + !event.isRemovedFromStore && + !event.isPartialVisistsRemoval + ) + ); + await PlacesUtils.history.remove("http://book.ma.rk/"); + await promiseNotifications; +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bmindex.js b/toolkit/components/places/tests/bookmarks/test_bmindex.js new file mode 100644 index 0000000000..c79da88282 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bmindex.js @@ -0,0 +1,133 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +const NUM_BOOKMARKS = 20; +const NUM_SEPARATORS = 5; +const NUM_FOLDERS = 10; +const NUM_ITEMS = NUM_BOOKMARKS + NUM_SEPARATORS + NUM_FOLDERS; +const MIN_RAND = -5; +const MAX_RAND = 40; + +async function check_contiguous_indexes(bookmarks) { + var indexes = []; + for (let bm of bookmarks) { + let bmIndex = (await PlacesUtils.bookmarks.fetch(bm.guid)).index; + info(`Index: ${bmIndex}\n`); + info("Checking duplicates\n"); + Assert.ok(!indexes.includes(bmIndex)); + info(`Checking out of range, found ${bookmarks.length} items\n`); + Assert.ok(bmIndex >= 0 && bmIndex < bookmarks.length); + indexes.push(bmIndex); + } + info("Checking all valid indexes have been used\n"); + Assert.equal(indexes.length, bookmarks.length); +} + +add_task(async function test_bookmarks_indexing() { + let bookmarks = []; + // Insert bookmarks with random indexes. + for (let i = 0; bookmarks.length < NUM_BOOKMARKS; i++) { + let randIndex = Math.round( + MIN_RAND + Math.random() * (MAX_RAND - MIN_RAND) + ); + try { + let bm = await PlacesUtils.bookmarks.insert({ + index: randIndex, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: `Test bookmark ${i}`, + url: `http://${i}.mozilla.org/`, + }); + if (randIndex < -1) { + do_throw("Creating a bookmark at an invalid index should throw"); + } + bookmarks.push(bm); + } catch (ex) { + if (randIndex >= -1) { + do_throw("Creating a bookmark at a valid index should not throw"); + } + } + } + await check_contiguous_indexes(bookmarks); + + // Insert separators with random indexes. + for (let i = 0; bookmarks.length < NUM_BOOKMARKS + NUM_SEPARATORS; i++) { + let randIndex = Math.round( + MIN_RAND + Math.random() * (MAX_RAND - MIN_RAND) + ); + try { + let bm = await PlacesUtils.bookmarks.insert({ + index: randIndex, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + if (randIndex < -1) { + do_throw("Creating a separator at an invalid index should throw"); + } + bookmarks.push(bm); + } catch (ex) { + if (randIndex >= -1) { + do_throw("Creating a separator at a valid index should not throw"); + } + } + } + await check_contiguous_indexes(bookmarks); + + // Insert folders with random indexes. + for (let i = 0; bookmarks.length < NUM_ITEMS; i++) { + let randIndex = Math.round( + MIN_RAND + Math.random() * (MAX_RAND - MIN_RAND) + ); + try { + let bm = await PlacesUtils.bookmarks.insert({ + index: randIndex, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: `Test folder ${i}`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + if (randIndex < -1) { + do_throw("Creating a folder at an invalid index should throw"); + } + bookmarks.push(bm); + } catch (ex) { + if (randIndex >= -1) { + do_throw("Creating a folder at a valid index should not throw"); + } + } + } + await check_contiguous_indexes(bookmarks); + + // Execute some random bookmark delete. + for (let i = 0; i < Math.ceil(NUM_ITEMS / 4); i++) { + let bm = bookmarks.splice( + Math.floor(Math.random() * bookmarks.length), + 1 + )[0]; + info(`Removing item with guid ${bm.guid}\n`); + await PlacesUtils.bookmarks.remove(bm); + } + await check_contiguous_indexes(bookmarks); + + // Execute some random bookmark move. This will also try to move it to + // invalid index values. + for (let i = 0; i < Math.ceil(NUM_ITEMS / 4); i++) { + let randIndex = Math.floor(Math.random() * bookmarks.length); + let bm = bookmarks[randIndex]; + let newIndex = Math.round(MIN_RAND + Math.random() * (MAX_RAND - MIN_RAND)); + info(`Moving item with guid ${bm.guid} to index ${newIndex}\n`); + try { + bm.index = newIndex; + await PlacesUtils.bookmarks.update(bm); + if (newIndex < -1) { + do_throw("Moving an item to a negative index should throw\n"); + } + } catch (ex) { + if (newIndex >= -1) { + do_throw("Moving an item to a valid index should not throw\n"); + } + } + } + await check_contiguous_indexes(bookmarks); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmark_observer.js b/toolkit/components/places/tests/bookmarks/test_bookmark_observer.js new file mode 100644 index 0000000000..9ad01cfab9 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmark_observer.js @@ -0,0 +1,937 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that each bookmark event gets the correct input. + +var gUnfiledFolderId; + +var gBookmarksObserver = { + expected: [], + setup(expected) { + this.expected = expected; + this.deferred = PromiseUtils.defer(); + return this.deferred.promise; + }, + + validateEvents(events) { + Assert.greaterOrEqual(this.expected.length, events.length); + for (let event of events) { + let expected = this.expected.shift(); + Assert.equal(expected.eventType, event.type); + let args = expected.args; + for (let i = 0; i < args.length; i++) { + Assert.ok( + args[i].check(event[args[i].name]), + event.type + "(args[" + i + "]: " + args[i].name + ")" + ); + } + } + + if (this.expected.length === 0) { + this.deferred.resolve(); + } + }, + + handlePlacesEvents(events) { + this.validateEvents(events); + }, +}; + +var gBookmarkSkipObserver = { + expected: null, + setup(expected) { + this.expected = expected; + this.deferred = PromiseUtils.defer(); + return this.deferred.promise; + }, + + validateEvents(events) { + events = events.filter(e => !e.isTagging); + Assert.greaterOrEqual(this.expected.length, events.length); + for (let event of events) { + let expectedEventType = this.expected.shift(); + Assert.equal(expectedEventType, event.type); + } + + if (this.expected.length === 0) { + this.deferred.resolve(); + } + }, + + handlePlacesEvents(events) { + this.validateEvents(events); + }, +}; + +add_task(async function setup() { + gUnfiledFolderId = await PlacesUtils.promiseItemId( + PlacesUtils.bookmarks.unfiledGuid + ); + gBookmarksObserver.handlePlacesEvents = + gBookmarksObserver.handlePlacesEvents.bind(gBookmarksObserver); + gBookmarkSkipObserver.handlePlacesEvents = + gBookmarkSkipObserver.handlePlacesEvents.bind(gBookmarkSkipObserver); + PlacesUtils.observers.addListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-tags-changed", + "bookmark-title-changed", + ], + gBookmarksObserver.handlePlacesEvents + ); + PlacesUtils.observers.addListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-tags-changed", + "bookmark-title-changed", + ], + gBookmarkSkipObserver.handlePlacesEvents + ); +}); + +add_task(async function bookmarkItemAdded_bookmark() { + const title = "Bookmark 1"; + let uri = Services.io.newURI("http://1.mozilla.org/"); + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-added"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === gUnfiledFolderId }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v == uri.spec }, + { name: "title", check: v => v === title }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title, + }); + await promise; +}); + +add_task(async function bookmarkItemAdded_separator() { + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-added"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === gUnfiledFolderId }, + { name: "index", check: v => v === 1 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v === "" }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + await promise; +}); + +add_task(async function bookmarkItemAdded_folder() { + const title = "Folder 1"; + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-added"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === gUnfiledFolderId }, + { name: "index", check: v => v === 2 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v === title }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await promise; +}); + +add_task(async function bookmarkTitleChanged() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + const title = "New title"; + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-title-changed"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-title-changed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "title", check: v => v === title }, + { name: "lastModified", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.update({ guid: bm.guid, title }); + await promise; +}); + +add_task(async function bookmarkTagsChanged() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + let uri = bm.url.URI; + const TAG = "tag"; + let promise = Promise.all([ + gBookmarkSkipObserver.setup([ + "bookmark-tags-changed", + "bookmark-tags-changed", + ]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-added", // This is the tag folder. + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === PlacesUtils.tagsFolderId }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v === TAG }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-added", // This is the tag. + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v == uri.spec }, + { name: "title", check: v => v === "" }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-tags-changed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { name: "lastModified", check: v => typeof v == "number" && v > 0 }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + { + name: "isTagging", + check: v => v === false, + }, + ], + }, + { + eventType: "bookmark-removed", // This is the tag. + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v == uri.spec }, + { name: "title", check: v => v == "" }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-tags-changed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { name: "lastModified", check: v => typeof v == "number" && v > 0 }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + { + name: "isTagging", + check: v => v === false, + }, + ], + }, + { + eventType: "bookmark-removed", // This is the tag folder. + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === PlacesUtils.tagsFolderId }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v == TAG }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + PlacesUtils.tagging.tagURI(uri, [TAG]); + PlacesUtils.tagging.untagURI(uri, [TAG]); + await promise; +}); + +add_task(async function bookmarkItemMoved_bookmark() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-moved", "bookmark-moved"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-moved", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "oldIndex", check: v => v === 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "oldParentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + { name: "url", check: v => typeof v == "string" }, + ], + }, + { + eventType: "bookmark-moved", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "oldIndex", check: v => v === 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "oldParentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + { name: "url", check: v => typeof v == "string" }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + await promise; +}); + +add_task(async function bookmarkItemRemoved_bookmark() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + let uri = bm.url.URI; + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-removed"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === gUnfiledFolderId }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v === uri.spec }, + { name: "title", check: v => v == "New title" }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.remove(bm); + await promise; +}); + +add_task(async function bookmarkItemRemoved_separator() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-removed"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v == "" }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.remove(bm); + await promise; +}); + +add_task(async function bookmarkItemRemoved_folder() { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + let promise = Promise.all([ + gBookmarkSkipObserver.setup(["bookmark-removed"]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v == "Folder 1" }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + await PlacesUtils.bookmarks.remove(bm); + await promise; +}); + +add_task(async function bookmarkItemRemoved_folder_recursive() { + const title = "Folder 3"; + const BMTITLE = "Bookmark 1"; + let uri = Services.io.newURI("http://1.mozilla.org/"); + let promise = Promise.all([ + gBookmarkSkipObserver.setup([ + "bookmark-added", + "bookmark-added", + "bookmark-added", + "bookmark-added", + "bookmark-removed", + "bookmark-removed", + "bookmark-removed", + "bookmark-removed", + ]), + gBookmarksObserver.setup([ + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => v === gUnfiledFolderId }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v === title }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v == uri.spec }, + { name: "title", check: v => v === BMTITLE }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 1 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v === title }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-added", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v == uri.spec }, + { name: "title", check: v => v === BMTITLE }, + { name: "dateAdded", check: v => typeof v == "number" && v > 0 }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v === uri.spec }, + { name: "title", check: v => v == BMTITLE }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 1 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v == title }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + { name: "url", check: v => v === uri.spec }, + { name: "title", check: v => v == BMTITLE }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + { + eventType: "bookmark-removed", + args: [ + { name: "id", check: v => typeof v == "number" && v > 0 }, + { name: "parentId", check: v => typeof v == "number" && v > 0 }, + { name: "index", check: v => v === 0 }, + { + name: "itemType", + check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { name: "url", check: v => v === "" }, + { name: "title", check: v => v == title }, + { + name: "guid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "parentGuid", + check: v => typeof v == "string" && PlacesUtils.isValidGuid(v), + }, + { + name: "source", + check: v => + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v), + }, + ], + }, + ]), + ]); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: uri, + title: BMTITLE, + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + url: uri, + title: BMTITLE, + }); + + await PlacesUtils.bookmarks.remove(folder); + await promise; +}); + +add_task(function cleanup() { + PlacesUtils.observers.removeListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-tags-changed", + "bookmark-title-changed", + ], + gBookmarksObserver.handlePlacesEvents + ); + PlacesUtils.observers.removeListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-tags-changed", + "bookmark-title-changed", + ], + gBookmarkSkipObserver.handlePlacesEvents + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js new file mode 100644 index 0000000000..55bcea5978 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js @@ -0,0 +1,207 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_eraseEverything() { + await PlacesTestUtils.addVisits({ + uri: NetUtil.newURI("http://example.com/"), + }); + await PlacesTestUtils.addVisits({ + uri: NetUtil.newURI("http://mozilla.org/"), + }); + let frecencyForExample = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://example.com/" } + ); + let frecencyForMozilla = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://mozilla.org/" } + ); + Assert.ok(frecencyForExample > 0); + Assert.ok(frecencyForMozilla > 0); + let unfiledFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(unfiledFolder); + let unfiledBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + }); + checkBookmarkObject(unfiledBookmark); + let unfiledBookmarkInFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: unfiledFolder.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://mozilla.org/", + }); + checkBookmarkObject(unfiledBookmarkInFolder); + + let menuFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(menuFolder); + let menuBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + }); + checkBookmarkObject(menuBookmark); + let menuBookmarkInFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: menuFolder.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://mozilla.org/", + }); + checkBookmarkObject(menuBookmarkInFolder); + + let toolbarFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(toolbarFolder); + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + }); + checkBookmarkObject(toolbarBookmark); + let toolbarBookmarkInFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: toolbarFolder.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://mozilla.org/", + }); + checkBookmarkObject(toolbarBookmarkInFolder); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://example.com/", + })) > frecencyForExample + ); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://example.com/", + })) > frecencyForMozilla + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://example.com/", + }), + frecencyForExample + ); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://example.com/", + }), + frecencyForMozilla + ); +}); + +add_task(async function test_eraseEverything_roots() { + await PlacesUtils.bookmarks.eraseEverything(); + + // Ensure the roots have not been removed. + Assert.ok( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.unfiledGuid) + ); + Assert.ok( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ); + Assert.ok(await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.menuGuid)); + Assert.ok(await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.tagsGuid)); + Assert.ok(await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.rootGuid)); +}); + +add_task(async function test_eraseEverything_reparented() { + // Create a folder with 1 bookmark in it... + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let bookmark1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://example.com/", + }); + // ...and a second folder. + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // Reparent the bookmark to the 2nd folder. + bookmark1.parentGuid = folder2.guid; + await PlacesUtils.bookmarks.update(bookmark1); + + // Erase everything. + await PlacesUtils.bookmarks.eraseEverything(); + + // All the above items should no longer be in the GUIDHelper cache. + for (let guid of [folder1.guid, bookmark1.guid, folder2.guid]) { + await Assert.rejects( + PlacesUtils.promiseItemId(guid), + /no item found for the given GUID/ + ); + } +}); + +add_task(async function test_notifications() { + let bms = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "test", + url: "http://example.com", + }, + { + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "test2", + url: "http://example.com/2", + }, + ], + }, + ], + }); + + let skipDescendantsObserver = expectPlacesObserverNotifications( + ["bookmark-removed"], + false, + true + ); + let receiveAllObserver = expectPlacesObserverNotifications( + ["bookmark-removed"], + false + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + let expectedNotifications = [ + { + type: "bookmark-removed", + guid: bms[1].guid, + }, + { + type: "bookmark-removed", + guid: bms[0].guid, + }, + ]; + + // If we're skipping descendents, we'll only be notified of the folder. + skipDescendantsObserver.check(expectedNotifications); + + // Note: Items of folders get notified first. + expectedNotifications.unshift({ + type: "bookmark-removed", + guid: bms[2].guid, + }); + + receiveAllObserver.check(expectedNotifications); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js new file mode 100644 index 0000000000..d04d3ee5da --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js @@ -0,0 +1,614 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gAccumulator = { + get callback() { + this.results = []; + return result => this.results.push(result); + }, +}; + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.fetch(), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch(null), + /Input should be a valid object/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guid: "123456789012", index: 0 }), + /The following properties were expected: parentGuid/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({}), + /Unexpected number of conditions provided: 0/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + }), + /Unexpected number of conditions provided: 0/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ + guid: "123456789012", + parentGuid: "012345678901", + index: 0, + }), + /Unexpected number of conditions provided: 2/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ + guid: "123456789012", + url: "http://example.com", + }), + /Unexpected number of conditions provided: 2/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch("test"), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch(123), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guid: "test" }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guid: null }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guid: 123 }), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guidPrefix: "" }), + /Invalid value for property 'guidPrefix'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guidPrefix: null }), + /Invalid value for property 'guidPrefix'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guidPrefix: 123 }), + /Invalid value for property 'guidPrefix'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guidPrefix: "123456789012" }), + /Invalid value for property 'guidPrefix'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ guidPrefix: "@" }), + /Invalid value for property 'guidPrefix'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ parentGuid: "test", index: 0 }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ parentGuid: null, index: 0 }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ parentGuid: 123, index: 0 }), + /Invalid value for property 'parentGuid'/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012", index: "0" }), + /Invalid value for property 'index'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012", index: null }), + /Invalid value for property 'index'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012", index: -10 }), + /Invalid value for property 'index'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ url: "http://te st/" }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ url: null }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ url: -10 }), + /Invalid value for property 'url'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.fetch("123456789012", "test"), + /onResult callback must be a valid function/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch("123456789012", {}), + /onResult callback must be a valid function/ + ); +}); + +add_task(async function fetch_nonexistent_guid() { + let bm = await PlacesUtils.bookmarks.fetch( + { guid: "123456789012" }, + gAccumulator.callback + ); + Assert.equal(bm, null); + Assert.equal(gAccumulator.results.length, 0); +}); + +add_task(async function fetch_bookmark() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch(bm1.guid, gAccumulator.callback); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + checkBookmarkObject(gAccumulator.results[0]); + Assert.deepEqual(gAccumulator.results[0], bm1); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.index, 0); + Assert.deepEqual(bm2.dateAdded, bm2.lastModified); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm2.url.href, "http://example.com/"); + Assert.equal(bm2.title, "a bookmark"); + Assert.strictEqual(bm2.childCount, undefined); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_bookmar_empty_title() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch(bm1.guid); + checkBookmarkObject(bm2); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.index, 0); + Assert.strictEqual(bm2.title, ""); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_folder() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + checkBookmarkObject(bm1); + Assert.deepEqual(bm1.dateAdded, bm1.lastModified); + + // Inserting a child updates both the childCount and lastModified of bm1, + // though the bm1 object is static once fetched, thus later we'll manually + // update it. + await PlacesUtils.bookmarks.insert({ + parentGuid: bm1.guid, + url: "https://www.mozilla.org/", + title: "", + }); + + let bm2 = await PlacesUtils.bookmarks.fetch(bm1.guid); + checkBookmarkObject(bm2); + + Assert.equal(bm2.childCount, 1); + bm1.childCount = bm2.childCount; + bm1.lastModified = bm2.lastModified; + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.index, 0); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(bm2.title, "a folder"); + Assert.ok(!("url" in bm2)); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_folder_empty_title() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch(bm1.guid); + checkBookmarkObject(bm2); + + Assert.equal(bm2.childCount, 0); + // Insert doesn't populate childCount (it would always be 0 anyway), so set + // it to be able to just use deepEqual. + bm1.childCount = bm2.childCount; + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.index, 0); + Assert.strictEqual(bm2.title, ""); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_separator() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch(bm1.guid); + checkBookmarkObject(bm2); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.index, 0); + Assert.deepEqual(bm2.dateAdded, bm2.lastModified); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.ok(!("url" in bm2)); + Assert.strictEqual(bm2.title, ""); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_byguid_prefix() { + const PREFIX = "PREFIX-"; + + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: PlacesUtils.generateGuidWithPrefix(PREFIX), + url: "http://bm1.example.com/", + title: "bookmark 1", + }); + checkBookmarkObject(bm1); + Assert.ok(bm1.guid.startsWith(PREFIX)); + + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: PlacesUtils.generateGuidWithPrefix(PREFIX), + url: "http://bm2.example.com/", + title: "bookmark 2", + }); + checkBookmarkObject(bm2); + Assert.ok(bm2.guid.startsWith(PREFIX)); + + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: PlacesUtils.generateGuidWithPrefix(PREFIX), + title: "a folder", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: bm3.guid, + url: "https://www.mozilla.org/", + title: "", + }); + checkBookmarkObject(bm3); + Assert.ok(bm3.guid.startsWith(PREFIX)); + + // Bookmark 4 doesn't have the same guid prefix, so it shouldn't be returned in the results. + let bm4 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://bm3.example.com/", + title: "bookmark 4", + }); + checkBookmarkObject(bm4); + Assert.ok(!bm4.guid.startsWith(PREFIX)); + + await PlacesUtils.bookmarks.fetch( + { guidPrefix: PREFIX }, + gAccumulator.callback + ); + + Assert.equal(gAccumulator.results.length, 3); + + // The results are returned by most recent first, so the first bookmark + // inserted is the last one in the returned array. + Assert.deepEqual(bm1, gAccumulator.results[2]); + Assert.deepEqual(bm2, gAccumulator.results[1]); + Assert.equal(gAccumulator.results[0].childCount, 1); + bm3.childCount = gAccumulator.results[0].childCount; + bm3.lastModified = gAccumulator.results[0].lastModified; + Assert.deepEqual(bm3, gAccumulator.results[0]); + + Assert.equal(bm3.childCount, 1); + + await PlacesUtils.bookmarks.remove(bm1); + await PlacesUtils.bookmarks.remove(bm2); + await PlacesUtils.bookmarks.remove(bm3); + await PlacesUtils.bookmarks.remove(bm4); +}); + +add_task(async function fetch_byposition_nonexisting_parentGuid() { + let bm = await PlacesUtils.bookmarks.fetch( + { parentGuid: "123456789012", index: 0 }, + gAccumulator.callback + ); + Assert.equal(bm, null); + Assert.equal(gAccumulator.results.length, 0); +}); + +add_task(async function fetch_byposition_nonexisting_index() { + let bm = await PlacesUtils.bookmarks.fetch( + { parentGuid: PlacesUtils.bookmarks.unfiledGuid, index: 100 }, + gAccumulator.callback + ); + Assert.equal(bm, null); + Assert.equal(gAccumulator.results.length, 0); +}); + +add_task(async function fetch_byposition() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch( + { parentGuid: bm1.parentGuid, index: bm1.index }, + gAccumulator.callback + ); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + checkBookmarkObject(gAccumulator.results[0]); + Assert.deepEqual(gAccumulator.results[0], bm1); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.index, 0); + Assert.deepEqual(bm2.dateAdded, bm2.lastModified); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm2.url.href, "http://example.com/"); + Assert.equal(bm2.title, "a bookmark"); +}); + +add_task(async function fetch_byposition_default_index() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/last", + title: "last child", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch( + { parentGuid: bm1.parentGuid, index: PlacesUtils.bookmarks.DEFAULT_INDEX }, + gAccumulator.callback + ); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + checkBookmarkObject(gAccumulator.results[0]); + Assert.deepEqual(gAccumulator.results[0], bm1); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.index, 1); + Assert.deepEqual(bm2.dateAdded, bm2.lastModified); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm2.url.href, "http://example.com/last"); + Assert.equal(bm2.title, "last child"); + + await PlacesUtils.bookmarks.remove(bm1.guid); +}); + +add_task(async function fetch_byurl_nonexisting() { + let bm = await PlacesUtils.bookmarks.fetch( + { url: "http://nonexisting.com/" }, + gAccumulator.callback + ); + Assert.equal(bm, null); + Assert.equal(gAccumulator.results.length, 0); +}); + +add_task(async function fetch_byurl() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://byurl.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + + // Also ensure that fecth-by-url excludes the tags folder. + PlacesUtils.tagging.tagURI(bm1.url.URI, ["Test Tag"]); + + let bm2 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback + ); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + checkBookmarkObject(gAccumulator.results[0]); + Assert.deepEqual(gAccumulator.results[0], bm1); + + Assert.deepEqual(bm1, bm2); + Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.deepEqual(bm2.dateAdded, bm2.lastModified); + Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm2.url.href, "http://byurl.com/"); + Assert.equal(bm2.title, "a bookmark"); + + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://byurl.com/", + title: "a bookmark", + }); + let bm4 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback + ); + checkBookmarkObject(bm4); + Assert.deepEqual(bm3, bm4); + Assert.equal(gAccumulator.results.length, 2); + gAccumulator.results.forEach(checkBookmarkObject); + Assert.deepEqual(gAccumulator.results[0], bm4); + + // After an update the returned bookmark should change. + await PlacesUtils.bookmarks.update({ guid: bm1.guid, title: "new title" }); + let bm5 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback + ); + checkBookmarkObject(bm5); + // Cannot use deepEqual cause lastModified changed. + Assert.equal(bm1.guid, bm5.guid); + Assert.ok(bm5.lastModified > bm1.lastModified); + Assert.equal(gAccumulator.results.length, 2); + gAccumulator.results.forEach(checkBookmarkObject); + Assert.deepEqual(gAccumulator.results[0], bm5); + + // cleanup + PlacesUtils.tagging.untagURI(bm1.url.URI, ["Test Tag"]); +}); + +add_task(async function fetch_concurrent() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://concurrent.url.com/", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback, + { concurrent: true } + ); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + Assert.deepEqual(gAccumulator.results[0], bm1); + Assert.deepEqual(bm1, bm2); + let bm3 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback, + { concurrent: false } + ); + checkBookmarkObject(bm3); + Assert.equal(gAccumulator.results.length, 1); + Assert.deepEqual(gAccumulator.results[0], bm1); + Assert.deepEqual(bm1, bm3); + let bm4 = await PlacesUtils.bookmarks.fetch( + { url: bm1.url }, + gAccumulator.callback, + {} + ); + checkBookmarkObject(bm4); + Assert.equal(gAccumulator.results.length, 1); + Assert.deepEqual(gAccumulator.results[0], bm1); + Assert.deepEqual(bm1, bm4); +}); + +add_task(async function fetch_by_parent() { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + checkBookmarkObject(folder1); + + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://bm1.example.com/", + title: "bookmark 1", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://bm2.example.com/", + title: "bookmark 2", + }); + checkBookmarkObject(bm2); + + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "sub folder", + index: 0, + }); + checkBookmarkObject(bm2); + await PlacesUtils.bookmarks.insert({ + parentGuid: bm3.guid, + url: "http://mozilla.org/", + title: "sub bookmark", + }); + + await PlacesUtils.bookmarks.fetch( + { parentGuid: folder1.guid }, + gAccumulator.callback + ); + + Assert.equal(gAccumulator.results.length, 3); + + Assert.equal(gAccumulator.results[0].childCount, 1); + bm3.childCount = gAccumulator.results[0].childCount; + bm3.lastModified = gAccumulator.results[0].lastModified; + Assert.deepEqual(bm3, gAccumulator.results[0]); + Assert.equal(bm1.url.href, gAccumulator.results[1].url.href); + Assert.equal(bm2.url.href, gAccumulator.results[2].url.href); + + await PlacesUtils.bookmarks.remove(folder1); +}); + +add_task(async function fetch_with_bookmark_path() { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Parent", + }); + checkBookmarkObject(folder1); + + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://bookmarkpath.example.com/", + title: "Child Bookmark", + }); + checkBookmarkObject(bm1); + + let bm2 = await PlacesUtils.bookmarks.fetch( + { guid: bm1.guid }, + gAccumulator.callback, + { includePath: true } + ); + checkBookmarkObject(bm2); + Assert.equal(gAccumulator.results.length, 1); + Assert.equal(bm2.path.length, 2); + Assert.equal(bm2.path[0].guid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm2.path[0].title, "unfiled"); + Assert.equal(bm2.path[1].guid, folder1.guid); + Assert.equal(bm2.path[1].title, folder1.title); + + await PlacesUtils.bookmarks.remove(folder1); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js new file mode 100644 index 0000000000..55cddb8820 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js @@ -0,0 +1,117 @@ +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.getRecent(), + /numberOfItems argument is required/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.getRecent("abc"), + /numberOfItems argument must be an integer/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.getRecent(1.2), + /numberOfItems argument must be an integer/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.getRecent(0), + /numberOfItems argument must be greater than zero/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.getRecent(-1), + /numberOfItems argument must be greater than zero/ + ); +}); + +add_task(async function getRecent_returns_recent_bookmarks() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/path", + title: "another bookmark", + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.net/", + title: "another bookmark", + }); + let bm4 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.net/path", + title: "yet another bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + checkBookmarkObject(bm3); + checkBookmarkObject(bm4); + + // Add a tag to the most recent url to prove it doesn't get returned. + PlacesUtils.tagging.tagURI(uri(bm4.url), ["Test Tag"]); + + // Add a separator. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + + // Add a query bookmark. + let queryURL = `place:parent=${PlacesUtils.bookmarks.menuGuid}&queryType=1`; + let bm5 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: queryURL, + title: "a test query", + }); + checkBookmarkObject(bm5); + + // Verify that getRecent only returns actual bookmarks. + let results = await PlacesUtils.bookmarks.getRecent(100); + Assert.equal( + results.length, + 4, + "The expected number of bookmarks was returned." + ); + checkBookmarkObject(results[0]); + Assert.deepEqual( + bm4, + results[0], + "The first result is the expected bookmark." + ); + checkBookmarkObject(results[1]); + Assert.deepEqual( + bm3, + results[1], + "The second result is the expected bookmark." + ); + checkBookmarkObject(results[2]); + Assert.deepEqual( + bm2, + results[2], + "The third result is the expected bookmark." + ); + checkBookmarkObject(results[3]); + Assert.deepEqual( + bm1, + results[3], + "The fourth result is the expected bookmark." + ); + + // Verify that getRecent utilizes the numberOfItems argument. + results = await PlacesUtils.bookmarks.getRecent(1); + Assert.equal( + results.length, + 1, + "The expected number of bookmarks was returned." + ); + checkBookmarkObject(results[0]); + Assert.deepEqual( + bm4, + results[0], + "The first result is the expected bookmark." + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js new file mode 100644 index 0000000000..df99a5fe3d --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js @@ -0,0 +1,432 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.insert(), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert(null), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({}), + /The following properties were expected/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ guid: "test" }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ guid: null }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ guid: 123 }), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ parentGuid: "test" }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ parentGuid: null }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ parentGuid: 123 }), + /Invalid value for property 'parentGuid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ index: "1" }), + /Invalid value for property 'index'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ index: -10 }), + /Invalid value for property 'index'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ dateAdded: -10 }), + /Invalid value for property 'dateAdded'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ dateAdded: "today" }), + /Invalid value for property 'dateAdded'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ dateAdded: Date.now() }), + /Invalid value for property 'dateAdded'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ dateAdded: new Date(NaN) }), + /Invalid value for property 'dateAdded'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ lastModified: -10 }), + /Invalid value for property 'lastModified'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ lastModified: "today" }), + /Invalid value for property 'lastModified'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ lastModified: Date.now() }), + /Invalid value for property 'lastModified'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ lastModified: new Date(NaN) }), + /Invalid value for property 'lastModified'/ + ); + + let past = new Date(Date.now() - 86400000); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ lastModified: past }), + /Invalid value for property 'lastModified'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.insert({ type: -1 }), + /Invalid value for property 'type'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ type: 100 }), + /Invalid value for property 'type'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.insert({ type: "bookmark" }), + /Invalid value for property 'type'/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: -1, + }), + /Invalid value for property 'title'/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: 10, + }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://te st", + }), + /Invalid value for property 'url'/ + ); + let longurl = "http://www.example.com/"; + for (let i = 0; i < 65536; i++) { + longurl += "a"; + } + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: longurl, + }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: NetUtil.newURI(longurl), + }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "te st", + }), + /Invalid value for property 'url'/ + ); +}); + +add_task(async function invalid_properties_for_bookmark_type() { + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + url: "http://www.moz.com/", + }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + url: "http://www.moz.com/", + }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + title: "test", + }), + /Invalid value for property 'title'/ + ); +}); + +add_task(async function test_insert_into_root_throws() { + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + const sandbox = sinon.createSandbox(); + sandbox.stub(PlacesUtils, "isInAutomation").get(() => false); + registerCleanupFunction(() => sandbox.restore()); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.rootGuid, + url: "http://example.com", + }), + /Invalid value for property 'parentGuid'/, + "Should throw when inserting a bookmark into the root." + ); + Assert.throws( + () => + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }), + /Invalid value for property 'parentGuid'/, + "Should throw when inserting a folder into the root." + ); + sandbox.restore(); +}); + +add_task(async function long_title_trim() { + let longtitle = "a"; + for (let i = 0; i < 4096; i++) { + longtitle += "a"; + } + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: longtitle, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 0); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(bm.title.length, 4096, "title should have been trimmed"); + Assert.ok(!("url" in bm), "url should not be set"); +}); + +add_task(async function create_separator() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 1); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function create_separator_w_title_fail() { + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + title: "a separator", + }); + Assert.ok(false, "Trying to set title for a separator should reject"); + } catch (ex) {} +}); + +add_task(async function create_separator_invalid_parent_fail() { + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: "123456789012", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + title: "a separator", + }); + Assert.ok( + false, + "Trying to create an item in a non existing parent reject" + ); + } catch (ex) {} +}); + +add_task(async function create_separator_given_guid() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + guid: "123456789012", + }); + checkBookmarkObject(bm); + Assert.equal(bm.guid, "123456789012"); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 2); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function create_item_given_guid_no_type_fail() { + try { + await PlacesUtils.bookmarks.insert({ parentGuid: "123456789012" }); + Assert.ok( + false, + "Trying to create an item with a given guid but no type should reject" + ); + } catch (ex) {} +}); + +add_task(async function create_separator_big_index() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 9999, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 3); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function create_separator_given_dateAdded() { + let time = new Date(); + let past = new Date(time - 86400000); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + dateAdded: past, + }); + checkBookmarkObject(bm); + Assert.equal(bm.dateAdded, past); + Assert.equal(bm.lastModified, past); +}); + +add_task(async function create_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.strictEqual(bm.title, ""); + + // And then create a nested folder. + let parentGuid = bm.guid; + bm = await PlacesUtils.bookmarks.insert({ + parentGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, parentGuid); + Assert.equal(bm.index, 0); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.strictEqual(bm.title, "a folder"); +}); + +add_task(async function create_bookmark() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let parentGuid = bm.guid; + + bm = await PlacesUtils.bookmarks.insert({ + parentGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, parentGuid); + Assert.equal(bm.index, 0); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm.url.href, "http://example.com/"); + Assert.equal(bm.title, "a bookmark"); + + // Check parent lastModified. + let parent = await PlacesUtils.bookmarks.fetch({ guid: bm.parentGuid }); + Assert.deepEqual(parent.lastModified, bm.dateAdded); + + bm = await PlacesUtils.bookmarks.insert({ + parentGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: new URL("http://example.com/"), + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, parentGuid); + Assert.equal(bm.index, 1); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm.url.href, "http://example.com/"); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function create_bookmark_frecency() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bm.url, + }), + 0, + "Check frecency has been updated" + ); +}); + +add_task(async function create_bookmark_without_type() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm.url.href, "http://example.com/"); + Assert.equal(bm.title, "a bookmark"); +}); + +add_task(async function test_url_with_apices() { + // Apices may confuse code and cause injection if mishandled. + const url = `javascript:alert("%s");alert('%s');`; + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + // Just a sanity check, this should not throw. + await PlacesUtils.history.remove(url); + let bm = await PlacesUtils.bookmarks.fetch({ url }); + await PlacesUtils.bookmarks.remove(bm); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_insertTree.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_insertTree.js new file mode 100644 index 0000000000..0fad7d4b31 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_insertTree.js @@ -0,0 +1,590 @@ +add_task(async function invalid_input_rejects() { + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(), + /Should be provided a valid tree object./ + ); + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(null), + /Should be provided a valid tree object./ + ); + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree("foo"), + /Should be provided a valid tree object./ + ); + + // All subsequent tests pass a valid parent guid. + let guid = PlacesUtils.bookmarks.unfiledGuid; + + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree({ guid, children: [] }), + /Should have a non-zero number of children to insert./ + ); + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree({ guid }), + /Should have a non-zero number of children to insert./ + ); + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree({ children: [{}], guid }), + /The following properties were expected: url/ + ); + + // Reuse another variable to make this easier to read: + let tree = { guid, children: [{ guid: "test" }] }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'guid'/ + ); + tree.children = [{ guid: null }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'guid'/ + ); + tree.children = [{ guid: 123 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'guid'/ + ); + + tree.children = [{ dateAdded: -10 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'dateAdded'/ + ); + tree.children = [{ dateAdded: "today" }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'dateAdded'/ + ); + tree.children = [{ dateAdded: Date.now() }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'dateAdded'/ + ); + tree.children = [{ dateAdded: new Date(NaN) }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'dateAdded'/ + ); + + tree.children = [{ lastModified: -10 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'lastModified'/ + ); + tree.children = [{ lastModified: "today" }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'lastModified'/ + ); + tree.children = [{ lastModified: Date.now() }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'lastModified'/ + ); + + let time = Date.now(); + let future = new Date(time + 86400000); + tree.children = [{ dateAdded: future, lastModified: time }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'dateAdded'/ + ); + let past = new Date(time - 86400000); + tree.children = [{ lastModified: past }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'lastModified'/ + ); + + tree.children = [{ type: -1 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'type'/ + ); + tree.children = [{ type: 100 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'type'/ + ); + tree.children = [{ type: "bookmark" }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'type'/ + ); + + tree.children = [{ type: PlacesUtils.bookmarks.TYPE_BOOKMARK, title: -1 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'title'/ + ); + + tree.children = [{ type: PlacesUtils.bookmarks.TYPE_BOOKMARK, url: 10 }]; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(tree), + /Invalid value for property 'url'/ + ); + + let treeWithBrokenURL = { + children: [ + { type: PlacesUtils.bookmarks.TYPE_BOOKMARK, url: "http://te st" }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(treeWithBrokenURL), + /Invalid value for property 'url'/ + ); + let longurl = "http://www.example.com/" + "a".repeat(65536); + let treeWithLongURL = { + children: [{ type: PlacesUtils.bookmarks.TYPE_BOOKMARK, url: longurl }], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(treeWithLongURL), + /Invalid value for property 'url'/ + ); + let treeWithLongURI = { + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: NetUtil.newURI(longurl), + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(treeWithLongURI), + /Invalid value for property 'url'/ + ); + let treeWithOtherBrokenURL = { + children: [{ type: PlacesUtils.bookmarks.TYPE_BOOKMARK, url: "te st" }], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(treeWithOtherBrokenURL), + /Invalid value for property 'url'/ + ); +}); + +add_task(async function invalid_properties_for_bookmark_type() { + let folderWithURL = { + children: [ + { type: PlacesUtils.bookmarks.TYPE_FOLDER, url: "http://www.moz.com/" }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(folderWithURL), + /Invalid value for property 'url'/ + ); + let separatorWithURL = { + children: [ + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + url: "http://www.moz.com/", + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(separatorWithURL), + /Invalid value for property 'url'/ + ); + let separatorWithTitle = { + children: [{ type: PlacesUtils.bookmarks.TYPE_SEPARATOR, title: "test" }], + guid: PlacesUtils.bookmarks.unfiledGuid, + }; + await Assert.throws( + () => PlacesUtils.bookmarks.insertTree(separatorWithTitle), + /Invalid value for property 'title'/ + ); +}); + +add_task(async function create_separator() { + let [bm] = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 0); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function create_plain_bm() { + let [bm] = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + url: "http://www.example.com/", + title: "Test", + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 1); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(bm.title, "Test"); + Assert.equal(bm.url.href, "http://www.example.com/"); +}); + +add_task(async function create_folder() { + let [bm] = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Test", + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + Assert.equal(bm.index, 2); + Assert.equal(bm.dateAdded, bm.lastModified); + Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(bm.title, "Test"); +}); + +add_task(async function create_in_tags() { + await Assert.throws( + () => + PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.example.com/", + title: "Test adding a tag", + }, + ], + guid: PlacesUtils.bookmarks.tagsGuid, + }), + /Can't use insertTree to insert tags/ + ); + let guidForTag = ( + await PlacesUtils.bookmarks.insert({ + title: "test-tag", + url: "http://www.unused.com/", + parentGuid: PlacesUtils.bookmarks.tagsGuid, + }) + ).guid; + await Assert.rejects( + PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.example.com/", + title: "Test adding an item to a tag", + }, + ], + guid: guidForTag, + }), + /Can't use insertTree to insert tags/ + ); + await PlacesUtils.bookmarks.remove(guidForTag); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function insert_into_root() { + await Assert.throws( + () => + PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.example.com/", + title: "Test inserting into root", + }, + ], + guid: PlacesUtils.bookmarks.rootGuid, + }), + /Can't insert into the root/ + ); +}); + +add_task(async function tree_where_separator_or_folder_has_kids() { + await Assert.throws( + () => + PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.example.com/", + title: "Test inserting into separator", + }, + ], + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }), + /Invalid value for property 'children'/ + ); + + await Assert.throws( + () => + PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + children: [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.example.com/", + title: "Test inserting into separator", + }, + ], + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }), + /Invalid value for property 'children'/ + ); +}); + +add_task(async function create_hierarchy() { + let obsInvoked = 0; + let listener = events => { + for (let event of events) { + obsInvoked++; + Assert.greater(event.id, 0, "Should have a valid itemId"); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let bms = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Root item", + children: [ + { + url: "http://www.example.com/1", + title: "BM 1", + }, + { + url: "http://www.example.com/2", + title: "BM 2", + }, + { + title: "Sub", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Sub BM 1", + url: "http://www.example.com/sub/1", + }, + { + title: "Sub BM 2", + url: "http://www.example.com/sub/2", + }, + ], + }, + ], + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + let parentFolder = null, + subFolder = null; + let prevBM = null; + for (let bm of bms) { + checkBookmarkObject(bm); + if (prevBM && prevBM.parentGuid == bm.parentGuid) { + Assert.equal(prevBM.index + 1, bm.index, "Indices should be subsequent"); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm.guid)).index, + bm.index, + "Index reflects inserted index" + ); + } + prevBM = bm; + if (bm.type == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bm.url, + }), + 0, + "Check frecency has been updated for bookmark " + bm.url + ); + } + if (bm.title == "Root item") { + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + parentFolder = bm; + } else if (!bm.title.startsWith("Sub BM")) { + Assert.equal(bm.parentGuid, parentFolder.guid); + if (bm.type == PlacesUtils.bookmarks.TYPE_FOLDER) { + subFolder = bm; + } + } else { + Assert.equal(bm.parentGuid, subFolder.guid); + } + } + Assert.equal(obsInvoked, bms.length); + Assert.equal(obsInvoked, 6); +}); + +add_task(async function insert_many_non_nested() { + let obsInvoked = 0; + let listener = events => { + for (let event of events) { + obsInvoked++; + Assert.greater(event.id, 0, "Should have a valid itemId"); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let bms = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + url: "http://www.example.com/1", + title: "Item 1", + }, + { + url: "http://www.example.com/2", + title: "Item 2", + }, + { + url: "http://www.example.com/3", + title: "Item 3", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "Item 4", + url: "http://www.example.com/4", + }, + { + title: "Item 5", + url: "http://www.example.com/5", + }, + ], + guid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + let startIndex = -1; + for (let bm of bms) { + checkBookmarkObject(bm); + if (startIndex == -1) { + startIndex = bm.index; + } else { + Assert.equal(++startIndex, bm.index, "Indices should be subsequent"); + } + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm.guid)).index, + bm.index, + "Index reflects inserted index" + ); + if (bm.type == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bm.url, + }), + 0, + "Check frecency has been updated for bookmark " + bm.url + ); + } + Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid); + } + Assert.equal(obsInvoked, bms.length); + Assert.equal(obsInvoked, 6); +}); + +add_task(async function create_in_folder() { + let mozFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Mozilla", + }); + + let notifications = []; + let listener = events => { + for (let event of events) { + notifications.push({ + itemId: event.id, + parentId: event.parentId, + index: event.index, + title: event.title, + guid: event.guid, + parentGuid: event.parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let bms = await PlacesUtils.bookmarks.insertTree({ + children: [ + { + url: "http://getfirefox.com", + title: "Get Firefox!", + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Community", + children: [ + { + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }, + { + url: "https://www.seamonkey-project.org", + title: "SeaMonkey", + }, + ], + }, + ], + guid: mozFolder.guid, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + let mozFolderId = await PlacesUtils.promiseItemId(mozFolder.guid); + let commFolderId = await PlacesUtils.promiseItemId(bms[1].guid); + deepEqual(notifications, [ + { + itemId: await PlacesUtils.promiseItemId(bms[0].guid), + parentId: mozFolderId, + index: 0, + title: "Get Firefox!", + guid: bms[0].guid, + parentGuid: mozFolder.guid, + }, + { + itemId: commFolderId, + parentId: mozFolderId, + index: 1, + title: "Community", + guid: bms[1].guid, + parentGuid: mozFolder.guid, + }, + { + itemId: await PlacesUtils.promiseItemId(bms[2].guid), + parentId: commFolderId, + index: 0, + title: "Get Thunderbird!", + guid: bms[2].guid, + parentGuid: bms[1].guid, + }, + { + itemId: await PlacesUtils.promiseItemId(bms[3].guid), + parentId: commFolderId, + index: 1, + title: "SeaMonkey", + guid: bms[3].guid, + parentGuid: bms[1].guid, + }, + ]); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_moveToFolder.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_moveToFolder.js new file mode 100644 index 0000000000..7cc3eb0916 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_moveToFolder.js @@ -0,0 +1,723 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function ensurePosition(info, parentGuid, index) { + print(`Checking ${info.guid}`); + checkBookmarkObject(info); + Assert.equal( + info.parentGuid, + parentGuid, + "Should be in the correct parent folder" + ); + Assert.equal(info.index, index, "Should have the correct index"); +} + +function insertChildren(folder, items) { + if (!items.length) { + return []; + } + + let children = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type === TYPE_BOOKMARK) { + children.push({ + title: `${i}`, + url: "http://example.com", + }); + } else { + throw new Error(`Type ${items[i].type} is not supported.`); + } + } + return PlacesUtils.bookmarks.insertTree({ + guid: folder.guid, + children, + }); +} + +async function dumpFolderChildren( + folder, + details, + folderA, + folderB, + originalAChildren, + originalBChildren +) { + info(`${folder} Details:`); + info(`Input: ${JSON.stringify(details.initial[folder])}`); + info(`Expected: ${JSON.stringify(details.expected[folder])}`); + info("Index\tOriginal\tExpected\tResult"); + + let originalChildren; + let folderGuid; + if (folder == "folderA") { + originalChildren = originalAChildren; + folderGuid = folderA.guid; + } else { + originalChildren = originalBChildren; + folderGuid = folderB.guid; + } + + let tree = await PlacesUtils.promiseBookmarksTree(folderGuid); + let childrenCount = tree.children ? tree.children.length : 0; + for (let i = 0; i < originalChildren.length || i < childrenCount; i++) { + let originalGuid = + i < originalChildren.length ? originalChildren[i].guid : " "; + let resultGuid = i < childrenCount ? tree.children[i].guid : " "; + let expectedGuid = " "; + if (i < details.expected[folder].length) { + let expected = details.expected[folder][i]; + expectedGuid = + expected.folder == "a" + ? originalAChildren[expected.originalIndex].guid + : originalBChildren[expected.originalIndex].guid; + } + info(`${i}\t${originalGuid}\t${expectedGuid}\t${resultGuid}\n`); + } +} + +async function checkExpectedResults( + details, + folder, + folderGuid, + lastModified, + movedItems, + folderAChildren, + folderBChildren +) { + let expectedResults = details.expected[folder]; + for (let i = 0; i < expectedResults.length; i++) { + let expectedDetails = expectedResults[i]; + let originalItem = + expectedDetails.folder == "a" + ? folderAChildren[expectedDetails.originalIndex] + : folderBChildren[expectedDetails.originalIndex]; + + // Check the item got updated correctly in the database. + let updatedItem = await PlacesUtils.bookmarks.fetch(originalItem.guid); + + ensurePosition(updatedItem, folderGuid, i); + Assert.greaterOrEqual( + updatedItem.lastModified.getTime(), + lastModified.getTime(), + "Last modified should be later or equal to before" + ); + } + + if (details.expected.skipResultIndexChecks) { + return; + } + + // Check the items returned from the actual move() call are correct. + let index = 0; + for (let item of details.initial[folder]) { + if (!("targetFolder" in item)) { + // We weren't moving this item, so skip it and continue. + continue; + } + + let movedItem = movedItems[index]; + let updatedItem = await PlacesUtils.bookmarks.fetch(movedItem.guid); + + ensurePosition(movedItem, updatedItem.parentGuid, updatedItem.index); + + index++; + } +} + +async function checkLastModifiedForFolders(details, folder, movedItems) { + // For the tests, the moves always come from folderA. + if ( + details.initial.folderA.some( + item => "targetFolder" in item && item.targetFolder == folder.title + ) + ) { + let updatedFolder = await PlacesUtils.bookmarks.fetch(folder.guid); + + Assert.greaterOrEqual( + updatedFolder.lastModified.getTime(), + folder.lastModified.getTime(), + "Should have updated the folder's last modified time." + ); + print(JSON.stringify(movedItems[0])); + Assert.deepEqual( + updatedFolder.lastModified, + movedItems[0].lastModified, + "Should have the same last modified as the moved items." + ); + } +} + +async function testMoveToFolder(details) { + await PlacesUtils.bookmarks.eraseEverything(); + + // Always create two folders by default. + let [folderA, folderB] = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "a", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "b", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + checkBookmarkObject(folderA); + let folderAChildren = await insertChildren(folderA, details.initial.folderA); + checkBookmarkObject(folderB); + let folderBChildren = await insertChildren(folderB, details.initial.folderB); + + const originalAChildren = folderAChildren.map(child => { + return { ...child }; + }); + const originalBChildren = folderBChildren.map(child => { + return { ...child }; + }); + + let lastModified; + if (folderAChildren.length) { + lastModified = folderAChildren[0].lastModified; + } else if (folderBChildren.length) { + lastModified = folderBChildren[0].lastModified; + } else { + throw new Error("No children added, can't determine lastModified"); + } + + // Work out which children to move and to where. + let childrenToUpdate = []; + for (let i = 0; i < details.initial.folderA.length; i++) { + if ("move" in details.initial.folderA[i]) { + childrenToUpdate.push(folderAChildren[i].guid); + } + } + + let observer; + if (details.notifications) { + observer = expectPlacesObserverNotifications(["bookmark-moved"]); + } + + let movedItems = await PlacesUtils.bookmarks.moveToFolder( + childrenToUpdate, + details.targetFolder == "a" ? folderA.guid : folderB.guid, + details.targetIndex + ); + + await dumpFolderChildren( + "folderA", + details, + folderA, + folderB, + originalAChildren, + originalBChildren + ); + await dumpFolderChildren( + "folderB", + details, + folderA, + folderB, + originalAChildren, + originalBChildren + ); + + Assert.equal(movedItems.length, childrenToUpdate.length); + await checkExpectedResults( + details, + "folderA", + folderA.guid, + lastModified, + movedItems, + folderAChildren, + folderBChildren + ); + await checkExpectedResults( + details, + "folderB", + folderB.guid, + lastModified, + movedItems, + folderAChildren, + folderBChildren + ); + + if (details.notifications) { + let expectedNotifications = []; + + for (let notification of details.notifications) { + let origItem = + notification.originalFolder == "folderA" + ? originalAChildren[notification.originalIndex] + : originalBChildren[notification.originalIndex]; + let newFolder = notification.newFolder == "folderA" ? folderA : folderB; + + expectedNotifications.push({ + type: "bookmark-moved", + id: await PlacesUtils.promiseItemId(origItem.guid), + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: origItem.url, + guid: origItem.guid, + parentGuid: newFolder.guid, + source: PlacesUtils.bookmarks.SOURCES.DEFAULT, + index: notification.newIndex, + oldParentGuid: origItem.parentGuid, + oldIndex: notification.originalIndex, + isTagging: false, + }); + } + observer.check(expectedNotifications); + } + + await checkLastModifiedForFolders(details, folderA, movedItems); + await checkLastModifiedForFolders(details, folderB, movedItems); +} + +const TYPE_BOOKMARK = PlacesUtils.bookmarks.TYPE_BOOKMARK; + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder(), + /guids should be an array/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder({}), + /guids should be an array/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder([]), + /guids should be an array of at least one item/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder(["test"]), + /Expected only valid GUIDs to be passed/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder([null]), + /Expected only valid GUIDs to be passed/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder([123]), + /Expected only valid GUIDs to be passed/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.moveToFolder(["123456789012"], 123), + /Error: parentGuid should be a valid GUID/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.moveToFolder( + ["123456789012"], + PlacesUtils.bookmarks.rootGuid + ), + /Cannot move bookmarks into root/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.moveToFolder(["123456789012"], "123456789012", -2), + /index should be a number greater than/ + ); + Assert.throws( + () => + PlacesUtils.bookmarks.moveToFolder( + ["123456789012"], + "123456789012", + "sdffd" + ), + /index should be a number greater than/ + ); +}); + +add_task(async function test_move_nonexisting_bookmark_rejects() { + await Assert.rejects( + PlacesUtils.bookmarks.moveToFolder(["123456789012"], "123456789012", -1), + /No bookmarks found for the provided GUID/, + "Should reject when moving a non-existing bookmark" + ); +}); + +add_task(async function test_move_folder_into_descendant_rejects() { + let parent = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let descendant = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + await Assert.rejects( + PlacesUtils.bookmarks.moveToFolder([parent.guid], parent.guid, 0), + /Cannot insert a folder into itself or one of its descendants/, + "Should reject when moving a folder into itself" + ); + + await Assert.rejects( + PlacesUtils.bookmarks.moveToFolder([parent.guid], descendant.guid, 0), + /Cannot insert a folder into itself or one of its descendants/, + "Should reject when moving a folder into a descendant" + ); +}); + +add_task(async function test_move_from_differnt_with_no_target_rejects() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + await Assert.rejects( + PlacesUtils.bookmarks.moveToFolder([bm1.guid, bm2.guid], null, -1), + /All bookmarks should be in the same folder if no parent is specified/, + "Should reject when moving bookmarks from different folders with no target folder" + ); +}); + +add_task(async function test_move_append_same_folder() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + ], + folderB: [], + }, + targetFolder: "a", + targetIndex: -1, + expected: { + folderA: [ + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 2 }, + { folder: "a", originalIndex: 1 }, + ], + folderB: [], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 1, + newFolder: "folderA", + newIndex: 2, + }, + ], + }); +}); + +add_task(async function test_move_append_multiple_same_folder() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + ], + folderB: [], + }, + targetFolder: "a", + targetIndex: -1, + expected: { + folderA: [ + { folder: "a", originalIndex: 3 }, + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 1 }, + { folder: "a", originalIndex: 2 }, + ], + folderB: [], + }, + // These are all inserted at position 3 as that's what the views require + // to be notified, to ensure the new items are displayed in their correct + // positions. + notifications: [ + { + originalFolder: "folderA", + originalIndex: 0, + newFolder: "folderA", + newIndex: 3, + }, + { + originalFolder: "folderA", + originalIndex: 1, + newFolder: "folderA", + newIndex: 3, + }, + { + originalFolder: "folderA", + originalIndex: 2, + newFolder: "folderA", + newIndex: 3, + }, + ], + }); +}); + +add_task(async function test_move_append_multiple_new_folder() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + ], + folderB: [], + }, + targetFolder: "b", + targetIndex: -1, + expected: { + folderA: [], + folderB: [ + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 1 }, + { folder: "a", originalIndex: 2 }, + ], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 0, + newFolder: "folderB", + newIndex: 0, + }, + { + originalFolder: "folderA", + originalIndex: 1, + newFolder: "folderB", + newIndex: 1, + }, + { + originalFolder: "folderA", + originalIndex: 2, + newFolder: "folderB", + newIndex: 2, + }, + ], + }); +}); + +add_task(async function test_move_append_multiple_new_folder_with_existing() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK, move: true }, + ], + folderB: [{ type: TYPE_BOOKMARK }, { type: TYPE_BOOKMARK }], + }, + targetFolder: "b", + targetIndex: -1, + expected: { + folderA: [], + folderB: [ + { folder: "b", originalIndex: 0 }, + { folder: "b", originalIndex: 1 }, + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 1 }, + { folder: "a", originalIndex: 2 }, + ], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 0, + newFolder: "folderB", + newIndex: 2, + }, + { + originalFolder: "folderA", + originalIndex: 1, + newFolder: "folderB", + newIndex: 3, + }, + { + originalFolder: "folderA", + originalIndex: 2, + newFolder: "folderB", + newIndex: 4, + }, + ], + }); +}); + +add_task(async function test_move_insert_same_folder_up() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK, move: true }, + ], + folderB: [], + }, + targetFolder: "a", + targetIndex: 0, + expected: { + folderA: [ + { folder: "a", originalIndex: 2 }, + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 1 }, + ], + folderB: [], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 2, + newFolder: "folderA", + newIndex: 0, + }, + ], + }); +}); + +add_task(async function test_move_insert_same_folder_down() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK }, + ], + folderB: [], + }, + targetFolder: "a", + targetIndex: 2, + expected: { + folderA: [ + { folder: "a", originalIndex: 1 }, + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 2 }, + ], + folderB: [], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 0, + newFolder: "folderA", + newIndex: 1, + }, + ], + }); +}); + +add_task( + async function test_move_insert_multiple_same_folder_split_locations() { + await testMoveToFolder({ + initial: { + folderA: [ + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK, move: true }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK }, + { type: TYPE_BOOKMARK, move: true }, + ], + folderB: [], + }, + targetFolder: "a", + targetIndex: 2, + expected: { + folderA: [ + { folder: "a", originalIndex: 1 }, + { folder: "a", originalIndex: 0 }, + { folder: "a", originalIndex: 3 }, + { folder: "a", originalIndex: 6 }, + { folder: "a", originalIndex: 9 }, + { folder: "a", originalIndex: 2 }, + { folder: "a", originalIndex: 4 }, + { folder: "a", originalIndex: 5 }, + { folder: "a", originalIndex: 7 }, + { folder: "a", originalIndex: 8 }, + ], + folderB: [], + }, + notifications: [ + { + originalFolder: "folderA", + originalIndex: 0, + newFolder: "folderA", + newIndex: 1, + }, + { + originalFolder: "folderA", + originalIndex: 3, + newFolder: "folderA", + newIndex: 2, + }, + { + originalFolder: "folderA", + originalIndex: 6, + newFolder: "folderA", + newIndex: 3, + }, + { + originalFolder: "folderA", + originalIndex: 9, + newFolder: "folderA", + newIndex: 4, + }, + ], + }); + } +); + +add_task(async function test_move_folder_with_descendant() { + let parent = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + url: "http://example.com/", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + }); + let descendant = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(descendant.index, 1); + let lastModified = bm.lastModified; + + // This is moving to a nonexisting index by purpose, it will be appended. + [bm] = await PlacesUtils.bookmarks.moveToFolder( + [bm.guid], + descendant.guid, + 1 + ); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, descendant.guid); + Assert.equal(bm.index, 0); + Assert.ok(bm.lastModified >= lastModified); + + parent = await PlacesUtils.bookmarks.fetch(parent.guid); + descendant = await PlacesUtils.bookmarks.fetch(descendant.guid); + Assert.deepEqual(parent.lastModified, bm.lastModified); + Assert.deepEqual(descendant.lastModified, bm.lastModified); + Assert.equal(descendant.index, 0); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal(bm.parentGuid, descendant.guid); + Assert.equal(bm.index, 0); + + [bm] = await PlacesUtils.bookmarks.moveToFolder([bm.guid], parent.guid, 0); + Assert.equal(bm.parentGuid, parent.guid); + Assert.equal(bm.index, 0); + + descendant = await PlacesUtils.bookmarks.fetch(descendant.guid); + Assert.equal(descendant.index, 1); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js new file mode 100644 index 0000000000..08afb6001d --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js @@ -0,0 +1,1135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +add_task(async function insert_separator_notification() { + let observer = expectPlacesObserverNotifications(["bookmark-added"]); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + observer.check([ + { + type: "bookmark-added", + id: itemId, + itemType: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentId, + index: bm.index, + url: bm.url, + title: bm.title, + dateAdded: bm.dateAdded, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function insert_folder_notification() { + let observer = expectPlacesObserverNotifications(["bookmark-added"]); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "a folder", + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + observer.check([ + { + type: "bookmark-added", + id: itemId, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + parentId, + index: bm.index, + url: bm.url, + title: bm.title, + dateAdded: bm.dateAdded, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function insert_folder_notitle_notification() { + let observer = expectPlacesObserverNotifications(["bookmark-added"]); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + strictEqual(bm.title, "", "Should return empty string for untitled folder"); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + observer.check([ + { + type: "bookmark-added", + id: itemId, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + parentId, + index: bm.index, + url: bm.url, + title: bm.title, + dateAdded: bm.dateAdded, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function insert_bookmark_notification() { + let observer = expectPlacesObserverNotifications(["bookmark-added"]); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://example.com/"), + title: "a bookmark", + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + observer.check([ + { + type: "bookmark-added", + id: itemId, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentId, + index: bm.index, + url: bm.url, + title: bm.title, + dateAdded: bm.dateAdded, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function insert_bookmark_notitle_notification() { + let observer = expectPlacesObserverNotifications(["bookmark-added"]); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://example.com/"), + }); + strictEqual(bm.title, "", "Should return empty string for untitled bookmark"); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + observer.check([ + { + type: "bookmark-added", + id: itemId, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentId, + index: bm.index, + url: bm.url, + title: bm.title, + dateAdded: bm.dateAdded, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function insert_bookmark_tag_notification() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://tag.example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + let tagFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "tag", + }); + const observer = expectPlacesObserverNotifications([ + "bookmark-added", + "bookmark-tags-changed", + ]); + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tagFolder.guid, + url: new URL("http://tag.example.com/"), + }); + let tagId = await PlacesUtils.promiseItemId(tag.guid); + let tagParentId = await PlacesUtils.promiseItemId(tag.parentGuid); + + observer.check([ + { + type: "bookmark-added", + id: tagId, + parentId: tagParentId, + index: tag.index, + itemType: tag.type, + url: tag.url, + title: "", + dateAdded: tag.dateAdded, + guid: tag.guid, + parentGuid: tag.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: true, + }, + { + type: "bookmark-tags-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + tags: ["tag"], + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function update_bookmark_lastModified() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://lastmod.example.com/"), + }); + const observer = expectPlacesObserverNotifications(["bookmark-time-changed"]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + lastModified: new Date(), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + observer.check([ + { + type: "bookmark-time-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + dateAdded: bm.dateAdded, + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function update_bookmark_title() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://title.example.com/"), + }); + const observer = expectPlacesObserverNotifications([ + "bookmark-title-changed", + ]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + title: "new title", + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + observer.check([ + { + type: "bookmark-title-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + title: bm.title, + guid: bm.guid, + parentGuid: bm.parentGuid, + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function update_bookmark_uri() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://url.example.com/"), + }); + const observer = expectPlacesObserverNotifications(["bookmark-url-changed"]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + url: "http://mozilla.org/", + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + observer.check([ + { + type: "bookmark-url-changed", + id: itemId, + itemType: bm.type, + url: bm.url.href, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + lastModified: bm.lastModified, + }, + ]); +}); + +add_task(async function update_move_same_folder() { + // Ensure there are at least two items in place (others test do so for us, + // but we don't have to depend on that). + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://move.example.com/"), + }); + let bmItemId = await PlacesUtils.promiseItemId(bm.guid); + let bmOldIndex = bm.index; + + let observer = expectPlacesObserverNotifications(["bookmark-moved"]); + + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + Assert.equal(bm.index, 0); + observer.check([ + { + type: "bookmark-moved", + id: bmItemId, + itemType: bm.type, + url: "http://move.example.com/", + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + index: bm.index, + oldParentGuid: bm.parentGuid, + oldIndex: bmOldIndex, + isTagging: false, + }, + ]); + + // Test that we get the right index for DEFAULT_INDEX input. + bmOldIndex = 0; + observer = expectPlacesObserverNotifications(["bookmark-moved"]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + Assert.ok(bm.index > 0); + observer.check([ + { + type: "bookmark-moved", + id: bmItemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + index: bm.index, + oldParentGuid: bm.parentGuid, + oldIndex: bmOldIndex, + isTagging: false, + }, + ]); +}); + +add_task(async function update_move_different_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://move.example.com/"), + }); + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bmItemId = await PlacesUtils.promiseItemId(bm.guid); + let bmOldIndex = bm.index; + + const observer = expectPlacesObserverNotifications(["bookmark-moved"]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: folder.guid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + Assert.equal(bm.index, 0); + observer.check([ + { + type: "bookmark-moved", + id: bmItemId, + itemType: bm.type, + url: "http://move.example.com/", + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + index: bm.index, + oldParentGuid: PlacesUtils.bookmarks.unfiledGuid, + oldIndex: bmOldIndex, + isTagging: false, + }, + ]); +}); + +add_task(async function update_move_tag_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://move.example.com/"), + }); + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "tag", + }); + let bmItemId = await PlacesUtils.promiseItemId(bm.guid); + let bmOldIndex = bm.index; + + const observer = expectPlacesObserverNotifications(["bookmark-moved"]); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: folder.guid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + Assert.equal(bm.index, 0); + observer.check([ + { + type: "bookmark-moved", + id: bmItemId, + itemType: bm.type, + url: "http://move.example.com/", + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + index: bm.index, + oldParentGuid: PlacesUtils.bookmarks.unfiledGuid, + oldIndex: bmOldIndex, + isTagging: true, + }, + ]); +}); + +add_task(async function remove_bookmark() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://remove.example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.remove(bm.guid); + observer.check([ + { + type: "bookmark-removed", + id: itemId, + parentId, + index: bm.index, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + ]); +}); + +add_task(async function remove_multiple_bookmarks() { + let bm1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://remove.example.com/", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://remove1.example.com/", + }); + let itemId1 = await PlacesUtils.promiseItemId(bm1.guid); + let parentId1 = await PlacesUtils.promiseItemId(bm1.parentGuid); + let itemId2 = await PlacesUtils.promiseItemId(bm2.guid); + let parentId2 = await PlacesUtils.promiseItemId(bm2.parentGuid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.remove([bm1, bm2]); + observer.check([ + { + type: "bookmark-removed", + id: itemId1, + parentId: parentId1, + index: bm1.index, + url: bm1.url, + guid: bm1.guid, + parentGuid: bm1.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: itemId2, + parentId: parentId2, + index: bm2.index - 1, + url: bm2.url, + guid: bm2.guid, + parentGuid: bm2.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + ]); +}); + +add_task(async function remove_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.remove(bm.guid); + observer.check([ + { + type: "bookmark-removed", + id: itemId, + parentId, + index: bm.index, + url: null, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + ]); +}); + +add_task(async function remove_bookmark_tag_notification() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://untag.example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + let tagFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "tag", + }); + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tagFolder.guid, + url: new URL("http://untag.example.com/"), + }); + let tagId = await PlacesUtils.promiseItemId(tag.guid); + let tagParentId = await PlacesUtils.promiseItemId(tag.parentGuid); + + const observer = expectPlacesObserverNotifications([ + "bookmark-removed", + "bookmark-tags-changed", + ]); + await PlacesUtils.bookmarks.remove(tag.guid); + + observer.check([ + { + type: "bookmark-removed", + id: tagId, + parentId: tagParentId, + index: tag.index, + url: tag.url, + guid: tag.guid, + parentGuid: tag.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: true, + }, + { + type: "bookmark-tags-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + tags: [], + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function rename_bookmark_tag_notification() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL("http://renametag.example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + let tagFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "tag", + }); + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tagFolder.guid, + url: new URL("http://renametag.example.com/"), + }); + let tagParentId = await PlacesUtils.promiseItemId(tag.parentGuid); + + const observer = expectPlacesObserverNotifications([ + "bookmark-title-changed", + "bookmark-tags-changed", + ]); + tagFolder = await PlacesUtils.bookmarks.update({ + guid: tagFolder.guid, + title: "renamed", + }); + + observer.check([ + { + type: "bookmark-title-changed", + id: tagParentId, + title: "renamed", + guid: tagFolder.guid, + url: "", + lastModified: tagFolder.lastModified, + parentGuid: tagFolder.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: true, + }, + { + type: "bookmark-tags-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + tags: ["renamed"], + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); + +add_task(async function remove_folder_notification() { + let folder1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let folder1Id = await PlacesUtils.promiseItemId(folder1.guid); + let folder1ParentId = await PlacesUtils.promiseItemId(folder1.parentGuid); + + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder1.guid, + url: new URL("http://example.com/"), + }); + let bmItemId = await PlacesUtils.promiseItemId(bm.guid); + + let folder2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: folder1.guid, + }); + let folder2Id = await PlacesUtils.promiseItemId(folder2.guid); + + let bm2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder2.guid, + url: new URL("http://example.com/"), + }); + let bm2ItemId = await PlacesUtils.promiseItemId(bm2.guid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.remove(folder1.guid); + + observer.check([ + { + type: "bookmark-removed", + id: bm2ItemId, + parentId: folder2Id, + index: bm2.index, + url: bm2.url, + guid: bm2.guid, + parentGuid: bm2.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder2Id, + parentId: folder1Id, + index: folder2.index, + url: null, + guid: folder2.guid, + parentGuid: folder2.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + { + type: "bookmark-removed", + id: bmItemId, + parentId: folder1Id, + index: bm.index, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder1Id, + parentId: folder1ParentId, + index: folder1.index, + url: null, + guid: folder1.guid, + parentGuid: folder1.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + ]); +}); + +add_task(async function multiple_tags() { + const BOOKMARK_URL = "http://multipletags.example.com/"; + const TAG_NAMES = ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6"]; + + const bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: new URL(BOOKMARK_URL), + }); + const itemId = await PlacesUtils.promiseItemId(bm.guid); + + info("Register all tags"); + const tagFolders = await Promise.all( + TAG_NAMES.map(tagName => + PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: tagName, + }) + ) + ); + + info("Test adding tags to bookmark"); + for (let i = 0; i < tagFolders.length; i++) { + const tagFolder = tagFolders[i]; + const expectedTagNames = TAG_NAMES.slice(0, i + 1); + + const observer = expectPlacesObserverNotifications([ + "bookmark-tags-changed", + ]); + + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tagFolder.guid, + url: new URL(BOOKMARK_URL), + }); + + observer.check([ + { + type: "bookmark-tags-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + tags: expectedTagNames, + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + } + + info("Test removing tags from bookmark"); + for (const removedLength of [1, 2, 3]) { + const removedTags = tagFolders.splice(0, removedLength); + + const observer = expectPlacesObserverNotifications([ + "bookmark-tags-changed", + ]); + + // We can remove multiple tags at one time. + await PlacesUtils.bookmarks.remove(removedTags); + + const expectedResults = []; + + for (let i = 0; i < removedLength; i++) { + TAG_NAMES.splice(0, 1); + const expectedTagNames = [...TAG_NAMES]; + + expectedResults.push({ + type: "bookmark-tags-changed", + id: itemId, + itemType: bm.type, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + tags: expectedTagNames, + lastModified: bm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }); + } + + observer.check(expectedResults); + } +}); + +add_task(async function eraseEverything_notification() { + // Let's start from a clean situation. + await PlacesUtils.bookmarks.eraseEverything(); + + let folder1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let folder1Id = await PlacesUtils.promiseItemId(folder1.guid); + let folder1ParentId = await PlacesUtils.promiseItemId(folder1.parentGuid); + + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder1.guid, + url: new URL("http://example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + + let folder2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let folder2Id = await PlacesUtils.promiseItemId(folder2.guid); + let folder2ParentId = await PlacesUtils.promiseItemId(folder2.parentGuid); + + let toolbarBm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: new URL("http://example.com/"), + }); + let toolbarBmId = await PlacesUtils.promiseItemId(toolbarBm.guid); + let toolbarBmParentId = await PlacesUtils.promiseItemId(toolbarBm.parentGuid); + + let menuBm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: new URL("http://example.com/"), + }); + let menuBmId = await PlacesUtils.promiseItemId(menuBm.guid); + let menuBmParentId = await PlacesUtils.promiseItemId(menuBm.parentGuid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.eraseEverything(); + + // Bookmarks should always be notified before their parents. + observer.check([ + { + type: "bookmark-removed", + id: itemId, + parentId, + index: bm.index, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder2Id, + parentId: folder2ParentId, + index: folder2.index, + url: null, + guid: folder2.guid, + parentGuid: folder2.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder1Id, + parentId: folder1ParentId, + index: folder1.index, + url: null, + guid: folder1.guid, + parentGuid: folder1.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + { + type: "bookmark-removed", + id: menuBmId, + parentId: menuBmParentId, + index: menuBm.index, + url: menuBm.url, + guid: menuBm.guid, + parentGuid: menuBm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: toolbarBmId, + parentId: toolbarBmParentId, + index: toolbarBm.index, + url: toolbarBm.url, + guid: toolbarBm.guid, + parentGuid: toolbarBm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + ]); +}); + +add_task(async function eraseEverything_reparented_notification() { + // Let's start from a clean situation. + await PlacesUtils.bookmarks.eraseEverything(); + + let folder1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let folder1Id = await PlacesUtils.promiseItemId(folder1.guid); + let folder1ParentId = await PlacesUtils.promiseItemId(folder1.parentGuid); + + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder1.guid, + url: new URL("http://example.com/"), + }); + let itemId = await PlacesUtils.promiseItemId(bm.guid); + + let folder2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let folder2Id = await PlacesUtils.promiseItemId(folder2.guid); + let folder2ParentId = await PlacesUtils.promiseItemId(folder2.parentGuid); + + bm.parentGuid = folder2.guid; + bm = await PlacesUtils.bookmarks.update(bm); + let parentId = await PlacesUtils.promiseItemId(bm.parentGuid); + + let observer = expectPlacesObserverNotifications(["bookmark-removed"]); + await PlacesUtils.bookmarks.eraseEverything(); + + // Bookmarks should always be notified before their parents. + observer.check([ + { + type: "bookmark-removed", + id: itemId, + parentId, + index: bm.index, + url: bm.url, + guid: bm.guid, + parentGuid: bm.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder2Id, + parentId: folder2ParentId, + index: folder2.index, + url: null, + guid: folder2.guid, + parentGuid: folder2.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + { + type: "bookmark-removed", + id: folder1Id, + parentId: folder1ParentId, + index: folder1.index, + url: null, + guid: folder1.guid, + parentGuid: folder1.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + isTagging: false, + }, + ]); +}); + +add_task(async function reorder_notification() { + let bookmarks = [ + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example2.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example3.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + ]; + let sorted = []; + for (let bm of bookmarks) { + sorted.push(await PlacesUtils.bookmarks.insert(bm)); + } + + // Randomly reorder the array. + sorted.sort(() => 0.5 - Math.random()); + // Ensure there's at least one item out of place, since random does not + // necessarily mean they are unordered. + if (sorted[0].url == bookmarks[0].url) { + sorted.push(sorted.shift()); + } + + const observer = expectPlacesObserverNotifications(["bookmark-moved"]); + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.unfiledGuid, + sorted.map(bm => bm.guid) + ); + + let expectedNotifications = []; + for (let i = 0; i < sorted.length; ++i) { + let child = sorted[i]; + let childId = await PlacesUtils.promiseItemId(child.guid); + expectedNotifications.push({ + type: "bookmark-moved", + id: childId, + itemType: child.type, + url: child.url || "", + guid: child.guid, + parentGuid: child.parentGuid, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + index: i, + oldParentGuid: child.parentGuid, + oldIndex: child.index, + isTagging: false, + }); + } + + observer.check(expectedNotifications); +}); + +add_task(async function update_notitle_notification() { + let toolbarBmURI = Services.io.newURI("https://example.com"); + let toolbarItemId = await PlacesUtils.promiseItemId( + PlacesUtils.bookmarks.toolbarGuid + ); + let toolbarBmId = PlacesUtils.bookmarks.insertBookmark( + toolbarItemId, + toolbarBmURI, + 0, + "Bookmark" + ); + let toolbarBmGuid = await PlacesUtils.promiseItemGuid(toolbarBmId); + + let menuFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Folder", + }); + let menuFolderId = await PlacesUtils.promiseItemId(menuFolder.guid); + + const observer = expectPlacesObserverNotifications([ + "bookmark-title-changed", + ]); + + PlacesUtils.bookmarks.setItemTitle(toolbarBmId, null); + strictEqual( + PlacesUtils.bookmarks.getItemTitle(toolbarBmId), + "", + "Legacy API should return empty string for untitled bookmark" + ); + + let updatedMenuBm = await PlacesUtils.bookmarks.update({ + guid: menuFolder.guid, + title: null, + }); + strictEqual( + updatedMenuBm.title, + "", + "Async API should return empty string for untitled bookmark" + ); + + let toolbarBmModified = await PlacesUtils.bookmarks.fetch(toolbarBmGuid); + observer.check([ + { + type: "bookmark-title-changed", + id: toolbarBmId, + itemType: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: toolbarBmURI.spec, + title: "", + guid: toolbarBmGuid, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + lastModified: toolbarBmModified.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-title-changed", + id: menuFolderId, + itemType: PlacesUtils.bookmarks.TYPE_FOLDER, + url: "", + title: "", + guid: menuFolder.guid, + parentGuid: PlacesUtils.bookmarks.menuGuid, + lastModified: updatedMenuBm.lastModified, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js new file mode 100644 index 0000000000..01bb591e3c --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js @@ -0,0 +1,465 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const UNVISITED_BOOKMARK_BONUS = 140; + +function promiseRankingChanged() { + return PlacesTestUtils.waitForNotification("pages-rank-changed"); +} + +add_task(async function setup() { + Services.prefs.setIntPref( + "places.frecency.unvisitedBookmarkBonus", + UNVISITED_BOOKMARK_BONUS + ); +}); + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.remove(), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove(null), + /Input should be a valid object/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.remove("test"), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove(123), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.remove({ guid: "test" }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ guid: null }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ guid: 123 }), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.remove({ parentGuid: "test" }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ parentGuid: null }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ parentGuid: 123 }), + /Invalid value for property 'parentGuid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.remove({ url: "http://te st/" }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ url: null }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.remove({ url: -10 }), + /Invalid value for property 'url'/ + ); +}); + +add_task(async function remove_nonexistent_guid() { + try { + await PlacesUtils.bookmarks.remove({ guid: "123456789012" }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/No bookmarks found for the provided GUID/.test(ex)); + } +}); + +add_task(async function remove_roots_fail() { + let guids = [ + PlacesUtils.bookmarks.rootGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.tagsGuid, + PlacesUtils.bookmarks.mobileGuid, + ]; + for (let guid of guids) { + Assert.throws( + () => PlacesUtils.bookmarks.remove(guid), + /It's not possible to remove Places root folders\./ + ); + } +}); + +add_task(async function remove_bookmark() { + // When removing a bookmark we need to check the frecency. First we confirm + // that there is a normal update when it is inserted. + let promise = promiseRankingChanged(); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; + + // This second one checks the frecency is changed when we remove the bookmark. + promise = promiseRankingChanged(); + + await PlacesUtils.bookmarks.remove(bm1.guid); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; +}); + +add_task(async function remove_multiple_bookmarks_simple() { + // When removing a bookmark we need to check the frecency. First we confirm + // that there is a normal update when it is inserted. + const promise1 = promiseRankingChanged(); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + const promise2 = promiseRankingChanged(); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example1.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm2); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await Promise.all([promise1, promise2]); + + // We should get a pages-rank-changed event with the removal of + // multiple bookmarks. + const promise3 = promiseRankingChanged(); + + await PlacesUtils.bookmarks.remove([bm1, bm2]); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise3; +}); + +add_task(async function remove_multiple_bookmarks_complex() { + let bms = []; + for (let i = 0; i < 10; i++) { + bms.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `http://example.com/${i}`, + title: `bookmark ${i}`, + }) + ); + } + + // Remove bookmarks 2 and 3. + let bmsToRemove = bms.slice(2, 4); + let notifiedIndexes = []; + let notificationPromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => { + for (let event of events) { + notifiedIndexes.push({ guid: event.guid, index: event.index }); + } + return notifiedIndexes.length == bmsToRemove.length; + } + ); + await PlacesUtils.bookmarks.remove(bmsToRemove); + await notificationPromise; + + let indexModifier = 0; + for (let i = 0; i < bmsToRemove.length; i++) { + Assert.equal( + notifiedIndexes[i].guid, + bmsToRemove[i].guid, + `Should have been notified of the correct guid for item ${i}` + ); + Assert.equal( + notifiedIndexes[i].index, + bmsToRemove[i].index - indexModifier, + `Should have been notified of the correct index for the item ${i}` + ); + indexModifier++; + } + + let expectedIndex = 0; + for (let bm of [bms[0], bms[1], ...bms.slice(4)]) { + const fetched = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal( + fetched.index, + expectedIndex, + "Should have the correct index after consecutive item removal" + ); + bm.index = fetched.index; + expectedIndex++; + } + + // Remove some more including non-consecutive. + bmsToRemove = [bms[1], bms[5], bms[6], bms[8]]; + notifiedIndexes = []; + notificationPromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => { + for (let event of events) { + notifiedIndexes.push({ guid: event.guid, index: event.index }); + } + return notifiedIndexes.length == bmsToRemove.length; + } + ); + await PlacesUtils.bookmarks.remove(bmsToRemove); + await notificationPromise; + + indexModifier = 0; + for (let i = 0; i < bmsToRemove.length; i++) { + Assert.equal( + notifiedIndexes[i].guid, + bmsToRemove[i].guid, + `Should have been notified of the correct guid for item ${i}` + ); + Assert.equal( + notifiedIndexes[i].index, + bmsToRemove[i].index - indexModifier, + `Should have been notified of the correct index for the item ${i}` + ); + indexModifier++; + } + + expectedIndex = 0; + const expectedRemaining = [bms[0], bms[4], bms[7], bms[9]]; + for (let bm of expectedRemaining) { + const fetched = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal( + fetched.index, + expectedIndex, + "Should have the correct index after non-consecutive item removal" + ); + expectedIndex++; + } + + // Tidy up + await PlacesUtils.bookmarks.remove(expectedRemaining); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function remove_bookmark_empty_title() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "", + }); + checkBookmarkObject(bm1); + + await PlacesUtils.bookmarks.remove(bm1.guid); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); +}); + +add_task(async function remove_folder() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + checkBookmarkObject(bm1); + + await PlacesUtils.bookmarks.remove(bm1.guid); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); + + // No wait for pages-rank-changed event in this test as the folder doesn't have + // any children that would need updating. +}); + +add_task(async function test_contents_removed() { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "", + }); + + let skipDescendantsObserver = expectPlacesObserverNotifications( + ["bookmark-removed"], + false, + true + ); + let receiveAllObserver = expectPlacesObserverNotifications( + ["bookmark-removed"], + false, + false + ); + const promise = promiseRankingChanged(); + await PlacesUtils.bookmarks.remove(folder1); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(folder1.guid), null); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; + + let expectedNotifications = [ + { + type: "bookmark-removed", + guid: folder1.guid, + }, + ]; + + // If we're skipping descendents, we'll only be notified of the folder. + skipDescendantsObserver.check(expectedNotifications); + + // Note: Items of folders get notified first. + expectedNotifications.unshift({ + type: "bookmark-removed", + guid: bm1.guid, + }); + // If we don't skip descendents, we'll be notified of the folder and the + // bookmark. + receiveAllObserver.check(expectedNotifications); +}); + +add_task(async function test_nested_contents_removed() { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "", + }); + + const promise = promiseRankingChanged(); + await PlacesUtils.bookmarks.remove(folder1); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(folder1.guid), null); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(folder2.guid), null); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; +}); + +add_task(async function remove_folder_empty_title() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "", + }); + checkBookmarkObject(bm1); + + await PlacesUtils.bookmarks.remove(bm1.guid); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); +}); + +add_task(async function remove_separator() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + checkBookmarkObject(bm1); + + await PlacesUtils.bookmarks.remove(bm1.guid); + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(bm1.guid), null); +}); + +add_task(async function test_nested_content_fails_when_not_allowed() { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + await Assert.rejects( + PlacesUtils.bookmarks.remove(folder1, { + preventRemovalOfNonEmptyFolders: true, + }), + /Cannot remove a non-empty folder./ + ); +}); + +add_task(async function test_remove_bookmark_with_invalid_url() { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "folder", + }); + let guid = "invalid_____"; + let folderedGuid = "invalid____2"; + let url = "invalid-uri"; + await PlacesUtils.withConnectionWrapper("test_bookmarks_remove", async db => { + await db.execute( + ` + INSERT INTO moz_places(url, url_hash, title, rev_host, guid) + VALUES (:url, hash(:url), 'Invalid URI', '.', GENERATE_GUID()) + `, + { url } + ); + await db.execute( + `INSERT INTO moz_bookmarks (type, fk, parent, position, guid) + VALUES (:type, + (SELECT id FROM moz_places WHERE url = :url), + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)), + :guid) + `, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid, + } + ); + await db.execute( + `INSERT INTO moz_bookmarks (type, fk, parent, position, guid) + VALUES (:type, + (SELECT id FROM moz_places WHERE url = :url), + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)), + :guid) + `, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url, + parentGuid: folder.guid, + guid: folderedGuid, + } + ); + }); + await PlacesUtils.bookmarks.remove(guid); + Assert.strictEqual( + await PlacesUtils.bookmarks.fetch(guid), + null, + "Should not throw and not find the bookmark" + ); + + await PlacesUtils.bookmarks.remove(folder); + Assert.strictEqual( + await PlacesUtils.bookmarks.fetch(folderedGuid), + null, + "Should not throw and not find the bookmark" + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_remove_batch.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove_batch.js new file mode 100644 index 0000000000..51a2a4dc2b --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove_batch.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test whether do batch removal if multiple bookmarks are removed at once. + +add_task(async function test_remove_multiple_bookmarks() { + info("Test for remove multiple bookmarks at once"); + + info("Insert multiple bookmarks"); + const testBookmarks = [ + { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/1", + }, + { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/2", + }, + { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/3", + }, + ]; + const bookmarks = await Promise.all( + testBookmarks.map(bookmark => PlacesUtils.bookmarks.insert(bookmark)) + ); + Assert.equal( + bookmarks.length, + testBookmarks.length, + "All test data are insterted correctly" + ); + + info("Remove multiple bookmarks"); + const onRemoved = PlacesTestUtils.waitForNotification("bookmark-removed"); + await PlacesUtils.bookmarks.remove(bookmarks); + const events = await onRemoved; + + info("Check whether all bookmark-removed events called at at once or not"); + assertBookmarkRemovedEvents(events, bookmarks); +}); + +add_task(async function test_remove_folder_with_bookmarks() { + info("Test for remove a folder that has multiple bookmarks"); + + info("Insert a folder"); + const testFolder = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }; + const folder = await PlacesUtils.bookmarks.insert(testFolder); + Assert.ok(folder, "A folder is inserted correctly"); + + info("Insert multiple bookmarks to inserted folder"); + const testBookmarks = [ + { + parentGuid: folder.guid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/1", + }, + { + parentGuid: folder.guid, + url: "http://example.com/2", + }, + { + parentGuid: folder.guid, + url: "http://example.com/3", + }, + ]; + const bookmarks = await Promise.all( + testBookmarks.map(bookmark => PlacesUtils.bookmarks.insert(bookmark)) + ); + Assert.equal( + bookmarks.length, + testBookmarks.length, + "All test data are insterted correctly" + ); + + info("Remove the inserted folder"); + const notifiedEvents = []; + const onRemoved = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => { + notifiedEvents.push(events); + return notifiedEvents.length === 2; + } + ); + await PlacesUtils.bookmarks.remove(folder); + await onRemoved; + + info("Check whether all bookmark-removed events called at at once or not"); + const eventsForBookmarks = notifiedEvents[0]; + assertBookmarkRemovedEvents(eventsForBookmarks, bookmarks); + + info("Check whether a bookmark-removed event called for the folder"); + const eventsForFolder = notifiedEvents[1]; + Assert.equal( + eventsForFolder.length, + 1, + "The length of notified events is correct" + ); + Assert.equal( + eventsForFolder[0].guid, + folder.guid, + "The guid of event is correct" + ); +}); + +function assertBookmarkRemovedEvents(events, expectedBookmarks) { + Assert.equal( + events.length, + expectedBookmarks.length, + "The length of notified events is correct" + ); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const expectedBookmark = expectedBookmarks[i]; + Assert.equal( + event.guid, + expectedBookmark.guid, + `The guid of events[${i}] is correct` + ); + Assert.equal( + event.url, + expectedBookmark.url, + `The url of events[${i}] is correct` + ); + } +} diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js new file mode 100644 index 0000000000..7df909c704 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js @@ -0,0 +1,310 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.reorder(), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder(null), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.reorder("test"), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder(123), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.reorder({ guid: "test" }), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012"), + /Must provide a sorted array of children GUIDs./ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", {}), + /Must provide a sorted array of children GUIDs./ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", null), + /Must provide a sorted array of children GUIDs./ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", []), + /Must provide a sorted array of children GUIDs./ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", [null]), + /Invalid GUID found in the sorted children array/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", [""]), + /Invalid GUID found in the sorted children array/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", [{}]), + /Invalid GUID found in the sorted children array/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.reorder("123456789012", ["012345678901", null]), + /Invalid GUID found in the sorted children array/ + ); +}); + +add_task(async function reorder_nonexistent_guid() { + await Assert.rejects( + PlacesUtils.bookmarks.reorder("123456789012", ["012345678901"]), + /No folder found for the provided GUID/, + "Should throw for nonexisting guid" + ); +}); + +add_task(async function reorder() { + let bookmarks = [ + { + url: "http://example1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + url: "http://example2.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + url: "http://example3.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + ]; + + let sorted = []; + for (let bm of bookmarks) { + sorted.push(await PlacesUtils.bookmarks.insert(bm)); + } + + // Check the initial append sorting. + Assert.ok( + sorted.every((bm, i) => bm.index == i), + "Initial bookmarks sorting is correct" + ); + + // Apply random sorting and run multiple tests. + for (let t = 0; t < 4; t++) { + sorted.sort(() => 0.5 - Math.random()); + let sortedGuids = sorted.map(child => child.guid); + dump("Expected order: " + sortedGuids.join() + "\n"); + // Add a nonexisting guid to the array, to ensure nothing will break. + sortedGuids.push("123456789012"); + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.unfiledGuid, + sortedGuids + ); + for (let i = 0; i < sorted.length; ++i) { + let item = await PlacesUtils.bookmarks.fetch(sorted[i].guid); + Assert.equal(item.index, i); + } + } + + info("Test partial sorting"); + { + // Try a partial sorting by passing 2 entries in same order as they + // currently have. No entries should change order. + let sortedGuids = [sorted[0].guid, sorted[3].guid]; + dump("Expected order: " + sorted.map(b => b.guid).join() + "\n"); + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.unfiledGuid, + sortedGuids + ); + for (let i = 0; i < sorted.length; ++i) { + let item = await PlacesUtils.bookmarks.fetch(sorted[i].guid); + Assert.equal(item.index, i); + } + } + + { + // Try a partial sorting by passing 2 entries out of order + // The unspecified entries should be appended and retain the original order + sorted = [sorted[1], sorted[0]].concat(sorted.slice(2)); + let sortedGuids = [sorted[0].guid, sorted[1].guid]; + dump("Expected order: " + sorted.map(b => b.guid).join() + "\n"); + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.unfiledGuid, + sortedGuids + ); + for (let i = 0; i < sorted.length; ++i) { + let item = await PlacesUtils.bookmarks.fetch(sorted[i].guid); + Assert.equal(item.index, i); + } + } + // Use triangular numbers to detect skipped position. + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + `SELECT parent + FROM moz_bookmarks + GROUP BY parent + HAVING (SUM(DISTINCT position + 1) - (count(*) * (count(*) + 1) / 2)) <> 0` + ); + Assert.equal( + rows.length, + 0, + "All the bookmarks should have consistent positions" + ); +}); + +add_task(async function move_and_reorder() { + // Start clean. + await PlacesUtils.bookmarks.eraseEverything(); + + let bm1 = await PlacesUtils.bookmarks.insert({ + url: "http://example1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let f1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + url: "http://example2.com/", + parentGuid: f1.guid, + }); + let f2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + url: "http://example3.com/", + parentGuid: f2.guid, + }); + let bm4 = await PlacesUtils.bookmarks.insert({ + url: "http://example4.com/", + parentGuid: f2.guid, + }); + let bm5 = await PlacesUtils.bookmarks.insert({ + url: "http://example5.com/", + parentGuid: f2.guid, + }); + + // Invert f2 children. + // This is critical to reproduce the bug, cause it inverts the position + // compared to the natural insertion order. + await PlacesUtils.bookmarks.reorder(f2.guid, [bm5.guid, bm4.guid, bm3.guid]); + + bm1.parentGuid = f1.guid; + bm1.index = 0; + await PlacesUtils.bookmarks.update(bm1); + + bm1 = await PlacesUtils.bookmarks.fetch(bm1.guid); + Assert.equal(bm1.index, 0); + bm2 = await PlacesUtils.bookmarks.fetch(bm2.guid); + Assert.equal(bm2.index, 1); + bm3 = await PlacesUtils.bookmarks.fetch(bm3.guid); + Assert.equal(bm3.index, 2); + bm4 = await PlacesUtils.bookmarks.fetch(bm4.guid); + Assert.equal(bm4.index, 1); + bm5 = await PlacesUtils.bookmarks.fetch(bm5.guid); + Assert.equal(bm5.index, 0); + + // No-op reorder on f1 children. + // Nothing should change. Though, due to bug 1293365 this was causing children + // of other folders to get messed up. + await PlacesUtils.bookmarks.reorder(f1.guid, [bm1.guid, bm2.guid]); + + bm1 = await PlacesUtils.bookmarks.fetch(bm1.guid); + Assert.equal(bm1.index, 0); + bm2 = await PlacesUtils.bookmarks.fetch(bm2.guid); + Assert.equal(bm2.index, 1); + bm3 = await PlacesUtils.bookmarks.fetch(bm3.guid); + Assert.equal(bm3.index, 2); + bm4 = await PlacesUtils.bookmarks.fetch(bm4.guid); + Assert.equal(bm4.index, 1); + bm5 = await PlacesUtils.bookmarks.fetch(bm5.guid); + Assert.equal(bm5.index, 0); +}); + +add_task(async function reorder_empty_folder_invalid_children() { + // Start clean. + await PlacesUtils.bookmarks.eraseEverything(); + + let f1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + // Specifying a child that doesn't exist should cause that to be ignored. + // However, before bug 1333304, doing this on an empty folder threw. + await PlacesUtils.bookmarks.reorder(f1.guid, ["123456789012"]); +}); + +add_task(async function reorder_lastModified() { + // Start clean. + await PlacesUtils.bookmarks.eraseEverything(); + + let lastModified = new Date(Date.now() - 1000); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded: lastModified, + lastModified, + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded: lastModified, + lastModified, + }, + ], + }); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.menuGuid, + lastModified, + }); + + info("Reorder and set explicit last modified time"); + let newLastModified = new Date(lastModified.getTime() + 500); + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.menuGuid, + ["bookmarkBBBB", "bookmarkAAAA"], + { lastModified: newLastModified } + ); + for (let guid of [ + PlacesUtils.bookmarks.menuGuid, + "bookmarkAAAA", + "bookmarkBBBB", + ]) { + let info = await PlacesUtils.bookmarks.fetch(guid); + Assert.equal(info.lastModified.getTime(), newLastModified.getTime()); + } + + info("Reorder and set default last modified time"); + await PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, [ + "bookmarkAAAA", + "bookmarkBBBB", + ]); + for (let guid of [ + PlacesUtils.bookmarks.menuGuid, + "bookmarkAAAA", + "bookmarkBBBB", + ]) { + let info = await PlacesUtils.bookmarks.fetch(guid); + Assert.greater(info.lastModified.getTime(), newLastModified.getTime()); + } +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js new file mode 100644 index 0000000000..2b34b2c8f9 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js @@ -0,0 +1,339 @@ +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.search(), + /Query object is required/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.search(null), + /Query object is required/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.search({ title: 50 }), + /Title option must be a string/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.search({ url: { url: "wombat" } }), + /Url option must be a string or a URL object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.search(50), + /Query must be an object or a string/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.search(true), + /Query must be an object or a string/ + ); +}); + +add_task(async function search_bookmark() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/", + title: "another bookmark", + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://menu.org/", + title: "an on-menu bookmark", + }); + let bm4 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://toolbar.org/", + title: "an on-toolbar bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + checkBookmarkObject(bm3); + checkBookmarkObject(bm4); + + // finds a result by query + let results = await PlacesUtils.bookmarks.search("example.com"); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm1, results[0]); + + // finds multiple results + results = await PlacesUtils.bookmarks.search("example"); + Assert.equal(results.length, 2); + checkBookmarkObject(results[0]); + checkBookmarkObject(results[1]); + + // finds menu bookmarks + results = await PlacesUtils.bookmarks.search("an on-menu bookmark"); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm3, results[0]); + + // finds toolbar bookmarks + results = await PlacesUtils.bookmarks.search("an on-toolbar bookmark"); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm4, results[0]); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_bookmark_by_query_object() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/", + title: "another bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + + let results = await PlacesUtils.bookmarks.search({ query: "example.com" }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + + Assert.deepEqual(bm1, results[0]); + + results = await PlacesUtils.bookmarks.search({ query: "example" }); + Assert.equal(results.length, 2); + checkBookmarkObject(results[0]); + checkBookmarkObject(results[1]); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_bookmark_by_url() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/path", + title: "another bookmark", + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/path", + title: "third bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + checkBookmarkObject(bm3); + + // finds the correct result by url + let results = await PlacesUtils.bookmarks.search({ + url: "http://example.com/", + }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm1, results[0]); + + // normalizes the url + results = await PlacesUtils.bookmarks.search({ url: "http:/example.com" }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm1, results[0]); + + // returns multiple matches + results = await PlacesUtils.bookmarks.search({ + url: "http://example.org/path", + }); + Assert.equal(results.length, 2); + + // requires exact match + results = await PlacesUtils.bookmarks.search({ url: "http://example.org/" }); + Assert.equal(results.length, 0); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_bookmark_by_title() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/path", + title: "another bookmark", + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.net/", + title: "another bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + checkBookmarkObject(bm3); + + // finds the correct result by title + let results = await PlacesUtils.bookmarks.search({ title: "a bookmark" }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm1, results[0]); + + // returns multiple matches + results = await PlacesUtils.bookmarks.search({ title: "another bookmark" }); + Assert.equal(results.length, 2); + + // requires exact match + results = await PlacesUtils.bookmarks.search({ title: "bookmark" }); + Assert.equal(results.length, 0); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_bookmark_combinations() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.org/path", + title: "another bookmark", + }); + let bm3 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.net/", + title: "third bookmark", + }); + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + checkBookmarkObject(bm3); + + // finds the correct result if title and url match + let results = await PlacesUtils.bookmarks.search({ + url: "http://example.com/", + title: "a bookmark", + }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm1, results[0]); + + // does not match if query is not matching but url and title match + results = await PlacesUtils.bookmarks.search({ + url: "http://example.com/", + title: "a bookmark", + query: "nonexistent", + }); + Assert.equal(results.length, 0); + + // does not match if one parameter is not matching + results = await PlacesUtils.bookmarks.search({ + url: "http://what.ever", + title: "a bookmark", + }); + Assert.equal(results.length, 0); + + // query only matches if other fields match as well + results = await PlacesUtils.bookmarks.search({ + query: "bookmark", + url: "http://example.net/", + }); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm3, results[0]); + + // non-matching query will also return no results + results = await PlacesUtils.bookmarks.search({ + query: "nonexistent", + url: "http://example.net/", + }); + Assert.equal(results.length, 0); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_folder() { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a test folder", + }); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(folder); + checkBookmarkObject(bm); + + // also finds folders + let results = await PlacesUtils.bookmarks.search("a test folder"); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.equal(folder.title, results[0].title); + Assert.equal(folder.type, results[0].type); + Assert.equal(folder.parentGuid, results[0].parentGuid); + + // finds elements in folders + results = await PlacesUtils.bookmarks.search("example.com"); + Assert.equal(results.length, 1); + checkBookmarkObject(results[0]); + Assert.deepEqual(bm, results[0]); + Assert.equal(folder.guid, results[0].parentGuid); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_includes_separators() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + + checkBookmarkObject(bm1); + checkBookmarkObject(bm2); + + let results = await PlacesUtils.bookmarks.search({}); + Assert.ok( + results.findIndex(bookmark => { + return bookmark.guid == bm1.guid; + }) > -1, + "The bookmark was found in the results." + ); + Assert.ok( + results.findIndex(bookmark => { + return bookmark.guid == bm2.guid; + }) > -1, + "The separator was included in the results." + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function search_excludes_tags() { + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + checkBookmarkObject(bm1); + PlacesUtils.tagging.tagURI(bm1.url.URI, ["Test Tag"]); + + let results = await PlacesUtils.bookmarks.search("example.com"); + // If tags are not being excluded, this would return two results, one representing the tag. + Assert.equal(1, results.length, "A single object was returned from search."); + Assert.deepEqual(bm1, results[0], "The bookmark was returned."); + + results = await PlacesUtils.bookmarks.search("Test Tag"); + Assert.equal(0, results.length, "The tag folder was not returned."); + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js new file mode 100644 index 0000000000..1c9bead831 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js @@ -0,0 +1,587 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function invalid_input_throws() { + Assert.throws( + () => PlacesUtils.bookmarks.update(), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update(null), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({}), + /The following properties were expected/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ guid: "test" }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ guid: null }), + /Invalid value for property 'guid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ guid: 123 }), + /Invalid value for property 'guid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ parentGuid: "test" }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ parentGuid: null }), + /Invalid value for property 'parentGuid'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ parentGuid: 123 }), + /Invalid value for property 'parentGuid'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ index: "1" }), + /Invalid value for property 'index'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ index: -10 }), + /Invalid value for property 'index'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ dateAdded: -10 }), + /Invalid value for property 'dateAdded'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ dateAdded: "today" }), + /Invalid value for property 'dateAdded'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ dateAdded: Date.now() }), + /Invalid value for property 'dateAdded'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ lastModified: -10 }), + /Invalid value for property 'lastModified'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ lastModified: "today" }), + /Invalid value for property 'lastModified'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ lastModified: Date.now() }), + /Invalid value for property 'lastModified'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ type: -1 }), + /Invalid value for property 'type'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ type: 100 }), + /Invalid value for property 'type'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ type: "bookmark" }), + /Invalid value for property 'type'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ url: 10 }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ url: "http://te st" }), + /Invalid value for property 'url'/ + ); + let longurl = "http://www.example.com/"; + for (let i = 0; i < 65536; i++) { + longurl += "a"; + } + Assert.throws( + () => PlacesUtils.bookmarks.update({ url: longurl }), + /Invalid value for property 'url'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ url: NetUtil.newURI(longurl) }), + /Invalid value for property 'url'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ url: "te st" }), + /Invalid value for property 'url'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ title: -1 }), + /Invalid value for property 'title'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.update({ title: {} }), + /Invalid value for property 'title'/ + ); + + Assert.throws( + () => PlacesUtils.bookmarks.update({ guid: "123456789012" }), + /Not enough properties to update/ + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.update({ + guid: "123456789012", + parentGuid: "012345678901", + }), + /The following properties were expected: index/ + ); +}); + +add_task(async function move_roots_fail() { + let guids = [ + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.tagsGuid, + PlacesUtils.bookmarks.mobileGuid, + ]; + for (let guid of guids) { + await Assert.rejects( + PlacesUtils.bookmarks.update({ + guid, + index: -1, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }), + /It's not possible to move Places root folders\./, + `Should reject when attempting to move ${guid}` + ); + } +}); + +add_task(async function nonexisting_bookmark_throws() { + try { + await PlacesUtils.bookmarks.update({ guid: "123456789012", title: "test" }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/No bookmarks found for the provided GUID/.test(ex)); + } +}); + +add_task(async function invalid_properties_for_existing_bookmark() { + let bm = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + }); + + try { + await PlacesUtils.bookmarks.update({ + guid: bm.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/The bookmark type cannot be changed/.test(ex)); + } + + try { + await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: "123456789012", + index: 1, + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/No bookmarks found for the provided parentGuid/.test(ex)); + } + + let past = new Date(Date.now() - 86400000); + try { + await PlacesUtils.bookmarks.update({ guid: bm.guid, lastModified: past }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/Invalid value for property 'lastModified'/.test(ex)); + } + + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + try { + await PlacesUtils.bookmarks.update({ + guid: folder.guid, + url: "http://example.com/", + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/Invalid value for property 'url'/.test(ex)); + } + + let separator = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + try { + await PlacesUtils.bookmarks.update({ + guid: separator.guid, + url: "http://example.com/", + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/Invalid value for property 'url'/.test(ex)); + } + try { + await PlacesUtils.bookmarks.update({ guid: separator.guid, title: "test" }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok(/Invalid value for property 'title'/.test(ex)); + } +}); + +add_task(async function long_title_trim() { + let longtitle = "a"; + for (let i = 0; i < 4096; i++) { + longtitle += "a"; + } + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "title", + }); + checkBookmarkObject(bm); + + bm = await PlacesUtils.bookmarks.update({ guid: bm.guid, title: longtitle }); + let newTitle = bm.title; + Assert.equal(newTitle.length, 4096, "title should have been trimmed"); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal(bm.title, newTitle); +}); + +add_task(async function update_lastModified() { + let yesterday = new Date(Date.now() - 86400000); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "title", + dateAdded: yesterday, + }); + checkBookmarkObject(bm); + Assert.deepEqual(bm.lastModified, yesterday); + + let time = new Date(); + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + lastModified: time, + }); + checkBookmarkObject(bm); + Assert.deepEqual(bm.lastModified, time); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.deepEqual(bm.lastModified, time); + + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + lastModified: yesterday, + }); + Assert.deepEqual(bm.lastModified, yesterday); + + bm = await PlacesUtils.bookmarks.update({ guid: bm.guid, title: "title2" }); + Assert.ok(bm.lastModified >= time); + + bm = await PlacesUtils.bookmarks.update({ guid: bm.guid, title: "" }); + Assert.strictEqual(bm.title, ""); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.strictEqual(bm.title, ""); +}); + +add_task(async function update_url() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "title", + }); + checkBookmarkObject(bm); + let lastModified = bm.lastModified; + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: bm.url } + ); + Assert.greater(frecency, 0, "Check frecency has been updated"); + + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + url: "http://mozilla.org/", + }); + checkBookmarkObject(bm); + Assert.ok(bm.lastModified >= lastModified); + Assert.equal(bm.url.href, "http://mozilla.org/"); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal(bm.url.href, "http://mozilla.org/"); + Assert.ok(bm.lastModified >= lastModified); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://example.com/", + }), + frecency, + "Check frecency for example.com" + ); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: "http://mozilla.org/", + }), + frecency, + "Check frecency for mozilla.org" + ); +}); + +add_task(async function update_index() { + let parent = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let f1 = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(f1.index, 0); + let f2 = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(f2.index, 1); + let f3 = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(f3.index, 2); + let lastModified = f1.lastModified; + + f1 = await PlacesUtils.bookmarks.update({ + guid: f1.guid, + parentGuid: f1.parentGuid, + index: 1, + }); + checkBookmarkObject(f1); + Assert.equal(f1.index, 1); + Assert.ok(f1.lastModified >= lastModified); + + parent = await PlacesUtils.bookmarks.fetch(f1.parentGuid); + Assert.deepEqual(parent.lastModified, f1.lastModified); + + f2 = await PlacesUtils.bookmarks.fetch(f2.guid); + Assert.equal(f2.index, 0); + + f3 = await PlacesUtils.bookmarks.fetch(f3.guid); + Assert.equal(f3.index, 2); + + f3 = await PlacesUtils.bookmarks.update({ guid: f3.guid, index: 0 }); + f1 = await PlacesUtils.bookmarks.fetch(f1.guid); + Assert.equal(f1.index, 2); + + f2 = await PlacesUtils.bookmarks.fetch(f2.guid); + Assert.equal(f2.index, 1); +}); + +add_task(async function update_move_folder_into_descendant_throws() { + let parent = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let descendant = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + try { + await PlacesUtils.bookmarks.update({ + guid: parent.guid, + parentGuid: parent.guid, + index: 0, + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok( + /Cannot insert a folder into itself or one of its descendants/.test(ex) + ); + } + + try { + await PlacesUtils.bookmarks.update({ + guid: parent.guid, + parentGuid: descendant.guid, + index: 0, + }); + Assert.ok(false, "Should have thrown"); + } catch (ex) { + Assert.ok( + /Cannot insert a folder into itself or one of its descendants/.test(ex) + ); + } +}); + +add_task(async function update_move_into_root_folder_rejects() { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + }); + + Assert.throws( + () => + PlacesUtils.bookmarks.update({ + guid: bm.guid, + index: -1, + parentGuid: PlacesUtils.bookmarks.rootGuid, + }), + /Invalid value for property 'parentGuid'/, + "Should reject when attempting to move a bookmark into the root" + ); + + Assert.throws( + () => + PlacesUtils.bookmarks.update({ + guid: folder.guid, + index: -1, + parentGuid: PlacesUtils.bookmarks.rootGuid, + }), + /Invalid value for property 'parentGuid'/, + "Should reject when attempting to move a bookmark into the root" + ); +}); + +add_task(async function update_move() { + let parent = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + url: "http://example.com/", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + }); + let descendant = await PlacesUtils.bookmarks.insert({ + parentGuid: parent.guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(descendant.index, 1); + let lastModified = bm.lastModified; + + // This is moving to a nonexisting index by purpose, it will be appended. + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: descendant.guid, + index: 1, + }); + checkBookmarkObject(bm); + Assert.equal(bm.parentGuid, descendant.guid); + Assert.equal(bm.index, 0); + Assert.ok(bm.lastModified >= lastModified); + + parent = await PlacesUtils.bookmarks.fetch(parent.guid); + descendant = await PlacesUtils.bookmarks.fetch(descendant.guid); + Assert.deepEqual(parent.lastModified, bm.lastModified); + Assert.deepEqual(descendant.lastModified, bm.lastModified); + Assert.equal(descendant.index, 0); + + bm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal(bm.parentGuid, descendant.guid); + Assert.equal(bm.index, 0); + + bm = await PlacesUtils.bookmarks.update({ + guid: bm.guid, + parentGuid: parent.guid, + index: 0, + }); + Assert.equal(bm.parentGuid, parent.guid); + Assert.equal(bm.index, 0); + + descendant = await PlacesUtils.bookmarks.fetch(descendant.guid); + Assert.equal(descendant.index, 1); +}); + +add_task(async function update_move_append() { + let folder_a = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(folder_a); + let folder_b = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkBookmarkObject(folder_b); + + /* folder_a: [sep_1, sep_2, sep_3], folder_b: [] */ + let sep_1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder_a.guid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + checkBookmarkObject(sep_1); + let sep_2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder_a.guid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + checkBookmarkObject(sep_2); + let sep_3 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder_a.guid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + checkBookmarkObject(sep_3); + + function ensurePosition(info, parentGuid, index) { + checkBookmarkObject(info); + Assert.equal(info.parentGuid, parentGuid); + Assert.equal(info.index, index); + } + + // folder_a: [sep_2, sep_3, sep_1], folder_b: [] + sep_1.index = PlacesUtils.bookmarks.DEFAULT_INDEX; + // Note sep_1 includes parentGuid even though we're not moving the item to + // another folder + sep_1 = await PlacesUtils.bookmarks.update(sep_1); + ensurePosition(sep_1, folder_a.guid, 2); + sep_2 = await PlacesUtils.bookmarks.fetch(sep_2.guid); + ensurePosition(sep_2, folder_a.guid, 0); + sep_3 = await PlacesUtils.bookmarks.fetch(sep_3.guid); + ensurePosition(sep_3, folder_a.guid, 1); + sep_1 = await PlacesUtils.bookmarks.fetch(sep_1.guid); + ensurePosition(sep_1, folder_a.guid, 2); + + // folder_a: [sep_2, sep_1], folder_b: [sep_3] + sep_3.index = PlacesUtils.bookmarks.DEFAULT_INDEX; + sep_3.parentGuid = folder_b.guid; + sep_3 = await PlacesUtils.bookmarks.update(sep_3); + ensurePosition(sep_3, folder_b.guid, 0); + sep_2 = await PlacesUtils.bookmarks.fetch(sep_2.guid); + ensurePosition(sep_2, folder_a.guid, 0); + sep_1 = await PlacesUtils.bookmarks.fetch(sep_1.guid); + ensurePosition(sep_1, folder_a.guid, 1); + sep_3 = await PlacesUtils.bookmarks.fetch(sep_3.guid); + ensurePosition(sep_3, folder_b.guid, 0); + + // folder_a: [sep_1], folder_b: [sep_3, sep_2] + sep_2.index = Number.MAX_SAFE_INTEGER; + sep_2.parentGuid = folder_b.guid; + sep_2 = await PlacesUtils.bookmarks.update(sep_2); + ensurePosition(sep_2, folder_b.guid, 1); + sep_1 = await PlacesUtils.bookmarks.fetch(sep_1.guid); + ensurePosition(sep_1, folder_a.guid, 0); + sep_3 = await PlacesUtils.bookmarks.fetch(sep_3.guid); + ensurePosition(sep_3, folder_b.guid, 0); + sep_2 = await PlacesUtils.bookmarks.fetch(sep_2.guid); + ensurePosition(sep_2, folder_b.guid, 1); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js b/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js new file mode 100644 index 0000000000..be6b4ad669 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js @@ -0,0 +1,23 @@ +// Bug 1192692 - promiseBookmarksTree caches items without adding observers to +// invalidate the cache. +add_task(async function boookmarks_tree_cache() { + // Note that for this test to be effective, it needs to use the "old" sync + // bookmarks methods - using, eg, PlacesUtils.bookmarks.insert() doesn't + // demonstrate the problem as it indirectly arranges for the observers to + // be added. + let id = PlacesUtils.bookmarks.insertBookmark( + await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.unfiledGuid), + uri("http://example.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "A title" + ); + + await PlacesUtils.promiseBookmarksTree(); + + PlacesUtils.bookmarks.removeItem(id); + + await Assert.rejects( + PlacesUtils.promiseItemGuid(id), + /no item found for the given itemId/ + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_insertTree_fixupOrSkipInvalidEntries.js b/toolkit/components/places/tests/bookmarks/test_insertTree_fixupOrSkipInvalidEntries.js new file mode 100644 index 0000000000..9a676e0ed0 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_insertTree_fixupOrSkipInvalidEntries.js @@ -0,0 +1,114 @@ +function insertTree(tree) { + return PlacesUtils.bookmarks.insertTree(tree, { + fixupOrSkipInvalidEntries: true, + }); +} + +add_task(async function () { + let guid = PlacesUtils.bookmarks.unfiledGuid; + await Assert.throws( + () => insertTree({ guid, children: [] }), + /Should have a non-zero number of children to insert./ + ); + await Assert.throws( + () => insertTree({ guid: "invalid", children: [{}] }), + /The parent guid is not valid/ + ); + + let now = new Date(); + let url = "http://mozilla.com/"; + let obs = { + count: 0, + lastIndex: 0, + handlePlacesEvent(events) { + for (let event of events) { + obs.count++; + let lastIndex = obs.lastIndex; + obs.lastIndex = event.index; + if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + Assert.equal(event.url, url, "Found the expected url"); + } + Assert.ok( + event.index == 0 || event.index == lastIndex + 1, + "Consecutive indices" + ); + Assert.ok(event.dateAdded >= now, "Found a valid dateAdded"); + Assert.ok(PlacesUtils.isValidGuid(event.guid), "guid is valid"); + } + }, + }; + PlacesUtils.observers.addListener(["bookmark-added"], obs.handlePlacesEvent); + + let tree = { + guid, + children: [ + { + // Should be inserted, and the invalid guid should be replaced. + guid: "test", + url, + }, + { + // Should be skipped, since the url is invalid. + url: "fake_url", + }, + { + // Should be skipped, since the type is invalid. + url, + type: 999, + }, + { + // Should be skipped, since the type is invalid. + type: 999, + children: [ + { + url, + }, + ], + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "test", + children: [ + { + // Should fix lastModified and dateAdded. + url, + lastModified: null, + }, + { + // Should be skipped, since the url is invalid. + url: "fake_url", + dateAdded: null, + }, + { + // Should fix lastModified and dateAdded. + url, + dateAdded: undefined, + }, + { + // Should be skipped since it's a separator with a url + url, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + // Should fix lastModified and dateAdded. + url, + dateAdded: new Date(now - 86400000), + lastModified: new Date(now - 172800000), // less than dateAdded + }, + ], + }, + ], + }; + + let bms = await insertTree(tree); + for (let bm of bms) { + checkBookmarkObject(bm); + } + Assert.equal(bms.length, 5); + Assert.equal(obs.count, bms.length); + + PlacesUtils.observers.removeListener( + ["bookmark-added"], + obs.handlePlacesEvent + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_keywords.js b/toolkit/components/places/tests/bookmarks/test_keywords.js new file mode 100644 index 0000000000..fca9ebf32a --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_keywords.js @@ -0,0 +1,691 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const URI1 = "http://test1.mozilla.org/"; +const URI2 = "http://test2.mozilla.org/"; +const URI3 = "http://test3.mozilla.org/"; + +async function check_keyword(aURI, aKeyword) { + if (aKeyword) { + aKeyword = aKeyword.toLowerCase(); + } + + if (aKeyword) { + let uri = await PlacesUtils.keywords.fetch(aKeyword); + Assert.equal(uri.url, aURI); + // Check case insensitivity. + uri = await PlacesUtils.keywords.fetch(aKeyword.toUpperCase()); + Assert.equal(uri.url, aURI); + } else { + let entry = await PlacesUtils.keywords.fetch({ url: aURI }); + if (entry) { + throw new Error(`${aURI.spec} should not have a keyword`); + } + } +} + +async function check_orphans() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT id FROM moz_keywords k + WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id) + ` + ); + Assert.equal(rows.length, 0); +} + +function expectNotifications() { + const observer = { + notifications: [], + _start() { + this._handle = this._handle.bind(this); + PlacesUtils.observers.addListener( + ["bookmark-keyword-changed"], + this._handle + ); + }, + _handle(events) { + for (const event of events) { + this.notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + keyword: event.keyword, + lastModified: new Date(event.lastModified), + source: event.source, + isTagging: event.isTagging, + }); + } + }, + check(expected) { + PlacesUtils.observers.removeListener( + ["bookmark-keyword-changed"], + this._handle + ); + Assert.deepEqual(this.notifications, expected); + }, + }; + observer._start(); + return observer; +} + +add_task(function test_invalid_input() {}); + +add_task(async function test_addBookmarkAndKeyword() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + await check_keyword(URI1, null); + let fc = await foreign_count(URI1); + let observer = expectNotifications(); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI1, + title: "test", + }); + await PlacesUtils.keywords.insert({ url: URI1, keyword: "keyword" }); + let itemId = await PlacesUtils.promiseItemId(bookmark.guid); + observer.check([ + { + type: "bookmark-keyword-changed", + id: itemId, + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + await check_keyword(URI1, "keyword"); + Assert.equal(await foreign_count(URI1), fc + 2); // + 1 bookmark + 1 keyword + await check_orphans(); +}); + +add_task(async function test_addBookmarkToURIHavingKeyword() { + // The uri has already a keyword. + await check_keyword(URI1, "keyword"); + let fc = await foreign_count(URI1); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI1, + title: "test", + }); + await check_keyword(URI1, "keyword"); + Assert.equal(await foreign_count(URI1), fc + 1); // + 1 bookmark + await PlacesUtils.bookmarks.remove(bookmark); + await check_orphans(); +}); + +add_task(async function test_sameKeywordDifferentURI() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let fc1 = await foreign_count(URI1); + let fc2 = await foreign_count(URI2); + let observer = expectNotifications(); + + let bookmark2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI2, + title: "test2", + }); + await check_keyword(URI1, "keyword"); + await check_keyword(URI2, null); + + await PlacesUtils.keywords.insert({ url: URI2, keyword: "kEyWoRd" }); + + let bookmark1 = await PlacesUtils.bookmarks.fetch({ url: URI1 }); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark1.guid), + itemType: bookmark1.type, + url: bookmark1.url, + guid: bookmark1.guid, + parentGuid: bookmark1.parentGuid, + keyword: "", + lastModified: new Date(bookmark1.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark2.guid), + itemType: bookmark2.type, + url: bookmark2.url, + guid: bookmark2.guid, + parentGuid: bookmark2.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark2.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + // The keyword should have been "moved" to the new URI. + await check_keyword(URI1, null); + Assert.equal(await foreign_count(URI1), fc1 - 1); // - 1 keyword + await check_keyword(URI2, "keyword"); + Assert.equal(await foreign_count(URI2), fc2 + 2); // + 1 bookmark + 1 keyword + await check_orphans(); +}); + +add_task(async function test_sameURIDifferentKeyword() { + let fc = await foreign_count(URI2); + let observer = expectNotifications(); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI2, + title: "test2", + }); + await check_keyword(URI2, "keyword"); + + await PlacesUtils.keywords.insert({ url: URI2, keyword: "keyword2" }); + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: URI2 }, bm => bookmarks.push(bm)); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmarks[0].guid), + itemType: bookmarks[0].type, + url: bookmarks[0].url, + guid: bookmarks[0].guid, + parentGuid: bookmarks[0].parentGuid, + keyword: "keyword2", + lastModified: new Date(bookmarks[0].lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmarks[1].guid), + itemType: bookmarks[1].type, + url: bookmarks[1].url, + guid: bookmarks[1].guid, + parentGuid: bookmarks[1].parentGuid, + keyword: "keyword2", + lastModified: new Date(bookmarks[1].lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + await check_keyword(URI2, "keyword2"); + Assert.equal(await foreign_count(URI2), fc + 1); // + 1 bookmark - 1 keyword + 1 keyword + await check_orphans(); +}); + +add_task(async function test_removeBookmarkWithKeyword() { + let fc = await foreign_count(URI2); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI2, + title: "test", + }); + + // The keyword should not be removed, since there are other bookmarks yet. + await PlacesUtils.bookmarks.remove(bookmark); + + await check_keyword(URI2, "keyword2"); + Assert.equal(await foreign_count(URI2), fc); // + 1 bookmark - 1 bookmark + await check_orphans(); +}); + +add_task(async function test_unsetKeyword() { + let fc = await foreign_count(URI2); + let observer = expectNotifications(); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI2, + title: "test", + }); + + // The keyword should be removed from any bookmark. + await PlacesUtils.keywords.remove("keyword2"); + + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: URI2 }, bookmark => + bookmarks.push(bookmark) + ); + Assert.equal(bookmarks.length, 3, "Check number of bookmarks"); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmarks[0].guid), + itemType: bookmarks[0].type, + url: bookmarks[0].url, + guid: bookmarks[0].guid, + parentGuid: bookmarks[0].parentGuid, + keyword: "", + lastModified: new Date(bookmarks[0].lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmarks[1].guid), + itemType: bookmarks[1].type, + url: bookmarks[1].url, + guid: bookmarks[1].guid, + parentGuid: bookmarks[1].parentGuid, + keyword: "", + lastModified: new Date(bookmarks[1].lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmarks[2].guid), + itemType: bookmarks[2].type, + url: bookmarks[2].url, + guid: bookmarks[2].guid, + parentGuid: bookmarks[2].parentGuid, + keyword: "", + lastModified: new Date(bookmarks[2].lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(URI1, null); + await check_keyword(URI2, null); + Assert.equal(await foreign_count(URI2), fc); // + 1 bookmark - 1 keyword + await check_orphans(); +}); + +add_task(async function test_addRemoveBookmark() { + let fc = await foreign_count(URI3); + let observer = expectNotifications(); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: URI3, + title: "test3", + }); + let itemId = await PlacesUtils.promiseItemId(bookmark.guid); + await PlacesUtils.keywords.insert({ url: URI3, keyword: "keyword" }); + await PlacesUtils.bookmarks.remove(bookmark); + + observer.check([ + { + type: "bookmark-keyword-changed", + id: itemId, + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(URI3, null); + Assert.equal(await foreign_count(URI3), fc); // +- 1 bookmark +- 1 keyword + await check_orphans(); +}); + +add_task(async function test_reassign() { + // Should move keywords from old URL to new URL. + info("Old URL with keywords; new URL without keywords"); + { + let oldURL = "http://example.com/1/kw"; + let oldBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: oldURL, + }); + await PlacesUtils.keywords.insert({ + url: oldURL, + keyword: "kw1-1", + postData: "a=b", + }); + await PlacesUtils.keywords.insert({ + url: oldURL, + keyword: "kw1-2", + postData: "c=d", + }); + let oldFC = await foreign_count(oldURL); + equal(oldFC, 3); + + let newURL = "http://example.com/2/no-kw"; + let newBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: newURL, + }); + let newFC = await foreign_count(newURL); + equal(newFC, 1); + + let observer = expectNotifications(); + await PlacesUtils.keywords.reassign(oldURL, newURL); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(oldBmk.guid), + itemType: oldBmk.type, + url: oldBmk.url, + guid: oldBmk.guid, + parentGuid: oldBmk.parentGuid, + keyword: "", + lastModified: new Date(oldBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "kw1-1", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "kw1-2", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(oldURL, null); + await check_keyword(newURL, "kw1-1"); + await check_keyword(newURL, "kw1-2"); + + equal(await foreign_count(oldURL), oldFC - 2); // Removed both keywords. + equal(await foreign_count(newURL), newFC + 2); // Added two keywords. + } + + // Should not remove any keywords from new URL. + info("Old URL without keywords; new URL with keywords"); + { + let oldURL = "http://example.com/3/no-kw"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: oldURL, + }); + let oldFC = await foreign_count(oldURL); + equal(oldFC, 1); + + let newURL = "http://example.com/4/kw"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: newURL, + }); + await PlacesUtils.keywords.insert({ + url: newURL, + keyword: "kw4-1", + }); + let newFC = await foreign_count(newURL); + equal(newFC, 2); + + let observer = expectNotifications(); + await PlacesUtils.keywords.reassign(oldURL, newURL); + observer.check([]); + + await check_keyword(newURL, "kw4-1"); + + equal(await foreign_count(oldURL), oldFC); + equal(await foreign_count(newURL), newFC); + } + + // Should remove all keywords from new URL, then move keywords from old URL. + info("Old URL with keywords; new URL with keywords"); + { + let oldURL = "http://example.com/8/kw"; + let oldBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: oldURL, + }); + await PlacesUtils.keywords.insert({ + url: oldURL, + keyword: "kw8-1", + postData: "a=b", + }); + await PlacesUtils.keywords.insert({ + url: oldURL, + keyword: "kw8-2", + postData: "c=d", + }); + let oldFC = await foreign_count(oldURL); + equal(oldFC, 3); + + let newURL = "http://example.com/9/kw"; + let newBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: newURL, + }); + await PlacesUtils.keywords.insert({ + url: newURL, + keyword: "kw9-1", + }); + let newFC = await foreign_count(newURL); + equal(newFC, 2); + + let observer = expectNotifications(); + await PlacesUtils.keywords.reassign(oldURL, newURL); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(oldBmk.guid), + itemType: oldBmk.type, + url: oldBmk.url, + guid: oldBmk.guid, + parentGuid: oldBmk.parentGuid, + keyword: "", + lastModified: new Date(oldBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "kw8-1", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(newBmk.guid), + itemType: newBmk.type, + url: newBmk.url, + guid: newBmk.guid, + parentGuid: newBmk.parentGuid, + keyword: "kw8-2", + lastModified: new Date(newBmk.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(oldURL, null); + await check_keyword(newURL, "kw8-1"); + await check_keyword(newURL, "kw8-2"); + + equal(await foreign_count(oldURL), oldFC - 2); // Removed both keywords. + equal(await foreign_count(newURL), newFC + 1); // Removed old keyword; added two keywords. + } + + // Should do nothing. + info("Old URL without keywords; new URL without keywords"); + { + let oldURL = "http://example.com/10/no-kw"; + let oldFC = await foreign_count(oldURL); + + let newURL = "http://example.com/11/no-kw"; + let newFC = await foreign_count(newURL); + + let observer = expectNotifications(); + await PlacesUtils.keywords.reassign(oldURL, newURL); + observer.check([]); + + equal(await foreign_count(oldURL), oldFC); + equal(await foreign_count(newURL), newFC); + } + + await check_orphans(); +}); + +add_task(async function test_invalidation() { + info("Insert bookmarks"); + let fx = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Get Firefox!", + url: "http://getfirefox.com", + }); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Get Thunderbird!", + url: "http://getthunderbird.com", + }); + + info("Set keywords for bookmarks"); + await PlacesUtils.keywords.insert({ url: fx.url, keyword: "fx" }); + await PlacesUtils.keywords.insert({ url: tb.url, keyword: "tb" }); + + info("Invalidate cached keywords"); + await PlacesUtils.keywords.invalidateCachedKeywords(); + + info("Change URL of bookmark with keyword"); + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-keyword-changed", + events => + events.some(event => event.guid === fx.guid && event.keyword === "fx") + ); + await PlacesUtils.bookmarks.update({ + guid: fx.guid, + url: "https://www.mozilla.org/firefox", + }); + await promiseNotification; + + let entriesByKeyword = []; + await PlacesUtils.keywords.fetch({ keyword: "fx" }, e => + entriesByKeyword.push(e.url.href) + ); + deepEqual( + entriesByKeyword, + ["https://www.mozilla.org/firefox"], + "Should return new URL for keyword" + ); + + ok( + !(await PlacesUtils.keywords.fetch({ url: "http://getfirefox.com" })), + "Should not return keywords for old URL" + ); + + let entiresByURL = []; + await PlacesUtils.keywords.fetch( + { url: "https://www.mozilla.org/firefox" }, + e => entiresByURL.push(e.keyword) + ); + deepEqual(entiresByURL, ["fx"], "Should return keyword for new URL"); + + info("Invalidate cached keywords"); + await PlacesUtils.keywords.invalidateCachedKeywords(); + + info("Remove bookmark with keyword"); + await PlacesUtils.bookmarks.remove(tb.guid); + + ok( + !(await PlacesUtils.keywords.fetch({ url: "http://getthunderbird.com" })), + "Should not return keywords for removed bookmark URL" + ); + + ok( + !(await PlacesUtils.keywords.fetch({ keyword: "tb" })), + "Should not return URL for removed bookmark keyword" + ); + await check_orphans(); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_eraseAllBookmarks() { + info("Insert bookmarks"); + let fx = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Get Firefox!", + url: "http://getfirefox.com", + }); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Get Thunderbird!", + url: "http://getthunderbird.com", + }); + + info("Set keywords for bookmarks"); + await PlacesUtils.keywords.insert({ url: fx.url, keyword: "fx" }); + await PlacesUtils.keywords.insert({ url: tb.url, keyword: "tb" }); + + info("Erase everything"); + await PlacesUtils.bookmarks.eraseEverything(); + + ok( + !(await PlacesUtils.keywords.fetch({ keyword: "fx" })), + "Should remove Firefox keyword" + ); + + ok( + !(await PlacesUtils.keywords.fetch({ keyword: "tb" })), + "Should remove Thunderbird keyword" + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js b/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js new file mode 100644 index 0000000000..1c9e55849d --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/** + * This test ensures that reinserting a folder within a transaction gives it + * the same GUID, and passes it to the observers. + */ + +add_task(async function test_removeFolderTransaction_reinsert() { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Test folder", + }); + let fx = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + title: "Get Firefox!", + url: "http://getfirefox.com", + }); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + title: "Get Thunderbird!", + url: "http://getthunderbird.com", + }); + + let notifications = []; + function checkNotifications(expected, message) { + deepEqual(notifications, expected, message); + notifications.length = 0; + } + + let listener = events => { + for (let event of events) { + notifications.push([ + event.type, + event.id, + event.parentId, + event.guid, + event.parentGuid, + ]); + } + }; + PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed"], + listener + ); + PlacesUtils.registerShutdownFunction(function () { + PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed"], + listener + ); + }); + + let transaction = PlacesTransactions.Remove({ guid: folder.guid }); + + let folderId = await PlacesUtils.promiseItemId(folder.guid); + let fxId = await PlacesUtils.promiseItemId(fx.guid); + let tbId = await PlacesUtils.promiseItemId(tb.guid); + + await transaction.transact(); + let bookmarksMenuItemId = await PlacesUtils.promiseItemId( + PlacesUtils.bookmarks.menuGuid + ); + checkNotifications( + [ + ["bookmark-removed", tbId, folderId, tb.guid, folder.guid], + ["bookmark-removed", fxId, folderId, fx.guid, folder.guid], + [ + "bookmark-removed", + folderId, + bookmarksMenuItemId, + folder.guid, + PlacesUtils.bookmarks.menuGuid, + ], + ], + "Executing transaction should remove folder and its descendants" + ); + + await PlacesTransactions.undo(); + + folderId = await PlacesUtils.promiseItemId(folder.guid); + fxId = await PlacesUtils.promiseItemId(fx.guid); + tbId = await PlacesUtils.promiseItemId(tb.guid); + + checkNotifications( + [ + [ + "bookmark-added", + folderId, + bookmarksMenuItemId, + folder.guid, + PlacesUtils.bookmarks.menuGuid, + ], + ["bookmark-added", fxId, folderId, fx.guid, folder.guid], + ["bookmark-added", tbId, folderId, tb.guid, folder.guid], + ], + "Undo should reinsert folder with different id but same GUID" + ); + + await PlacesTransactions.redo(); + + checkNotifications( + [ + ["bookmark-removed", tbId, folderId, tb.guid, folder.guid], + ["bookmark-removed", fxId, folderId, fx.guid, folder.guid], + [ + "bookmark-removed", + folderId, + bookmarksMenuItemId, + folder.guid, + PlacesUtils.bookmarks.menuGuid, + ], + ], + "Redo should pass the GUID to observer" + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_savedsearches.js b/toolkit/components/places/tests/bookmarks/test_savedsearches.js new file mode 100644 index 0000000000..a10307983d --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_savedsearches.js @@ -0,0 +1,224 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// a search term that matches a default bookmark +const searchTerm = "about"; + +var testRoot; + +add_task(async function setup() { + // create a folder to hold all the tests + // this makes the tests more tolerant of changes to the default bookmarks set + // also, name it using the search term, for testing that containers that match don't show up in query results + testRoot = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: searchTerm, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); +}); + +add_task(async function test_savedsearches_bookmarks() { + // add a bookmark that matches the search term + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: searchTerm, + url: "http://foo.com", + }); + + // create a saved-search that matches a default bookmark + let search = await PlacesUtils.bookmarks.insert({ + parentGuid: testRoot.guid, + title: searchTerm, + url: + "place:terms=" + + searchTerm + + "&excludeQueries=1&expandQueries=1&queryType=1", + }); + + // query for the test root, expandQueries=0 + // the query should show up as a regular bookmark + try { + let options = PlacesUtils.history.getNewQueryOptions(); + options.expandQueries = 0; + let query = PlacesUtils.history.getNewQuery(); + query.setParents([testRoot.guid]); + let result = PlacesUtils.history.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.equal(cc, 1); + for (let i = 0; i < cc; i++) { + let node = rootNode.getChild(i); + // test that queries have valid itemId + Assert.ok(node.itemId > 0); + // test that the container is closed + node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + Assert.equal(node.containerOpen, false); + } + rootNode.containerOpen = false; + } catch (ex) { + do_throw("expandQueries=0 query error: " + ex); + } + + // bookmark saved search + // query for the test root, expandQueries=1 + // the query should show up as a query container, with 1 child + try { + let options = PlacesUtils.history.getNewQueryOptions(); + options.expandQueries = 1; + let query = PlacesUtils.history.getNewQuery(); + query.setParents([testRoot.guid]); + let result = PlacesUtils.history.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.equal(cc, 1); + for (let i = 0; i < cc; i++) { + let node = rootNode.getChild(i); + // test that query node type is container when expandQueries=1 + Assert.equal(node.type, node.RESULT_TYPE_QUERY); + // test that queries (as containers) have valid itemId + Assert.ok(node.itemId > 0); + node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + node.containerOpen = true; + + // test that queries have children when excludeItems=1 + // test that query nodes don't show containers (shouldn't have our folder that matches) + // test that queries don't show themselves in query results (shouldn't have our saved search) + Assert.equal(node.childCount, 1); + + // test that bookmark shows in query results + var item = node.getChild(0); + Assert.equal(item.bookmarkGuid, bookmark.guid); + + // XXX - FAILING - test live-update of query results - add a bookmark that matches the query + // var tmpBmId = PlacesUtils.bookmarks.insertBookmark( + // root, uri("http://" + searchTerm + ".com"), + // PlacesUtils.bookmarks.DEFAULT_INDEX, searchTerm + "blah"); + // do_check_eq(query.childCount, 2); + + // XXX - test live-update of query results - delete a bookmark that matches the query + // PlacesUtils.bookmarks.removeItem(tmpBMId); + // do_check_eq(query.childCount, 1); + + // test live-update of query results - add a folder that matches the query + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: searchTerm + "zaa", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + Assert.equal(node.childCount, 1); + // test live-update of query results - add a query that matches the query + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: searchTerm + "blah", + url: "place:terms=foo&excludeQueries=1&expandQueries=1&queryType=1", + }); + Assert.equal(node.childCount, 1); + } + rootNode.containerOpen = false; + } catch (ex) { + do_throw("expandQueries=1 bookmarks query: " + ex); + } + + // delete the bookmark search + await PlacesUtils.bookmarks.remove(search); +}); + +add_task(async function test_savedsearches_history() { + // add a visit that matches the search term + var testURI = uri("http://" + searchTerm + ".com"); + await PlacesTestUtils.addVisits({ uri: testURI, title: searchTerm }); + + // create a saved-search that matches the visit we added + var searchItem = await PlacesUtils.bookmarks.insert({ + parentGuid: testRoot.guid, + title: searchTerm, + url: + "place:terms=" + + searchTerm + + "&excludeQueries=1&expandQueries=1&queryType=0", + }); + + // query for the test root, expandQueries=1 + // the query should show up as a query container, with 1 child + try { + var options = PlacesUtils.history.getNewQueryOptions(); + options.expandQueries = 1; + var query = PlacesUtils.history.getNewQuery(); + query.setParents([testRoot.guid]); + var result = PlacesUtils.history.executeQuery(query, options); + var rootNode = result.root; + rootNode.containerOpen = true; + var cc = rootNode.childCount; + Assert.equal(cc, 1); + for (var i = 0; i < cc; i++) { + var node = rootNode.getChild(i); + // test that query node type is container when expandQueries=1 + Assert.equal(node.type, node.RESULT_TYPE_QUERY); + // test that queries (as containers) have valid itemId + Assert.equal(node.bookmarkGuid, searchItem.guid); + node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + node.containerOpen = true; + + // test that queries have children when excludeItems=1 + // test that query nodes don't show containers (shouldn't have our folder that matches) + // test that queries don't show themselves in query results (shouldn't have our saved search) + Assert.equal(node.childCount, 1); + + // test that history visit shows in query results + var item = node.getChild(0); + Assert.equal(item.type, item.RESULT_TYPE_URI); + Assert.equal(item.itemId, -1); // history visit + Assert.equal(item.uri, testURI.spec); // history visit + + // test live-update of query results - add a history visit that matches the query + await PlacesTestUtils.addVisits({ + uri: uri("http://foo.com"), + title: searchTerm + "blah", + }); + Assert.equal(node.childCount, 2); + + // test live-update of query results - delete a history visit that matches the query + await PlacesUtils.history.remove("http://foo.com"); + Assert.equal(node.childCount, 1); + node.containerOpen = false; + } + + // test live-update of moved queries + let tmpFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: testRoot.guid, + title: "foo", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + searchItem.parentGuid = tmpFolder.guid; + await PlacesUtils.bookmarks.update(searchItem); + var tmpFolderNode = rootNode.getChild(0); + Assert.equal(tmpFolderNode.bookmarkGuid, tmpFolder.guid); + tmpFolderNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + tmpFolderNode.containerOpen = true; + Assert.equal(tmpFolderNode.childCount, 1); + + // test live-update of renamed queries + searchItem.title = "foo"; + await PlacesUtils.bookmarks.update(searchItem); + Assert.equal(tmpFolderNode.title, "foo"); + + // test live-update of deleted queries + await PlacesUtils.bookmarks.remove(searchItem); + Assert.throws( + () => (tmpFolderNode = rootNode.getChild(1)), + /NS_ERROR_ILLEGAL_VALUE/, + "getting a deleted child should throw" + ); + + tmpFolderNode.containerOpen = false; + rootNode.containerOpen = false; + } catch (ex) { + do_throw("expandQueries=1 bookmarks query: " + ex); + } +}); diff --git a/toolkit/components/places/tests/bookmarks/test_sync_fields.js b/toolkit/components/places/tests/bookmarks/test_sync_fields.js new file mode 100644 index 0000000000..7db76e96e6 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_sync_fields.js @@ -0,0 +1,438 @@ +// 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(); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_tags.js b/toolkit/components/places/tests/bookmarks/test_tags.js new file mode 100644 index 0000000000..3334d09b7c --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_tags.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_fetchTags() { + let tags = await PlacesUtils.bookmarks.fetchTags(); + Assert.deepEqual(tags, []); + + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://page1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + PlacesUtils.tagging.tagURI(bm.url.URI, ["1", "2"]); + tags = await PlacesUtils.bookmarks.fetchTags(); + Assert.deepEqual(tags, [ + { name: "1", count: 1 }, + { name: "2", count: 1 }, + ]); + + PlacesUtils.tagging.untagURI(bm.url.URI, ["1"]); + tags = await PlacesUtils.bookmarks.fetchTags(); + Assert.deepEqual(tags, [{ name: "2", count: 1 }]); + + let bm2 = await PlacesUtils.bookmarks.insert({ + url: "http://page2.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI(bm2.url.URI, ["2", "3"]); + tags = await PlacesUtils.bookmarks.fetchTags(); + Assert.deepEqual(tags, [ + { name: "2", count: 2 }, + { name: "3", count: 1 }, + ]); +}); + +add_task(async function test_fetch_by_tags() { + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ tags: "" }), + /Invalid value for property 'tags'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ tags: [] }), + /Invalid value for property 'tags'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ tags: null }), + /Invalid value for property 'tags'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ tags: [""] }), + /Invalid value for property 'tags'/ + ); + Assert.throws( + () => PlacesUtils.bookmarks.fetch({ tags: ["valid", null] }), + /Invalid value for property 'tags'/ + ); + + info("Add bookmarks with tags."); + let bm1 = await PlacesUtils.bookmarks.insert({ + url: "http://bacon.org/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI(bm1.url.URI, ["egg", "ratafià"]); + let bm2 = await PlacesUtils.bookmarks.insert({ + url: "http://mushroom.org/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI(bm2.url.URI, ["egg"]); + + info("Fetch a single tag."); + let bms = []; + Assert.equal( + (await PlacesUtils.bookmarks.fetch({ tags: ["egg"] }, b => bms.push(b))) + .guid, + bm2.guid, + "Found the expected recent bookmark" + ); + Assert.deepEqual( + bms.map(b => b.guid), + [bm2.guid, bm1.guid], + "Found the expected bookmarks" + ); + + info("Fetch multiple tags."); + bms = []; + Assert.equal( + ( + await PlacesUtils.bookmarks.fetch({ tags: ["egg", "ratafià"] }, b => + bms.push(b) + ) + ).guid, + bm1.guid, + "Found the expected recent bookmark" + ); + Assert.deepEqual( + bms.map(b => b.guid), + [bm1.guid], + "Found the expected bookmarks" + ); + + info("Fetch a nonexisting tag."); + bms = []; + Assert.equal( + await PlacesUtils.bookmarks.fetch({ tags: ["egg", "tomato"] }, b => + bms.push(b) + ), + null, + "Should not find any bookmark" + ); + Assert.deepEqual(bms, [], "Should not find any bookmark"); + + info("Check case insensitive"); + bms = []; + Assert.equal( + ( + await PlacesUtils.bookmarks.fetch({ tags: ["eGg", "raTafiÀ"] }, b => + bms.push(b) + ) + ).guid, + bm1.guid, + "Found the expected recent bookmark" + ); + Assert.deepEqual( + bms.map(b => b.guid), + [bm1.guid], + "Found the expected bookmarks" + ); +}); diff --git a/toolkit/components/places/tests/bookmarks/test_untitled.js b/toolkit/components/places/tests/bookmarks/test_untitled.js new file mode 100644 index 0000000000..6e756d79a6 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/test_untitled.js @@ -0,0 +1,114 @@ +add_task(async function test_untitled_visited_bookmark() { + let fxURI = uri("http://getfirefox.com"); + + await PlacesUtils.history.insert({ + url: fxURI, + title: "Get Firefox!", + visits: [ + { + date: new Date(), + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ], + }); + + let { root: node } = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ); + + try { + let fxBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: fxURI, + }); + strictEqual(fxBmk.title, "", "Visited bookmark should not have title"); + + await PlacesTestUtils.promiseAsyncUpdates(); + + let fxBmkId = await PlacesUtils.promiseItemId(fxBmk.guid); + strictEqual( + PlacesUtils.bookmarks.getItemTitle(fxBmkId), + "", + "Should return empty string for untitled visited bookmark" + ); + + let fxBmkNode = node.getChild(0); + equal(fxBmkNode.itemId, fxBmkId, "Visited bookmark ID should match"); + strictEqual( + fxBmkNode.title, + "", + "Visited bookmark node should not have title" + ); + } finally { + node.containerOpen = false; + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_untitled_unvisited_bookmark() { + let tbURI = uri("http://getthunderbird.com"); + + let { root: node } = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ); + + try { + let tbBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: tbURI, + }); + strictEqual(tbBmk.title, "", "Unvisited bookmark should not have title"); + + await PlacesTestUtils.promiseAsyncUpdates(); + + let tbBmkId = await PlacesUtils.promiseItemId(tbBmk.guid); + strictEqual( + PlacesUtils.bookmarks.getItemTitle(tbBmkId), + "", + "Should return empty string for untitled unvisited bookmark" + ); + + let tbBmkNode = node.getChild(0); + equal(tbBmkNode.itemId, tbBmkId, "Unvisited bookmark ID should match"); + strictEqual( + tbBmkNode.title, + "", + "Unvisited bookmark node should not have title" + ); + } finally { + node.containerOpen = false; + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_untitled_folder() { + let { root: node } = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ); + + try { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + await PlacesTestUtils.promiseAsyncUpdates(); + + let folderId = await PlacesUtils.promiseItemId(folder.guid); + strictEqual( + PlacesUtils.bookmarks.getItemTitle(folderId), + "", + "Should return empty string for untitled folder" + ); + + let folderNode = node.getChild(0); + equal(folderNode.itemId, folderId, "Folder ID should match"); + strictEqual(folderNode.title, "", "Folder node should not have title"); + } finally { + node.containerOpen = false; + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/bookmarks/xpcshell.ini b/toolkit/components/places/tests/bookmarks/xpcshell.ini new file mode 100644 index 0000000000..91d3741bb4 --- /dev/null +++ b/toolkit/components/places/tests/bookmarks/xpcshell.ini @@ -0,0 +1,48 @@ +[DEFAULT] +head = head_bookmarks.js +skip-if = toolkit == 'android' +firefox-appdir = browser + +[test_1016953-renaming-uncompressed.js] +[test_1017502-bookmarks_foreign_count.js] +[test_384228.js] +[test_385829.js] +[test_388695.js] +[test_393498.js] +[test_405938_restore_queries.js] +[test_424958-json-quoted-folders.js] +[test_448584.js] +[test_458683.js] +[test_466303-json-remove-backups.js] +[test_477583_json-backup-in-future.js] +[test_818584-discard-duplicate-backups.js] +[test_818587_compress-bookmarks-backups.js] +[test_818593-store-backup-metadata.js] +[test_992901-backup-unsorted-hierarchy.js] +[test_997030-bookmarks-html-encode.js] +[test_1129529.js] +support-files = + bookmarks_long_tag.json +[test_async_observers.js] +[test_bmindex.js] +[test_bookmark_observer.js] +[test_bookmarkstree_cache.js] +[test_bookmarks_eraseEverything.js] +[test_bookmarks_fetch.js] +[test_bookmarks_getRecent.js] +[test_bookmarks_insert.js] +[test_bookmarks_insertTree.js] +[test_bookmarks_notifications.js] +[test_bookmarks_moveToFolder.js] +[test_bookmarks_remove.js] +[test_bookmarks_remove_batch.js] +[test_bookmarks_reorder.js] +[test_bookmarks_search.js] +[test_bookmarks_update.js] +[test_insertTree_fixupOrSkipInvalidEntries.js] +[test_keywords.js] +[test_removeFolderTransaction_reinsert.js] +[test_savedsearches.js] +[test_sync_fields.js] +[test_tags.js] +[test_untitled.js] diff --git a/toolkit/components/places/tests/browser/1601563-1.html b/toolkit/components/places/tests/browser/1601563-1.html new file mode 100644 index 0000000000..4f92778561 --- /dev/null +++ b/toolkit/components/places/tests/browser/1601563-1.html @@ -0,0 +1,20 @@ +<!doctype html> +<title>First title</title> +<iframe srcdoc=""></iframe> +<script> +onload = function() { + // This iframe doc shouldn't override our title. + let doc = document.querySelector("iframe").contentDocument; + doc.open(); + doc.write("<title>This is not your title</title>Hello"); + doc.close(); + + if (doc.title == "This is not your title") { + // Now navigate away so that the test has something to wait for to ensure the + // relevant code has run. + let link = document.createElement("a"); + link.href = window.location.href.replace("-1.html", "-2.html"); + link.click(); + } +} +</script> diff --git a/toolkit/components/places/tests/browser/1601563-2.html b/toolkit/components/places/tests/browser/1601563-2.html new file mode 100644 index 0000000000..b1c000cd5a --- /dev/null +++ b/toolkit/components/places/tests/browser/1601563-2.html @@ -0,0 +1,3 @@ +<!doctype html> +<title>Second title</title> +Nothing to see here. diff --git a/toolkit/components/places/tests/browser/399606-history.go-0.html b/toolkit/components/places/tests/browser/399606-history.go-0.html new file mode 100644 index 0000000000..6e36aa23de --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-history.go-0.html @@ -0,0 +1,13 @@ +<html> +<head> +<title>history.go(0)</title> +<script> +setTimeout(function() { + history.go(0); +}, 1000); +</script> +</head> +<body> +Testing history.go(0) +</body> +</html> diff --git a/toolkit/components/places/tests/browser/399606-httprefresh.html b/toolkit/components/places/tests/browser/399606-httprefresh.html new file mode 100644 index 0000000000..e43455ee05 --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-httprefresh.html @@ -0,0 +1,8 @@ +<html><head> +<meta http-equiv="content-type" content="text/html; charset=UTF-8"> + +<meta http-equiv="refresh" content="1"> +<title>httprefresh</title> +</head><body> +Testing httprefresh +</body></html> diff --git a/toolkit/components/places/tests/browser/399606-location.reload.html b/toolkit/components/places/tests/browser/399606-location.reload.html new file mode 100644 index 0000000000..54eefab1c3 --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-location.reload.html @@ -0,0 +1,13 @@ +<html> +<head> +<title>location.reload()</title> +<script> +setTimeout(function() { + location.reload(); +}, 100); +</script> +</head> +<body> +Testing location.reload(); +</body> +</html> diff --git a/toolkit/components/places/tests/browser/399606-location.replace.html b/toolkit/components/places/tests/browser/399606-location.replace.html new file mode 100644 index 0000000000..8a72c96722 --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-location.replace.html @@ -0,0 +1,13 @@ +<html> +<head> +<title>location.replace</title> +<script> +setTimeout(function() { + location.replace(window.location.href); +}, 1000); +</script> +</head> +<body> +Testing location.replace +</body> +</html> diff --git a/toolkit/components/places/tests/browser/399606-window.location.href.html b/toolkit/components/places/tests/browser/399606-window.location.href.html new file mode 100644 index 0000000000..490b08e40c --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-window.location.href.html @@ -0,0 +1,14 @@ +<html> +<head> +<title>window.location.href</title> +<script> +setTimeout(function() { + // eslint-disable-next-line no-self-assign + window.location.href = window.location.href; +}, 1000); +</script> +</head> +<body> +Testing window.location.href +</body> +</html> diff --git a/toolkit/components/places/tests/browser/399606-window.location.html b/toolkit/components/places/tests/browser/399606-window.location.html new file mode 100644 index 0000000000..b84366cf8d --- /dev/null +++ b/toolkit/components/places/tests/browser/399606-window.location.html @@ -0,0 +1,14 @@ +<html> +<head> +<title>window.location</title> +<script> +setTimeout(function() { + // eslint-disable-next-line no-self-assign + window.location = window.location; +}, 1000); +</script> +</head> +<body> +Testing window.location +</body> +</html> diff --git a/toolkit/components/places/tests/browser/461710_link_page-2.html b/toolkit/components/places/tests/browser/461710_link_page-2.html new file mode 100644 index 0000000000..726373f83e --- /dev/null +++ b/toolkit/components/places/tests/browser/461710_link_page-2.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Link page 2</title> + <style type="text/css"> + a:link { color: #0000ff; } + a:visited { color: #ff0000; } + </style> + </head> + <body> + <p><a href="461710_visited_page.html" id="link">Link to the second visited page</a></p> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/461710_link_page-3.html b/toolkit/components/places/tests/browser/461710_link_page-3.html new file mode 100644 index 0000000000..d465cf79c6 --- /dev/null +++ b/toolkit/components/places/tests/browser/461710_link_page-3.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Link page 3</title> + <style type="text/css"> + a:link { color: #0000ff; } + a:visited { color: #ff0000; } + </style> + </head> + <body> + <p><a href="461710_visited_page.html" id="link">Link to the third visited page</a></p> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/461710_link_page.html b/toolkit/components/places/tests/browser/461710_link_page.html new file mode 100644 index 0000000000..05189fa41b --- /dev/null +++ b/toolkit/components/places/tests/browser/461710_link_page.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Link page</title> + <style type="text/css"> + a:link { color: #0000ff; } + a:visited { color: #ff0000; } + </style> + </head> + <body> + <p><a href="461710_visited_page.html" id="link">Link to the visited page</a></p> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/461710_visited_page.html b/toolkit/components/places/tests/browser/461710_visited_page.html new file mode 100644 index 0000000000..3ff52f69c8 --- /dev/null +++ b/toolkit/components/places/tests/browser/461710_visited_page.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Visited page</title> + </head> + <body> + <p>This page is marked as visited</p> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/begin.html b/toolkit/components/places/tests/browser/begin.html new file mode 100644 index 0000000000..da4c16dd25 --- /dev/null +++ b/toolkit/components/places/tests/browser/begin.html @@ -0,0 +1,10 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> + +<html> + <body> + <a id="clickme" href="redirect_twice.sjs">Redirect twice</a> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/browser.ini b/toolkit/components/places/tests/browser/browser.ini new file mode 100644 index 0000000000..33526ebb20 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser.ini @@ -0,0 +1,88 @@ +[DEFAULT] +support-files = + head.js + +[browser_bug399606.js] +https_first_disabled = true +support-files = + 399606-history.go-0.html + 399606-httprefresh.html + 399606-location.reload.html + 399606-location.replace.html + 399606-window.location.html + 399606-window.location.href.html +[browser_bug461710.js] +https_first_disabled = true +support-files = + 461710_link_page-2.html + 461710_link_page-3.html + 461710_link_page.html + 461710_visited_page.html +[browser_bug646422.js] +https_first_disabled = true +[browser_bug680727.js] +https_first_disabled = true +skip-if = verify +[browser_bug1601563.js] +https_first_disabled = true +support-files = + 1601563-1.html + 1601563-2.html +[browser_double_redirect.js] +https_first_disabled = true +support-files = + begin.html + final.html + redirect_once.sjs + redirect_twice.sjs +[browser_favicon_privatebrowsing_perwindowpb.js] +[browser_history_post.js] +https_first_disabled = true +support-files = + history_post.html + history_post.sjs +[browser_notfound.js] +[browser_onvisit_title_null_for_navigation.js] +https_first_disabled = true +skip-if = verify +support-files = + empty_page.html +[browser_redirect.js] +support-files = + redirect.sjs + redirect-target.html +[browser_redirect_self.js] +support-files = + redirect_self.sjs +[browser_multi_redirect_frecency.js] +https_first_disabled = true +support-files = + final.html + redirect_once.sjs + redirect_thrice.sjs + redirect_twice.sjs + redirect_twice_perma.sjs +[browser_settitle.js] +https_first_disabled = true +support-files = + title1.html + title2.html +[browser_visited_notfound.js] +[browser_visituri.js] +https_first_disabled = true +support-files = + begin.html + final.html + redirect_once.sjs + redirect_twice.sjs +[browser_visituri_nohistory.js] +support-files = + begin.html + final.html + favicon-normal16.png + favicon-normal32.png +[browser_visituri_privatebrowsing_perwindowpb.js] +support-files = + begin.html + favicon.html + final.html diff --git a/toolkit/components/places/tests/browser/browser_bug1601563.js b/toolkit/components/places/tests/browser/browser_bug1601563.js new file mode 100644 index 0000000000..41e278ee54 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_bug1601563.js @@ -0,0 +1,40 @@ +const PREFIX = + "http://example.com/tests/toolkit/components/places/tests/browser/1601563"; + +function titleUpdate(pageUrl) { + let lastTitle = null; + return PlacesTestUtils.waitForNotification("page-title-changed", events => { + if (pageUrl != events[0].url) { + return false; + } + lastTitle = events[0].title; + return true; + }).then(() => { + return lastTitle; + }); +} + +add_task(async function () { + registerCleanupFunction(PlacesUtils.history.clear); + const FIRST_URL = PREFIX + "-1.html"; + const SECOND_URL = PREFIX + "-2.html"; + let firstTitlePromise = titleUpdate(FIRST_URL); + let secondTitlePromise = titleUpdate(SECOND_URL); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FIRST_URL); + + let firstTitle = await firstTitlePromise; + is(firstTitle, "First title", "First title should match the page"); + + let secondTitle = await secondTitlePromise; + is(secondTitle, "Second title", "Second title should match the page"); + + let entry = await PlacesUtils.history.fetch(FIRST_URL); + is( + entry.title, + firstTitle, + "Should not override first title with document.open()ed frame" + ); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/places/tests/browser/browser_bug399606.js b/toolkit/components/places/tests/browser/browser_bug399606.js new file mode 100644 index 0000000000..f593d68528 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_bug399606.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function () { + registerCleanupFunction(PlacesUtils.history.clear); + + const URIS = [ + "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.href.html", + "http://example.com/tests/toolkit/components/places/tests/browser/399606-history.go-0.html", + "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.replace.html", + "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.reload.html", + "http://example.com/tests/toolkit/components/places/tests/browser/399606-httprefresh.html", + "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.html", + ]; + + // Create and add history observer. + let count = 0; + let expectedURI = null; + function onVisitsListener(aEvents) { + for (let event of aEvents) { + info("Received onVisits: " + event.url); + if (event.url == expectedURI) { + count++; + } + } + } + + async function promiseLoadedThreeTimes(uri) { + count = 0; + expectedURI = uri; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + PlacesObservers.addListener(["page-visited"], onVisitsListener); + BrowserTestUtils.loadURIString(gBrowser, uri); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); + PlacesObservers.removeListener(["page-visited"], onVisitsListener); + BrowserTestUtils.removeTab(tab); + } + + for (let uri of URIS) { + await promiseLoadedThreeTimes(uri); + is( + count, + 1, + "'page-visited' has been received right number of times for " + uri + ); + } +}); diff --git a/toolkit/components/places/tests/browser/browser_bug461710.js b/toolkit/components/places/tests/browser/browser_bug461710.js new file mode 100644 index 0000000000..6815860929 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_bug461710.js @@ -0,0 +1,89 @@ +const kRed = "rgb(255, 0, 0)"; +const kBlue = "rgb(0, 0, 255)"; + +const prefix = + "http://example.com/tests/toolkit/components/places/tests/browser/461710_"; + +add_task(async function () { + registerCleanupFunction(PlacesUtils.history.clear); + let normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let tests = [ + { + private: false, + topic: "uri-visit-saved", + subtest: "visited_page.html", + }, + { + private: false, + subtest: "link_page.html", + color: kRed, + message: "Visited link coloring should work outside of private mode", + }, + { + private: true, + subtest: "link_page-2.html", + color: kBlue, + message: "Visited link coloring should not work inside of private mode", + }, + { + private: false, + subtest: "link_page-3.html", + color: kRed, + message: "Visited link coloring should work outside of private mode", + }, + ]; + + let uri = Services.io.newURI(prefix + tests[0].subtest); + for (let test of tests) { + info(test.subtest); + let promise = null; + if (test.topic) { + promise = TestUtils.topicObserved(test.topic, subject => + uri.equals(subject.QueryInterface(Ci.nsIURI)) + ); + } + await BrowserTestUtils.withNewTab( + { + gBrowser: test.private ? privateWindow.gBrowser : normalWindow.gBrowser, + url: prefix + test.subtest, + }, + async function (browser) { + if (promise) { + await promise; + } + + if (test.color) { + // In e10s waiting for visited-status-resolution is not enough to ensure links + // have been updated, because it only tells us that messages to update links + // have been dispatched. We must still wait for the actual links to update. + await TestUtils.waitForCondition(async function () { + let color = await SpecialPowers.spawn( + browser, + [], + async function () { + let elem = content.document.getElementById("link"); + return content.windowUtils.getVisitedDependentComputedStyle( + elem, + "", + "color" + ); + } + ); + return color == test.color; + }, test.message); + // The harness will consider the test as failed overall if there were no + // passes or failures, so record it as a pass. + ok(true, test.message); + } + } + ); + } + + let promisePBExit = TestUtils.topicObserved("last-pb-context-exited"); + await BrowserTestUtils.closeWindow(privateWindow); + await promisePBExit; + await BrowserTestUtils.closeWindow(normalWindow); +}); diff --git a/toolkit/components/places/tests/browser/browser_bug646422.js b/toolkit/components/places/tests/browser/browser_bug646422.js new file mode 100644 index 0000000000..cb6512ed4e --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_bug646422.js @@ -0,0 +1,44 @@ +/* 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/. */ + +/** + * Test for Bug 646224. Make sure that after changing the URI via + * history.pushState, the history service has a title stored for the new URI. + **/ + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + + const newTitlePromise = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => /new_page$/.test(events[0].url) + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let title = content.document.title; + content.history.pushState("", "", "new_page"); + Assert.ok(title, "Content window should initially have a title."); + }); + + const events = await newTitlePromise; + const newtitle = events[0].title; + + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ newtitle }], + async function (args) { + Assert.equal( + args.newtitle, + content.document.title, + "Title after pushstate." + ); + } + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/places/tests/browser/browser_bug680727.js b/toolkit/components/places/tests/browser/browser_bug680727.js new file mode 100644 index 0000000000..2fe2377d34 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_bug680727.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Ensure that clicking the button in the Offline mode neterror page updates + global history. See bug 680727. */ +/* TEST_PATH=toolkit/components/places/tests/browser/browser_bug680727.js make -C $(OBJDIR) mochitest-browser-chrome */ + +const kUniqueURI = Services.io.newURI("http://mochi.test:8888/#bug_680727"); +var proxyPrefValue; +var ourTab; + +function test() { + waitForExplicitFinish(); + + // Tests always connect to localhost, and per bug 87717, localhost is now + // reachable in offline mode. To avoid this, disable any proxy. + proxyPrefValue = Services.prefs.getIntPref("network.proxy.type"); + Services.prefs.setIntPref("network.proxy.type", 0); + + // Clear network cache. + Services.cache2.clear(); + + // Go offline, expecting the error page. + Services.io.offline = true; + + BrowserTestUtils.openNewForegroundTab(gBrowser).then(tab => { + ourTab = tab; + BrowserTestUtils.browserLoaded( + ourTab.linkedBrowser, + false, + null, + true + ).then(errorListener); + BrowserTestUtils.loadURIString(ourTab.linkedBrowser, kUniqueURI.spec); + }); +} + +// ------------------------------------------------------------------------------ +// listen to loading the neterror page. (offline mode) +function errorListener() { + ok(Services.io.offline, "Services.io.offline is true."); + + // This is an error page. + SpecialPowers.spawn(ourTab.linkedBrowser, [kUniqueURI.spec], function (uri) { + Assert.equal( + content.document.documentURI.substring(0, 27), + "about:neterror?e=netOffline", + "Document URI is the error page." + ); + + // But location bar should show the original request. + Assert.equal( + content.location.href, + uri, + "Docshell URI is the original URI." + ); + }).then(() => { + // Global history does not record URI of a failed request. + PlacesTestUtils.promiseAsyncUpdates().then(() => { + PlacesUtils.history.hasVisits(kUniqueURI).then(isVisited => { + errorAsyncListener(kUniqueURI, isVisited); + }); + }); + }); +} + +function errorAsyncListener(aURI, aIsVisited) { + ok( + kUniqueURI.equals(aURI) && !aIsVisited, + "The neterror page is not listed in global history." + ); + + Services.prefs.setIntPref("network.proxy.type", proxyPrefValue); + + // Now press the "Try Again" button, with offline mode off. + Services.io.offline = false; + + BrowserTestUtils.browserLoaded(ourTab.linkedBrowser, false, null, true).then( + reloadListener + ); + + SpecialPowers.spawn(ourTab.linkedBrowser, [], function () { + Assert.ok( + content.document.querySelector("#netErrorButtonContainer > .try-again"), + "The error page has got a .try-again element" + ); + content.document + .querySelector("#netErrorButtonContainer > .try-again") + .click(); + }); +} + +// ------------------------------------------------------------------------------ +// listen to reload of neterror. +function reloadListener() { + // This listener catches "DOMContentLoaded" on being called + // nsIWPL::onLocationChange(...). That is right *AFTER* + // IHistory::VisitURI(...) is called. + ok(!Services.io.offline, "Services.io.offline is false."); + + SpecialPowers.spawn(ourTab.linkedBrowser, [kUniqueURI.spec], function (uri) { + // This is not an error page. + Assert.equal( + content.document.documentURI, + uri, + "Document URI is not the offline-error page, but the original URI." + ); + }).then(() => { + // Check if global history remembers the successfully-requested URI. + PlacesTestUtils.promiseAsyncUpdates().then(() => { + PlacesUtils.history.hasVisits(kUniqueURI).then(isVisited => { + reloadAsyncListener(kUniqueURI, isVisited); + }); + }); + }); +} + +function reloadAsyncListener(aURI, aIsVisited) { + ok(kUniqueURI.equals(aURI) && aIsVisited, "We have visited the URI."); + PlacesUtils.history.clear().then(finish); +} + +registerCleanupFunction(async function () { + Services.prefs.setIntPref("network.proxy.type", proxyPrefValue); + Services.io.offline = false; + BrowserTestUtils.removeTab(ourTab); +}); diff --git a/toolkit/components/places/tests/browser/browser_double_redirect.js b/toolkit/components/places/tests/browser/browser_double_redirect.js new file mode 100644 index 0000000000..435bd86f19 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_double_redirect.js @@ -0,0 +1,83 @@ +// Test for bug 411966. +// When a page redirects multiple times, from_visit should point to the +// previous visit in the chain, not to the first visit in the chain. + +add_task(async function () { + await PlacesUtils.history.clear(); + + const BASE_URL = + "http://example.com/tests/toolkit/components/places/tests/browser/"; + const TEST_URI = NetUtil.newURI(BASE_URL + "begin.html"); + const FIRST_REDIRECTING_URI = NetUtil.newURI(BASE_URL + "redirect_twice.sjs"); + const FINAL_URI = NetUtil.newURI( + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html" + ); + + let promiseVisits = new Promise(resolve => { + let observer = { + _notified: [], + onVisit(uri, id, time, referrerId, transition) { + info("Received onVisit: " + uri); + this._notified.push(uri); + + if (uri != FINAL_URI.spec) { + return; + } + + is(this._notified.length, 4); + PlacesObservers.removeListener(["page-visited"], this.handleEvents); + + (async function () { + // Get all pages visited from the original typed one + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + `SELECT url FROM moz_historyvisits + JOIN moz_places h ON h.id = place_id + WHERE from_visit IN + (SELECT v.id FROM moz_historyvisits v + JOIN moz_places p ON p.id = v.place_id + WHERE p.url_hash = hash(:url) AND p.url = :url) + `, + { url: TEST_URI.spec } + ); + + is(rows.length, 1, "Found right number of visits"); + let visitedUrl = rows[0].getResultByName("url"); + // Check that redirect from_visit is not from the original typed one + is( + visitedUrl, + FIRST_REDIRECTING_URI.spec, + "Check referrer for " + visitedUrl + ); + + resolve(); + })(); + }, + handleEvents(events) { + is(events.length, 1, "Right number of visits notified"); + is(events[0].type, "page-visited"); + let { url, visitId, visitTime, referringVisitId, transitionType } = + events[0]; + this.onVisit(url, visitId, visitTime, referringVisitId, transitionType); + }, + }; + observer.handleEvents = observer.handleEvents.bind(observer); + PlacesObservers.addListener(["page-visited"], observer.handleEvents); + }); + + PlacesUtils.history.markPageAsTyped(TEST_URI); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URI.spec, + }, + async function (browser) { + // Load begin page, click link on page to record visits. + await BrowserTestUtils.synthesizeMouseAtCenter("#clickme", {}, browser); + + await promiseVisits; + } + ); + + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js new file mode 100644 index 0000000000..35450f0be6 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js @@ -0,0 +1,43 @@ +/* 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/. */ + +function test() { + waitForExplicitFinish(); + + const pageURI = + "http://example.org/tests/toolkit/components/places/tests/browser/favicon.html"; + let windowsToClose = []; + + registerCleanupFunction(function () { + windowsToClose.forEach(function (aWin) { + aWin.close(); + }); + }); + + function testOnWindow(aIsPrivate, aCallback) { + whenNewWindowLoaded({ private: aIsPrivate }, function (aWin) { + windowsToClose.push(aWin); + executeSoon(() => aCallback(aWin)); + }); + } + + function waitForTabLoad(aWin, aCallback) { + BrowserTestUtils.browserLoaded(aWin.gBrowser.selectedBrowser).then( + aCallback + ); + BrowserTestUtils.loadURIString(aWin.gBrowser.selectedBrowser, pageURI); + } + + testOnWindow(true, function (win) { + waitForTabLoad(win, function () { + PlacesUtils.favicons.getFaviconURLForPage( + NetUtil.newURI(pageURI), + function (uri, dataLen, data, mimeType) { + is(uri, null, "No result should be found"); + finish(); + } + ); + }); + }); +} diff --git a/toolkit/components/places/tests/browser/browser_history_post.js b/toolkit/components/places/tests/browser/browser_history_post.js new file mode 100644 index 0000000000..a62592516f --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_history_post.js @@ -0,0 +1,35 @@ +const PAGE_URI = + "http://example.com/tests/toolkit/components/places/tests/browser/history_post.html"; +const SJS_URI = NetUtil.newURI( + "http://example.com/tests/toolkit/components/places/tests/browser/history_post.sjs" +); + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URI }, + async function (aBrowser) { + await SpecialPowers.spawn(aBrowser, [], async function () { + let doc = content.document; + let submit = doc.getElementById("submit"); + let iframe = doc.getElementById("post_iframe"); + let p = new Promise((resolve, reject) => { + iframe.addEventListener( + "load", + function () { + resolve(); + }, + { once: true } + ); + }); + submit.click(); + await p; + }); + let visited = await PlacesUtils.history.hasVisits(SJS_URI); + ok(!visited, "The POST page should not be added to history"); + ok( + !(await PlacesTestUtils.isPageInDB(SJS_URI.spec)), + "The page should not be in the database" + ); + } + ); +}); diff --git a/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js b/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js new file mode 100644 index 0000000000..a406422a2f --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const ROOT_URI = + "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/"; +const REDIRECT_URI = Services.io.newURI(ROOT_URI + "redirect_thrice.sjs"); +const INTERMEDIATE_URI_1 = Services.io.newURI( + ROOT_URI + "redirect_twice_perma.sjs" +); +const INTERMEDIATE_URI_2 = Services.io.newURI(ROOT_URI + "redirect_once.sjs"); +const TARGET_URI = Services.io.newURI( + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html" +); + +const REDIRECT_SOURCE_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.redirectSourceVisitBonus" +); +const PERM_REDIRECT_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.permRedirectVisitBonus" +); +const TYPED_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.typedVisitBonus" +); + +// Ensure that decay frecency doesn't kick in during tests (as a result +// of idle-daily). +Services.prefs.setCharPref("places.frecency.decayRate", "1.0"); + +registerCleanupFunction(async function () { + Services.prefs.clearUserPref("places.frecency.decayRate"); + await PlacesUtils.history.clear(); +}); + +async function check_uri(uri, frecency, hidden) { + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: uri, + }), + frecency, + "Frecency of the page is the expected one" + ); + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { + url: uri, + }), + hidden, + "Hidden value of the page is the expected one" + ); +} + +async function waitVisitedNotifications() { + let redirectNotified = false; + await PlacesTestUtils.waitForNotification("page-visited", visits => { + is(visits.length, 1, "Was notified for the right number of visits."); + let { url } = visits[0]; + info("Received 'page-visited': " + url); + if (url == REDIRECT_URI.spec) { + redirectNotified = true; + } + return url == TARGET_URI.spec; + }); + return redirectNotified; +} + +let firstRedirectBonus = 0; +let nextRedirectBonus = 0; +let targetBonus = 0; + +add_task(async function test_multiple_redirect() { + // The redirect source bonus overrides the link bonus. + let visitedPromise = waitVisitedNotifications(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: REDIRECT_URI.spec, + }, + async function () { + info("Waiting for onVisits"); + let redirectNotified = await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + firstRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(REDIRECT_URI, firstRedirectBonus, 1); + nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1); + await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1); + // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't + // currently track redirects across multiple redirects, we fallback to the + // PERM_REDIRECT_VISIT_BONUS. + targetBonus += PERM_REDIRECT_VISIT_BONUS; + await check_uri(TARGET_URI, targetBonus, 0); + } + ); +}); + +add_task(async function test_multiple_redirect_typed() { + // The typed bonus wins because the redirect is permanent. + PlacesUtils.history.markPageAsTyped(REDIRECT_URI); + let visitedPromise = waitVisitedNotifications(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: REDIRECT_URI.spec, + }, + async function () { + info("Waiting for onVisits"); + let redirectNotified = await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + firstRedirectBonus += TYPED_VISIT_BONUS; + await check_uri(REDIRECT_URI, firstRedirectBonus, 1); + nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1); + await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1); + // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't + // currently track redirects across multiple redirects, we fallback to the + // PERM_REDIRECT_VISIT_BONUS. + targetBonus += PERM_REDIRECT_VISIT_BONUS; + await check_uri(TARGET_URI, targetBonus, 0); + } + ); +}); + +add_task(async function test_second_typed_visit() { + // The typed bonus wins because the redirect is permanent. + PlacesUtils.history.markPageAsTyped(REDIRECT_URI); + let visitedPromise = waitVisitedNotifications(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: REDIRECT_URI.spec, + }, + async function () { + info("Waiting for onVisits"); + let redirectNotified = await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + firstRedirectBonus += TYPED_VISIT_BONUS; + await check_uri(REDIRECT_URI, firstRedirectBonus, 1); + nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1); + await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1); + // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't + // currently track redirects across multiple redirects, we fallback to the + // PERM_REDIRECT_VISIT_BONUS. + targetBonus += PERM_REDIRECT_VISIT_BONUS; + await check_uri(TARGET_URI, targetBonus, 0); + } + ); +}); + +add_task(async function test_subsequent_link_visit() { + // Another non typed visit. + let visitedPromise = waitVisitedNotifications(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: REDIRECT_URI.spec, + }, + async function () { + info("Waiting for onVisits"); + let redirectNotified = await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + firstRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(REDIRECT_URI, firstRedirectBonus, 1); + nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS; + await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1); + await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1); + // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't + // currently track redirects across multiple redirects, we fallback to the + // PERM_REDIRECT_VISIT_BONUS. + targetBonus += PERM_REDIRECT_VISIT_BONUS; + await check_uri(TARGET_URI, targetBonus, 0); + } + ); +}); diff --git a/toolkit/components/places/tests/browser/browser_notfound.js b/toolkit/components/places/tests/browser/browser_notfound.js new file mode 100644 index 0000000000..6f53866018 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_notfound.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function () { + const TEST_URL = "http://mochi.test:8888/notFoundPage.html"; + + // Used to verify errors are not marked as typed. + PlacesUtils.history.markPageAsTyped(NetUtil.newURI(TEST_URL)); + + // Create and add history observer. + let visitedPromise = new Promise(resolve => { + function listener(aEvents) { + is(aEvents.length, 1, "Right number of visits notified"); + is(aEvents[0].type, "page-visited"); + let uri = NetUtil.newURI(aEvents[0].url); + PlacesObservers.removeListener(["page-visited"], listener); + info("Received 'page-visited': " + uri.spec); + fieldForUrl(uri, "frecency", function (aFrecency) { + is(aFrecency, 0, "Frecency should be 0"); + fieldForUrl(uri, "hidden", function (aHidden) { + is(aHidden, 0, "Page should not be hidden"); + fieldForUrl(uri, "typed", function (aTyped) { + is(aTyped, 0, "page should not be marked as typed"); + resolve(); + }); + }); + }); + } + PlacesObservers.addListener(["page-visited"], listener); + }); + + let newTabPromise = BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + await Promise.all([visitedPromise, newTabPromise]); + + await PlacesUtils.history.clear(); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js b/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js new file mode 100644 index 0000000000..a7c583975a --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js @@ -0,0 +1,41 @@ +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +add_task(async function checkTitleNotificationForNavigation() { + const EXPECTED_URL = Services.io.newURI(TEST_PATH + "empty_page.html"); + + const promiseVisit = PlacesTestUtils.waitForNotification( + "page-visited", + events => events[0].url === EXPECTED_URL.spec + ); + + const promiseTitle = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url === EXPECTED_URL.spec + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EXPECTED_URL.spec + ); + + const visitEvents = await promiseVisit; + Assert.equal(visitEvents.length, 1, "Right number of visits notified"); + Assert.equal(visitEvents[0].type, "page-visited"); + info("'page-visited': " + visitEvents[0].url); + Assert.equal(visitEvents[0].lastKnownTitle, null, "Should not have a title"); + + const titleEvents = await promiseTitle; + Assert.equal(titleEvents.length, 1, "Right number of title changed notified"); + Assert.equal(titleEvents[0].type, "page-title-changed"); + info("'page-title-changed': " + titleEvents[0].url); + Assert.equal( + titleEvents[0].title, + "I am an empty page", + "Should have correct title in titlechanged notification" + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/places/tests/browser/browser_redirect.js b/toolkit/components/places/tests/browser/browser_redirect.js new file mode 100644 index 0000000000..912b817ad1 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_redirect.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const ROOT_URI = + "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/"; +const REDIRECT_URI = Services.io.newURI(ROOT_URI + "redirect.sjs"); +const TARGET_URI = Services.io.newURI(ROOT_URI + "redirect-target.html"); + +const REDIRECT_SOURCE_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.redirectSourceVisitBonus" +); +const LINK_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.linkVisitBonus" +); +const TYPED_VISIT_BONUS = Services.prefs.getIntPref( + "places.frecency.typedVisitBonus" +); + +// Ensure that decay frecency doesn't kick in during tests (as a result +// of idle-daily). +Services.prefs.setCharPref("places.frecency.decayRate", "1.0"); + +registerCleanupFunction(async function () { + Services.prefs.clearUserPref("places.frecency.decayRate"); + await PlacesUtils.history.clear(); +}); + +let redirectSourceFrecency = 0; +let redirectTargetFrecency = 0; + +async function check_uri(uri, frecency, hidden) { + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: uri, + }), + frecency, + "Frecency of the page is the expected one" + ); + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { + url: uri, + }), + hidden, + "Hidden value of the page is the expected one" + ); +} + +add_task(async function redirect_check_new_typed_visit() { + // Used to verify the redirect bonus overrides the typed bonus. + PlacesUtils.history.markPageAsTyped(REDIRECT_URI); + + redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS; + redirectTargetFrecency += TYPED_VISIT_BONUS; + let redirectNotified = false; + + let visitedPromise = PlacesTestUtils.waitForNotification( + "page-visited", + visits => { + is(visits.length, 1, "Was notified for the right number of visits."); + let { url } = visits[0]; + info("Received 'page-visited': " + url); + if (url == REDIRECT_URI.spec) { + redirectNotified = true; + } + return url == TARGET_URI.spec; + } + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REDIRECT_URI.spec + ); + info("Waiting for onVisits"); + await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + await check_uri(REDIRECT_URI, redirectSourceFrecency, 1); + await check_uri(TARGET_URI, redirectTargetFrecency, 0); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function redirect_check_second_typed_visit() { + // A second visit with a typed url. + PlacesUtils.history.markPageAsTyped(REDIRECT_URI); + + redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS; + redirectTargetFrecency += TYPED_VISIT_BONUS; + let redirectNotified = false; + + let visitedPromise = PlacesTestUtils.waitForNotification( + "page-visited", + visits => { + is(visits.length, 1, "Was notified for the right number of visits."); + let { url } = visits[0]; + info("Received 'page-visited': " + url); + if (url == REDIRECT_URI.spec) { + redirectNotified = true; + } + return url == TARGET_URI.spec; + } + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REDIRECT_URI.spec + ); + info("Waiting for onVisits"); + await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + await check_uri(REDIRECT_URI, redirectSourceFrecency, 1); + await check_uri(TARGET_URI, redirectTargetFrecency, 0); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function redirect_check_subsequent_link_visit() { + // Another visit, but this time as a visited url. + redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS; + redirectTargetFrecency += LINK_VISIT_BONUS; + let redirectNotified = false; + + let visitedPromise = PlacesTestUtils.waitForNotification( + "page-visited", + visits => { + is(visits.length, 1, "Was notified for the right number of visits."); + let { url } = visits[0]; + info("Received 'page-visited': " + url); + if (url == REDIRECT_URI.spec) { + redirectNotified = true; + } + return url == TARGET_URI.spec; + } + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REDIRECT_URI.spec + ); + info("Waiting for onVisits"); + await visitedPromise; + ok(redirectNotified, "The redirect should have been notified"); + + await check_uri(REDIRECT_URI, redirectSourceFrecency, 1); + await check_uri(TARGET_URI, redirectTargetFrecency, 0); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/places/tests/browser/browser_redirect_self.js b/toolkit/components/places/tests/browser/browser_redirect_self.js new file mode 100644 index 0000000000..7ed7ee0af0 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_redirect_self.js @@ -0,0 +1,51 @@ +/* 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/. */ + +/* + * Tests a page that redirects to itself. On the initial visit the page should + * be marked as hidden, but then the second visit should unhide it. + * This ensures that that the history anti-flooding system doesn't skip the + * second visit. + */ + +add_task(async function () { + await PlacesUtils.history.clear(); + Cc["@mozilla.org/browser/history;1"] + .getService(Ci.mozIAsyncHistory) + .clearCache(); + const url = + "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/redirect_self.sjs"; + let visitCount = 0; + function onVisitsListener(events) { + visitCount++; + Assert.equal(events.length, 1, "Right number of visits notified"); + Assert.equal(events[0].url, url, "Got a visit for the expected url"); + if (visitCount == 1) { + Assert.ok(events[0].hidden, "The visit should be hidden"); + } else { + Assert.ok(!events[0].hidden, "The visit should not be hidden"); + } + } + PlacesObservers.addListener(["page-visited"], onVisitsListener); + registerCleanupFunction(async function () { + PlacesObservers.removeListener(["page-visited"], onVisitsListener); + await PlacesUtils.history.clear(); + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async browser => { + await TestUtils.waitForCondition(() => visitCount == 2); + // Check that the visit is not hidden in the database. + Assert.ok( + !(await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { + url, + })), + "The url should not be hidden in the database" + ); + } + ); +}); diff --git a/toolkit/components/places/tests/browser/browser_settitle.js b/toolkit/components/places/tests/browser/browser_settitle.js new file mode 100644 index 0000000000..3519891bbe --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_settitle.js @@ -0,0 +1,48 @@ +var conn = PlacesUtils.history.DBConnection; + +/** + * Gets a single column value from either the places or historyvisits table. + */ +function getColumn(table, column, url) { + var stmt = conn.createStatement( + `SELECT ${column} FROM ${table} WHERE url_hash = hash(:val) AND url = :val` + ); + try { + stmt.params.val = url; + stmt.executeStep(); + return stmt.row[column]; + } finally { + stmt.finalize(); + } +} + +add_task(async function () { + // Make sure titles are correctly saved for a URI with the proper + // notifications. + const titleChangedPromise = + PlacesTestUtils.waitForNotification("page-title-changed"); + + const url1 = + "http://example.com/tests/toolkit/components/places/tests/browser/title1.html"; + await BrowserTestUtils.openNewForegroundTab(gBrowser, url1); + + const url2 = + "http://example.com/tests/toolkit/components/places/tests/browser/title2.html"; + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url2); + await loadPromise; + + const events = await titleChangedPromise; + is( + events[0].url, + "http://example.com/tests/toolkit/components/places/tests/browser/title2.html" + ); + is(events[0].title, "Some title"); + is(events[0].pageGuid, getColumn("moz_places", "guid", events[0].url)); + + const title = getColumn("moz_places", "title", events[0].url); + is(title, events[0].title); + + gBrowser.removeCurrentTab(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/browser/browser_visited_notfound.js b/toolkit/components/places/tests/browser/browser_visited_notfound.js new file mode 100644 index 0000000000..1b97c307e2 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_visited_notfound.js @@ -0,0 +1,60 @@ +add_task(async function test() { + const TEST_URL = "http://mochi.test:8888/notFoundPage.html"; + // Ensure that decay frecency doesn't kick in during tests (as a result + // of idle-daily). + Services.prefs.setCharPref("places.frecency.decayRate", "1.0"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("places.frecency.decayRate"); + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); + }); + + // First add a visit to the page, this will ensure that later we skip + // updating the frecency for a newly not-found page. + await PlacesTestUtils.addVisits({ uri: TEST_URL }); + let frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: TEST_URL } + ); + is(frecency, 100, "Check initial frecency"); + + // Used to verify errors are not marked as typed. + PlacesUtils.history.markPageAsTyped(NetUtil.newURI(TEST_URL)); + + let promiseVisit = new Promise(resolve => { + function onVisits(events) { + PlacesObservers.removeListener(["page-visited"], onVisits); + is(events.length, 1, "Right number of visits"); + is(events[0].type, "page-visited"); + is(events[0].url, TEST_URL, "Check visited url"); + resolve(); + } + PlacesObservers.addListener(["page-visited"], onVisits); + }); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, TEST_URL); + await promiseVisit; + + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL, + }), + frecency, + "Frecency should be unchanged" + ); + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { + url: TEST_URL, + }), + 0, + "Page should not be hidden" + ); + is( + await PlacesTestUtils.getDatabaseValue("moz_places", "typed", { + url: TEST_URL, + }), + 0, + "page should not be marked as typed" + ); +}); diff --git a/toolkit/components/places/tests/browser/browser_visituri.js b/toolkit/components/places/tests/browser/browser_visituri.js new file mode 100644 index 0000000000..6633ac188b --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_visituri.js @@ -0,0 +1,100 @@ +/** + * One-time observer callback. + */ +function promiseObserve(name, checkFn) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject) { + if (checkFn(subject)) { + Services.obs.removeObserver(observer, name); + resolve(); + } + }, name); + }); +} + +var conn = PlacesUtils.history.DBConnection; + +/** + * Gets a single column value from either the places or historyvisits table. + */ +function getColumn(table, column, fromColumnName, fromColumnValue) { + let sql = `SELECT ${column} + FROM ${table} + WHERE ${fromColumnName} = :val + ${fromColumnName == "url" ? "AND url_hash = hash(:val)" : ""} + LIMIT 1`; + let stmt = conn.createStatement(sql); + try { + stmt.params.val = fromColumnValue; + ok(stmt.executeStep(), "Expect to get a row"); + return stmt.row[column]; + } finally { + stmt.reset(); + } +} + +add_task(async function () { + // Make sure places visit chains are saved correctly with a redirect + // transitions. + + // Part 1: observe history events that fire when a visit occurs. + // Make sure visits appear in order, and that the visit chain is correct. + var expectedUrls = [ + "http://example.com/tests/toolkit/components/places/tests/browser/begin.html", + "http://example.com/tests/toolkit/components/places/tests/browser/redirect_twice.sjs", + "http://example.com/tests/toolkit/components/places/tests/browser/redirect_once.sjs", + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html", + ]; + var currentIndex = 0; + + function checkObserver(subject) { + var uri = subject.QueryInterface(Ci.nsIURI); + var expected = expectedUrls[currentIndex]; + is(uri.spec, expected, "Saved URL visit " + uri.spec); + + var placeId = getColumn("moz_places", "id", "url", uri.spec); + var fromVisitId = getColumn( + "moz_historyvisits", + "from_visit", + "place_id", + placeId + ); + + if (currentIndex == 0) { + is(fromVisitId, 0, "First visit has no from visit"); + } else { + var lastVisitId = getColumn( + "moz_historyvisits", + "place_id", + "id", + fromVisitId + ); + var fromVisitUrl = getColumn("moz_places", "url", "id", lastVisitId); + is( + fromVisitUrl, + expectedUrls[currentIndex - 1], + "From visit was " + expectedUrls[currentIndex - 1] + ); + } + + currentIndex++; + return currentIndex >= expectedUrls.length; + } + let visitUriPromise = promiseObserve("uri-visit-saved", checkObserver); + + const testUrl = + "http://example.com/tests/toolkit/components/places/tests/browser/begin.html"; + await BrowserTestUtils.openNewForegroundTab(gBrowser, testUrl); + + // Load begin page, click link on page to record visits. + await BrowserTestUtils.synthesizeMouseAtCenter( + "#clickme", + {}, + gBrowser.selectedBrowser + ); + await visitUriPromise; + + await PlacesUtils.history.clear(); + + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/places/tests/browser/browser_visituri_nohistory.js b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js new file mode 100644 index 0000000000..3708b388e5 --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js @@ -0,0 +1,44 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const INITIAL_URL = + "http://example.com/tests/toolkit/components/places/tests/browser/begin.html"; +const FINAL_URL = + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html"; + +/** + * One-time observer callback. + */ +function promiseObserve(name) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject) { + Services.obs.removeObserver(observer, name); + resolve(subject); + }, name); + }); +} + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + let visitUriPromise = promiseObserve("uri-visit-saved"); + + await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL); + + await SpecialPowers.popPrefEnv(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(gBrowser, FINAL_URL); + await browserLoadedPromise; + + let subject = await visitUriPromise; + let uri = subject.QueryInterface(Ci.nsIURI); + is(uri.spec, FINAL_URL, "received expected visit"); + + await PlacesUtils.history.clear(); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js new file mode 100644 index 0000000000..75fd2aa46d --- /dev/null +++ b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js @@ -0,0 +1,63 @@ +/* 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/. */ + +const initialURL = + "http://example.com/tests/toolkit/components/places/tests/browser/begin.html"; +const finalURL = + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html"; + +var observer; +var visitSavedPromise; + +add_setup(async function () { + visitSavedPromise = new Promise(resolve => { + observer = { + observe(subject, topic, data) { + // The uri-visit-saved topic should only work when on normal mode. + if (topic == "uri-visit-saved") { + Services.obs.removeObserver(observer, "uri-visit-saved"); + + // The expected visit should be the finalURL because private mode + // should not register a visit with the initialURL. + let uri = subject.QueryInterface(Ci.nsIURI); + resolve(uri.spec); + } + }, + }; + }); + + Services.obs.addObserver(observer, "uri-visit-saved"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +// Note: The private window test must be the first one to run, since we'll listen +// to the first uri-visit-saved notification, and we expect this test to not +// fire any, so we'll just find the non-private window test notification. +add_task(async function test_private_browsing_window() { + await testLoadInWindow({ private: true }, initialURL); +}); + +add_task(async function test_normal_window() { + await testLoadInWindow({ private: false }, finalURL); + + let url = await visitSavedPromise; + Assert.equal(url, finalURL, "Check received expected visit"); +}); + +async function testLoadInWindow(options, url) { + let win = await BrowserTestUtils.openNewBrowserWindow(options); + + registerCleanupFunction(async function () { + await BrowserTestUtils.closeWindow(win); + }); + + let loadedPromise = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, url); + await loadedPromise; +} diff --git a/toolkit/components/places/tests/browser/empty_page.html b/toolkit/components/places/tests/browser/empty_page.html new file mode 100644 index 0000000000..ac9d144cb4 --- /dev/null +++ b/toolkit/components/places/tests/browser/empty_page.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>I am an empty page</title> + </head> + <body>Empty</body> +</html> diff --git a/toolkit/components/places/tests/browser/favicon-normal16.png b/toolkit/components/places/tests/browser/favicon-normal16.png Binary files differnew file mode 100644 index 0000000000..62b69a3d03 --- /dev/null +++ b/toolkit/components/places/tests/browser/favicon-normal16.png diff --git a/toolkit/components/places/tests/browser/favicon-normal32.png b/toolkit/components/places/tests/browser/favicon-normal32.png Binary files differnew file mode 100644 index 0000000000..5535363c94 --- /dev/null +++ b/toolkit/components/places/tests/browser/favicon-normal32.png diff --git a/toolkit/components/places/tests/browser/favicon.html b/toolkit/components/places/tests/browser/favicon.html new file mode 100644 index 0000000000..a0f5ea9594 --- /dev/null +++ b/toolkit/components/places/tests/browser/favicon.html @@ -0,0 +1,13 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> + +<html> + <head> + <link rel="shortcut icon" href="http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal32.png"> + </head> + <body> + OK we're done! + </body> +</html> diff --git a/toolkit/components/places/tests/browser/final.html b/toolkit/components/places/tests/browser/final.html new file mode 100644 index 0000000000..ccd5819181 --- /dev/null +++ b/toolkit/components/places/tests/browser/final.html @@ -0,0 +1,10 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> + +<html> + <body> + OK we're done! + </body> +</html> diff --git a/toolkit/components/places/tests/browser/head.js b/toolkit/components/places/tests/browser/head.js new file mode 100644 index 0000000000..e4d0b5566b --- /dev/null +++ b/toolkit/components/places/tests/browser/head.js @@ -0,0 +1,74 @@ +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +const TRANSITION_LINK = PlacesUtils.history.TRANSITION_LINK; +const TRANSITION_TYPED = PlacesUtils.history.TRANSITION_TYPED; +const TRANSITION_BOOKMARK = PlacesUtils.history.TRANSITION_BOOKMARK; +const TRANSITION_REDIRECT_PERMANENT = + PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT; +const TRANSITION_REDIRECT_TEMPORARY = + PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY; +const TRANSITION_EMBED = PlacesUtils.history.TRANSITION_EMBED; +const TRANSITION_FRAMED_LINK = PlacesUtils.history.TRANSITION_FRAMED_LINK; +const TRANSITION_DOWNLOAD = PlacesUtils.history.TRANSITION_DOWNLOAD; + +/** + * Returns a moz_places field value for a url. + * + * @param {nsIURI|String} aURI + * The URI or spec to get field for. + * @param {String} aFieldName + * The field name to get the value of. + * @param {Function} aCallback + * Callback function that will get the property value. + */ +function fieldForUrl(aURI, aFieldName, aCallback) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = PlacesUtils.history.DBConnection.createAsyncStatement( + `SELECT ${aFieldName} FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url` + ); + stmt.params.page_url = url; + stmt.executeAsync({ + _value: -1, + handleResult(aResultSet) { + let row = aResultSet.getNextRow(); + if (!row) { + ok(false, "The page should exist in the database"); + } + this._value = row.getResultByName(aFieldName); + }, + handleError() {}, + handleCompletion(aReason) { + if (aReason != Ci.mozIStorageStatementCallback.REASON_FINISHED) { + ok(false, "The statement should properly succeed"); + } + aCallback(this._value); + }, + }); + stmt.finalize(); +} + +/** + * Promise wrapper for fieldForUrl. + * + * @param {nsIURI|String} aURI + * The URI or spec to get field for. + * @param {String} aFieldName + * The field name to get the value of. + * @return {Promise} + * A promise that is resolved with the value of the field. + */ +function promiseFieldForUrl(aURI, aFieldName) { + return new Promise(resolve => { + function callback(result) { + resolve(result); + } + fieldForUrl(aURI, aFieldName, callback); + }); +} + +function whenNewWindowLoaded(aOptions, aCallback) { + BrowserTestUtils.waitForNewWindow().then(aCallback); + OpenBrowserWindow(aOptions); +} diff --git a/toolkit/components/places/tests/browser/history_post.html b/toolkit/components/places/tests/browser/history_post.html new file mode 100644 index 0000000000..a579a9b8ae --- /dev/null +++ b/toolkit/components/places/tests/browser/history_post.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Test post pages are not added to history</title> + </head> + <body> + <iframe name="post_iframe" id="post_iframe"></iframe> + <form method="post" action="http://example.com/tests/toolkit/components/places/tests/browser/history_post.sjs" target="post_iframe"> + <input type="submit" id="submit"/> + </form> + </body> +</html> diff --git a/toolkit/components/places/tests/browser/history_post.sjs b/toolkit/components/places/tests/browser/history_post.sjs new file mode 100644 index 0000000000..08c1afe853 --- /dev/null +++ b/toolkit/components/places/tests/browser/history_post.sjs @@ -0,0 +1,5 @@ +function handleRequest(request, response) { + response.setStatusLine("1.0", 200, "OK"); + response.setHeader("Content-Type", "text/plain; charset=utf-8", false); + response.write("Ciao"); +} diff --git a/toolkit/components/places/tests/browser/previews/browser.ini b/toolkit/components/places/tests/browser/previews/browser.ini new file mode 100644 index 0000000000..dd77faa323 --- /dev/null +++ b/toolkit/components/places/tests/browser/previews/browser.ini @@ -0,0 +1,11 @@ +# 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/. + +[DEFAULT] +prefs = + browser.pagethumbnails.capturing_disabled=false + places.previews.enabled=true + places.previews.log=true + +[browser_thumbnails.js] diff --git a/toolkit/components/places/tests/browser/previews/browser_thumbnails.js b/toolkit/components/places/tests/browser/previews/browser_thumbnails.js new file mode 100644 index 0000000000..ef776694c2 --- /dev/null +++ b/toolkit/components/places/tests/browser/previews/browser_thumbnails.js @@ -0,0 +1,174 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests PlacesPreviews.jsm + */ +const { PlacesPreviews } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesPreviews.sys.mjs" +); +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const TEST_URL1 = "http://example.com/"; +const TEST_URL2 = "http://example.org/"; + +/** + * Counts tombstone entries. + * @returns {integer} number of tombstone entries. + */ +async function countTombstones() { + await PlacesTestUtils.promiseAsyncUpdates(); + let db = await PlacesUtils.promiseDBConnection(); + return ( + await db.execute("SELECT count(*) FROM moz_previews_tombstones") + )[0].getResultByIndex(0); +} + +add_task(async function test_thumbnail() { + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + // Ensure tombstones table has been emptied. + await TestUtils.waitForCondition(async () => { + return (await countTombstones()) == 0; + }); + PlacesPreviews.testSetDeletionTimeout(null); + }); + // Sanity check initial state. + Assert.equal(await countTombstones(), 0, "There's no tombstone entries"); + + info("Test preview creation and storage."); + await BrowserTestUtils.withNewTab(TEST_URL1, async browser => { + await retryUpdatePreview(browser.currentURI.spec); + let filePath = PlacesPreviews.getPathForUrl(TEST_URL1); + Assert.ok(await IOUtils.exists(filePath), "The screenshot exists"); + Assert.equal( + filePath.substring(filePath.lastIndexOf(".")), + PlacesPreviews.fileExtension, + "Check extension" + ); + await testImageFile(filePath); + await testMozPageThumb(TEST_URL1); + }); +}); + +add_task(async function test_page_removal() { + info("Store another preview and test page removal."); + await BrowserTestUtils.withNewTab(TEST_URL2, async browser => { + await retryUpdatePreview(browser.currentURI.spec); + let filePath = PlacesPreviews.getPathForUrl(TEST_URL2); + Assert.ok(await IOUtils.exists(filePath), "The screenshot exists"); + }); + + // Set deletion time to a small value so it runs immediately. + PlacesPreviews.testSetDeletionTimeout(0); + info("Wait for deletion, check one preview is removed, not the other one."); + let promiseDeleted = new Promise(resolve => { + PlacesPreviews.once("places-preview-deleted", (topic, filePath) => { + resolve(filePath); + }); + }); + await PlacesUtils.history.remove(TEST_URL1); + + let deletedFilePath = await promiseDeleted; + Assert.ok( + !(await IOUtils.exists(deletedFilePath)), + "Check deleted file has been removed" + ); + + info("Check tombstones table has been emptied."); + Assert.equal(await countTombstones(), 0, "There's no tombstone entries"); + + info("Check the other thumbnail has not been removed."); + let path = PlacesPreviews.getPathForUrl(TEST_URL2); + Assert.ok(await IOUtils.exists(path), "Check non-deleted url is still there"); + await testImageFile(path); + await testMozPageThumb(TEST_URL2); +}); + +add_task(async function async_test_deleteOrphans() { + let path = PlacesPreviews.getPathForUrl(TEST_URL2); + Assert.ok(await IOUtils.exists(path), "Sanity check one preview exists"); + // Create a file in the given path that doesn't have an entry in Places. + let fakePath = PathUtils.join( + PlacesPreviews.getPath(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa." + PlacesPreviews.fileExtension + ); + // File contents don't matter. + await IOUtils.writeJSON(fakePath, { test: true }); + let promiseDeleted = new Promise(resolve => { + PlacesPreviews.once("places-preview-deleted", (topic, filePath) => { + resolve(filePath); + }); + }); + + await PlacesPreviews.deleteOrphans(); + let deletedFilePath = await promiseDeleted; + Assert.equal(deletedFilePath, fakePath, "Check orphan has been deleted"); + Assert.equal(await countTombstones(), 0, "There's no tombstone entries left"); + Assert.ok( + !(await IOUtils.exists(fakePath)), + "Ensure orphan has been deleted" + ); + + Assert.ok(await IOUtils.exists(path), "Ensure valid preview is still there"); +}); + +async function testImageFile(path) { + info("Load the file and check its content type."); + const buffer = await IOUtils.read(path); + const fourcc = new TextDecoder("utf-8").decode(buffer.slice(8, 12)); + Assert.equal(fourcc, "WEBP", "Check the stored preview is webp"); +} + +async function testMozPageThumb(url) { + info("Check moz-page-thumb protocol: " + PlacesPreviews.getPageThumbURL(url)); + let { data, contentType } = await fetchImage( + PlacesPreviews.getPageThumbURL(url) + ); + Assert.equal( + contentType, + PlacesPreviews.fileContentType, + "Check the content type" + ); + const fourcc = data.slice(8, 12); + Assert.equal(fourcc, "WEBP", "Check the returned preview is webp"); +} + +function fetchImage(url) { + return new Promise((resolve, reject) => { + NetUtil.asyncFetch( + { + uri: NetUtil.newURI(url), + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE, + }, + (input, status, request) => { + if (!Components.isSuccessCode(status)) { + reject(new Error("unable to load image")); + return; + } + + try { + let data = NetUtil.readInputStreamToString(input, input.available()); + let contentType = request.QueryInterface(Ci.nsIChannel).contentType; + input.close(); + resolve({ data, contentType }); + } catch (ex) { + reject(ex); + } + } + ); + }); +} + +/** + * Sometimes on macOS fetching the preview fails for timeout/network reasons, + * this retries so the test doesn't intermittently fail over it. + * @param {string} url The url to store a preview for. + * @returns {Promise} resolved once a preview has been captured. + */ +function retryUpdatePreview(url) { + return TestUtils.waitForCondition(() => PlacesPreviews.update(url)); +} diff --git a/toolkit/components/places/tests/browser/redirect-target.html b/toolkit/components/places/tests/browser/redirect-target.html new file mode 100644 index 0000000000..3700263385 --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect-target.html @@ -0,0 +1 @@ +<!DOCTYPE html><html><body><p>Ciao!</p></body></html> diff --git a/toolkit/components/places/tests/browser/redirect.sjs b/toolkit/components/places/tests/browser/redirect.sjs new file mode 100644 index 0000000000..ab47335ffe --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect.sjs @@ -0,0 +1,13 @@ +/* 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/. */ + +function handleRequest(request, response) { + let page = "<!DOCTYPE html><html><body><p>Redirecting...</p></body></html>"; + + response.setStatusLine(request.httpVersion, "301", "Moved Permanently"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Content-Length", page.length + "", false); + response.setHeader("Location", "redirect-target.html", false); + response.write(page); +} diff --git a/toolkit/components/places/tests/browser/redirect_once.sjs b/toolkit/components/places/tests/browser/redirect_once.sjs new file mode 100644 index 0000000000..b9ccd0829a --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect_once.sjs @@ -0,0 +1,13 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 301, "Found"); + response.setHeader( + "Location", + "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html", + false + ); +} diff --git a/toolkit/components/places/tests/browser/redirect_self.sjs b/toolkit/components/places/tests/browser/redirect_self.sjs new file mode 100644 index 0000000000..953afe5f26 --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect_self.sjs @@ -0,0 +1,27 @@ +/* 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/. */ + +// Script that redirects to itself once. + +function handleRequest(request, response) { + if ( + request.hasHeader("Cookie") && + request.getHeader("Cookie").includes("redirect-self") + ) { + response.setStatusLine("1.0", 200, "OK"); + // Expire the cookie. + response.setHeader( + "Set-Cookie", + "redirect-self=true; expires=Thu, 01 Jan 1970 00:00:00 GMT", + true + ); + response.setHeader("Content-Type", "text/plain; charset=utf-8", false); + response.write("OK"); + } else { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Set-Cookie", "redirect-self=true", true); + response.setHeader("Location", "redirect_self.sjs"); + response.write("Moved Temporarily"); + } +} diff --git a/toolkit/components/places/tests/browser/redirect_thrice.sjs b/toolkit/components/places/tests/browser/redirect_thrice.sjs new file mode 100644 index 0000000000..55154a736e --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect_thrice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_twice_perma.sjs", false); +} diff --git a/toolkit/components/places/tests/browser/redirect_twice.sjs b/toolkit/components/places/tests/browser/redirect_twice.sjs new file mode 100644 index 0000000000..099d20022e --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect_twice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_once.sjs", false); +} diff --git a/toolkit/components/places/tests/browser/redirect_twice_perma.sjs b/toolkit/components/places/tests/browser/redirect_twice_perma.sjs new file mode 100644 index 0000000000..a40abd4170 --- /dev/null +++ b/toolkit/components/places/tests/browser/redirect_twice_perma.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 301, "Found"); + response.setHeader("Location", "redirect_once.sjs", false); +} diff --git a/toolkit/components/places/tests/browser/title1.html b/toolkit/components/places/tests/browser/title1.html new file mode 100644 index 0000000000..3c98d693ec --- /dev/null +++ b/toolkit/components/places/tests/browser/title1.html @@ -0,0 +1,12 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> + +<html> + <head> + </head> + <body> + title1.html + </body> +</html> diff --git a/toolkit/components/places/tests/browser/title2.html b/toolkit/components/places/tests/browser/title2.html new file mode 100644 index 0000000000..8830328796 --- /dev/null +++ b/toolkit/components/places/tests/browser/title2.html @@ -0,0 +1,13 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> + +<html> + <head> + <title>Some title</title> + </head> + <body> + title2.html + </body> +</html> diff --git a/toolkit/components/places/tests/chrome/bad_links.atom b/toolkit/components/places/tests/chrome/bad_links.atom new file mode 100644 index 0000000000..4469272524 --- /dev/null +++ b/toolkit/components/places/tests/chrome/bad_links.atom @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + + <title>Example Feed</title> + <link href="http://example.org/"/> + <updated>2003-12-13T18:30:02Z</updated> + + <author> + <name>John Doe</name> + </author> + <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id> + + <entry> + + <title>First good item</title> + <link href="http://example.org/first"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id> + <updated>2003-12-13T18:30:02Z</updated> + + <summary>Some text.</summary> + </entry> + + <entry> + + <title>data: link</title> + <link href="data:text/plain,Hi"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b</id> + <updated>2003-12-13T18:30:03Z</updated> + + <summary>Some text.</summary> + </entry> + + <entry> + + <title>javascript: link</title> + <link href="javascript:alert('Hi')"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6c</id> + <updated>2003-12-13T18:30:04Z</updated> + + <summary>Some text.</summary> + </entry> + + <entry> + + <title>file: link</title> + <link href="file:///var/"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6d</id> + <updated>2003-12-13T18:30:05Z</updated> + + <summary>Some text.</summary> + </entry> + + <entry> + + <title>chrome: link</title> + <link href="chrome://browser/content/browser.js"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6e</id> + <updated>2003-12-13T18:30:06Z</updated> + + <summary>Some text.</summary> + </entry> + + <entry> + + <title>Last good item</title> + <link href="http://example.org/last"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b</id> + <updated>2003-12-13T18:30:07Z</updated> + + <summary>Some text.</summary> + </entry> + + +</feed> diff --git a/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml new file mode 100644 index 0000000000..f8482ca989 --- /dev/null +++ b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Test disableglobalhistory attribute on remote browsers" + onload="run_test()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <browser id="inprocess_disabled" src="about:blank" type="content" disableglobalhistory="true" /> + <browser id="inprocess_enabled" src="about:blank" type="content" /> + + <browser id="remote_disabled" src="about:blank" type="content" disableglobalhistory="true" /> + <browser id="remote_enabled" src="about:blank" type="content" /> + + <script type="text/javascript"> + + const { ContentTask } = ChromeUtils.import("resource://testing-common/ContentTask.jsm"); + ContentTask.setTestScope(window.arguments[0].wrappedJSObject); + + function expectUseGlobalHistory(id, expected) { + let browser = document.getElementById(id); + /* eslint-disable-next-line no-shadow */ + return ContentTask.spawn(browser, {id, expected}, function({id, expected}) { + Assert.equal(docShell.browsingContext.useGlobalHistory, expected, + "Got the right useGlobalHistory state in the docShell of " + id); + }); + } + + async function run_test() { + await expectUseGlobalHistory("inprocess_disabled", false); + await expectUseGlobalHistory("inprocess_enabled", true); + + await expectUseGlobalHistory("remote_disabled", false); + await expectUseGlobalHistory("remote_enabled", true); + window.arguments[0].done(); + ok(true); + } + + </script> +</window> diff --git a/toolkit/components/places/tests/chrome/chrome.ini b/toolkit/components/places/tests/chrome/chrome.ini new file mode 100644 index 0000000000..20f6e89db9 --- /dev/null +++ b/toolkit/components/places/tests/chrome/chrome.ini @@ -0,0 +1,7 @@ +[DEFAULT] +support-files = head.js + +[test_371798.xhtml] +[test_favicon_annotations.xhtml] +[test_browser_disableglobalhistory.xhtml] +support-files = browser_disableglobalhistory.xhtml diff --git a/toolkit/components/places/tests/chrome/head.js b/toolkit/components/places/tests/chrome/head.js new file mode 100644 index 0000000000..7c03e6f33d --- /dev/null +++ b/toolkit/components/places/tests/chrome/head.js @@ -0,0 +1,8 @@ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); diff --git a/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss new file mode 100644 index 0000000000..612b0a5c2e --- /dev/null +++ b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<rss version="2.0"> + <channel> + <title>feed title</title> + <ttl>180</ttl> + <item> + <title>linked feed item</title> + <link>http://feed-item-link.com</link> + </item> + <item> + <title>link-less feed item</title> + </item> + <item> + <title>linked feed item</title> + <link>http://feed-item-link.com</link> + </item> + </channel> +</rss> diff --git a/toolkit/components/places/tests/chrome/link-less-items.rss b/toolkit/components/places/tests/chrome/link-less-items.rss new file mode 100644 index 0000000000..a30d4a3531 --- /dev/null +++ b/toolkit/components/places/tests/chrome/link-less-items.rss @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<rss version="2.0"> + <channel> + <title>feed title</title> + <link>http://feed-link.com</link> + <ttl>180</ttl> + <item> + <title>linked feed item</title> + <link>http://feed-item-link.com</link> + </item> + <item> + <title>link-less feed item</title> + </item> + <item> + <title>linked feed item</title> + <link>http://feed-item-link.com</link> + </item> + </channel> +</rss> diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss b/toolkit/components/places/tests/chrome/rss_as_html.rss new file mode 100644 index 0000000000..e823050353 --- /dev/null +++ b/toolkit/components/places/tests/chrome/rss_as_html.rss @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="ISO-8859-1" ?> +<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> +<channel> +<title>sadfasdfasdfasfasdf</title> +<link>http://www.example.com</link> +<description>asdfasdfasdf.example.com</description> +<language>de</language> +<copyright>asdfasdfasdfasdf</copyright> +<lastBuildDate>Tue, 11 Mar 2008 18:52:52 +0100</lastBuildDate> +<docs>http://blogs.law.harvard.edu/tech/rss</docs> +<ttl>10</ttl> +<item> +<title>The First Title</title> +<link>http://www.example.com/index.html</link> +<pubDate>Tue, 11 Mar 2008 18:24:43 +0100</pubDate> +<content:encoded> +<![CDATA[ +<p> +askdlfjas;dfkjas;fkdj +</p> +]]> +</content:encoded> +<description>aklsjdhfasdjfahasdfhj</description> +<guid>http://foo.example.com/asdfasdf</guid> +</item> +</channel> +</rss> diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^ new file mode 100644 index 0000000000..04fbaa08fe --- /dev/null +++ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^ @@ -0,0 +1,2 @@ +HTTP 200 OK +Content-Type: text/html diff --git a/toolkit/components/places/tests/chrome/sample_feed.atom b/toolkit/components/places/tests/chrome/sample_feed.atom new file mode 100644 index 0000000000..add75efb4d --- /dev/null +++ b/toolkit/components/places/tests/chrome/sample_feed.atom @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + + <title>Example Feed</title> + <link href="http://example.org/"/> + <updated>2003-12-13T18:30:02Z</updated> + + <author> + <name>John Doe</name> + </author> + <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id> + + <entry> + + <title>Atom-Powered Robots Run Amok</title> + <link href="http://example.org/2003/12/13/atom03"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id> + <updated>2003-12-13T18:30:02Z</updated> + + <summary>Some text.</summary> + </entry> + +</feed> diff --git a/toolkit/components/places/tests/chrome/test_371798.xhtml b/toolkit/components/places/tests/chrome/test_371798.xhtml new file mode 100644 index 0000000000..33e866e51e --- /dev/null +++ b/toolkit/components/places/tests/chrome/test_371798.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<window title="Bug 371798" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script type="application/javascript" src="head.js" /> + +<body xmlns="http://www.w3.org/1999/xhtml" /> + +<script type="application/javascript"> +<![CDATA[ +// Test the asynchronous live-updating of bookmarks query results +SimpleTest.waitForExplicitFinish(); + +const TEST_URI = Services.io.newURI("http://foo.com"); + +(async function() { + // add 2 bookmarks to the toolbar, same URI, different titles (set later) + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URI + }); + + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URI + }); + + // query for bookmarks + let rootNode = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.toolbarGuid).root; + + // set up observer + const promiseObserved = PlacesTestUtils.waitForNotification( + "bookmark-title-changed" + ); + + // modify the bookmark's title + await PlacesUtils.bookmarks.update({ + guid: bm2.guid, title: "foo" + }); + + // wait for notification + await promiseObserved; + + // Continue after our observer gets notified of onItemChanged + // which is triggered by updating the item's title. + // After receiving the notification, our original query should also + // have been live-updated, so we can iterate through its children, + // to check that only the modified bookmark has changed. + + // result node should be updated + let cc = rootNode.childCount; + for (let i = 0; i < cc; ++i) { + let node = rootNode.getChild(i); + // test that bm1 does not have new title + if (node.bookmarkGuid == bm1.guid) + ok(node.title != "foo", + "Changing a bookmark's title did not affect the title of other bookmarks with the same URI"); + } + rootNode.containerOpen = false; + + // clean up + await PlacesUtils.bookmarks.remove(bm1); + await PlacesUtils.bookmarks.remove(bm2); +})().catch(err => { + ok(false, `uncaught error: ${err}`); +}).then(() => { + SimpleTest.finish(); +}); +]]> +</script> + +</window> diff --git a/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml new file mode 100644 index 0000000000..6a7d32dabe --- /dev/null +++ b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml @@ -0,0 +1,25 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window title="Test disableglobalhistory attribute on remote browsers" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + </body> + + <script type="text/javascript"> + SimpleTest.waitForExplicitFinish(); + + let w = window.browsingContext.topChromeWindow.openDialog('browser_disableglobalhistory.xhtml', '_blank', 'chrome,resizable=yes,width=400,height=600', window); + + function done() { + w.close(); + SimpleTest.finish(); + } + </script> + +</window> diff --git a/toolkit/components/places/tests/chrome/test_favicon_annotations.xhtml b/toolkit/components/places/tests/chrome/test_favicon_annotations.xhtml new file mode 100644 index 0000000000..a08347460f --- /dev/null +++ b/toolkit/components/places/tests/chrome/test_favicon_annotations.xhtml @@ -0,0 +1,135 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<!-- + * This file tests the moz-anno protocol, which was added in Bug 316077 and how + * it loads favicons. +--> + +<window title="Favicon Annotation Protocol Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="test();"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/WindowSnapshot.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <script type="application/javascript" src="head.js" /> + + <script type="application/javascript"> + <![CDATA[ + +let tests = [ + { + desc: "moz-anno URI with no data in the database loads default icon", + url: "http://mozilla.org/2009/made-up-favicon/places-rocks/", + expectedIcon: PlacesUtils.favicons.defaultFavicon.spec, + }, + { + desc: "URI added to the database is properly loaded", + url: "http://mozilla.org/should-be-barney/", + expectedIcon: "data:image/png,%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%10%00%00%00%10%08%06%00%00%00%1F%F3%FFa%00%00%00%04gAMA%00%00%AF%C87%05%8A%E9%00%00%00%19tEXtSoftware%00Adobe%20ImageReadyq%C9e%3C%00%00%01%D6IDATx%DAb%FC%FF%FF%3F%03%25%00%20%80%98%909%EF%DF%BFg%EF%EC%EC%FC%AD%AC%AC%FC%DF%95%91%F1%BF%89%89%C9%7F%20%FF%D7%EA%D5%AB%B7%DF%BBwO%16%9B%01%00%01%C4%00r%01%08%9F9s%C6%CD%D8%D8%F8%BF%0B%03%C3%FF3%40%BC%0A%88%EF%02q%1A%10%BB%40%F1%AAU%ABv%C1%D4%C30%40%00%81%89%993g%3E%06%1A%F6%3F%14%AA%11D%97%03%F1%7Fc%08%0D%E2%2B))%FD%17%04%89%A1%19%00%10%40%0C%D00%F8%0F3%00%C8%F8%BF%1B%E4%0Ac%88a%E5%60%17%19%FF%0F%0D%0D%05%1B%02v%D9%DD%BB%0A0%03%00%02%08%AC%B9%A3%A3%E3%17%03%D4v%90%01%EF%18%106%C3%0Cz%07%C5%BB%A1%DE%82y%07%20%80%A0%A6%08B%FCn%0C1%60%26%D4%20d%C3VA%C3%06%26%BE%0A%EA-%80%00%82%B9%E0%F7L4%0D%EF%90%F8%C6%60%2F%0A%82%BD%01%13%07%0700%D0%01%02%88%11%E4%02P%B41%DC%BB%C7%D0%014%0D%E8l%06W%20%06%BA%88%A1%1C%1AS%15%40%7C%16%CA6.%2Fgx%BFg%0F%83%CB%D9%B3%0C%7B%80%7C%80%00%02%BB%00%E8%9F%ED%20%1B%3A%A0%A6%9F%81%DA%DC%01%C5%B0%80%ED%80%FA%BF%BC%BC%FC%3F%83%12%90%9D%96%F6%1F%20%80%18%DE%BD%7B%C7%0E%8E%05AD%20%FEGr%A6%A0%A0%E0%7F%25P%80%02%9D%0F%D28%13%18%23%C6%C0%B0%02E%3D%C8%F5%00%01%04%8F%05P%A8%BA%40my%87%E4%12c%A8%8D%20%8B%D0%D3%00%08%03%04%10%9C%01R%E4%82d%3B%C8%A0%99%C6%90%90%C6%A5%19%84%01%02%08%9E%17%80%C9x%F7%7B%A0%DBVC%F9%A0%C0%5C%7D%16%2C%CE%00%F4%C6O%5C%99%09%20%800L%04y%A5%03%1A%95%A0%80%05%05%14.%DBA%18%20%80%18)%CD%CE%00%01%06%00%0C'%94%C7%C0k%C9%2C%00%00%00%00IEND%AEB%60%82", + }, + +]; + +/** + * The event listener placed on our test windows used to determine when it is + * safe to compare the two windows. + */ +let _results = []; +function loadEventHandler() +{ + _results.push(snapshotWindow(window)); + loadNextTest(); +} + +/** + * This runs the comparison. + */ +function compareResults(aIndex, aImage1, aImage2) +{ + let [correct, data1, data2] = compareSnapshots(aImage1, aImage2, true); + SimpleTest.ok(correct, + "Test '" + tests[aIndex].desc + "' matches expectations. " + + "Data from window 1 is '" + data1 + "'. " + + "Data from window 2 is '" + data2 + "'"); +} + +/** + * Loads the next set of URIs to compare against. + */ +let _counter = -1; +function loadNextTest() +{ + _counter++; + // If we have no more tests, finish. + if (_counter / 2 == tests.length) { + for (let i = 0; i < _results.length; i = i + 2) + compareResults(i / 2, _results[i], _results[i + 1]); + + SimpleTest.finish(); + return; + } + + let nextURI = function() { + let index = Math.floor(_counter / 2); + if ((_counter % 2) == 0) + return "moz-anno:favicon:" + tests[index].url; + return tests[index].expectedIcon; + } + + let img = document.getElementById("favicon"); + img.setAttribute("src", nextURI()); +} + +function test() +{ + SimpleTest.waitForExplicitFinish(); + (async () => { + await PlacesUtils.history.clear(); + + info("Inserting new visit"); + await PlacesUtils.history.insert({ + url: "http://example.com/favicon_annotations", + visits: [{ + transition: PlacesUtils.history.TRANSITIONS.TYPED + }] + }); + + // Set the favicon data. Note that the "moz-anno:" protocol requires + // the favicon to be stored in the database, but the + // replaceFaviconDataFromDataURL function will not save the favicon + // unless it is associated with a page. Thus, we must associate the + // icon with a page explicitly in order for it to be visible through + // the protocol. + info("Replace favicon data"); + var systemPrincipal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + Services.io.newURI(tests[1].url), + tests[1].expectedIcon, + (Date.now() + 86400) * 1000, + systemPrincipal); + info("Set favicon data"); + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI("http://example.com/favicon_annotations"), + Services.io.newURI(tests[1].url), + true, PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null, + systemPrincipal); + + // And start our test process. + loadNextTest(); + })(); +} + + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <img id="favicon" onload="loadEventHandler();"/> + <p id="display"></p> + <div id="content" style="display:none;"></div> + <pre id="test"></pre> + </body> +</window> diff --git a/toolkit/components/places/tests/expiration/head_expiration.js b/toolkit/components/places/tests/expiration/head_expiration.js new file mode 100644 index 0000000000..ce9fe48348 --- /dev/null +++ b/toolkit/components/places/tests/expiration/head_expiration.js @@ -0,0 +1,112 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +/** + * Causes expiration component to start, otherwise it would wait for the first + * history notification. + */ +function force_expiration_start() { + Cc["@mozilla.org/places/expiration;1"] + .getService(Ci.nsIObserver) + .observe(null, "testing-mode", null); +} + +/** + * Forces an expiration run. + * + * @param [optional] aLimit + * Limit for the expiration. Pass -1 for unlimited. + * Any other non-positive value will just expire orphans. + * + * @return {Promise} + * @resolves When expiration finishes. + * @rejects Never. + */ +function promiseForceExpirationStep(aLimit) { + let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED); + let expire = Cc["@mozilla.org/places/expiration;1"].getService( + Ci.nsIObserver + ); + expire.observe(null, "places-debug-start-expiration", aLimit); + return promise; +} + +/** + * Expiration preferences helpers. + */ + +function setInterval(aNewInterval) { + Services.prefs.setIntPref( + "places.history.expiration.interval_seconds", + aNewInterval + ); +} +function getInterval() { + return Services.prefs.getIntPref( + "places.history.expiration.interval_seconds" + ); +} +function clearInterval() { + try { + Services.prefs.clearUserPref("places.history.expiration.interval_seconds"); + } catch (ex) {} +} + +function setMaxPages(aNewMaxPages) { + Services.prefs.setIntPref( + "places.history.expiration.max_pages", + aNewMaxPages + ); +} +function getMaxPages() { + return Services.prefs.getIntPref("places.history.expiration.max_pages"); +} +function clearMaxPages() { + try { + Services.prefs.clearUserPref("places.history.expiration.max_pages"); + } catch (ex) {} +} + +function setHistoryEnabled(aHistoryEnabled) { + Services.prefs.setBoolPref("places.history.enabled", aHistoryEnabled); +} +function getHistoryEnabled() { + return Services.prefs.getBoolPref("places.history.enabled"); +} +function clearHistoryEnabled() { + try { + Services.prefs.clearUserPref("places.history.enabled"); + } catch (ex) {} +} + +/** + * Returns a PRTime in the past usable to add expirable visits. + * + * param [optional] daysAgo + * Expiration ignores any visit added in the last 7 days, so by default + * this will be set to 7. + * @note to be safe against DST issues we go back one day more. + */ +function getExpirablePRTime(daysAgo = 7) { + let dateObj = new Date(); + // Normalize to midnight + dateObj.setHours(0); + dateObj.setMinutes(0); + dateObj.setSeconds(0); + dateObj.setMilliseconds(0); + dateObj = new Date(dateObj.getTime() - (daysAgo + 1) * 86400000); + return dateObj.getTime() * 1000; +} diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_never.js b/toolkit/components/places/tests/expiration/test_annos_expire_never.js new file mode 100644 index 0000000000..39c55ecc04 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_annos_expire_never.js @@ -0,0 +1,72 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * What this is aimed to test: + * + * EXPIRE_NEVER annotations should be expired when a page is removed from the + * database. + * If the annotation is a page annotation this will happen when the page is + * expired, namely when the page has no visits and is not bookmarked. + */ + +add_task(async function test_annos_expire_never() { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + // Expire all expirable pages. + setMaxPages(0); + + // Add some visited page and a couple expire never annotations for each. + let now = getExpirablePRTime(); + for (let i = 0; i < 5; i++) { + let pageURI = uri("http://page_anno." + i + ".mozilla.org/"); + await PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ }); + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([ + ["page_expire1", "test"], + ["page_expire2", "test"], + ]), + }); + } + + let pages = await getPagesWithAnnotation("page_expire1"); + Assert.equal(pages.length, 5); + pages = await getPagesWithAnnotation("page_expire2"); + Assert.equal(pages.length, 5); + + // Add other visited page and a couple expire never annotations for each. + // We won't expire these visits, so the annotations should survive. + for (let i = 0; i < 5; i++) { + let pageURI = uri("http://persist_page_anno." + i + ".mozilla.org/"); + await PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ }); + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([ + ["page_persist1", "test"], + ["page_persist2", "test"], + ]), + }); + } + + pages = await getPagesWithAnnotation("page_persist1"); + Assert.equal(pages.length, 5); + pages = await getPagesWithAnnotation("page_persist2"); + Assert.equal(pages.length, 5); + + // Expire all visits for the first 5 pages and the bookmarks. + await promiseForceExpirationStep(5); + + pages = await getPagesWithAnnotation("page_expire1"); + Assert.equal(pages.length, 0); + pages = await getPagesWithAnnotation("page_expire2"); + Assert.equal(pages.length, 0); + pages = await getPagesWithAnnotation("page_persist1"); + Assert.equal(pages.length, 5); + pages = await getPagesWithAnnotation("page_persist2"); + Assert.equal(pages.length, 5); +}); diff --git a/toolkit/components/places/tests/expiration/test_clearHistory.js b/toolkit/components/places/tests/expiration/test_clearHistory.js new file mode 100644 index 0000000000..a4684f0269 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_clearHistory.js @@ -0,0 +1,57 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * What this is aimed to test: + * + * History.clear() should expire everything but bookmarked pages and valid + * annos. + */ + +add_task(async function test_historyClear() { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + // Expire all expirable pages. + setMaxPages(0); + + // Add some bookmarked page with visit and annotations. + for (let i = 0; i < 5; i++) { + let pageURI = uri("http://item_anno." + i + ".mozilla.org/"); + // This visit will be expired. + await PlacesTestUtils.addVisits({ uri: pageURI }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: pageURI, + title: null, + }); + // Will persist because the page is bookmarked. + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([["persist", "test"]]), + }); + } + + // Add some visited page and annotations for each. + for (let i = 0; i < 5; i++) { + // All page annotations related to these expired pages are expected to + // expire as well. + let pageURI = uri("http://page_anno." + i + ".mozilla.org/"); + await PlacesTestUtils.addVisits({ uri: pageURI }); + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([["expire", "test"]]), + }); + } + + // Expire all visits for the bookmarks + await PlacesUtils.history.clear(); + + Assert.equal((await getPagesWithAnnotation("expire")).length, 0); + + let pages = await getPagesWithAnnotation("persist"); + Assert.equal(pages.length, 5); +}); diff --git a/toolkit/components/places/tests/expiration/test_debug_expiration.js b/toolkit/components/places/tests/expiration/test_debug_expiration.js new file mode 100644 index 0000000000..204295d46c --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_debug_expiration.js @@ -0,0 +1,469 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * What this is aimed to test: + * + * Expiration can be manually triggered through a debug topic, but that should + * only expire orphan entries, unless -1 is passed as limit. + */ + +const EXPIRE_DAYS = 90; +var gExpirableTime = getExpirablePRTime(EXPIRE_DAYS); +var gNonExpirableTime = getExpirablePRTime(EXPIRE_DAYS - 2); + +add_task(async function test_expire_orphans() { + // Add visits to 2 pages and force a orphan expiration. Visits should survive. + await PlacesTestUtils.addVisits({ + uri: uri("http://page1.mozilla.org/"), + visitDate: gExpirableTime++, + }); + await PlacesTestUtils.addVisits({ + uri: uri("http://page2.mozilla.org/"), + visitDate: gExpirableTime++, + }); + // Create a orphan place. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://page3.mozilla.org/", + title: "", + }); + await PlacesUtils.bookmarks.remove(bm); + + // Expire now. + await promiseForceExpirationStep(0); + + // Check that visits survived. + Assert.equal(visits_in_database("http://page1.mozilla.org/"), 1); + Assert.equal(visits_in_database("http://page2.mozilla.org/"), 1); + Assert.ok(!page_in_database("http://page3.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_orphans_optionalarg() { + // Add visits to 2 pages and force a orphan expiration. Visits should survive. + await PlacesTestUtils.addVisits({ + uri: uri("http://page1.mozilla.org/"), + visitDate: gExpirableTime++, + }); + await PlacesTestUtils.addVisits({ + uri: uri("http://page2.mozilla.org/"), + visitDate: gExpirableTime++, + }); + // Create a orphan place. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://page3.mozilla.org/", + title: "", + }); + await PlacesUtils.bookmarks.remove(bm); + + // Expire now. + await promiseForceExpirationStep(); + + // Check that visits survived. + Assert.equal(visits_in_database("http://page1.mozilla.org/"), 1); + Assert.equal(visits_in_database("http://page2.mozilla.org/"), 1); + Assert.ok(!page_in_database("http://page3.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_limited() { + await PlacesTestUtils.addVisits([ + { + // Should be expired cause it's the oldest visit + uri: "http://old.mozilla.org/", + visitDate: gExpirableTime++, + }, + { + // Should not be expired cause we limit 1 + uri: "http://new.mozilla.org/", + visitDate: gExpirableTime++, + }, + ]); + + // Expire now. + await promiseForceExpirationStep(1); + + // Check that newer visit survived. + Assert.equal(visits_in_database("http://new.mozilla.org/"), 1); + // Other visits should have been expired. + Assert.ok(!page_in_database("http://old.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_visitcount_longurl() { + let longurl = "http://long.mozilla.org/" + "a".repeat(232); + let longurl2 = "http://long2.mozilla.org/" + "a".repeat(232); + await PlacesTestUtils.addVisits([ + { + // Should be expired cause it's the oldest visit + uri: "http://old.mozilla.org/", + visitDate: gExpirableTime++, + }, + { + // Should not be expired cause it has 2 visits. + uri: longurl, + visitDate: gExpirableTime++, + }, + { + uri: longurl, + visitDate: gNonExpirableTime, + }, + { + // Should be expired cause it has 1 old visit. + uri: longurl2, + visitDate: gExpirableTime++, + }, + ]); + + await promiseForceExpirationStep(1); + + // Check that some visits survived. + Assert.equal(visits_in_database(longurl), 2); + // Check visit has been removed. + Assert.equal(visits_in_database(longurl2), 0); + + // Other visits should have been expired. + Assert.ok(!page_in_database("http://old.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_limited_exoticurl() { + await PlacesTestUtils.addVisits([ + { + // Should be expired cause it's the oldest visit + uri: "http://old.mozilla.org/", + visitDate: gExpirableTime++, + }, + { + // Should not be expired cause younger than EXPIRE_DAYS. + uri: "http://nonexpirable-download.mozilla.org", + visitDate: gNonExpirableTime, + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + }, + { + // Should be expired cause it's a long url older than EXPIRE_DAYS. + uri: "http://download.mozilla.org", + visitDate: gExpirableTime++, + transition: 7, + }, + ]); + + await promiseForceExpirationStep(1); + + // Check that some visits survived. + Assert.equal( + visits_in_database("http://nonexpirable-download.mozilla.org/"), + 1 + ); + // The visits are gone, the url is not yet, cause we limited the expiration + // to one entry, and we already removed http://old.mozilla.org/. + // The page normally would be expired by the next expiration run. + Assert.equal(visits_in_database("http://download.mozilla.org/"), 0); + // Other visits should have been expired. + Assert.ok(!page_in_database("http://old.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_exotic_hidden() { + let visits = [ + { + // Should be expired cause it's the oldest visit + uri: "http://old.mozilla.org/", + visitDate: gExpirableTime++, + expectedCount: 0, + }, + { + // Expirable typed hidden url. + uri: "https://typedhidden.mozilla.org/", + visitDate: gExpirableTime++, + transition: PlacesUtils.history.TRANSITIONS.FRAMED_LINK, + expectedCount: 2, + }, + { + // Mark as typed. + uri: "https://typedhidden.mozilla.org/", + visitDate: gExpirableTime++, + transition: PlacesUtils.history.TRANSITIONS.TYPED, + expectedCount: 2, + }, + { + // Expirable non-typed hidden url. + uri: "https://hidden.mozilla.org/", + visitDate: gExpirableTime++, + transition: PlacesUtils.history.TRANSITIONS.FRAMED_LINK, + expectedCount: 0, + }, + ]; + await PlacesTestUtils.addVisits(visits); + for (let visit of visits) { + Assert.greater(visits_in_database(visit.uri), 0); + } + + await promiseForceExpirationStep(1); + + for (let visit of visits) { + Assert.equal( + visits_in_database(visit.uri), + visit.expectedCount, + `${visit.uri} should${ + visit.expectedCount == 0 ? " " : " not " + }have been expired` + ); + } + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_unlimited() { + let longurl = "http://long.mozilla.org/" + "a".repeat(232); + await PlacesTestUtils.addVisits([ + { + uri: "http://old.mozilla.org/", + visitDate: gExpirableTime++, + }, + { + uri: "http://new.mozilla.org/", + visitDate: gExpirableTime++, + }, + // Add expirable visits. + { + uri: "http://download.mozilla.org/", + visitDate: gExpirableTime++, + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + { + uri: longurl, + visitDate: gExpirableTime++, + }, + + // Add non-expirable visits + { + uri: "http://nonexpirable.mozilla.org/", + visitDate: getExpirablePRTime(5), + }, + { + uri: "http://nonexpirable-download.mozilla.org/", + visitDate: getExpirablePRTime(5), + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + { + uri: longurl, + visitDate: getExpirablePRTime(5), + }, + ]); + + await promiseForceExpirationStep(-1); + + // Check that some visits survived. + Assert.equal(visits_in_database("http://nonexpirable.mozilla.org/"), 1); + Assert.equal( + visits_in_database("http://nonexpirable-download.mozilla.org/"), + 1 + ); + Assert.equal(visits_in_database(longurl), 1); + // Other visits should have been expired. + Assert.ok(!page_in_database("http://old.mozilla.org/")); + Assert.ok(!page_in_database("http://download.mozilla.org/")); + Assert.ok(!page_in_database("http://new.mozilla.org/")); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_expire_icons() { + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + + const entries = [ + { + desc: "Not expired because recent", + page: "https://recent.notexpired.org/", + icon: "https://recent.notexpired.org/test_icon.png", + root: "https://recent.notexpired.org/favicon.ico", + iconExpired: false, + removed: false, + }, + { + desc: "Not expired because recent, no root", + page: "https://recentnoroot.notexpired.org/", + icon: "https://recentnoroot.notexpired.org/test_icon.png", + iconExpired: false, + removed: false, + }, + { + desc: "Expired because old with root", + page: "https://oldroot.expired.org/", + icon: "https://oldroot.expired.org/test_icon.png", + root: "https://oldroot.expired.org/favicon.ico", + iconExpired: true, + removed: true, + }, + { + desc: "Not expired because bookmarked, even if old with root", + page: "https://oldrootbm.notexpired.org/", + icon: "https://oldrootbm.notexpired.org/test_icon.png", + root: "https://oldrootbm.notexpired.org/favicon.ico", + bookmarked: true, + iconExpired: true, + removed: false, + }, + { + desc: "Not Expired because old but has no root", + page: "https://old.notexpired.org/", + icon: "https://old.notexpired.org/test_icon.png", + iconExpired: true, + removed: false, + }, + { + desc: "Expired because it's an orphan page", + page: "http://root.ref.org/#test", + icon: undefined, + iconExpired: false, + removed: true, + }, + { + desc: "Expired because it's an orphan page", + page: "http://root.ref.org/#test", + icon: undefined, + skipHistory: true, + iconExpired: false, + removed: true, + }, + ]; + + for (let entry of entries) { + if (!entry.skipHistory) { + await PlacesTestUtils.addVisits(entry.page); + } + if (entry.bookmarked) { + await PlacesUtils.bookmarks.insert({ + url: entry.page, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + + if (entry.icon) { + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + Services.io.newURI(entry.icon), + dataUrl, + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await PlacesTestUtils.addFavicons(new Map([[entry.page, entry.icon]])); + Assert.equal( + await getFaviconUrlForPage(entry.page), + entry.icon, + "Sanity check the icon exists" + ); + } else { + // This is an orphan page entry. + await PlacesUtils.withConnectionWrapper("addOrphanPage", async db => { + await db.execute( + `INSERT INTO moz_pages_w_icons (page_url, page_url_hash) + VALUES (:url, hash(:url))`, + { url: entry.page } + ); + }); + } + + if (entry.root) { + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + Services.io.newURI(entry.root), + dataUrl, + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await PlacesTestUtils.addFavicons(new Map([[entry.page, entry.root]])); + } + + if (entry.iconExpired) { + // Set an expired time on the icon. + await PlacesUtils.withConnectionWrapper("expireFavicon", async db => { + await db.execute( + `UPDATE moz_icons_to_pages SET expire_ms = 1 + WHERE icon_id = (SELECT id FROM moz_icons WHERE icon_url = :url)`, + { url: entry.icon } + ); + if (entry.root) { + await db.execute( + `UPDATE moz_icons SET expire_ms = 1 WHERE icon_url = :url`, + { url: entry.root } + ); + } + }); + } + if (entry.icon) { + Assert.equal( + await getFaviconUrlForPage(entry.page), + entry.icon, + "Sanity check the initial icon value" + ); + } + } + + info("Run expiration"); + await promiseForceExpirationStep(-1); + + info("Check expiration"); + for (let entry of entries) { + Assert.ok(page_in_database(entry.page)); + + if (!entry.removed) { + Assert.equal( + await getFaviconUrlForPage(entry.page), + entry.icon, + entry.desc + ); + continue; + } + + if (entry.root) { + Assert.equal( + await getFaviconUrlForPage(entry.page), + entry.root, + entry.desc + ); + continue; + } + + if (entry.icon) { + await Assert.rejects( + getFaviconUrlForPage(entry.page), + /Unable to find an icon/, + entry.desc + ); + continue; + } + + // This was an orphan page entry. + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + `SELECT count(*) FROM moz_pages_w_icons WHERE page_url_hash = hash(:url)`, + { url: entry.page } + ); + Assert.equal(rows[0].getResultByIndex(0), 0, "Orphan page was removed"); + } + + // Clean up. + await PlacesUtils.history.clear(); +}); + +add_setup(async function () { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + // Set maxPages to a low value, so it's easy to go over it. + setMaxPages(1); +}); diff --git a/toolkit/components/places/tests/expiration/test_idle_daily.js b/toolkit/components/places/tests/expiration/test_idle_daily.js new file mode 100644 index 0000000000..11547e37dc --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_idle_daily.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that expiration runs on idle-daily. + +add_task(async function test_expiration_on_idle_daily() { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + let expirationPromise = TestUtils.topicObserved( + PlacesUtils.TOPIC_EXPIRATION_FINISHED + ); + + let expire = Cc["@mozilla.org/places/expiration;1"].getService( + Ci.nsIObserver + ); + expire.observe(null, "idle-daily", null); + + await expirationPromise; +}); diff --git a/toolkit/components/places/tests/expiration/test_interactions_expiration.js b/toolkit/components/places/tests/expiration/test_interactions_expiration.js new file mode 100644 index 0000000000..67b4b466c3 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_interactions_expiration.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests expiration of Places interactions data. + */ +// Number of days in the past where interactions will be expired. +const EXPIRE_DAYS = 60; +// Should be more recent than EXPIRED_DAYS. +const RECENT_DATE = new Date() - (EXPIRE_DAYS - 1) * 86400000; + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.places.interactions.enabled", true); + Services.prefs.setIntPref( + "browser.places.interactions.expireDays", + EXPIRE_DAYS + ); +}); + +add_task(async function test_expire_interactions() { + // Add visits and metadata to 2 pages and force expiration. + await PlacesTestUtils.addVisits([ + "https://expired.mozilla.org/", + "https://interactions-expired.mozilla.org/", + "https://some-interaction-expired.mozilla.org/", + "https://not-expired.mozilla.org/", + ]); + // Insert dummy interactions for all the pages. + await addDummyInteractions("https://removed.mozilla.org/", [0]); + await addDummyInteractions("https://interactions-expired.mozilla.org/", [ + EXPIRE_DAYS + 10, + ]); + await addDummyInteractions("https://some-interactions-expired.mozilla.org/", [ + 0, + EXPIRE_DAYS + 10, + ]); + await addDummyInteractions("https://not-expired.mozilla.org/", [ + 0, + EXPIRE_DAYS / 2, + ]); + + info("Remove a page from history and check interactions are removed"); + await PlacesUtils.history.remove("https://removed.mozilla.org/"); + await checkDummyInteractions("https://removed.mozilla.org/", 0); + + // Expire now. + await promiseForceExpirationStep(-1); + + info("Test interactions expiration result"); + await checkDummyInteractions("https://interactions-expired.mozilla.org/", 0); + await checkDummyInteractions( + "https://some-interactions-expired.mozilla.org/", + 1 + ); + await checkDummyInteractions("https://not-expired.mozilla.org/", 2); + + // Clean up. + await PlacesUtils.history.clear(); +}); + +async function addDummyInteractions(url, interactionDaysAgo) { + await PlacesTestUtils.addVisits(url); + await PlacesUtils.withConnectionWrapper( + "test_interactions_expiration.js: addDummyInteraction", + async db => { + await db.execute( + `INSERT INTO moz_places_metadata (place_id, created_at, updated_at) VALUES ( + (SELECT id FROM moz_places WHERE url_hash = hash(:url)), + strftime('%s','now','localtime','-' || :days || ' day','start of day','utc') * 1000, + strftime('%s','now','localtime','-' || :days || ' day','start of day','utc') * 1000 + )`, + interactionDaysAgo.map(days => ({ url, days })) + ); + } + ); +} + +async function checkDummyInteractions(url, interactionsLen) { + info("Check interactions for " + url); + await PlacesUtils.withConnectionWrapper( + "test_interactions_expiration.js: addDummyInteraction", + async db => { + let rows = await db.execute( + `SELECT updated_at + FROM moz_places_metadata + WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url)) + ORDER BY updated_at DESC`, + { url } + ); + let dates = rows.map(r => new Date(r.getResultByName("updated_at"))); + Assert.equal( + rows.length, + interactionsLen, + "Found expected number of interactions" + ); + Assert.ok( + dates.every(d => d > RECENT_DATE), + "All interactions are recent" + ); + } + ); +} diff --git a/toolkit/components/places/tests/expiration/test_notifications.js b/toolkit/components/places/tests/expiration/test_notifications.js new file mode 100644 index 0000000000..d52319a9c9 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_notifications.js @@ -0,0 +1,36 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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"; + +/** + * What this is aimed to test: + * + * Ensure that History (through category cache) notifies us just once. + */ + +var gObserver = { + notifications: 0, + observe(aSubject, aTopic, aData) { + this.notifications++; + }, +}; +Services.obs.addObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED); + +add_task(async function test_history_expirations_notify_just_once() { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + promiseForceExpirationStep(1); + + await new Promise(resolve => { + do_timeout(2000, resolve); + }); + + Assert.equal(gObserver.notifications, 1); + + Services.obs.removeObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED); +}); diff --git a/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js new file mode 100644 index 0000000000..172f29cf96 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js @@ -0,0 +1,156 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * What this is aimed to test: + * + * Expiring only visits for a page, but not the full page, should fire an + * page-removed for all visits notification. + */ + +var tests = [ + { + desc: "Add 1 bookmarked page.", + addPages: 1, + visitsPerPage: 1, + addBookmarks: 1, + limitExpiration: -1, + expectedNotifications: 1, // Will expire visits for 1 page. + expectedIsPartialRemoval: true, + }, + + { + desc: "Add 2 pages, 1 bookmarked.", + addPages: 2, + visitsPerPage: 1, + addBookmarks: 1, + limitExpiration: -1, + expectedNotifications: 1, // Will expire visits for 1 page. + expectedIsPartialRemoval: true, + }, + + { + desc: "Add 10 pages, none bookmarked.", + addPages: 10, + visitsPerPage: 1, + addBookmarks: 0, + limitExpiration: -1, + expectedNotifications: 0, // Will expire only full pages. + expectedIsPartialRemoval: false, + }, + + { + desc: "Add 10 pages, all bookmarked.", + addPages: 10, + visitsPerPage: 1, + addBookmarks: 10, + limitExpiration: -1, + expectedNotifications: 10, // Will expire visits for all pages. + expectedIsPartialRemoval: true, + }, + + { + desc: "Add 10 pages with lot of visits, none bookmarked.", + addPages: 10, + visitsPerPage: 10, + addBookmarks: 0, + limitExpiration: 10, + expectedNotifications: 10, // Will expire 1 visit for each page, but won't + // expire pages since they still have visits. + expectedIsPartialRemoval: true, + }, +]; + +add_task(async () => { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + // Expire anything that is expirable. + setMaxPages(0); + + for (let testIndex = 1; testIndex <= tests.length; testIndex++) { + let currentTest = tests[testIndex - 1]; + info("TEST " + testIndex + ": " + currentTest.desc); + currentTest.receivedNotifications = 0; + + // Setup visits. + let timeInMicroseconds = getExpirablePRTime(8); + + function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; + } + + for (let j = 0; j < currentTest.visitsPerPage; j++) { + for (let i = 0; i < currentTest.addPages; i++) { + let page = "http://" + testIndex + "." + i + ".mozilla.org/"; + await PlacesTestUtils.addVisits({ + uri: uri(page), + visitDate: newTimeInMicroseconds(), + }); + } + } + + // Setup bookmarks. + currentTest.bookmarks = []; + for (let i = 0; i < currentTest.addBookmarks; i++) { + let page = "http://" + testIndex + "." + i + ".mozilla.org/"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: null, + url: page, + }); + currentTest.bookmarks.push(page); + } + + // Observe history. + let notificationsHandled = new Promise(resolve => { + const listener = async events => { + for (const event of events) { + Assert.equal(event.type, "page-removed"); + Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED); + + if (event.isRemovedFromStore) { + // Check this uri was not bookmarked. + Assert.equal(currentTest.bookmarks.indexOf(event.url), -1); + do_check_valid_places_guid(event.pageGuid); + } else { + currentTest.receivedNotifications++; + await check_guid_for_uri( + Services.io.newURI(event.url), + event.pageGuid + ); + Assert.equal( + event.isPartialVisistsRemoval, + currentTest.expectedIsPartialRemoval, + "Should have the correct flag setting for partial removal" + ); + } + } + PlacesObservers.removeListener(["page-removed"], listener); + resolve(); + }; + PlacesObservers.addListener(["page-removed"], listener); + }); + + // Expire now. + await promiseForceExpirationStep(currentTest.limitExpiration); + await notificationsHandled; + + Assert.equal( + currentTest.receivedNotifications, + currentTest.expectedNotifications + ); + + // Clean up. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + } + + clearMaxPages(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js new file mode 100644 index 0000000000..c8f7cf4aa0 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js @@ -0,0 +1,110 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * What this is aimed to test: + * + * Expiring a full page should fire an page-removed event notification. + */ + +var tests = [ + { + desc: "Add 1 bookmarked page.", + addPages: 1, + addBookmarks: 1, + expectedNotifications: 0, // No expirable pages. + }, + + { + desc: "Add 2 pages, 1 bookmarked.", + addPages: 2, + addBookmarks: 1, + expectedNotifications: 1, // Only one expirable page. + }, + + { + desc: "Add 10 pages, none bookmarked.", + addPages: 10, + addBookmarks: 0, + expectedNotifications: 10, // Will expire everything. + }, + + { + desc: "Add 10 pages, all bookmarked.", + addPages: 10, + addBookmarks: 10, + expectedNotifications: 0, // No expirable pages. + }, +]; + +add_task(async () => { + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + // Expire anything that is expirable. + setMaxPages(0); + + for (let testIndex = 1; testIndex <= tests.length; testIndex++) { + let currentTest = tests[testIndex - 1]; + print("\nTEST " + testIndex + ": " + currentTest.desc); + currentTest.receivedNotifications = 0; + + // Setup visits. + let now = getExpirablePRTime(); + for (let i = 0; i < currentTest.addPages; i++) { + let page = "http://" + testIndex + "." + i + ".mozilla.org/"; + await PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now++ }); + } + + // Setup bookmarks. + currentTest.bookmarks = []; + for (let i = 0; i < currentTest.addBookmarks; i++) { + let page = "http://" + testIndex + "." + i + ".mozilla.org/"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: null, + url: page, + }); + currentTest.bookmarks.push(page); + } + + // Observe history. + const listener = events => { + for (const event of events) { + Assert.equal(event.type, "page-removed"); + + if (!event.isRemovedFromStore) { + continue; + } + + currentTest.receivedNotifications++; + // Check this uri was not bookmarked. + Assert.equal(currentTest.bookmarks.indexOf(event.url), -1); + do_check_valid_places_guid(event.pageGuid); + Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED); + } + }; + PlacesObservers.addListener(["page-removed"], listener); + + // Expire now. + await promiseForceExpirationStep(-1); + + PlacesObservers.removeListener(["page-removed"], listener); + + Assert.equal( + currentTest.receivedNotifications, + currentTest.expectedNotifications + ); + + // Clean up. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + } + + clearMaxPages(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/expiration/test_pref_interval.js b/toolkit/components/places/tests/expiration/test_pref_interval.js new file mode 100644 index 0000000000..5bf340e7c4 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_pref_interval.js @@ -0,0 +1,62 @@ +/** + * What this is aimed to test: + * + * Expiration relies on an interval, that is user-preffable setting + * "places.history.expiration.interval_seconds". + * On pref change it will stop current interval timer and fire a new one, + * that will obey the new value. + * If the pref is set to a number <= 0 we will use the default value. + */ + +// Default timer value for expiration in seconds. Must have same value as +// PREF_INTERVAL_SECONDS_NOTSET in nsPlacesExpiration. +const DEFAULT_TIMER_DELAY_SECONDS = 3 * 60; + +// Sync this with the const value in the component. +const EXPIRE_AGGRESSIVITY_MULTIPLIER = 3; + +var tests = [ + { + desc: "Set interval to 1s.", + interval: 1, + expectedTimerDelay: 1 * EXPIRE_AGGRESSIVITY_MULTIPLIER, + }, + + { + desc: "Set interval to a negative value.", + interval: -1, + expectedTimerDelay: + DEFAULT_TIMER_DELAY_SECONDS * EXPIRE_AGGRESSIVITY_MULTIPLIER, + }, + + { + desc: "Set interval to 0.", + interval: 0, + expectedTimerDelay: + DEFAULT_TIMER_DELAY_SECONDS * EXPIRE_AGGRESSIVITY_MULTIPLIER, + }, + + { + desc: "Set interval to a large value.", + interval: 100, + expectedTimerDelay: 100 * EXPIRE_AGGRESSIVITY_MULTIPLIER, + }, +]; + +add_task(async function test() { + // The pref should not exist by default. + Assert.throws(() => getInterval(), /NS_ERROR_UNEXPECTED/); + + // Force the component, so it will start observing preferences. + force_expiration_start(); + + for (let currentTest of tests) { + currentTest = tests.shift(); + print(currentTest.desc); + let promise = promiseTopicObserved("test-interval-changed"); + setInterval(currentTest.interval); + let [, data] = await promise; + Assert.equal(data, currentTest.expectedTimerDelay); + } + clearInterval(); +}); diff --git a/toolkit/components/places/tests/expiration/test_pref_maxpages.js b/toolkit/components/places/tests/expiration/test_pref_maxpages.js new file mode 100644 index 0000000000..e4583359f1 --- /dev/null +++ b/toolkit/components/places/tests/expiration/test_pref_maxpages.js @@ -0,0 +1,116 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * What this is aimed to test: + * + * Expiration will obey to hardware spec, but user can set a custom maximum + * number of pages to retain, to restrict history, through + * "places.history.expiration.max_pages". + * This limit is used at next expiration run. + * If the pref is set to a number < 0 we will use the default value. + */ + +var tests = [ + { + desc: "Set max_pages to a negative value, with 1 page.", + maxPages: -1, + addPages: 1, + expectedNotifications: 0, // Will ignore and won't expire anything. + }, + + { + desc: "Set max_pages to 0.", + maxPages: 0, + addPages: 1, + expectedNotifications: 1, + }, + + { + desc: "Set max_pages to 0, with 2 pages.", + maxPages: 0, + addPages: 2, + expectedNotifications: 2, // Will expire everything. + }, + + // Notice if we are over limit we do a full step of expiration. So we ensure + // that we will expire if we are over the limit, but we don't ensure that we + // will expire exactly up to the limit. Thus in this case we expire + // everything. + { + desc: "Set max_pages to 1 with 2 pages.", + maxPages: 1, + addPages: 2, + expectedNotifications: 2, // Will expire everything (in this case). + }, + + { + desc: "Set max_pages to 10, with 9 pages.", + maxPages: 10, + addPages: 9, + expectedNotifications: 0, // We are at the limit, won't expire anything. + }, + + { + desc: "Set max_pages to 10 with 10 pages.", + maxPages: 10, + addPages: 10, + expectedNotifications: 0, // We are below the limit, won't expire anything. + }, +]; + +add_task(async function test_pref_maxpages() { + // The pref should not exist by default. + try { + getMaxPages(); + do_throw("interval pref should not exist by default"); + } catch (ex) {} + + // Set interval to a large value so we don't expire on it. + setInterval(3600); // 1h + + for (let testIndex = 1; testIndex <= tests.length; testIndex++) { + let currentTest = tests[testIndex - 1]; + print("\nTEST " + testIndex + ": " + currentTest.desc); + currentTest.receivedNotifications = 0; + + // Setup visits. + let now = getExpirablePRTime(); + for (let i = 0; i < currentTest.addPages; i++) { + let page = "http://" + testIndex + "." + i + ".mozilla.org/"; + await PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now-- }); + } + + const listener = events => { + for (const event of events) { + print("page-removed " + event.url); + Assert.equal(event.type, "page-removed"); + Assert.ok(event.isRemovedFromStore); + Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED); + currentTest.receivedNotifications++; + } + }; + PlacesObservers.addListener(["page-removed"], listener); + + setMaxPages(currentTest.maxPages); + + // Expire now. + await promiseForceExpirationStep(-1); + + PlacesObservers.removeListener(["page-removed"], listener); + + Assert.equal( + currentTest.receivedNotifications, + currentTest.expectedNotifications + ); + + // Clean up. + await PlacesUtils.history.clear(); + } + + clearMaxPages(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/expiration/xpcshell.ini b/toolkit/components/places/tests/expiration/xpcshell.ini new file mode 100644 index 0000000000..72d276685c --- /dev/null +++ b/toolkit/components/places/tests/expiration/xpcshell.ini @@ -0,0 +1,14 @@ +[DEFAULT] +head = head_expiration.js +skip-if = toolkit == 'android' + +[test_annos_expire_never.js] +[test_clearHistory.js] +[test_debug_expiration.js] +[test_idle_daily.js] +[test_interactions_expiration.js] +[test_notifications.js] +[test_notifications_pageRemoved_allVisits.js] +[test_notifications_pageRemoved_fromStore.js] +[test_pref_interval.js] +[test_pref_maxpages.js] diff --git a/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png b/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png Binary files differnew file mode 100644 index 0000000000..22f825c500 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png b/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png Binary files differnew file mode 100644 index 0000000000..fa61cc5046 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png Binary files differnew file mode 100644 index 0000000000..42640cbb53 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png Binary files differnew file mode 100644 index 0000000000..81d1b8ae19 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png Binary files differnew file mode 100644 index 0000000000..7983889098 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png Binary files differnew file mode 100644 index 0000000000..2756cf0cb3 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png Binary files differnew file mode 100644 index 0000000000..fc464f8e99 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png Binary files differnew file mode 100644 index 0000000000..c1412038a3 --- /dev/null +++ b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png diff --git a/toolkit/components/places/tests/favicons/favicon-animated16.png b/toolkit/components/places/tests/favicons/favicon-animated16.png Binary files differnew file mode 100644 index 0000000000..8913387fc9 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-animated16.png diff --git a/toolkit/components/places/tests/favicons/favicon-big16.ico b/toolkit/components/places/tests/favicons/favicon-big16.ico Binary files differnew file mode 100644 index 0000000000..d44438903b --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-big16.ico diff --git a/toolkit/components/places/tests/favicons/favicon-big32.jpg b/toolkit/components/places/tests/favicons/favicon-big32.jpg Binary files differnew file mode 100644 index 0000000000..b2131bf0c1 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-big32.jpg diff --git a/toolkit/components/places/tests/favicons/favicon-big4.jpg b/toolkit/components/places/tests/favicons/favicon-big4.jpg Binary files differnew file mode 100644 index 0000000000..b84fcd35a6 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-big4.jpg diff --git a/toolkit/components/places/tests/favicons/favicon-big48.ico b/toolkit/components/places/tests/favicons/favicon-big48.ico Binary files differnew file mode 100644 index 0000000000..f22522411d --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-big48.ico diff --git a/toolkit/components/places/tests/favicons/favicon-big64.png b/toolkit/components/places/tests/favicons/favicon-big64.png Binary files differnew file mode 100644 index 0000000000..2756cf0cb3 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-big64.png diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame16.png b/toolkit/components/places/tests/favicons/favicon-multi-frame16.png Binary files differnew file mode 100644 index 0000000000..519e08cc21 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-multi-frame16.png diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame32.png b/toolkit/components/places/tests/favicons/favicon-multi-frame32.png Binary files differnew file mode 100644 index 0000000000..5ae61de789 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-multi-frame32.png diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame64.png b/toolkit/components/places/tests/favicons/favicon-multi-frame64.png Binary files differnew file mode 100644 index 0000000000..57123f351b --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-multi-frame64.png diff --git a/toolkit/components/places/tests/favicons/favicon-multi.ico b/toolkit/components/places/tests/favicons/favicon-multi.ico Binary files differnew file mode 100644 index 0000000000..e98adcafeb --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-multi.ico diff --git a/toolkit/components/places/tests/favicons/favicon-normal16.png b/toolkit/components/places/tests/favicons/favicon-normal16.png Binary files differnew file mode 100644 index 0000000000..62b69a3d03 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-normal16.png diff --git a/toolkit/components/places/tests/favicons/favicon-normal32.png b/toolkit/components/places/tests/favicons/favicon-normal32.png Binary files differnew file mode 100644 index 0000000000..5535363c94 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-normal32.png diff --git a/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg Binary files differnew file mode 100644 index 0000000000..422ee7ea0b --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg diff --git a/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg Binary files differnew file mode 100644 index 0000000000..e8514966a0 --- /dev/null +++ b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg diff --git a/toolkit/components/places/tests/favicons/head_favicons.js b/toolkit/components/places/tests/favicons/head_favicons.js new file mode 100644 index 0000000000..d8109c66e0 --- /dev/null +++ b/toolkit/components/places/tests/favicons/head_favicons.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + +/** + * Checks that the favicon for the given page matches the provided data. + * + * @param aPageURI + * nsIURI object for the page to check. + * @param aExpectedMimeType + * Expected MIME type of the icon, for example "image/png". + * @param aExpectedData + * Expected icon data, expressed as an array of byte values. + * @param aCallback + * This function is called after the check finished. + */ +function checkFaviconDataForPage( + aPageURI, + aExpectedMimeType, + aExpectedData, + aCallback +) { + PlacesUtils.favicons.getFaviconDataForPage( + aPageURI, + async function (aURI, aDataLen, aData, aMimeType) { + Assert.equal(aExpectedMimeType, aMimeType); + Assert.ok(compareArrays(aExpectedData, aData)); + await check_guid_for_uri(aPageURI); + aCallback(); + } + ); +} + +/** + * Checks that the given page has no associated favicon. + * + * @param aPageURI + * nsIURI object for the page to check. + * @param aCallback + * This function is called after the check finished. + */ +function checkFaviconMissingForPage(aPageURI, aCallback) { + PlacesUtils.favicons.getFaviconURLForPage( + aPageURI, + function (aURI, aDataLen, aData, aMimeType) { + Assert.ok(aURI === null); + aCallback(); + } + ); +} + +function promiseFaviconMissingForPage(aPageURI) { + return new Promise(resolve => checkFaviconMissingForPage(aPageURI, resolve)); +} + +function promiseFaviconChanged(aExpectedPageURI, aExpectedFaviconURI) { + return new Promise(resolve => { + PlacesTestUtils.waitForNotification("favicon-changed", async events => { + for (let e of events) { + if (e.url == aExpectedPageURI.spec) { + Assert.equal(e.faviconUrl, aExpectedFaviconURI.spec); + await check_guid_for_uri(aExpectedPageURI, e.pageGuid); + resolve(); + } + } + }); + }); +} diff --git a/toolkit/components/places/tests/favicons/noise.png b/toolkit/components/places/tests/favicons/noise.png Binary files differnew file mode 100644 index 0000000000..d6876295cd --- /dev/null +++ b/toolkit/components/places/tests/favicons/noise.png diff --git a/toolkit/components/places/tests/favicons/test_copyFavicons.js b/toolkit/components/places/tests/favicons/test_copyFavicons.js new file mode 100644 index 0000000000..687b799a4b --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_copyFavicons.js @@ -0,0 +1,166 @@ +const TEST_URI1 = Services.io.newURI("http://mozilla.com/"); +const TEST_URI2 = Services.io.newURI("http://places.com/"); +const TEST_URI3 = Services.io.newURI("http://bookmarked.com/"); +const LOAD_NON_PRIVATE = PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE; +const LOAD_PRIVATE = PlacesUtils.favicons.FAVICON_LOAD_PRIVATE; + +function copyFavicons(source, dest, inPrivate) { + return new Promise(resolve => { + PlacesUtils.favicons.copyFavicons( + source, + dest, + inPrivate ? LOAD_PRIVATE : LOAD_NON_PRIVATE, + resolve + ); + }); +} + +function promisePageChanged(url) { + return PlacesTestUtils.waitForNotification("favicon-changed", events => + events.some(e => e.url == url) + ); +} + +add_task(async function test_copyFavicons_inputcheck() { + Assert.throws( + () => PlacesUtils.favicons.copyFavicons(null, TEST_URI2, LOAD_PRIVATE), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => PlacesUtils.favicons.copyFavicons(TEST_URI1, null, LOAD_PRIVATE), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, 3), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, -1), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, null), + /NS_ERROR_ILLEGAL_VALUE/ + ); +}); + +add_task(async function test_copyFavicons_noop() { + info("Unknown uris"); + Assert.equal( + await copyFavicons(TEST_URI1, TEST_URI2, false), + null, + "Icon should not have been copied" + ); + + info("Unknown dest uri"); + await PlacesTestUtils.addVisits(TEST_URI1); + Assert.equal( + await copyFavicons(TEST_URI1, TEST_URI2, false), + null, + "Icon should not have been copied" + ); + + info("Unknown dest uri"); + await PlacesTestUtils.addVisits(TEST_URI1); + Assert.equal( + await copyFavicons(TEST_URI1, TEST_URI2, false), + null, + "Icon should not have been copied" + ); + + info("Unknown dest uri, source has icon"); + await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI); + Assert.equal( + await copyFavicons(TEST_URI1, TEST_URI2, false), + null, + "Icon should not have been copied" + ); + + info("Known uris, source has icon, private"); + await PlacesTestUtils.addVisits(TEST_URI2); + Assert.equal( + await copyFavicons(TEST_URI1, TEST_URI2, true), + null, + "Icon should not have been copied" + ); + + PlacesUtils.favicons.expireAllFavicons(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_copyFavicons() { + info("Normal copy across 2 pages"); + await PlacesTestUtils.addVisits(TEST_URI1); + await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI); + await setFaviconForPage(TEST_URI1, SMALLSVG_DATA_URI); + await PlacesTestUtils.addVisits(TEST_URI2); + let promiseChange = promisePageChanged(TEST_URI2.spec); + Assert.equal( + (await copyFavicons(TEST_URI1, TEST_URI2, false)).spec, + SMALLSVG_DATA_URI.spec, + "Icon should have been copied" + ); + await promiseChange; + Assert.equal( + await getFaviconUrlForPage(TEST_URI2, 1), + SMALLPNG_DATA_URI.spec, + "Small icon found" + ); + Assert.equal( + await getFaviconUrlForPage(TEST_URI2), + SMALLSVG_DATA_URI.spec, + "Large icon found" + ); + + info("Private copy to a bookmarked page"); + await PlacesUtils.bookmarks.insert({ + url: TEST_URI3, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + promiseChange = promisePageChanged(TEST_URI3.spec); + Assert.equal( + (await copyFavicons(TEST_URI1, TEST_URI3, true)).spec, + SMALLSVG_DATA_URI.spec, + "Icon should have been copied" + ); + await promiseChange; + Assert.equal( + await getFaviconUrlForPage(TEST_URI3, 1), + SMALLPNG_DATA_URI.spec, + "Small icon found" + ); + Assert.equal( + await getFaviconUrlForPage(TEST_URI3), + SMALLSVG_DATA_URI.spec, + "Large icon found" + ); + + PlacesUtils.favicons.expireAllFavicons(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_copyFavicons_overlap() { + info("Copy to a page that has one of the favicons already"); + await PlacesTestUtils.addVisits(TEST_URI1); + await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI); + await setFaviconForPage(TEST_URI1, SMALLSVG_DATA_URI); + await PlacesTestUtils.addVisits(TEST_URI2); + await setFaviconForPage(TEST_URI2, SMALLPNG_DATA_URI); + let promiseChange = promisePageChanged(TEST_URI2.spec); + Assert.equal( + (await copyFavicons(TEST_URI1, TEST_URI2, false)).spec, + SMALLSVG_DATA_URI.spec, + "Icon should have been copied" + ); + await promiseChange; + Assert.equal( + await getFaviconUrlForPage(TEST_URI2, 1), + SMALLPNG_DATA_URI.spec, + "Small icon found" + ); + Assert.equal( + await getFaviconUrlForPage(TEST_URI2), + SMALLSVG_DATA_URI.spec, + "Large icon found" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_expireAllFavicons.js b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js new file mode 100644 index 0000000000..73c3ca6e4b --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js @@ -0,0 +1,38 @@ +/** + * This file tests that favicons are correctly expired by expireAllFavicons. + */ + +"use strict"; + +const TEST_PAGE_URI = NetUtil.newURI("http://example.com/"); +const BOOKMARKED_PAGE_URI = NetUtil.newURI("http://example.com/bookmarked"); + +add_task(async function test_expireAllFavicons() { + // Add a visited page. + await PlacesTestUtils.addVisits({ + uri: TEST_PAGE_URI, + transition: TRANSITION_TYPED, + }); + + // Set a favicon for our test page. + await setFaviconForPage(TEST_PAGE_URI, SMALLPNG_DATA_URI); + + // Add a page with a bookmark. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: BOOKMARKED_PAGE_URI, + title: "Test bookmark", + }); + + // Set a favicon for our bookmark. + await setFaviconForPage(BOOKMARKED_PAGE_URI, SMALLPNG_DATA_URI); + + // Start expiration only after data has been saved in the database. + let promise = promiseTopicObserved(PlacesUtils.TOPIC_FAVICONS_EXPIRED); + PlacesUtils.favicons.expireAllFavicons(); + await promise; + + // Check that the favicons for the pages we added were removed. + await promiseFaviconMissingForPage(TEST_PAGE_URI); + await promiseFaviconMissingForPage(BOOKMARKED_PAGE_URI); +}); diff --git a/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js b/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js new file mode 100644 index 0000000000..00516a2a0b --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests that favicons migrated from a previous profile, having a 0 + * expiration, will be properly expired when fetching new ones. + */ + +add_task(async function test_storing_a_normal_16x16_icon() { + const PAGE_URL = "http://places.test"; + await PlacesTestUtils.addVisits(PAGE_URL); + await setFaviconForPage(PAGE_URL, SMALLPNG_DATA_URI); + + // Now set expiration to 0 and change the payload. + info("Set expiration to 0 and replace favicon data"); + await PlacesUtils.withConnectionWrapper("Change favicons payload", db => { + return db.execute(`UPDATE moz_icons SET expire_ms = 0, data = "test"`); + }); + + let { data, mimeType } = await getFaviconDataForPage(PAGE_URL); + Assert.equal(mimeType, "image/png"); + Assert.deepEqual( + data, + "test".split("").map(c => c.charCodeAt(0)) + ); + + info("Refresh favicon"); + await setFaviconForPage(PAGE_URL, SMALLPNG_DATA_URI, false); + await compareFavicons("page-icon:" + PAGE_URL, SMALLPNG_DATA_URI); +}); diff --git a/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js b/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js new file mode 100644 index 0000000000..d5a7c42ba3 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests that adding new icons for a page expired old ones. + */ + +add_task(async function test_expire_associated() { + const TEST_URL = "http://mozilla.com/"; + await PlacesTestUtils.addVisits(TEST_URL); + const TEST_URL2 = "http://test.mozilla.com/"; + await PlacesTestUtils.addVisits(TEST_URL2); + + let favicons = [ + { + name: "favicon-normal16.png", + mimeType: "image/png", + expired: true, + }, + { + name: "favicon-normal32.png", + mimeType: "image/png", + }, + { + name: "favicon-big64.png", + mimeType: "image/png", + }, + ]; + + for (let icon of favicons) { + let data = readFileData(do_get_file(icon.name)); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(TEST_URL + icon.name), + data, + icon.mimeType + ); + await setFaviconForPage(TEST_URL, TEST_URL + icon.name); + if (icon.expired) { + await expireIconRelationsForPage(TEST_URL); + // Add the same icon to another page. + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(TEST_URL + icon.name), + data, + icon.mimeType, + icon.expire + ); + await setFaviconForPage(TEST_URL2, TEST_URL + icon.name); + } + } + + // Only the second and the third icons should have survived. + Assert.equal( + await getFaviconUrlForPage(TEST_URL, 16), + TEST_URL + favicons[1].name, + "Should retrieve the 32px icon, not the 16px one." + ); + Assert.equal( + await getFaviconUrlForPage(TEST_URL, 64), + TEST_URL + favicons[2].name, + "Should retrieve the 64px icon" + ); + + // The expired icon for page 2 should have survived. + Assert.equal( + await getFaviconUrlForPage(TEST_URL2, 16), + TEST_URL + favicons[0].name, + "Should retrieve the expired 16px icon" + ); +}); + +add_task(async function test_expire_root() { + async function countEntries(tablename) { + await PlacesTestUtils.promiseAsyncUpdates(); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute("SELECT * FROM " + tablename); + return rows.length; + } + + // Clear the database. + let promise = promiseTopicObserved(PlacesUtils.TOPIC_FAVICONS_EXPIRED); + PlacesUtils.favicons.expireAllFavicons(); + await promise; + + Assert.equal(await countEntries("moz_icons"), 0, "There should be no icons"); + + let pageURI = NetUtil.newURI("http://root.mozilla.com/"); + await PlacesTestUtils.addVisits(pageURI); + + // Insert an expired icon. + let iconURI = NetUtil.newURI(pageURI.spec + "favicon-normal16.png"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + iconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, iconURI); + Assert.equal( + await countEntries("moz_icons_to_pages"), + 1, + "There should be 1 association" + ); + // Set an expired time on the icon-page relation. + await expireIconRelationsForPage(pageURI.spec); + + // Now insert a new root icon. + let rootIconURI = NetUtil.newURI(pageURI.spec + "favicon.ico"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + rootIconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, rootIconURI); + + // Only the root icon should have survived. + Assert.equal( + await getFaviconUrlForPage(pageURI, 16), + rootIconURI.spec, + "Should retrieve the root icon." + ); + Assert.equal( + await countEntries("moz_icons_to_pages"), + 0, + "There should be no associations" + ); +}); + +async function expireIconRelationsForPage(url) { + // Set an expired time on the icon-page relation. + await PlacesUtils.withConnectionWrapper("expireFavicon", async db => { + await db.execute( + ` + UPDATE moz_icons_to_pages SET expire_ms = 0 + WHERE page_id = (SELECT id FROM moz_pages_w_icons WHERE page_url = :url) + `, + { url } + ); + // Also ensure the icon is not expired, here we should only replace entries + // based on their association expiration, not the icon expiration. + let count = ( + await db.execute( + ` + SELECT count(*) FROM moz_icons + WHERE expire_ms < strftime('%s','now','localtime','utc') * 1000 + ` + ) + )[0].getResultByIndex(0); + Assert.equal(count, 0, "All the icons should have future expiration"); + }); +} diff --git a/toolkit/components/places/tests/favicons/test_favicons_conversions.js b/toolkit/components/places/tests/favicons/test_favicons_conversions.js new file mode 100644 index 0000000000..28a0fffb7f --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_favicons_conversions.js @@ -0,0 +1,192 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the image conversions done by the favicon service. + */ + +// Globals + +// The pixel values we get on Windows are sometimes +/- 1 value compared to +// other platforms, so we need to skip some image content tests. +var isWindows = "@mozilla.org/windows-registry-key;1" in Cc; + +/** + * Checks the conversion of the given test image file. + * + * @param aFileName + * File that contains the favicon image, located in the test folder. + * @param aFileMimeType + * MIME type of the image contained in the file. + * @param aFileLength + * Expected length of the file. + * @param aExpectConversion + * If false, the icon should be stored as is. If true, the expected data + * is loaded from a file named "expected-" + aFileName + ".png". + * @param aVaryOnWindows + * Indicates that the content of the converted image can be different on + * Windows and should not be checked on that platform. + * @param aCallback + * This function is called after the check finished. + */ +async function checkFaviconDataConversion( + aFileName, + aFileMimeType, + aFileLength, + aExpectConversion, + aVaryOnWindows +) { + let pageURI = NetUtil.newURI("http://places.test/page/" + aFileName); + await PlacesTestUtils.addVisits({ + uri: pageURI, + transition: TRANSITION_TYPED, + }); + let faviconURI = NetUtil.newURI("http://places.test/icon/" + aFileName); + let fileData = readFileOfLength(aFileName, aFileLength); + + PlacesUtils.favicons.replaceFaviconData(faviconURI, fileData, aFileMimeType); + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + (aURI, aDataLen, aData, aMimeType) => { + if (!aExpectConversion) { + Assert.ok(compareArrays(aData, fileData)); + Assert.equal(aMimeType, aFileMimeType); + } else { + if (!aVaryOnWindows || !isWindows) { + let expectedFile = do_get_file("expected-" + aFileName + ".png"); + Assert.ok(compareArrays(aData, readFileData(expectedFile))); + } + Assert.equal(aMimeType, "image/png"); + } + resolve(); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); +} + +add_task(async function test_storing_a_normal_16x16_icon() { + // 16x16 png, 286 bytes. + // optimized: no + await checkFaviconDataConversion( + "favicon-normal16.png", + "image/png", + 286, + false, + false + ); +}); + +add_task(async function test_storing_a_normal_32x32_icon() { + // 32x32 png, 344 bytes. + // optimized: no + await checkFaviconDataConversion( + "favicon-normal32.png", + "image/png", + 344, + false, + false + ); +}); + +add_task(async function test_storing_a_big_16x16_icon() { + // in: 16x16 ico, 1406 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-big16.ico", + "image/x-icon", + 1406, + true, + false + ); +}); + +add_task(async function test_storing_an_oversize_4x4_icon() { + // in: 4x4 jpg, 4751 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-big4.jpg", + "image/jpeg", + 4751, + true, + false + ); +}); + +add_task(async function test_storing_an_oversize_32x32_icon() { + // in: 32x32 jpg, 3494 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-big32.jpg", + "image/jpeg", + 3494, + true, + true + ); +}); + +add_task(async function test_storing_an_oversize_48x48_icon() { + // in: 48x48 ico, 56646 bytes. + // (howstuffworks.com icon, contains 13 icons with sizes from 16x16 to + // 48x48 in varying depths) + // optimized: yes + await checkFaviconDataConversion( + "favicon-big48.ico", + "image/x-icon", + 56646, + true, + false + ); +}); + +add_task(async function test_storing_an_oversize_64x64_icon() { + // in: 64x64 png, 10698 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-big64.png", + "image/png", + 10698, + true, + false + ); +}); + +add_task(async function test_scaling_an_oversize_160x3_icon() { + // in: 160x3 jpg, 5095 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-scale160x3.jpg", + "image/jpeg", + 5095, + true, + false + ); +}); + +add_task(async function test_scaling_an_oversize_3x160_icon() { + // in: 3x160 jpg, 5059 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-scale3x160.jpg", + "image/jpeg", + 5059, + true, + false + ); +}); + +add_task(async function test_animated_16x16_icon() { + // in: 16x16 apng, 1791 bytes. + // optimized: yes + await checkFaviconDataConversion( + "favicon-animated16.png", + "image/png", + 1791, + true, + false + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js b/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js new file mode 100644 index 0000000000..a8e3774830 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js @@ -0,0 +1,114 @@ +/** + * This file tests the size ref on the icons protocols. + */ + +const PAGE_URL = "http://icon.mozilla.org/"; +const ICON16_URL = "http://places.test/favicon-normal16.png"; +const ICON32_URL = "http://places.test/favicon-normal32.png"; + +add_task(async function () { + await PlacesTestUtils.addVisits(PAGE_URL); + // Add 2 differently sized favicons for this page. + + let data = readFileData(do_get_file("favicon-normal16.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ICON16_URL), + data, + "image/png" + ); + await setFaviconForPage(PAGE_URL, ICON16_URL); + data = readFileData(do_get_file("favicon-normal32.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ICON32_URL), + data, + "image/png" + ); + await setFaviconForPage(PAGE_URL, ICON32_URL); + + const PAGE_ICON_URL = "page-icon:" + PAGE_URL; + + await compareFavicons( + PAGE_ICON_URL, + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Not specifying a ref should return the bigger icon" + ); + // Fake window object. + let win = { devicePixelRatio: 1.0 }; + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 16), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON16_URL)), + "Size=16 should return the 16px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 32), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=32 should return the 32px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 33), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=33 should return the 32px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 17), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=17 should return the 32px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 1), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON16_URL)), + "Size=1 should return the 16px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 0), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=0 should return the bigger icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, -1), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Invalid size should return the bigger icon" + ); + + // Add the icon also for the page with ref. + await PlacesTestUtils.addVisits(PAGE_URL + "#other§=12"); + await setFaviconForPage(PAGE_URL + "#other§=12", ICON16_URL, false); + await setFaviconForPage(PAGE_URL + "#other§=12", ICON32_URL, false); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#other§=12", 16), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON16_URL)), + "Pre-existing refs should be retained" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#other§=12", 32), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Pre-existing refs should be retained" + ); + + // If the ref-ed url is unknown, should still try to fetch icon for the unref-ed url. + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#randomstuff", 32), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Non-existing refs should be ignored" + ); + + win = { devicePixelRatio: 1.1 }; + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 16), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=16 with HIDPI should return the 32px icon" + ); + await compareFavicons( + PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 32), + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON32_URL)), + "Size=32 with HIDPI should return the 32px icon" + ); + + // Check setting a different default preferred size works. + PlacesUtils.favicons.setDefaultIconURIPreferredSize(16); + await compareFavicons( + PAGE_ICON_URL, + PlacesUtils.favicons.getFaviconLinkForIcon(NetUtil.newURI(ICON16_URL)), + "Not specifying a ref should return the set default size icon" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js new file mode 100644 index 0000000000..80f498f33f --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const FAVICON_URI = NetUtil.newURI(do_get_file("favicon-normal32.png")); +const FAVICON_DATA = readFileData(do_get_file("favicon-normal32.png")); +const FAVICON_MIMETYPE = "image/png"; +const ICON32_URL = "http://places.test/favicon-normal32.png"; + +add_task(async function test_normal() { + Assert.equal(FAVICON_DATA.length, 344); + let pageURI = NetUtil.newURI("http://example.com/normal"); + + await PlacesTestUtils.addVisits(pageURI); + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + FAVICON_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function () { + PlacesUtils.favicons.getFaviconDataForPage( + pageURI, + function (aURI, aDataLen, aData, aMimeType) { + Assert.ok(aURI.equals(FAVICON_URI)); + Assert.equal(FAVICON_DATA.length, aDataLen); + Assert.ok(compareArrays(FAVICON_DATA, aData)); + Assert.equal(FAVICON_MIMETYPE, aMimeType); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); +}); + +add_task(async function test_missing() { + let pageURI = NetUtil.newURI("http://example.com/missing"); + + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + pageURI, + function (aURI, aDataLen, aData, aMimeType) { + // Check also the expected data types. + Assert.ok(aURI === null); + Assert.ok(aDataLen === 0); + Assert.ok(aData.length === 0); + Assert.ok(aMimeType === ""); + resolve(); + } + ); + }); +}); + +add_task(async function test_fallback() { + const ROOT_URL = "https://www.example.com/"; + const ROOT_ICON_URL = ROOT_URL + "favicon.ico"; + const SUBPAGE_URL = ROOT_URL + "/missing"; + + info("Set icon for the root"); + await PlacesTestUtils.addVisits(ROOT_URL); + let data = readFileData(do_get_file("favicon-normal16.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ROOT_ICON_URL), + data, + "image/png" + ); + await setFaviconForPage(ROOT_URL, ROOT_ICON_URL); + + info("check fallback icons"); + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + NetUtil.newURI(ROOT_URL), + (aURI, aDataLen, aData, aMimeType) => { + Assert.equal(aURI.spec, ROOT_ICON_URL); + Assert.equal(aDataLen, data.length); + Assert.deepEqual(aData, data); + Assert.equal(aMimeType, "image/png"); + resolve(); + } + ); + }); + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + NetUtil.newURI(SUBPAGE_URL), + (aURI, aDataLen, aData, aMimeType) => { + Assert.equal(aURI.spec, ROOT_ICON_URL); + Assert.equal(aDataLen, data.length); + Assert.deepEqual(aData, data); + Assert.equal(aMimeType, "image/png"); + resolve(); + } + ); + }); + + info("Now add a proper icon for the page"); + await PlacesTestUtils.addVisits(SUBPAGE_URL); + let data32 = readFileData(do_get_file("favicon-normal32.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ICON32_URL), + data32, + "image/png" + ); + await setFaviconForPage(SUBPAGE_URL, ICON32_URL); + + info("check no fallback icons"); + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + NetUtil.newURI(ROOT_URL), + (aURI, aDataLen, aData, aMimeType) => { + Assert.equal(aURI.spec, ROOT_ICON_URL); + Assert.equal(aDataLen, data.length); + Assert.deepEqual(aData, data); + Assert.equal(aMimeType, "image/png"); + resolve(); + } + ); + }); + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + NetUtil.newURI(SUBPAGE_URL), + (aURI, aDataLen, aData, aMimeType) => { + Assert.equal(aURI.spec, ICON32_URL); + Assert.equal(aDataLen, data32.length); + Assert.deepEqual(aData, data32); + Assert.equal(aMimeType, "image/png"); + resolve(); + } + ); + }); +}); diff --git a/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js new file mode 100644 index 0000000000..e8f459cb08 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const ICON32_URL = "http://places.test/favicon-normal32.png"; + +add_task(async function test_normal() { + let pageURI = NetUtil.newURI("http://example.com/normal"); + + await PlacesTestUtils.addVisits(pageURI); + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function () { + PlacesUtils.favicons.getFaviconURLForPage( + pageURI, + function (aURI, aDataLen, aData, aMimeType) { + Assert.ok(aURI.equals(SMALLPNG_DATA_URI)); + + // Check also the expected data types. + Assert.ok(aDataLen === 0); + Assert.ok(aData.length === 0); + Assert.ok(aMimeType === ""); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); +}); + +add_task(async function test_missing() { + let pageURI = NetUtil.newURI("http://example.com/missing"); + + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconURLForPage( + pageURI, + function (aURI, aDataLen, aData, aMimeType) { + // Check also the expected data types. + Assert.ok(aURI === null); + Assert.ok(aDataLen === 0); + Assert.ok(aData.length === 0); + Assert.ok(aMimeType === ""); + resolve(); + } + ); + }); +}); + +add_task(async function test_fallback() { + const ROOT_URL = "https://www.example.com/"; + const ROOT_ICON_URL = ROOT_URL + "favicon.ico"; + const SUBPAGE_URL = ROOT_URL + "/missing"; + + info("Set icon for the root"); + await PlacesTestUtils.addVisits(ROOT_URL); + let data = readFileData(do_get_file("favicon-normal16.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ROOT_ICON_URL), + data, + "image/png" + ); + await setFaviconForPage(ROOT_URL, ROOT_ICON_URL); + + info("check fallback icons"); + Assert.equal( + await getFaviconUrlForPage(ROOT_URL), + ROOT_ICON_URL, + "The root should have its favicon" + ); + Assert.equal( + await getFaviconUrlForPage(SUBPAGE_URL), + ROOT_ICON_URL, + "The page should fallback to the root icon" + ); + + info("Now add a proper icon for the page"); + await PlacesTestUtils.addVisits(SUBPAGE_URL); + let data32 = readFileData(do_get_file("favicon-normal32.png")); + PlacesUtils.favicons.replaceFaviconData( + NetUtil.newURI(ICON32_URL), + data32, + "image/png" + ); + await setFaviconForPage(SUBPAGE_URL, ICON32_URL); + + info("check no fallback icons"); + Assert.equal( + await getFaviconUrlForPage(ROOT_URL), + ROOT_ICON_URL, + "The root should still have its favicon" + ); + Assert.equal( + await getFaviconUrlForPage(SUBPAGE_URL), + ICON32_URL, + "The page should also have its icon" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_heavy_favicon.js b/toolkit/components/places/tests/favicons/test_heavy_favicon.js new file mode 100644 index 0000000000..09adcaf6fa --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_heavy_favicon.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests a png with a large file size that can't fit MAX_FAVICON_BUFFER_SIZE, + * it should be downsized until it can be stored, rather than thrown away. + */ + +add_task(async function () { + let file = do_get_file("noise.png"); + let icon = { + file, + uri: NetUtil.newURI(file), + data: readFileData(file), + mimetype: "image/png", + }; + + // If this should fail, it means MAX_FAVICON_BUFFER_SIZE has been made bigger + // than this icon. For this test to make sense the icon shoul always be + // bigger than MAX_FAVICON_BUFFER_SIZE. Please update the icon! + Assert.ok( + icon.data.length > Ci.nsIFaviconService.MAX_FAVICON_BUFFER_SIZE, + "The test icon file size must be larger than Ci.nsIFaviconService.MAX_FAVICON_BUFFER_SIZE" + ); + + let pageURI = uri("http://foo.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + PlacesUtils.favicons.replaceFaviconData(icon.uri, icon.data, icon.mimetype); + await setFaviconForPage(pageURI, icon.uri); + Assert.equal( + await getFaviconUrlForPage(pageURI), + icon.uri.spec, + "A resampled version of the icon should be stored" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_incremental_vacuum.js b/toolkit/components/places/tests/favicons/test_incremental_vacuum.js new file mode 100644 index 0000000000..ab93121d47 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_incremental_vacuum.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests incremental vacuum of the favicons database. + +const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" +); + +add_task(async function () { + let icon = { + file: do_get_file("noise.png"), + mimetype: "image/png", + }; + + let url = "http://foo.bar/"; + await PlacesTestUtils.addVisits(url); + for (let i = 0; i < 10; ++i) { + let iconUri = NetUtil.newURI("http://mozilla.org/" + i); + let data = readFileData(icon.file); + PlacesUtils.favicons.replaceFaviconData(iconUri, data, icon.mimetype); + await setFaviconForPage(url, iconUri); + } + + let promise = TestUtils.topicObserved("places-favicons-expired"); + PlacesUtils.favicons.expireAllFavicons(); + await promise; + + let db = await PlacesUtils.promiseDBConnection(); + let state = ( + await db.execute("PRAGMA favicons.auto_vacuum") + )[0].getResultByIndex(0); + Assert.equal(state, 2, "auto_vacuum should be incremental"); + let count = ( + await db.execute("PRAGMA favicons.freelist_count") + )[0].getResultByIndex(0); + info(`Found ${count} freelist pages`); + let log = await PlacesDBUtils.incrementalVacuum(); + info(log); + let newCount = ( + await db.execute("PRAGMA favicons.freelist_count") + )[0].getResultByIndex(0); + info(`Found ${newCount} freelist pages`); + Assert.ok( + newCount < count, + "The number of freelist pages should have reduced" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js b/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js new file mode 100644 index 0000000000..59e87a8225 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js @@ -0,0 +1,88 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * This test ensures that the mime type is set for moz-anno channels of favicons + * properly. Added with work in bug 481227. + */ + +const testFaviconData = + "data:image/png,%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%10%00%00%00%10%08%06%00%00%00%1F%F3%FFa%00%00%00%04gAMA%00%00%AF%C87%05%8A%E9%00%00%00%19tEXtSoftware%00Adobe%20ImageReadyq%C9e%3C%00%00%01%D6IDATx%DAb%FC%FF%FF%3F%03%25%00%20%80%98%909%EF%DF%BFg%EF%EC%EC%FC%AD%AC%AC%FC%DF%95%91%F1%BF%89%89%C9%7F%20%FF%D7%EA%D5%AB%B7%DF%BBwO%16%9B%01%00%01%C4%00r%01%08%9F9s%C6%CD%D8%D8%F8%BF%0B%03%C3%FF3%40%BC%0A%88%EF%02q%1A%10%BB%40%F1%AAU%ABv%C1%D4%C30%40%00%81%89%993g%3E%06%1A%F6%3F%14%AA%11D%97%03%F1%7Fc%08%0D%E2%2B))%FD%17%04%89%A1%19%00%10%40%0C%D00%F8%0F3%00%C8%F8%BF%1B%E4%0Ac%88a%E5%60%17%19%FF%0F%0D%0D%05%1B%02v%D9%DD%BB%0A0%03%00%02%08%AC%B9%A3%A3%E3%17%03%D4v%90%01%EF%18%106%C3%0Cz%07%C5%BB%A1%DE%82y%07%20%80%A0%A6%08B%FCn%0C1%60%26%D4%20d%C3VA%C3%06%26%BE%0A%EA-%80%00%82%B9%E0%F7L4%0D%EF%90%F8%C6%60%2F%0A%82%BD%01%13%07%0700%D0%01%02%88%11%E4%02P%B41%DC%BB%C7%D0%014%0D%E8l%06W%20%06%BA%88%A1%1C%1AS%15%40%7C%16%CA6.%2Fgx%BFg%0F%83%CB%D9%B3%0C%7B%80%7C%80%00%02%BB%00%E8%9F%ED%20%1B%3A%A0%A6%9F%81%DA%DC%01%C5%B0%80%ED%80%FA%BF%BC%BC%FC%3F%83%12%90%9D%96%F6%1F%20%80%18%DE%BD%7B%C7%0E%8E%05AD%20%FEGr%A6%A0%A0%E0%7F%25P%80%02%9D%0F%D28%13%18%23%C6%C0%B0%02E%3D%C8%F5%00%01%04%8F%05P%A8%BA%40my%87%E4%12c%A8%8D%20%8B%D0%D3%00%08%03%04%10%9C%01R%E4%82d%3B%C8%A0%99%C6%90%90%C6%A5%19%84%01%02%08%9E%17%80%C9x%F7%7B%A0%DBVC%F9%A0%C0%5C%7D%16%2C%CE%00%F4%C6O%5C%99%09%20%800L%04y%A5%03%1A%95%A0%80%05%05%14.%DBA%18%20%80%18)%CD%CE%00%01%06%00%0C'%94%C7%C0k%C9%2C%00%00%00%00IEND%AEB%60%82"; +const testIconURI = uri("http://mozilla.org/favicon.png"); + +function streamListener(aExpectedContentType) { + this._expectedContentType = aExpectedContentType; + this.done = PromiseUtils.defer(); +} +streamListener.prototype = { + onStartRequest() {}, + onStopRequest(aRequest, aContext, aStatusCode) { + let channel = aRequest.QueryInterface(Ci.nsIChannel); + Assert.equal( + channel.contentType, + this._expectedContentType, + "The channel content type is the expected one" + ); + this.done.resolve(); + }, + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + aRequest.cancel(Cr.NS_ERROR_ABORT); + throw Components.Exception("", Cr.NS_ERROR_ABORT); + }, +}; + +add_task(async function () { + info("Test that the default icon has the right content type."); + let channel = NetUtil.newChannel({ + uri: PlacesUtils.favicons.defaultFavicon, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, + }); + let listener = new streamListener( + PlacesUtils.favicons.defaultFaviconMimeType + ); + channel.asyncOpen(listener); + await listener.done.promise; +}); + +add_task(async function () { + info( + "Test icon URI that we don't know anything about. Will serve the default icon." + ); + let channel = NetUtil.newChannel({ + uri: PlacesUtils.favicons.getFaviconLinkForIcon(testIconURI).spec, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, + }); + let listener = new streamListener( + PlacesUtils.favicons.defaultFaviconMimeType + ); + channel.asyncOpen(listener); + await listener.done.promise; +}); + +add_task(async function () { + info("Test that the content type of a favicon we add is correct."); + let testURI = uri("http://mozilla.org/"); + // Add the data before opening + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + testIconURI, + testFaviconData, + 0, + systemPrincipal + ); + await PlacesTestUtils.addVisits(testURI); + await setFaviconForPage(testURI, testIconURI); + // Open the channel + let channel = NetUtil.newChannel({ + uri: PlacesUtils.favicons.getFaviconLinkForIcon(testIconURI).spec, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, + }); + let listener = new streamListener("image/png"); + channel.asyncOpen(listener); + await listener.done.promise; +}); diff --git a/toolkit/components/places/tests/favicons/test_multiple_frames.js b/toolkit/components/places/tests/favicons/test_multiple_frames.js new file mode 100644 index 0000000000..eb59e7f9c6 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_multiple_frames.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests support for icons with multiple frames (like .ico files). + */ + +add_task(async function () { + // in: 48x48 ico, 56646 bytes. + // (howstuffworks.com icon, contains 13 icons with sizes from 16x16 to + // 48x48 in varying depths) + let pageURI = NetUtil.newURI("http://places.test/page/"); + await PlacesTestUtils.addVisits(pageURI); + let faviconURI = NetUtil.newURI("http://places.test/icon/favicon-multi.ico"); + // Fake window. + let win = { devicePixelRatio: 1.0 }; + let icoData = readFileData(do_get_file("favicon-multi.ico")); + PlacesUtils.favicons.replaceFaviconData(faviconURI, icoData, "image/x-icon"); + await setFaviconForPage(pageURI, faviconURI); + + for (let size of [16, 32, 64]) { + let file = do_get_file(`favicon-multi-frame${size}.png`); + let data = readFileData(file); + + info("Check getFaviconDataForPage"); + let icon = await getFaviconDataForPage(pageURI, size); + Assert.equal(icon.mimeType, "image/png"); + Assert.deepEqual(icon.data, data); + + info("Check moz-anno:favicon protocol"); + await compareFavicons( + Services.io.newFileURI(file), + PlacesUtils.urlWithSizeRef( + win, + PlacesUtils.favicons.getFaviconLinkForIcon(faviconURI).spec, + size + ) + ); + + info("Check page-icon protocol"); + await compareFavicons( + Services.io.newFileURI(file), + PlacesUtils.urlWithSizeRef(win, "page-icon:" + pageURI.spec, size) + ); + } +}); diff --git a/toolkit/components/places/tests/favicons/test_page-icon_protocol.js b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js new file mode 100644 index 0000000000..932040bafb --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js @@ -0,0 +1,243 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const ICON_DATAURL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; +const TEST_URI = NetUtil.newURI("http://mozilla.org/"); +const ICON_URI = NetUtil.newURI("http://mozilla.org/favicon.ico"); + +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); + +const PAGE_ICON_TEST_URLS = [ + "page-icon:http://example.com/", + "page-icon:http://a-site-never-before-seen.test", + // For the following, the page-icon protocol is expected to successfully + // return the default favicon. + "page-icon:test", + "page-icon:", + "page-icon:chrome://something.html", + "page-icon:foo://bar/baz", +]; + +XPCShellContentUtils.init(this); + +const HTML = String.raw`<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> +</head> +<body> + Hello from example.com! +</body> +</html>`; + +const server = XPCShellContentUtils.createHttpServer({ + hosts: ["example.com"], +}); + +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Type", "text/html"); + response.write(HTML); +}); + +function fetchIconForSpec(spec) { + return new Promise((resolve, reject) => { + NetUtil.asyncFetch( + { + uri: NetUtil.newURI(spec), + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, + }, + (input, status, request) => { + if (!Components.isSuccessCode(status)) { + reject(new Error("unable to load icon")); + return; + } + + try { + let data = NetUtil.readInputStreamToString(input, input.available()); + let contentType = request.QueryInterface(Ci.nsIChannel).contentType; + input.close(); + resolve({ data, contentType }); + } catch (ex) { + reject(ex); + } + } + ); + }); +} + +var gDefaultFavicon; +var gFavicon; + +add_task(async function setup() { + await PlacesTestUtils.addVisits(TEST_URI); + + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + ICON_URI, + ICON_DATAURL, + (Date.now() + 8640000) * 1000, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + TEST_URI, + ICON_URI, + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + gDefaultFavicon = await fetchIconForSpec( + PlacesUtils.favicons.defaultFavicon.spec + ); + gFavicon = await fetchIconForSpec(ICON_DATAURL); +}); + +add_task(async function known_url() { + let { data, contentType } = await fetchIconForSpec( + "page-icon:" + TEST_URI.spec + ); + Assert.equal(contentType, gFavicon.contentType); + Assert.deepEqual(data, gFavicon.data, "Got the favicon data"); +}); + +add_task(async function unknown_url() { + let { data, contentType } = await fetchIconForSpec( + "page-icon:http://www.moz.org/" + ); + Assert.equal(contentType, gDefaultFavicon.contentType); + Assert.deepEqual(data, gDefaultFavicon.data, "Got the default favicon data"); +}); + +add_task(async function invalid_url() { + let { data, contentType } = await fetchIconForSpec("page-icon:test"); + Assert.equal(contentType, gDefaultFavicon.contentType); + Assert.ok(data == gDefaultFavicon.data, "Got the default favicon data"); +}); + +add_task(async function subpage_url_fallback() { + let { data, contentType } = await fetchIconForSpec( + "page-icon:http://mozilla.org/missing" + ); + Assert.equal(contentType, gFavicon.contentType); + Assert.deepEqual(data, gFavicon.data, "Got the root favicon data"); +}); + +add_task(async function svg_icon() { + let faviconURI = NetUtil.newURI("http://places.test/favicon.svg"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + faviconURI, + SMALLSVG_DATA_URI.spec, + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await setFaviconForPage(TEST_URI, faviconURI); + let svgIcon = await fetchIconForSpec(SMALLSVG_DATA_URI.spec); + info(svgIcon.contentType); + let pageIcon = await fetchIconForSpec("page-icon:" + TEST_URI.spec); + Assert.equal(svgIcon.contentType, pageIcon.contentType); + Assert.deepEqual(svgIcon.data, pageIcon.data, "Got the root favicon data"); +}); + +add_task(async function page_with_ref() { + for (let url of [ + "http://places.test.ref/#myref", + "http://places.test.ref/#!&b=16", + "http://places.test.ref/#", + ]) { + await PlacesTestUtils.addVisits(url); + await setFaviconForPage(url, ICON_URI, false); + let { data, contentType } = await fetchIconForSpec("page-icon:" + url); + Assert.equal(contentType, gFavicon.contentType); + Assert.deepEqual(data, gFavicon.data, "Got the favicon data"); + await PlacesUtils.history.remove(url); + } +}); + +/** + * Tests that page-icon does not work in a normal content process. + */ +add_task(async function page_content_process() { + let contentPage = await XPCShellContentUtils.loadContentPage( + "http://example.com/", + { + remote: true, + } + ); + Assert.notEqual( + contentPage.browsingContext.currentRemoteType, + "privilegedabout" + ); + + await contentPage.spawn([PAGE_ICON_TEST_URLS], async URLS => { + // We expect each of these URLs to produce an error event when + // we attempt to load them in this process type. + /* global content */ + for (let url of URLS) { + let img = content.document.createElement("img"); + img.src = url; + let imgPromise = new Promise((resolve, reject) => { + img.addEventListener("error", e => { + Assert.ok(true, "Got expected load error."); + resolve(); + }); + img.addEventListener("load", e => { + Assert.ok(false, "Did not expect a successful load."); + reject(); + }); + }); + content.document.body.appendChild(img); + await imgPromise; + } + }); + + await contentPage.close(); +}); + +/** + * Tests that page-icon does work for privileged about content process + */ +add_task(async function page_privileged_about_content_process() { + // about:certificate loads in the privileged about content process. + let contentPage = await XPCShellContentUtils.loadContentPage( + "about:certificate", + { + remote: true, + } + ); + Assert.equal( + contentPage.browsingContext.currentRemoteType, + "privilegedabout" + ); + + await contentPage.spawn([PAGE_ICON_TEST_URLS], async URLS => { + // We expect each of these URLs to load correctly in this process + // type. + for (let url of URLS) { + let img = content.document.createElement("img"); + img.src = url; + let imgPromise = new Promise((resolve, reject) => { + img.addEventListener("error", e => { + Assert.ok(false, "Did not expect an error. "); + reject(); + }); + img.addEventListener("load", e => { + Assert.ok(true, "Got expected load event."); + resolve(); + }); + }); + content.document.body.appendChild(img); + await imgPromise; + } + }); + + await contentPage.close(); +}); diff --git a/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js new file mode 100644 index 0000000000..4e5c55e50a --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js @@ -0,0 +1,153 @@ +/** + * Test for bug 451499 <https://bugzilla.mozilla.org/show_bug.cgi?id=451499>: + * Wrong folder icon appears on queries. + */ + +"use strict"; + +add_task(async function test_query_result_favicon_changed_on_child() { + // Bookmark our test page, so it will appear in the query resultset. + const PAGE_URI = Services.io.newURI("http://example.com/test_query_result"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "test_bookmark", + url: PAGE_URI, + }); + + // Get the last 10 bookmarks added to the menu or the toolbar. + let query = PlacesUtils.history.getNewQuery(); + query.setParents([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + options.maxResults = 10; + options.excludeQueries = 1; + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + + let result = PlacesUtils.history.executeQuery(query, options); + let resultObserver = { + containerStateChanged(aContainerNode, aOldState, aNewState) { + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + // We set a favicon on PAGE_URI while the container is open. The + // favicon for the page must have data associated with it in order for + // the icon changed notifications to be sent, so we use a valid image + // data URI. + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI, + SMALLPNG_DATA_URI, + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + }, + nodeIconChanged(aNode) { + if (PlacesUtils.nodeIsContainer(aNode)) { + do_throw( + "The icon should be set only for the page," + + " not for the containing query." + ); + } + }, + }; + Object.setPrototypeOf(resultObserver, NavHistoryResultObserver.prototype); + result.addObserver(resultObserver); + + // Open the container and wait for containerStateChanged. We should start + // observing before setting |containerOpen| as that's caused by the + // setAndFetchFaviconForPage() call caused by the containerStateChanged + // observer above. + let promise = promiseFaviconChanged(PAGE_URI, SMALLPNG_DATA_URI); + result.root.containerOpen = true; + await promise; + + // We must wait for the asynchronous database thread to finish the + // operation, and then for the main thread to process any pending + // notifications that came from the asynchronous thread, before we can be + // sure that nodeIconChanged was not invoked in the meantime. + await PlacesTestUtils.promiseAsyncUpdates(); + result.removeObserver(resultObserver); + + // Free the resources immediately. + result.root.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task( + async function test_query_result_favicon_changed_not_affect_lastmodified() { + // Bookmark our test page, so it will appear in the query resultset. + const PAGE_URI2 = Services.io.newURI( + "http://example.com/test_query_result" + ); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "test_bookmark", + url: PAGE_URI2, + }); + + let result = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid); + + Assert.equal( + result.root.childCount, + 1, + "Should have only one item in the query" + ); + Assert.equal( + result.root.getChild(0).uri, + PAGE_URI2.spec, + "Should have the correct child" + ); + Assert.equal( + result.root.getChild(0).lastModified, + PlacesUtils.toPRTime(bm.lastModified), + "Should have the expected last modified date." + ); + + let promise = promiseFaviconChanged(PAGE_URI2, SMALLPNG_DATA_URI); + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI2, + SMALLPNG_DATA_URI, + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await promise; + + // Open the container and wait for containerStateChanged. We should start + // observing before setting |containerOpen| as that's caused by the + // setAndFetchFaviconForPage() call caused by the containerStateChanged + // observer above. + + // We must wait for the asynchronous database thread to finish the + // operation, and then for the main thread to process any pending + // notifications that came from the asynchronous thread, before we can be + // sure that nodeIconChanged was not invoked in the meantime. + await PlacesTestUtils.promiseAsyncUpdates(); + + Assert.equal( + result.root.childCount, + 1, + "Should have only one item in the query" + ); + Assert.equal( + result.root.getChild(0).uri, + PAGE_URI2.spec, + "Should have the correct child" + ); + Assert.equal( + result.root.getChild(0).lastModified, + PlacesUtils.toPRTime(bm.lastModified), + "Should not have changed the last modified date." + ); + + // Free the resources immediately. + result.root.containerOpen = false; + } +); diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconData.js b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js new file mode 100644 index 0000000000..2e9835eaa9 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js @@ -0,0 +1,395 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for replaceFaviconData() + */ + +var iconsvc = PlacesUtils.favicons; + +var originalFavicon = { + file: do_get_file("favicon-normal16.png"), + uri: uri(do_get_file("favicon-normal16.png")), + data: readFileData(do_get_file("favicon-normal16.png")), + mimetype: "image/png", +}; + +var uniqueFaviconId = 0; +function createFavicon(fileName) { + let tempdir = Services.dirsvc.get("TmpD", Ci.nsIFile); + + // remove any existing file at the path we're about to copy to + let outfile = tempdir.clone(); + outfile.append(fileName); + try { + outfile.remove(false); + } catch (e) {} + + originalFavicon.file.copyToFollowingLinks(tempdir, fileName); + + let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0); + + // append some data that sniffers/encoders will ignore that will distinguish + // the different favicons we'll create + uniqueFaviconId++; + let uniqueStr = "uid:" + uniqueFaviconId; + stream.write(uniqueStr, uniqueStr.length); + stream.close(); + + Assert.equal(outfile.leafName.substr(0, fileName.length), fileName); + + return { + file: outfile, + uri: uri(outfile), + data: readFileData(outfile), + mimetype: "image/png", + }; +} + +function checkCallbackSucceeded( + callbackMimetype, + callbackData, + sourceMimetype, + sourceData +) { + Assert.equal(callbackMimetype, sourceMimetype); + Assert.ok(compareArrays(callbackData, sourceData)); +} + +function run_test() { + // check that the favicon loaded correctly + Assert.equal(originalFavicon.data.length, 286); + run_next_test(); +} + +add_task(async function test_replaceFaviconData_validHistoryURI() { + info("test replaceFaviconData for valid history uri"); + + let pageURI = uri("http://test1.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let favicon = createFavicon("favicon1.png"); + + iconsvc.replaceFaviconData(favicon.uri, favicon.data, favicon.mimetype); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + favicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconData_validHistoryURI_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + dump("GOT " + aMimeType + "\n"); + checkCallbackSucceeded( + aMimeType, + aData, + favicon.mimetype, + favicon.data + ); + checkFaviconDataForPage( + pageURI, + favicon.mimetype, + favicon.data, + function test_replaceFaviconData_validHistoryURI_callback() { + favicon.file.remove(false); + resolve(); + } + ); + }, + systemPrincipal + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_overrideDefaultFavicon() { + info("test replaceFaviconData to override a later setAndFetchFaviconForPage"); + + let pageURI = uri("http://test2.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon2.png"); + let secondFavicon = createFavicon("favicon3.png"); + + iconsvc.replaceFaviconData( + firstFavicon.uri, + secondFavicon.data, + secondFavicon.mimetype + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconData_overrideDefaultFavicon_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconData_overrideDefaultFavicon_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + }, + systemPrincipal + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_replaceExisting() { + info( + "test replaceFaviconData to override a previous setAndFetchFaviconForPage" + ); + + let pageURI = uri("http://test3.bar"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon4.png"); + let secondFavicon = createFavicon("favicon5.png"); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconData_replaceExisting_firstSet_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + firstFavicon.mimetype, + firstFavicon.data + ); + checkFaviconDataForPage( + pageURI, + firstFavicon.mimetype, + firstFavicon.data, + function test_replaceFaviconData_overrideDefaultFavicon_firstCallback() { + iconsvc.replaceFaviconData( + firstFavicon.uri, + secondFavicon.data, + secondFavicon.mimetype + ); + PlacesTestUtils.promiseAsyncUpdates().then(() => { + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconData_overrideDefaultFavicon_secondCallback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + }, + systemPrincipal + ); + }); + } + ); + }, + systemPrincipal + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_unrelatedReplace() { + info("test replaceFaviconData to not make unrelated changes"); + + let pageURI = uri("http://test4.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let favicon = createFavicon("favicon6.png"); + let unrelatedFavicon = createFavicon("favicon7.png"); + + iconsvc.replaceFaviconData( + unrelatedFavicon.uri, + unrelatedFavicon.data, + unrelatedFavicon.mimetype + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + favicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconData_unrelatedReplace_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + favicon.mimetype, + favicon.data + ); + checkFaviconDataForPage( + pageURI, + favicon.mimetype, + favicon.data, + function test_replaceFaviconData_unrelatedReplace_callback() { + favicon.file.remove(false); + unrelatedFavicon.file.remove(false); + resolve(); + } + ); + }, + systemPrincipal + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_badInputs() { + info("test replaceFaviconData to throw on bad inputs"); + let icon = createFavicon("favicon8.png"); + + Assert.throws( + () => iconsvc.replaceFaviconData(icon.uri, icon.data, ""), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => iconsvc.replaceFaviconData(icon.uri, icon.data, "not-an-image"), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => iconsvc.replaceFaviconData(null, icon.data, icon.mimetype), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => iconsvc.replaceFaviconData(icon.uri, [], icon.mimetype), + /NS_ERROR_ILLEGAL_VALUE/ + ); + Assert.throws( + () => iconsvc.replaceFaviconData(icon.uri, null, icon.mimetype), + /NS_ERROR_XPC_CANT_CONVERT_PRIMITIVE_TO_ARRAY/ + ); + + icon.file.remove(false); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_twiceReplace() { + info("test replaceFaviconData on multiple replacements"); + + let pageURI = uri("http://test5.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon9.png"); + let secondFavicon = createFavicon("favicon10.png"); + + iconsvc.replaceFaviconData( + firstFavicon.uri, + firstFavicon.data, + firstFavicon.mimetype + ); + iconsvc.replaceFaviconData( + firstFavicon.uri, + secondFavicon.data, + secondFavicon.mimetype + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconData_twiceReplace_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconData_twiceReplace_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + }, + systemPrincipal + ); + }, + systemPrincipal + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconData_rootOverwrite() { + info("test replaceFaviconData doesn't overwrite root = 1"); + + async function getRootValue(url) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT root FROM moz_icons WHERE icon_url = :url", + { url } + ); + return rows[0].getResultByName("root"); + } + + const PAGE_URL = "http://rootoverwrite.bar/"; + let pageURI = Services.io.newURI(PAGE_URL); + const ICON_URL = "http://rootoverwrite.bar/favicon.ico"; + let iconURI = Services.io.newURI(ICON_URL); + + await PlacesTestUtils.addVisits(pageURI); + + let icon = createFavicon("favicon9.png"); + PlacesUtils.favicons.replaceFaviconData(iconURI, icon.data, icon.mimetype); + await PlacesTestUtils.addFavicons(new Map([[PAGE_URL, ICON_URL]])); + + Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1"); + let icon2 = createFavicon("favicon10.png"); + PlacesUtils.favicons.replaceFaviconData(iconURI, icon2.data, icon2.mimetype); + // replaceFaviconData doesn't have a callback, but we must wait its updated. + await PlacesTestUtils.promiseAsyncUpdates(); + Assert.equal(await getRootValue(ICON_URL), 1, "Check root did not change"); + + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js new file mode 100644 index 0000000000..c1b83fc8a7 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js @@ -0,0 +1,537 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for replaceFaviconData() + */ + +var iconsvc = PlacesUtils.favicons; + +var originalFavicon = { + file: do_get_file("favicon-normal16.png"), + uri: uri(do_get_file("favicon-normal16.png")), + data: readFileData(do_get_file("favicon-normal16.png")), + mimetype: "image/png", +}; + +var uniqueFaviconId = 0; +function createFavicon(fileName) { + let tempdir = Services.dirsvc.get("TmpD", Ci.nsIFile); + + // remove any existing file at the path we're about to copy to + let outfile = tempdir.clone(); + outfile.append(fileName); + try { + outfile.remove(false); + } catch (e) {} + + originalFavicon.file.copyToFollowingLinks(tempdir, fileName); + + let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0); + + // append some data that sniffers/encoders will ignore that will distinguish + // the different favicons we'll create + uniqueFaviconId++; + let uniqueStr = "uid:" + uniqueFaviconId; + stream.write(uniqueStr, uniqueStr.length); + stream.close(); + + Assert.equal(outfile.leafName.substr(0, fileName.length), fileName); + + return { + file: outfile, + uri: uri(outfile), + data: readFileData(outfile), + mimetype: "image/png", + }; +} + +function createDataURLForFavicon(favicon) { + return "data:" + favicon.mimetype + ";base64," + toBase64(favicon.data); +} + +function checkCallbackSucceeded( + callbackMimetype, + callbackData, + sourceMimetype, + sourceData +) { + Assert.equal(callbackMimetype, sourceMimetype); + Assert.ok(compareArrays(callbackData, sourceData)); +} + +function run_test() { + // check that the favicon loaded correctly + Assert.equal(originalFavicon.data.length, 286); + run_next_test(); +} + +add_task(async function test_replaceFaviconDataFromDataURL_validHistoryURI() { + info("test replaceFaviconDataFromDataURL for valid history uri"); + + let pageURI = uri("http://test1.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let favicon = createFavicon("favicon1.png"); + iconsvc.replaceFaviconDataFromDataURL( + favicon.uri, + createDataURLForFavicon(favicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + favicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_validHistoryURI_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + favicon.mimetype, + favicon.data + ); + checkFaviconDataForPage( + pageURI, + favicon.mimetype, + favicon.data, + function test_replaceFaviconDataFromDataURL_validHistoryURI_callback() { + favicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task( + async function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon() { + info( + "test replaceFaviconDataFromDataURL to override a later setAndFetchFaviconForPage" + ); + + let pageURI = uri("http://test2.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon2.png"); + let secondFavicon = createFavicon("favicon3.png"); + + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(secondFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); + } +); + +add_task(async function test_replaceFaviconDataFromDataURL_replaceExisting() { + info( + "test replaceFaviconDataFromDataURL to override a previous setAndFetchFaviconForPage" + ); + + let pageURI = uri("http://test3.bar"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon4.png"); + let secondFavicon = createFavicon("favicon5.png"); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_replaceExisting_firstSet_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + firstFavicon.mimetype, + firstFavicon.data + ); + checkFaviconDataForPage( + pageURI, + firstFavicon.mimetype, + firstFavicon.data, + function test_replaceFaviconDataFromDataURL_replaceExisting_firstCallback() { + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(secondFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconDataFromDataURL_replaceExisting_secondCallback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconDataFromDataURL_unrelatedReplace() { + info("test replaceFaviconDataFromDataURL to not make unrelated changes"); + + let pageURI = uri("http://test4.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let favicon = createFavicon("favicon6.png"); + let unrelatedFavicon = createFavicon("favicon7.png"); + + iconsvc.replaceFaviconDataFromDataURL( + unrelatedFavicon.uri, + createDataURLForFavicon(unrelatedFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + favicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_unrelatedReplace_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + favicon.mimetype, + favicon.data + ); + checkFaviconDataForPage( + pageURI, + favicon.mimetype, + favicon.data, + function test_replaceFaviconDataFromDataURL_unrelatedReplace_callback() { + favicon.file.remove(false); + unrelatedFavicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconDataFromDataURL_badInputs() { + info("test replaceFaviconDataFromDataURL to throw on bad inputs"); + + let favicon = createFavicon("favicon8.png"); + + let ex = null; + try { + iconsvc.replaceFaviconDataFromDataURL( + favicon.uri, + "", + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } catch (e) { + ex = e; + } finally { + Assert.ok(!!ex); + } + + ex = null; + try { + iconsvc.replaceFaviconDataFromDataURL( + null, + createDataURLForFavicon(favicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } catch (e) { + ex = e; + } finally { + Assert.ok(!!ex); + } + + favicon.file.remove(false); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_replaceFaviconDataFromDataURL_twiceReplace() { + info("test replaceFaviconDataFromDataURL on multiple replacements"); + + let pageURI = uri("http://test5.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon9.png"); + let secondFavicon = createFavicon("favicon10.png"); + + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(firstFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(secondFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_twiceReplace_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconDataFromDataURL_twiceReplace_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); +}); + +add_task( + async function test_replaceFaviconDataFromDataURL_afterRegularAssign() { + info("test replaceFaviconDataFromDataURL after replaceFaviconData"); + + let pageURI = uri("http://test6.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon11.png"); + let secondFavicon = createFavicon("favicon12.png"); + + iconsvc.replaceFaviconData( + firstFavicon.uri, + firstFavicon.data, + firstFavicon.mimetype + ); + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(secondFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_afterRegularAssign_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconDataFromDataURL_afterRegularAssign_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); + } +); + +add_task( + async function test_replaceFaviconDataFromDataURL_beforeRegularAssign() { + info("test replaceFaviconDataFromDataURL before replaceFaviconData"); + + let pageURI = uri("http://test7.bar/"); + await PlacesTestUtils.addVisits(pageURI); + + let firstFavicon = createFavicon("favicon13.png"); + let secondFavicon = createFavicon("favicon14.png"); + + iconsvc.replaceFaviconDataFromDataURL( + firstFavicon.uri, + createDataURLForFavicon(firstFavicon), + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + iconsvc.replaceFaviconData( + firstFavicon.uri, + secondFavicon.data, + secondFavicon.mimetype + ); + + await new Promise(resolve => { + iconsvc.setAndFetchFaviconForPage( + pageURI, + firstFavicon.uri, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + function test_replaceFaviconDataFromDataURL_beforeRegularAssign_check( + aURI, + aDataLen, + aData, + aMimeType + ) { + checkCallbackSucceeded( + aMimeType, + aData, + secondFavicon.mimetype, + secondFavicon.data + ); + checkFaviconDataForPage( + pageURI, + secondFavicon.mimetype, + secondFavicon.data, + function test_replaceFaviconDataFromDataURL_beforeRegularAssign_callback() { + firstFavicon.file.remove(false); + secondFavicon.file.remove(false); + resolve(); + } + ); + }, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + await PlacesUtils.history.clear(); + } +); + +/* toBase64 copied from image/test/unit/test_encoder_png.js */ + +/* Convert data (an array of integers) to a Base64 string. */ +const toBase64Table = + // eslint-disable-next-line no-useless-concat + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789+/"; +const base64Pad = "="; +function toBase64(data) { + let result = ""; + let length = data.length; + let i; + // Convert every three bytes to 4 ascii characters. + for (i = 0; i < length - 2; i += 3) { + result += toBase64Table[data[i] >> 2]; + result += toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)]; + result += toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)]; + result += toBase64Table[data[i + 2] & 0x3f]; + } + + // Convert the remaining 1 or 2 bytes, pad out to 4 characters. + if (length % 3) { + i = length - (length % 3); + result += toBase64Table[data[i] >> 2]; + if (length % 3 == 2) { + result += toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)]; + result += toBase64Table[(data[i + 1] & 0x0f) << 2]; + result += base64Pad; + } else { + result += toBase64Table[(data[i] & 0x03) << 4]; + result += base64Pad + base64Pad; + } + } + + return result; +} diff --git a/toolkit/components/places/tests/favicons/test_root_icons.js b/toolkit/components/places/tests/favicons/test_root_icons.js new file mode 100644 index 0000000000..f0487cc162 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_root_icons.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests root icons associations and expiration + */ + +add_task(async function () { + let pageURI = NetUtil.newURI("http://www.places.test/page/"); + await PlacesTestUtils.addVisits(pageURI); + let faviconURI = NetUtil.newURI("http://www.places.test/favicon.ico"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + faviconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, faviconURI); + + // Sanity checks. + Assert.equal(await getFaviconUrlForPage(pageURI), faviconURI.spec); + Assert.equal( + await getFaviconUrlForPage("https://places.test/somethingelse/"), + faviconURI.spec + ); + + // Check database entries. + await PlacesTestUtils.promiseAsyncUpdates(); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute("SELECT * FROM moz_icons"); + Assert.equal(rows.length, 1, "There should only be 1 icon entry"); + Assert.equal( + rows[0].getResultByName("root"), + 1, + "It should be marked as a root icon" + ); + rows = await db.execute("SELECT * FROM moz_pages_w_icons"); + Assert.equal(rows.length, 0, "There should be no page entry"); + rows = await db.execute("SELECT * FROM moz_icons_to_pages"); + Assert.equal(rows.length, 0, "There should be no relation entry"); + + // Add another pages to the same host. The icon should not be removed. + await PlacesTestUtils.addVisits("http://places.test/page2/"); + await PlacesUtils.history.remove(pageURI); + + // Still works since the icon has not been removed. + Assert.equal(await getFaviconUrlForPage(pageURI), faviconURI.spec); + + // Remove all the pages for the given domain. + await PlacesUtils.history.remove("http://places.test/page2/"); + // The icon should be removed along with the domain. + rows = await db.execute("SELECT * FROM moz_icons"); + Assert.equal(rows.length, 0, "The icon should have been removed"); +}); + +add_task(async function test_removePagesByTimeframe() { + const BASE_URL = "http://www.places.test"; + // Add a visit in the past with no directly associated icon. + let oldPageURI = NetUtil.newURI(`${BASE_URL}/old/`); + await PlacesTestUtils.addVisits({ + uri: oldPageURI, + visitDate: new Date(Date.now() - 86400000), + }); + // And another more recent visit. + let pageURI = NetUtil.newURI(`${BASE_URL}/page/`); + await PlacesTestUtils.addVisits({ + uri: pageURI, + visitDate: new Date(Date.now() - 7200000), + }); + + // Add a normal icon to the most recent page. + let faviconURI = NetUtil.newURI(`${BASE_URL}/page/favicon.ico`); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + faviconURI, + SMALLSVG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, faviconURI); + // Add a root icon to the most recent page. + let rootIconURI = NetUtil.newURI(`${BASE_URL}/favicon.ico`); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + rootIconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, rootIconURI); + + // Sanity checks. + Assert.equal( + await getFaviconUrlForPage(pageURI), + faviconURI.spec, + "Should get the biggest icon" + ); + Assert.equal( + await getFaviconUrlForPage(pageURI, 1), + rootIconURI.spec, + "Should get the smallest icon" + ); + Assert.equal( + await getFaviconUrlForPage(oldPageURI), + rootIconURI.spec, + "Should get the root icon" + ); + + info("Removing the newer page, not the old one"); + await PlacesUtils.history.removeByFilter({ + beginDate: new Date(Date.now() - 14400000), + endDate: new Date(), + }); + await PlacesTestUtils.promiseAsyncUpdates(); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute("SELECT * FROM moz_icons"); + Assert.equal(rows.length, 1, "There should only be 1 icon entry"); + Assert.equal( + rows[0].getResultByName("root"), + 1, + "It should be marked as a root icon" + ); + rows = await db.execute("SELECT * FROM moz_pages_w_icons"); + Assert.equal(rows.length, 0, "There should be no page entry"); + rows = await db.execute("SELECT * FROM moz_icons_to_pages"); + Assert.equal(rows.length, 0, "There should be no relation entry"); + + await PlacesUtils.history.removeByFilter({ + beginDate: new Date(0), + endDt: new Date(), + }); + await PlacesTestUtils.promiseAsyncUpdates(); + rows = await db.execute("SELECT * FROM moz_icons"); + // Debug logging for possible intermittent failure (bug 1358368). + if (rows.length) { + dump_table("moz_icons"); + } + Assert.equal(rows.length, 0, "There should be no icon entry"); +}); + +add_task(async function test_different_host() { + let pageURI = NetUtil.newURI("http://places.test/page/"); + await PlacesTestUtils.addVisits(pageURI); + let faviconURI = NetUtil.newURI("http://mozilla.test/favicon.ico"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + faviconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI, faviconURI); + + Assert.equal( + await getFaviconUrlForPage(pageURI), + faviconURI.spec, + "Should get the png icon" + ); + // Check the icon is not marked as a root icon in the database. + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT root FROM moz_icons WHERE icon_url = :url", + { url: faviconURI.spec } + ); + Assert.strictEqual(rows[0].getResultByName("root"), 0); +}); + +add_task(async function test_same_size() { + // Add two icons with the same size, one is a root icon. Check that the + // non-root icon is preferred when a smaller size is requested. + let data = readFileData(do_get_file("favicon-normal32.png")); + let pageURI = NetUtil.newURI("http://new_places.test/page/"); + await PlacesTestUtils.addVisits(pageURI); + + let faviconURI = NetUtil.newURI("http://new_places.test/favicon.ico"); + PlacesUtils.favicons.replaceFaviconData(faviconURI, data, "image/png"); + await setFaviconForPage(pageURI, faviconURI); + faviconURI = NetUtil.newURI("http://new_places.test/another_icon.ico"); + PlacesUtils.favicons.replaceFaviconData(faviconURI, data, "image/png"); + await setFaviconForPage(pageURI, faviconURI); + + Assert.equal( + await getFaviconUrlForPage(pageURI, 20), + faviconURI.spec, + "Should get the non-root icon" + ); +}); + +add_task(async function test_root_on_different_host() { + async function getRootValue(url) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT root FROM moz_icons WHERE icon_url = :url", + { url } + ); + return rows[0].getResultByName("root"); + } + + // Check that a root icon associated to 2 domains is not removed when the + // root domain is removed. + const TEST_URL1 = "http://places1.test/page/"; + let pageURI1 = NetUtil.newURI(TEST_URL1); + await PlacesTestUtils.addVisits(pageURI1); + + const TEST_URL2 = "http://places2.test/page/"; + let pageURI2 = NetUtil.newURI(TEST_URL2); + await PlacesTestUtils.addVisits(pageURI2); + + // Root favicon for TEST_URL1. + const ICON_URL = "http://places1.test/favicon.ico"; + let iconURI = NetUtil.newURI(ICON_URL); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + iconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI1, iconURI); + Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1"); + Assert.equal( + await getFaviconUrlForPage(pageURI1, 16), + ICON_URL, + "The icon should been found" + ); + + // Same favicon for TEST_URL2. + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + iconURI, + SMALLPNG_DATA_URI.spec, + 0, + systemPrincipal + ); + await setFaviconForPage(pageURI2, iconURI); + Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1"); + Assert.equal( + await getFaviconUrlForPage(pageURI2, 16), + ICON_URL, + "The icon should be found" + ); + + await PlacesUtils.history.remove(pageURI1); + + Assert.equal( + await getFaviconUrlForPage(pageURI2, 16), + ICON_URL, + "The icon should not have been removed" + ); +}); diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js new file mode 100644 index 0000000000..1b4ea87ec0 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js @@ -0,0 +1,123 @@ +/* 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/. */ + +// This file tests the normal operation of setAndFetchFaviconForPage. + +let gTests = [ + { + desc: "Normal test", + href: "http://example.com/normal", + loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + async setup() { + await PlacesTestUtils.addVisits({ + uri: this.href, + transition: TRANSITION_TYPED, + }); + }, + }, + { + desc: "Bookmarked about: uri", + href: "about:testAboutURI_bookmarked", + loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + async setup() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: this.href, + }); + }, + }, + { + desc: "Bookmarked in private window", + href: "http://example.com/privateBrowsing_bookmarked", + loadType: PlacesUtils.favicons.FAVICON_LOAD_PRIVATE, + async setup() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: this.href, + }); + }, + }, + { + desc: "Bookmarked with disabled history", + href: "http://example.com/disabledHistory_bookmarked", + loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + async setup() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: this.href, + }); + Services.prefs.setBoolPref("places.history.enabled", false); + }, + clean() { + Services.prefs.setBoolPref("places.history.enabled", true); + }, + }, +]; + +add_task(async function () { + let faviconURI = SMALLPNG_DATA_URI; + let faviconMimeType = "image/png"; + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + for (let test of gTests) { + info(test.desc); + let pageURI = PlacesUtils.toURI(test.href); + + await test.setup(); + + let pageGuid; + let promise = PlacesTestUtils.waitForNotification( + "favicon-changed", + events => + events.some(e => { + if (e.url == pageURI.spec && e.faviconUrl == faviconURI.spec) { + pageGuid = e.pageGuid; + return true; + } + return false; + }) + ); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + faviconURI, + true, + test.private, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await promise; + + Assert.equal( + pageGuid, + await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: pageURI, + }), + "Page guid is correct" + ); + let { dataLen, data, mimeType } = await PlacesUtils.promiseFaviconData( + pageURI.spec + ); + Assert.equal(faviconMimeType, mimeType, "Check expected MimeType"); + Assert.equal( + SMALLPNG_DATA_LEN, + data.length, + "Check favicon data for the given page matches the provided data" + ); + Assert.equal( + dataLen, + data.length, + "Check favicon dataLen for the given page matches the provided data" + ); + + if (test.clean) { + await test.clean(); + } + } +}); diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js new file mode 100644 index 0000000000..1901ca86a7 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js @@ -0,0 +1,156 @@ +/* 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/. */ + +/** + * This file tests setAndFetchFaviconForPage when it is called with invalid + * arguments, and when no favicon is stored for the given arguments. + */ + +let faviconURI = Services.io.newURI( + "http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal16.png" +); +add_task(async function () { + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + // We'll listen for favicon changes for the whole test, to ensure only the + // last one will send a notification. Due to thread serialization, at that + // point we can be sure previous calls didn't send a notification. + let lastPageURI = Services.io.newURI("http://example.com/verification"); + let promiseIconChanged = PlacesTestUtils.waitForNotification( + "favicon-changed", + events => + events.some( + e => e.url == lastPageURI.spec && e.faviconUrl == SMALLPNG_DATA_URI.spec + ) + ); + + info("Test null page uri"); + Assert.throws( + () => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + null, + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + /NS_ERROR_ILLEGAL_VALUE/, + "Exception expected because aPageURI is null" + ); + + info("Test null favicon uri"); + Assert.throws( + () => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI("http://example.com/null_faviconURI"), + null, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + /NS_ERROR_ILLEGAL_VALUE/, + "Exception expected because aFaviconURI is null." + ); + + info("Test about uri"); + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI("about:testAboutURI"), + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + info("Test private browsing non bookmarked uri"); + let pageURI = Services.io.newURI("http://example.com/privateBrowsing"); + await PlacesTestUtils.addVisits({ + uri: pageURI, + transitionType: TRANSITION_TYPED, + }); + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + info("Test disabled history"); + pageURI = Services.io.newURI("http://example.com/disabledHistory"); + await PlacesTestUtils.addVisits({ + uri: pageURI, + transition: TRANSITION_TYPED, + }); + Services.prefs.setBoolPref("places.history.enabled", false); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + // The setAndFetchFaviconForPage function calls CanAddURI synchronously, thus + // we can set the preference back to true immediately. + Services.prefs.setBoolPref("places.history.enabled", true); + + info("Test error icon"); + // This error icon must stay in sync with FAVICON_ERRORPAGE_URL in + // nsIFaviconService.idl and aboutNetError.xhtml. + let faviconErrorPageURI = Services.io.newURI( + "chrome://global/skin/icons/info.svg" + ); + pageURI = Services.io.newURI("http://example.com/errorIcon"); + await PlacesTestUtils.addVisits({ + uri: pageURI, + transition: TRANSITION_TYPED, + }); + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + faviconErrorPageURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + info("Test nonexisting page"); + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI("http://example.com/nonexistingPage"), + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + info("Final sanity check"); + // This is the only test that should cause the waitForFaviconChanged + // callback to be invoked. + await PlacesTestUtils.addVisits({ + uri: lastPageURI, + transition: TRANSITION_TYPED, + }); + PlacesUtils.favicons.setAndFetchFaviconForPage( + lastPageURI, + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await promiseIconChanged; +}); diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js new file mode 100644 index 0000000000..feda238f97 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js @@ -0,0 +1,89 @@ +/* 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/. */ + +// This file tests setAndFetchFaviconForPage on bookmarked redirects. + +add_task(async function same_host_redirect() { + // Add a bookmarked page that redirects to another page, set a favicon on the + // latter and check the former gets it too, if they are in the same host. + let srcUrl = "http://bookmarked.com/"; + let destUrl = "https://other.bookmarked.com/"; + await PlacesTestUtils.addVisits([ + { uri: srcUrl, transition: TRANSITION_LINK }, + { + uri: destUrl, + transition: TRANSITION_REDIRECT_TEMPORARY, + referrer: srcUrl, + }, + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: srcUrl, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + let promise = PlacesTestUtils.waitForNotification("favicon-changed", events => + events.some(e => e.url == srcUrl && e.faviconUrl == SMALLPNG_DATA_URI.spec) + ); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI(destUrl), + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await promise; + + // The favicon should be set also on the bookmarked url that redirected. + let { dataLen } = await PlacesUtils.promiseFaviconData(srcUrl); + Assert.equal(dataLen, SMALLPNG_DATA_LEN, "Check favicon dataLen"); +}); + +add_task(async function other_host_redirect() { + // Add a bookmarked page that redirects to another page, set a favicon on the + // latter and check the former gets it too, if they are in the same host. + let srcUrl = "http://first.com/"; + let destUrl = "https://notfirst.com/"; + await PlacesTestUtils.addVisits([ + { uri: srcUrl, transition: TRANSITION_LINK }, + { + uri: destUrl, + transition: TRANSITION_REDIRECT_TEMPORARY, + referrer: srcUrl, + }, + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: srcUrl, + }); + + let promise = Promise.race([ + PlacesTestUtils.waitForNotification("favicon-changed", events => + events.some( + e => e.url == srcUrl && e.faviconUrl == SMALLPNG_DATA_URI.spec + ) + ), + new Promise((resolve, reject) => + do_timeout(300, () => reject(new Error("timeout"))) + ), + ]); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI(destUrl), + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await Assert.rejects(promise, /timeout/); +}); diff --git a/toolkit/components/places/tests/favicons/test_svg_favicon.js b/toolkit/components/places/tests/favicons/test_svg_favicon.js new file mode 100644 index 0000000000..8d9f2edf11 --- /dev/null +++ b/toolkit/components/places/tests/favicons/test_svg_favicon.js @@ -0,0 +1,34 @@ +const PAGEURI = NetUtil.newURI("http://deliciousbacon.com/"); + +add_task(async function () { + // First, add a history entry or else Places can't save a favicon. + await PlacesTestUtils.addVisits({ + uri: PAGEURI, + transition: TRANSITION_LINK, + visitDate: Date.now() * 1000, + }); + + await new Promise(resolve => { + function onSetComplete(aURI, aDataLen, aData, aMimeType, aWidth) { + equal(aURI.spec, SMALLSVG_DATA_URI.spec, "setFavicon aURI check"); + equal(aDataLen, 263, "setFavicon aDataLen check"); + equal(aMimeType, "image/svg+xml", "setFavicon aMimeType check"); + dump(aWidth); + resolve(); + } + + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGEURI, + SMALLSVG_DATA_URI, + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + onSetComplete, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + let data = await PlacesUtils.promiseFaviconData(PAGEURI.spec); + equal(data.uri.spec, SMALLSVG_DATA_URI.spec, "getFavicon aURI check"); + equal(data.dataLen, 263, "getFavicon aDataLen check"); + equal(data.mimeType, "image/svg+xml", "getFavicon aMimeType check"); +}); diff --git a/toolkit/components/places/tests/favicons/xpcshell.ini b/toolkit/components/places/tests/favicons/xpcshell.ini new file mode 100644 index 0000000000..86616c1631 --- /dev/null +++ b/toolkit/components/places/tests/favicons/xpcshell.ini @@ -0,0 +1,49 @@ +[DEFAULT] +head = head_favicons.js +skip-if = toolkit == 'android' +support-files = + expected-favicon-animated16.png.png + expected-favicon-big32.jpg.png + expected-favicon-big4.jpg.png + expected-favicon-big16.ico.png + expected-favicon-big48.ico.png + expected-favicon-big64.png.png + expected-favicon-scale160x3.jpg.png + expected-favicon-scale3x160.jpg.png + favicon-animated16.png + favicon-big16.ico + favicon-big32.jpg + favicon-big4.jpg + favicon-big48.ico + favicon-big64.png + favicon-multi.ico + favicon-multi-frame16.png + favicon-multi-frame32.png + favicon-multi-frame64.png + favicon-normal16.png + favicon-normal32.png + favicon-scale160x3.jpg + favicon-scale3x160.jpg + noise.png + +[test_copyFavicons.js] +[test_expireAllFavicons.js] +[test_expire_migrated_icons.js] +[test_expire_on_new_icons.js] +[test_favicons_conversions.js] +[test_favicons_protocols_ref.js] +[test_getFaviconDataForPage.js] +[test_getFaviconURLForPage.js] +[test_heavy_favicon.js] +[test_incremental_vacuum.js] +[test_moz-anno_favicon_mime_type.js] +[test_multiple_frames.js] +[test_page-icon_protocol.js] +[test_query_result_favicon_changed_on_child.js] +[test_replaceFaviconData.js] +[test_replaceFaviconDataFromDataURL.js] +[test_root_icons.js] +[test_setAndFetchFaviconForPage.js] +[test_setAndFetchFaviconForPage_failures.js] +[test_setAndFetchFaviconForPage_redirects.js] +[test_svg_favicon.js] diff --git a/toolkit/components/places/tests/gtest/mock_Link.h b/toolkit/components/places/tests/gtest/mock_Link.h new file mode 100644 index 0000000000..aa83a2fe7b --- /dev/null +++ b/toolkit/components/places/tests/gtest/mock_Link.h @@ -0,0 +1,72 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +/** + * This is a mock Link object which can be used in tests. + */ + +#ifndef mock_Link_h__ +#define mock_Link_h__ + +#include "mozilla/MemoryReporting.h" +#include "mozilla/dom/Link.h" +#include "mozilla/StaticPrefs_layout.h" + +class mock_Link : public mozilla::dom::Link { + public: + NS_DECL_ISUPPORTS + + typedef void (*Handler)(State); + + explicit mock_Link(Handler aHandlerFunction, bool aRunNextTest = true) + : mozilla::dom::Link(), mRunNextTest(aRunNextTest) { + AwaitNewNotification(aHandlerFunction); + } + + void VisitedQueryFinished(bool aVisited) final { + // Notify our callback function. + mHandler(aVisited ? State::Visited : State::Unvisited); + + // Break the cycle so the object can be destroyed. + mDeathGrip = nullptr; + } + + size_t SizeOfExcludingThis(mozilla::SizeOfState& aState) const final { + return 0; // the value shouldn't matter + } + + void NodeInfoChanged(mozilla::dom::Document* aOldDoc) final {} + + bool GotNotified() const { return !mDeathGrip; } + + void AwaitNewNotification(Handler aNewHandler) { + MOZ_ASSERT(!mDeathGrip, "Still waiting for a notification"); + // Create a cyclic ownership, so that the link will be released only + // after its status has been updated. This will ensure that, when it should + // run the next test, it will happen at the end of the test function, if + // the link status has already been set before. Indeed the link status is + // updated on a separate connection, thus may happen at any time. + mDeathGrip = this; + mHandler = aNewHandler; + } + + protected: + ~mock_Link() { + // Run the next test if we are supposed to. + if (mRunNextTest) { + run_next_test(); + } + } + + private: + Handler mHandler = nullptr; + bool mRunNextTest; + RefPtr<Link> mDeathGrip; +}; + +NS_IMPL_ISUPPORTS(mock_Link, mozilla::dom::Link) + +#endif // mock_Link_h__ diff --git a/toolkit/components/places/tests/gtest/moz.build b/toolkit/components/places/tests/gtest/moz.build new file mode 100644 index 0000000000..eb7157efc5 --- /dev/null +++ b/toolkit/components/places/tests/gtest/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +UNIFIED_SOURCES += [ + "test_casing.cpp", + "test_IHistory.cpp", +] + +FINAL_LIBRARY = "xul-gtest" diff --git a/toolkit/components/places/tests/gtest/places_test_harness.h b/toolkit/components/places/tests/gtest/places_test_harness.h new file mode 100644 index 0000000000..f2d3e06c35 --- /dev/null +++ b/toolkit/components/places/tests/gtest/places_test_harness.h @@ -0,0 +1,353 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +#include "gtest/gtest.h" +#include "nsThreadUtils.h" +#include "nsDocShellCID.h" + +#include "nsToolkitCompsCID.h" +#include "nsServiceManagerUtils.h" +#include "nsINavHistoryService.h" +#include "nsIObserverService.h" +#include "nsIThread.h" +#include "nsIURI.h" +#include "mozilla/IHistory.h" +#include "mozilla/SpinEventLoopUntil.h" +#include "mozIStorageConnection.h" +#include "mozIStorageStatement.h" +#include "mozIStorageAsyncStatement.h" +#include "mozIStorageStatementCallback.h" +#include "mozIStoragePendingStatement.h" +#include "nsIObserver.h" +#include "prinrval.h" +#include "prtime.h" +#include "mozilla/Attributes.h" + +#define WAITFORTOPIC_TIMEOUT_SECONDS 5 + +#define do_check_true(aCondition) EXPECT_TRUE(aCondition) + +#define do_check_false(aCondition) EXPECT_FALSE(aCondition) + +#define do_check_success(aResult) do_check_true(NS_SUCCEEDED(aResult)) + +#define do_check_eq(aExpected, aActual) do_check_true(aExpected == aActual) + +struct Test { + void (*func)(void); + const char* const name; +}; +#define PTEST(aName) \ + { aName, #aName } + +/** + * Runs the next text. + */ +void run_next_test(); + +/** + * To be used around asynchronous work. + */ +void do_test_pending(); +void do_test_finished(); + +/** + * Spins current thread until a topic is received. + */ +class WaitForTopicSpinner final : public nsIObserver { + public: + NS_DECL_ISUPPORTS + + explicit WaitForTopicSpinner(const char* const aTopic) + : mTopicReceived(false), mStartTime(PR_IntervalNow()) { + nsCOMPtr<nsIObserverService> observerService = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + do_check_true(observerService); + (void)observerService->AddObserver(this, aTopic, false); + } + + void Spin() { + bool timedOut = false; + mozilla::SpinEventLoopUntil( + "places:WaitForTopicSpinner::Spin"_ns, [&]() -> bool { + if (mTopicReceived) { + return true; + } + + if ((PR_IntervalNow() - mStartTime) > + (WAITFORTOPIC_TIMEOUT_SECONDS * PR_USEC_PER_SEC)) { + timedOut = true; + return true; + } + + return false; + }); + + if (timedOut) { + // Timed out waiting for the topic. + do_check_true(false); + } + } + + NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) override { + mTopicReceived = true; + nsCOMPtr<nsIObserverService> observerService = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + do_check_true(observerService); + (void)observerService->RemoveObserver(this, aTopic); + return NS_OK; + } + + private: + ~WaitForTopicSpinner() = default; + + bool mTopicReceived; + PRIntervalTime mStartTime; +}; +NS_IMPL_ISUPPORTS(WaitForTopicSpinner, nsIObserver) + +/** + * Spins current thread until an async statement is executed. + */ +class PlacesAsyncStatementSpinner final : public mozIStorageStatementCallback { + public: + NS_DECL_ISUPPORTS + NS_DECL_MOZISTORAGESTATEMENTCALLBACK + + PlacesAsyncStatementSpinner(); + void SpinUntilCompleted(); + uint16_t completionReason; + + protected: + ~PlacesAsyncStatementSpinner() = default; + + volatile bool mCompleted; +}; + +NS_IMPL_ISUPPORTS(PlacesAsyncStatementSpinner, mozIStorageStatementCallback) + +PlacesAsyncStatementSpinner::PlacesAsyncStatementSpinner() + : completionReason(0), mCompleted(false) {} + +NS_IMETHODIMP +PlacesAsyncStatementSpinner::HandleResult(mozIStorageResultSet* aResultSet) { + return NS_OK; +} + +NS_IMETHODIMP +PlacesAsyncStatementSpinner::HandleError(mozIStorageError* aError) { + return NS_OK; +} + +NS_IMETHODIMP +PlacesAsyncStatementSpinner::HandleCompletion(uint16_t aReason) { + completionReason = aReason; + mCompleted = true; + return NS_OK; +} + +void PlacesAsyncStatementSpinner::SpinUntilCompleted() { + nsCOMPtr<nsIThread> thread(::do_GetCurrentThread()); + nsresult rv = NS_OK; + bool processed = true; + while (!mCompleted && NS_SUCCEEDED(rv)) { + rv = thread->ProcessNextEvent(true, &processed); + } +} + +struct PlaceRecord { + int64_t id; + int32_t hidden; + int32_t typed; + int32_t visitCount; + nsCString guid; +}; + +struct VisitRecord { + int64_t id; + int64_t lastVisitId; + int32_t transitionType; +}; + +already_AddRefed<mozilla::IHistory> do_get_IHistory() { + nsCOMPtr<mozilla::IHistory> history = do_GetService(NS_IHISTORY_CONTRACTID); + do_check_true(history); + return history.forget(); +} + +already_AddRefed<nsINavHistoryService> do_get_NavHistory() { + nsCOMPtr<nsINavHistoryService> serv = + do_GetService(NS_NAVHISTORYSERVICE_CONTRACTID); + do_check_true(serv); + return serv.forget(); +} + +already_AddRefed<mozIStorageConnection> do_get_db() { + nsCOMPtr<nsINavHistoryService> history = do_get_NavHistory(); + do_check_true(history); + + nsCOMPtr<mozIStorageConnection> dbConn; + nsresult rv = history->GetDBConnection(getter_AddRefs(dbConn)); + do_check_success(rv); + return dbConn.forget(); +} + +/** + * Get the place record from the database. + * + * @param aURI The unique URI of the place we are looking up + * @param result Out parameter where the result is stored + */ +void do_get_place(nsIURI* aURI, PlaceRecord& result) { + nsCOMPtr<mozIStorageConnection> dbConn = do_get_db(); + nsCOMPtr<mozIStorageStatement> stmt; + + nsCString spec; + nsresult rv = aURI->GetSpec(spec); + do_check_success(rv); + + rv = dbConn->CreateStatement( + nsLiteralCString( + "SELECT id, hidden, typed, visit_count, guid FROM moz_places " + "WHERE url_hash = hash(?1) AND url = ?1"), + getter_AddRefs(stmt)); + do_check_success(rv); + + rv = stmt->BindUTF8StringByIndex(0, spec); + do_check_success(rv); + + bool hasResults; + rv = stmt->ExecuteStep(&hasResults); + do_check_success(rv); + if (!hasResults) { + result.id = 0; + return; + } + + rv = stmt->GetInt64(0, &result.id); + do_check_success(rv); + rv = stmt->GetInt32(1, &result.hidden); + do_check_success(rv); + rv = stmt->GetInt32(2, &result.typed); + do_check_success(rv); + rv = stmt->GetInt32(3, &result.visitCount); + do_check_success(rv); + rv = stmt->GetUTF8String(4, result.guid); + do_check_success(rv); +} + +/** + * Gets the most recent visit to a place. + * + * @param placeID ID from the moz_places table + * @param result Out parameter where visit is stored + */ +void do_get_lastVisit(int64_t placeId, VisitRecord& result) { + nsCOMPtr<mozIStorageConnection> dbConn = do_get_db(); + nsCOMPtr<mozIStorageStatement> stmt; + + nsresult rv = dbConn->CreateStatement( + nsLiteralCString( + "SELECT id, from_visit, visit_type FROM moz_historyvisits " + "WHERE place_id=?1 " + "LIMIT 1"), + getter_AddRefs(stmt)); + do_check_success(rv); + + rv = stmt->BindInt64ByIndex(0, placeId); + do_check_success(rv); + + bool hasResults; + rv = stmt->ExecuteStep(&hasResults); + do_check_success(rv); + + if (!hasResults) { + result.id = 0; + return; + } + + rv = stmt->GetInt64(0, &result.id); + do_check_success(rv); + rv = stmt->GetInt64(1, &result.lastVisitId); + do_check_success(rv); + rv = stmt->GetInt32(2, &result.transitionType); + do_check_success(rv); +} + +void do_wait_async_updates() { + nsCOMPtr<mozIStorageConnection> db = do_get_db(); + nsCOMPtr<mozIStorageAsyncStatement> stmt; + + db->CreateAsyncStatement("BEGIN EXCLUSIVE"_ns, getter_AddRefs(stmt)); + nsCOMPtr<mozIStoragePendingStatement> pending; + (void)stmt->ExecuteAsync(nullptr, getter_AddRefs(pending)); + + db->CreateAsyncStatement("COMMIT"_ns, getter_AddRefs(stmt)); + RefPtr<PlacesAsyncStatementSpinner> spinner = + new PlacesAsyncStatementSpinner(); + (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending)); + + spinner->SpinUntilCompleted(); +} + +/** + * Adds a URI to the database. + * + * @param aURI + * The URI to add to the database. + */ +void addURI(nsIURI* aURI) { + nsCOMPtr<mozilla::IHistory> history = do_GetService(NS_IHISTORY_CONTRACTID); + do_check_true(history); + nsresult rv = history->VisitURI(nullptr, aURI, nullptr, + mozilla::IHistory::TOP_LEVEL, 0); + do_check_success(rv); + + do_wait_async_updates(); +} + +static const char TOPIC_PROFILE_CHANGE_QM[] = "profile-before-change-qm"; +static const char TOPIC_PLACES_CONNECTION_CLOSED[] = "places-connection-closed"; + +class WaitForConnectionClosed final : public nsIObserver { + RefPtr<WaitForTopicSpinner> mSpinner; + + ~WaitForConnectionClosed() = default; + + public: + NS_DECL_ISUPPORTS + + WaitForConnectionClosed() { + nsCOMPtr<nsIObserverService> os = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + MOZ_ASSERT(os); + if (os) { + // The places-connection-closed notification happens because of things + // that occur during profile-before-change, so we use the stage after that + // to wait for it. + MOZ_ALWAYS_SUCCEEDS( + os->AddObserver(this, TOPIC_PROFILE_CHANGE_QM, false)); + } + mSpinner = new WaitForTopicSpinner(TOPIC_PLACES_CONNECTION_CLOSED); + } + + NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) override { + nsCOMPtr<nsIObserverService> os = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + MOZ_ASSERT(os); + if (os) { + MOZ_ALWAYS_SUCCEEDS(os->RemoveObserver(this, aTopic)); + } + + mSpinner->Spin(); + + return NS_OK; + } +}; + +NS_IMPL_ISUPPORTS(WaitForConnectionClosed, nsIObserver) diff --git a/toolkit/components/places/tests/gtest/places_test_harness_tail.h b/toolkit/components/places/tests/gtest/places_test_harness_tail.h new file mode 100644 index 0000000000..0464d14e0d --- /dev/null +++ b/toolkit/components/places/tests/gtest/places_test_harness_tail.h @@ -0,0 +1,89 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +#include "nsWidgetsCID.h" +#include "nsIUserIdleService.h" +#include "mozilla/StackWalk.h" + +#ifndef TEST_NAME +# error "Must #define TEST_NAME before including places_test_harness_tail.h" +#endif + +int gTestsIndex = 0; + +#define TEST_INFO_STR "TEST-INFO | " + +class RunNextTest : public mozilla::Runnable { + public: + RunNextTest() : mozilla::Runnable("RunNextTest") {} + NS_IMETHOD Run() override { + NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?"); + if (gTestsIndex < int(mozilla::ArrayLength(gTests))) { + do_test_pending(); + Test& test = gTests[gTestsIndex++]; + (void)fprintf(stderr, TEST_INFO_STR "Running %s.\n", test.name); + test.func(); + } + + do_test_finished(); + return NS_OK; + } +}; + +static const bool kDebugRunNextTest = false; + +void run_next_test() { + if (kDebugRunNextTest) { + printf_stderr("run_next_test()\n"); + MozWalkTheStack(stderr); + } + nsCOMPtr<nsIRunnable> event = new RunNextTest(); + do_check_success(NS_DispatchToCurrentThread(event)); +} + +int gPendingTests = 0; + +void do_test_pending() { + NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?"); + if (kDebugRunNextTest) { + printf_stderr("do_test_pending()\n"); + MozWalkTheStack(stderr); + } + gPendingTests++; +} + +void do_test_finished() { + NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?"); + NS_ASSERTION(gPendingTests > 0, "Invalid pending test count!"); + gPendingTests--; +} + +void disable_idle_service() { + (void)fprintf(stderr, TEST_INFO_STR "Disabling Idle Service.\n"); + + nsCOMPtr<nsIUserIdleService> idle = + do_GetService("@mozilla.org/widget/useridleservice;1"); + idle->SetDisabled(true); +} + +TEST(IHistory, Test) +{ + RefPtr<WaitForConnectionClosed> spinClose = new WaitForConnectionClosed(); + + // Tinderboxes are constantly on idle. Since idle tasks can interact with + // tests, causing random failures, disable the idle service. + disable_idle_service(); + + do_test_pending(); + run_next_test(); + + // Spin the event loop until we've run out of tests to run. + mozilla::SpinEventLoopUntil("places:TEST(IHistory, Test)"_ns, + [&]() { return !gPendingTests; }); + + // And let any other events finish before we quit. + (void)NS_ProcessPendingEvents(nullptr); +} diff --git a/toolkit/components/places/tests/gtest/test_IHistory.cpp b/toolkit/components/places/tests/gtest/test_IHistory.cpp new file mode 100644 index 0000000000..1a54c2c401 --- /dev/null +++ b/toolkit/components/places/tests/gtest/test_IHistory.cpp @@ -0,0 +1,445 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +#include "places_test_harness.h" +#include "nsIPrefBranch.h" +#include "nsIPrefService.h" +#include "nsString.h" +#include "mozilla/Attributes.h" +#include "mozilla/SpinEventLoopUntil.h" +#include "mozilla/StaticPrefs_layout.h" +#include "nsNetUtil.h" + +#include "mock_Link.h" +using namespace mozilla; +using namespace mozilla::dom; + +/** + * This file tests the IHistory interface. + */ + +//////////////////////////////////////////////////////////////////////////////// +//// Helper Methods + +void expect_visit(Link::State aState) { + do_check_true(aState == Link::State::Visited); +} + +void expect_no_visit(Link::State aState) { + do_check_true(aState == Link::State::Unvisited); +} + +already_AddRefed<nsIURI> new_test_uri() { + // Create a unique spec. + static int32_t specNumber = 0; + nsCString spec = "http://mozilla.org/"_ns; + spec.AppendInt(specNumber++); + + // Create the URI for the spec. + nsCOMPtr<nsIURI> testURI; + nsresult rv = NS_NewURI(getter_AddRefs(testURI), spec); + do_check_success(rv); + return testURI.forget(); +} + +class VisitURIObserver final : public nsIObserver { + ~VisitURIObserver() = default; + + public: + NS_DECL_ISUPPORTS + + explicit VisitURIObserver(int aExpectedVisits = 1) + : mVisits(0), mExpectedVisits(aExpectedVisits) { + nsCOMPtr<nsIObserverService> observerService = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + do_check_true(observerService); + (void)observerService->AddObserver(this, "uri-visit-saved", false); + } + + void WaitForNotification() { + SpinEventLoopUntil("places:VisitURIObserver::WaitForNotification"_ns, + [&]() { return mVisits >= mExpectedVisits; }); + } + + NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) override { + mVisits++; + + if (mVisits == mExpectedVisits) { + nsCOMPtr<nsIObserverService> observerService = + do_GetService(NS_OBSERVERSERVICE_CONTRACTID); + (void)observerService->RemoveObserver(this, "uri-visit-saved"); + } + + return NS_OK; + } + + private: + int mVisits; + int mExpectedVisits; +}; +NS_IMPL_ISUPPORTS(VisitURIObserver, nsIObserver) + +//////////////////////////////////////////////////////////////////////////////// +//// Test Functions + +void test_set_places_enabled() { + // Ensure places is enabled for everyone. + nsresult rv; + nsCOMPtr<nsIPrefBranch> prefBranch = + do_GetService(NS_PREFSERVICE_CONTRACTID, &rv); + do_check_success(rv); + + rv = prefBranch->SetBoolPref("places.history.enabled", true); + do_check_success(rv); + + // Run the next test. + run_next_test(); +} + +void test_wait_checkpoint() { + // This "fake" test is here to wait for the initial WAL checkpoint we force + // after creating the database schema, since that may happen at any time, + // and cause concurrent readers to access an older checkpoint. + nsCOMPtr<mozIStorageConnection> db = do_get_db(); + nsCOMPtr<mozIStorageAsyncStatement> stmt; + db->CreateAsyncStatement("SELECT 1"_ns, getter_AddRefs(stmt)); + RefPtr<PlacesAsyncStatementSpinner> spinner = + new PlacesAsyncStatementSpinner(); + nsCOMPtr<mozIStoragePendingStatement> pending; + (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending)); + spinner->SpinUntilCompleted(); + + // Run the next test. + run_next_test(); +} + +// These variables are shared between part 1 and part 2 of the test. Part 2 +// sets the nsCOMPtr's to nullptr, freeing the reference. +namespace test_unvisited_does_not_notify { +nsCOMPtr<nsIURI> testURI; +RefPtr<mock_Link> testLink; +} // namespace test_unvisited_does_not_notify +void test_unvisited_does_not_notify_part1() { + using namespace test_unvisited_does_not_notify; + + // This test is done in two parts. The first part registers for a URI that + // should not be visited. We then run another test that will also do a + // lookup and will be notified. Since requests are answered in the order they + // are requested (at least as long as the same URI isn't asked for later), we + // will know that the Link was not notified. + + // First, we need a test URI. + testURI = new_test_uri(); + + // Create our test Link. + testLink = new mock_Link(expect_no_visit); + + // Now, register our Link to be notified. + nsCOMPtr<IHistory> history = do_get_IHistory(); + history->RegisterVisitedCallback(testURI, testLink); + + // Run the next test. + run_next_test(); +} + +void test_visited_notifies() { + // First, we add our test URI to history. + nsCOMPtr<nsIURI> testURI = new_test_uri(); + addURI(testURI); + + // Create our test Link. The callback function will release the reference we + // have on the Link. + RefPtr<Link> link = new mock_Link(expect_visit); + + // Now, register our Link to be notified. + nsCOMPtr<IHistory> history = do_get_IHistory(); + history->RegisterVisitedCallback(testURI, link); + + // Note: test will continue upon notification. +} + +void test_unvisited_does_not_notify_part2() { + using namespace test_unvisited_does_not_notify; + + SpinEventLoopUntil("places:test_unvisited_does_not_notify_part2"_ns, + [&]() { return testLink->GotNotified(); }); + + // We would have had a failure at this point had the content node been told it + // was visited. Therefore, now we change it so that it expects a visited + // notification, and unregisters itself after addURI. + testLink->AwaitNewNotification(expect_visit); + addURI(testURI); + + // Clear the stored variables now. + testURI = nullptr; + testLink = nullptr; +} + +void test_same_uri_notifies_both() { + // First, we add our test URI to history. + nsCOMPtr<nsIURI> testURI = new_test_uri(); + addURI(testURI); + + // Create our two test Links. The callback function will release the + // reference we have on the Links. Only the second Link should run the next + // test! + RefPtr<Link> link1 = new mock_Link(expect_visit, false); + RefPtr<Link> link2 = new mock_Link(expect_visit); + + // Now, register our Link to be notified. + nsCOMPtr<IHistory> history = do_get_IHistory(); + history->RegisterVisitedCallback(testURI, link1); + history->RegisterVisitedCallback(testURI, link2); + + // Note: test will continue upon notification. +} + +void test_unregistered_visited_does_not_notify() { + // This test must have a test that has a successful notification after it. + // The Link would have been notified by now if we were buggy and notified + // unregistered Links (due to request serialization). + + nsCOMPtr<nsIURI> testURI = new_test_uri(); + RefPtr<Link> link = new mock_Link(expect_no_visit, false); + nsCOMPtr<IHistory> history(do_get_IHistory()); + history->RegisterVisitedCallback(testURI, link); + + // Unregister the Link. + history->UnregisterVisitedCallback(testURI, link); + + // And finally add a visit for the URI. + addURI(testURI); + + // If history tries to notify us, we'll either crash because the Link will + // have been deleted (we are the only thing holding a reference to it), or our + // expect_no_visit call back will produce a failure. Either way, the test + // will be reported as a failure. + + // Run the next test. + run_next_test(); +} + +void test_new_visit_notifies_waiting_Link() { + // Create our test Link. The callback function will release the reference we + // have on the link. + // + // Note that this will query the database and we'll get an _unvisited_ + // notification, then (after we addURI) a _visited_ one. + RefPtr<mock_Link> link = new mock_Link(expect_no_visit); + + // Now, register our content node to be notified. + nsCOMPtr<nsIURI> testURI = new_test_uri(); + nsCOMPtr<IHistory> history = do_get_IHistory(); + history->RegisterVisitedCallback(testURI, link); + + SpinEventLoopUntil("places:test_new_visit_notifies_waiting_Link"_ns, + [&]() { return link->GotNotified(); }); + + link->AwaitNewNotification(expect_visit); + + // Add ourselves to history. + addURI(testURI); + + // Note: test will continue upon notification. +} + +void test_RegisterVisitedCallback_returns_before_notifying() { + // Add a URI so that it's already in history. + nsCOMPtr<nsIURI> testURI = new_test_uri(); + addURI(testURI); + + // Create our test Link. + RefPtr<Link> link = new mock_Link(expect_no_visit, false); + + // Now, register our content node to be notified. It should not be notified. + nsCOMPtr<IHistory> history = do_get_IHistory(); + history->RegisterVisitedCallback(testURI, link); + + // Remove ourselves as an observer. We would have failed if we had been + // notified. + history->UnregisterVisitedCallback(testURI, link); + + run_next_test(); +} + +void test_visituri_inserts() { + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + PlaceRecord place; + do_get_place(visitedURI, place); + + do_check_true(place.id > 0); + do_check_false(place.hidden); + do_check_false(place.typed); + do_check_eq(place.visitCount, 1); + + run_next_test(); +} + +void test_visituri_updates() { + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + RefPtr<VisitURIObserver> finisher; + + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + PlaceRecord place; + do_get_place(visitedURI, place); + + do_check_eq(place.visitCount, 2); + + run_next_test(); +} + +void test_visituri_preserves_shown_and_typed() { + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + // this simulates the uri visit happening in a frame. Normally frame + // transitions would be hidden unless it was previously loaded top-level + history->VisitURI(nullptr, visitedURI, lastURI, 0, 0); + + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(2); + finisher->WaitForNotification(); + + PlaceRecord place; + do_get_place(visitedURI, place); + do_check_false(place.hidden); + + run_next_test(); +} + +void test_visituri_creates_visit() { + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + PlaceRecord place; + VisitRecord visit; + do_get_place(visitedURI, place); + do_get_lastVisit(place.id, visit); + + do_check_true(visit.id > 0); + do_check_eq(visit.lastVisitId, 0); + do_check_eq(visit.transitionType, nsINavHistoryService::TRANSITION_LINK); + + run_next_test(); +} + +void test_visituri_transition_typed() { + nsCOMPtr<nsINavHistoryService> navHistory = do_get_NavHistory(); + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + + navHistory->MarkPageAsTyped(visitedURI); + history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL, + 0); + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + PlaceRecord place; + VisitRecord visit; + do_get_place(visitedURI, place); + do_get_lastVisit(place.id, visit); + + do_check_true(visit.transitionType == nsINavHistoryService::TRANSITION_TYPED); + + run_next_test(); +} + +void test_visituri_transition_embed() { + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsCOMPtr<nsIURI> lastURI = new_test_uri(); + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + + history->VisitURI(nullptr, visitedURI, lastURI, 0, 0); + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + PlaceRecord place; + VisitRecord visit; + do_get_place(visitedURI, place); + do_get_lastVisit(place.id, visit); + + do_check_eq(place.id, 0); + do_check_eq(visit.id, 0); + + run_next_test(); +} + +void test_new_visit_adds_place_guid() { + // First, add a visit and wait. This will also add a place. + nsCOMPtr<nsIURI> visitedURI = new_test_uri(); + nsCOMPtr<IHistory> history = do_get_IHistory(); + nsresult rv = history->VisitURI(nullptr, visitedURI, nullptr, + mozilla::IHistory::TOP_LEVEL, 0); + do_check_success(rv); + RefPtr<VisitURIObserver> finisher = new VisitURIObserver(); + finisher->WaitForNotification(); + + // Check that we have a guid for our visit. + PlaceRecord place; + do_get_place(visitedURI, place); + do_check_eq(place.visitCount, 1); + do_check_eq(place.guid.Length(), 12u); + + run_next_test(); +} + +//////////////////////////////////////////////////////////////////////////////// +//// Test Harness + +/** + * Note: for tests marked "Order Important!", please see the test for details. + */ +Test gTests[] = { + PTEST(test_set_places_enabled), // Must come first! + PTEST(test_wait_checkpoint), // Must come second! + PTEST(test_unvisited_does_not_notify_part1), // Order Important! + PTEST(test_visited_notifies), + PTEST(test_unvisited_does_not_notify_part2), // Order Important! + PTEST(test_same_uri_notifies_both), + PTEST(test_unregistered_visited_does_not_notify), // Order Important! + PTEST(test_new_visit_notifies_waiting_Link), + PTEST(test_RegisterVisitedCallback_returns_before_notifying), + PTEST(test_visituri_inserts), + PTEST(test_visituri_updates), + PTEST(test_visituri_preserves_shown_and_typed), + PTEST(test_visituri_creates_visit), + PTEST(test_visituri_transition_typed), + PTEST(test_visituri_transition_embed), + PTEST(test_new_visit_adds_place_guid), +}; + +#define TEST_NAME "IHistory" +#include "places_test_harness_tail.h" diff --git a/toolkit/components/places/tests/gtest/test_casing.cpp b/toolkit/components/places/tests/gtest/test_casing.cpp new file mode 100644 index 0000000000..079d64bbd0 --- /dev/null +++ b/toolkit/components/places/tests/gtest/test_casing.cpp @@ -0,0 +1,29 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ : + * 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/. */ + +#include "gtest/gtest.h" +#include "mozilla/intl/UnicodeProperties.h" + +// Verify the assertion in SQLFunctions.cpp / nextSearchCandidate that the +// only non-ASCII characters that lower-case to ASCII ones are: +// * U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE +// * U+212A KELVIN SIGN +TEST(MatchAutocompleteCasing, CaseAssumption) +{ + for (uint32_t c = 128; c < 0x110000; c++) { + if (c != 304 && c != 8490) { + ASSERT_GE(mozilla::intl::UnicodeProperties::ToLower(c), 128U); + } + } +} + +// Verify the assertion that all ASCII characters lower-case to ASCII. +TEST(MatchAutocompleteCasing, CaseAssumption2) +{ + for (uint32_t c = 0; c < 128; c++) { + ASSERT_LT(mozilla::intl::UnicodeProperties::ToLower(c), 128U); + } +} diff --git a/toolkit/components/places/tests/head_common.js b/toolkit/components/places/tests/head_common.js new file mode 100644 index 0000000000..9ae5dd4e0f --- /dev/null +++ b/toolkit/components/places/tests/head_common.js @@ -0,0 +1,928 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +const NS_APP_USER_PROFILE_50_DIR = "ProfD"; + +// Shortcuts to transitions type. +const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK; +const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED; +const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK; +const TRANSITION_EMBED = Ci.nsINavHistoryService.TRANSITION_EMBED; +const TRANSITION_FRAMED_LINK = Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK; +const TRANSITION_REDIRECT_PERMANENT = + Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT; +const TRANSITION_REDIRECT_TEMPORARY = + Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY; +const TRANSITION_DOWNLOAD = Ci.nsINavHistoryService.TRANSITION_DOWNLOAD; +const TRANSITION_RELOAD = Ci.nsINavHistoryService.TRANSITION_RELOAD; + +const TITLE_LENGTH_MAX = 4096; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { PlacesSyncUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesSyncUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + NetUtil: "resource://gre/modules/NetUtil.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "SMALLPNG_DATA_URI", function () { + return NetUtil.newURI( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==" + ); +}); +const SMALLPNG_DATA_LEN = 67; + +XPCOMUtils.defineLazyGetter(this, "SMALLSVG_DATA_URI", function () { + return NetUtil.newURI( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy5" + + "3My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBmaWxs" + + "PSIjNDI0ZTVhIj4NCiAgPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iN" + + "DQiIHN0cm9rZT0iIzQyNGU1YSIgc3Ryb2tlLXdpZHRoPSIxMSIgZmlsbD" + + "0ibm9uZSIvPg0KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjI0LjYiIHI9IjY" + + "uNCIvPg0KICA8cmVjdCB4PSI0NSIgeT0iMzkuOSIgd2lkdGg9IjEwLjEi" + + "IGhlaWdodD0iNDEuOCIvPg0KPC9zdmc%2BDQo%3D" + ); +}); + +XPCOMUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +var gTestDir = do_get_cwd(); + +// Initialize profile. +var gProfD = do_get_profile(true); + +Services.prefs.setBoolPref("browser.urlbar.usepreloadedtopurls.enabled", false); +registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.urlbar.usepreloadedtopurls.enabled") +); + +// Remove any old database. +clearDB(); + +/** + * Shortcut to create a nsIURI. + * + * @param aSpec + * URLString of the uri. + */ +function uri(aSpec) { + return NetUtil.newURI(aSpec); +} + +/** + * Gets the database connection. If the Places connection is invalid it will + * try to create a new connection. + * + * @param [optional] aForceNewConnection + * Forces creation of a new connection to the database. When a + * connection is asyncClosed it cannot anymore schedule async statements, + * though connectionReady will keep returning true (Bug 726990). + * + * @return The database connection or null if unable to get one. + */ +var gDBConn; +function DBConn(aForceNewConnection) { + if (!aForceNewConnection) { + let db = PlacesUtils.history.DBConnection; + if (db.connectionReady) { + return db; + } + } + + // If the Places database connection has been closed, create a new connection. + if (!gDBConn || aForceNewConnection) { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("places.sqlite"); + let dbConn = (gDBConn = Services.storage.openDatabase(file)); + + // Be sure to cleanly close this connection. + promiseTopicObserved("profile-before-change").then(() => + dbConn.asyncClose() + ); + } + + return gDBConn.connectionReady ? gDBConn : null; +} + +/** + * Reads data from the provided inputstream. + * + * @return an array of bytes. + */ +function readInputStreamData(aStream) { + let bistream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + try { + bistream.setInputStream(aStream); + let expectedData = []; + let avail; + while ((avail = bistream.available())) { + expectedData = expectedData.concat(bistream.readByteArray(avail)); + } + return expectedData; + } finally { + bistream.close(); + } +} + +/** + * Reads the data from the specified nsIFile. + * + * @param aFile + * The nsIFile to read from. + * @return an array of bytes. + */ +function readFileData(aFile) { + let inputStream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + // init the stream as RD_ONLY, -1 == default permissions. + inputStream.init(aFile, 0x01, -1, null); + + // Check the returned size versus the expected size. + let size = inputStream.available(); + let bytes = readInputStreamData(inputStream); + if (size != bytes.length) { + throw new Error("Didn't read expected number of bytes"); + } + return bytes; +} + +/** + * Reads the data from the named file, verifying the expected file length. + * + * @param aFileName + * This file should be located in the same folder as the test. + * @param aExpectedLength + * Expected length of the file. + * + * @return The array of bytes read from the file. + */ +function readFileOfLength(aFileName, aExpectedLength) { + let data = readFileData(do_get_file(aFileName)); + Assert.equal(data.length, aExpectedLength); + return data; +} + +/** + * Returns the base64-encoded version of the given string. This function is + * similar to window.btoa, but is available to xpcshell tests also. + * + * @param aString + * Each character in this string corresponds to a byte, and must be a + * code point in the range 0-255. + * + * @return The base64-encoded string. + */ +function base64EncodeString(aString) { + var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.setData(aString, aString.length); + var encoder = Cc["@mozilla.org/scriptablebase64encoder;1"].createInstance( + Ci.nsIScriptableBase64Encoder + ); + return encoder.encodeToString(stream, aString.length); +} + +/** + * Compares two arrays, and returns true if they are equal. + * + * @param aArray1 + * First array to compare. + * @param aArray2 + * Second array to compare. + */ +function compareArrays(aArray1, aArray2) { + if (aArray1.length != aArray2.length) { + print("compareArrays: array lengths differ\n"); + return false; + } + + for (let i = 0; i < aArray1.length; i++) { + if (aArray1[i] != aArray2[i]) { + print( + "compareArrays: arrays differ at index " + + i + + ": " + + "(" + + aArray1[i] + + ") != (" + + aArray2[i] + + ")\n" + ); + return false; + } + } + + return true; +} + +/** + * Deletes a previously created sqlite file from the profile folder. + */ +function clearDB() { + try { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("places.sqlite"); + if (file.exists()) { + file.remove(false); + } + } catch (ex) { + dump("Exception: " + ex); + } +} + +/** + * Dumps the rows of a table out to the console. + * + * @param aName + * The name of the table or view to output. + */ +function dump_table(aName, dbConn) { + if (!dbConn) { + dbConn = DBConn(); + } + let stmt = dbConn.createStatement("SELECT * FROM " + aName); + + print("\n*** Printing data from " + aName); + let count = 0; + while (stmt.executeStep()) { + let columns = stmt.numEntries; + + if (count == 0) { + // Print the column names. + for (let i = 0; i < columns; i++) { + dump(stmt.getColumnName(i) + "\t"); + } + dump("\n"); + } + + // Print the rows. + for (let i = 0; i < columns; i++) { + switch (stmt.getTypeOfIndex(i)) { + case Ci.mozIStorageValueArray.VALUE_TYPE_NULL: + dump("NULL\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER: + dump(stmt.getInt64(i) + "\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT: + dump(stmt.getDouble(i) + "\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT: + dump(stmt.getString(i) + "\t"); + break; + } + } + dump("\n"); + + count++; + } + print("*** There were a total of " + count + " rows of data.\n"); + + stmt.finalize(); +} + +/** + * Checks if an address is found in the database. + * @param aURI + * nsIURI or address to look for. + * @return place id of the page or 0 if not found + */ +function page_in_database(aURI) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url" + ); + stmt.params.url = url; + try { + if (!stmt.executeStep()) { + return 0; + } + return stmt.getInt64(0); + } finally { + stmt.finalize(); + } +} + +/** + * Checks how many visits exist for a specified page. + * @param aURI + * nsIURI or address to look for. + * @return number of visits found. + */ +function visits_in_database(aURI) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + `SELECT count(*) FROM moz_historyvisits v + JOIN moz_places h ON h.id = v.place_id + WHERE url_hash = hash(:url) AND url = :url` + ); + stmt.params.url = url; + try { + if (!stmt.executeStep()) { + return 0; + } + return stmt.getInt64(0); + } finally { + stmt.finalize(); + } +} + +/** + * Allows waiting for an observer notification once. + * + * @param aTopic + * Notification topic to observe. + * + * @return {Promise} + * @resolves The array [aSubject, aData] from the observed notification. + * @rejects Never. + */ +function promiseTopicObserved(aTopic) { + return new Promise(resolve => { + Services.obs.addObserver(function observe( + aObsSubject, + aObsTopic, + aObsData + ) { + Services.obs.removeObserver(observe, aObsTopic); + resolve([aObsSubject, aObsData]); + }, + aTopic); + }); +} + +/** + * Simulates a Places shutdown. + */ +var shutdownPlaces = function () { + info("shutdownPlaces: starting"); + let promise = new Promise(resolve => { + Services.obs.addObserver(resolve, "places-connection-closed"); + }); + let hs = PlacesUtils.history.QueryInterface(Ci.nsIObserver); + hs.observe(null, "profile-change-teardown", null); + info("shutdownPlaces: sent profile-change-teardown"); + hs.observe(null, "test-simulate-places-shutdown", null); + info("shutdownPlaces: sent test-simulate-places-shutdown"); + return promise.then(() => { + info("shutdownPlaces: complete"); + }); +}; + +const FILENAME_BOOKMARKS_HTML = "bookmarks.html"; +const FILENAME_BOOKMARKS_JSON = + "bookmarks-" + PlacesBackups.toISODateString(new Date()) + ".json"; + +/** + * Creates a bookmarks.html file in the profile folder from a given source file. + * + * @param aFilename + * Name of the file to copy to the profile folder. This file must + * exist in the directory that contains the test files. + * + * @return nsIFile object for the file. + */ +function create_bookmarks_html(aFilename) { + if (!aFilename) { + do_throw("you must pass a filename to create_bookmarks_html function"); + } + remove_bookmarks_html(); + let bookmarksHTMLFile = gTestDir.clone(); + bookmarksHTMLFile.append(aFilename); + Assert.ok(bookmarksHTMLFile.exists()); + bookmarksHTMLFile.copyTo(gProfD, FILENAME_BOOKMARKS_HTML); + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + Assert.ok(profileBookmarksHTMLFile.exists()); + return profileBookmarksHTMLFile; +} + +/** + * Remove bookmarks.html file from the profile folder. + */ +function remove_bookmarks_html() { + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + if (profileBookmarksHTMLFile.exists()) { + profileBookmarksHTMLFile.remove(false); + Assert.ok(!profileBookmarksHTMLFile.exists()); + } +} + +/** + * Check bookmarks.html file exists in the profile folder. + * + * @return nsIFile object for the file. + */ +function check_bookmarks_html() { + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + Assert.ok(profileBookmarksHTMLFile.exists()); + return profileBookmarksHTMLFile; +} + +/** + * Creates a JSON backup in the profile folder folder from a given source file. + * + * @param aFilename + * Name of the file to copy to the profile folder. This file must + * exist in the directory that contains the test files. + * + * @return nsIFile object for the file. + */ +function create_JSON_backup(aFilename) { + if (!aFilename) { + do_throw("you must pass a filename to create_JSON_backup function"); + } + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + if (!bookmarksBackupDir.exists()) { + bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8)); + Assert.ok(bookmarksBackupDir.exists()); + } + let profileBookmarksJSONFile = bookmarksBackupDir.clone(); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + if (profileBookmarksJSONFile.exists()) { + profileBookmarksJSONFile.remove(); + } + let bookmarksJSONFile = gTestDir.clone(); + bookmarksJSONFile.append(aFilename); + Assert.ok(bookmarksJSONFile.exists()); + bookmarksJSONFile.copyTo(bookmarksBackupDir, FILENAME_BOOKMARKS_JSON); + profileBookmarksJSONFile = bookmarksBackupDir.clone(); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + Assert.ok(profileBookmarksJSONFile.exists()); + return profileBookmarksJSONFile; +} + +/** + * Remove bookmarksbackup dir and all backups from the profile folder. + */ +function remove_all_JSON_backups() { + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + if (bookmarksBackupDir.exists()) { + bookmarksBackupDir.remove(true); + Assert.ok(!bookmarksBackupDir.exists()); + } +} + +/** + * Check a JSON backup file for today exists in the profile folder. + * + * @param aIsAutomaticBackup The boolean indicates whether it's an automatic + * backup. + * @return nsIFile object for the file. + */ +function check_JSON_backup(aIsAutomaticBackup) { + let profileBookmarksJSONFile; + if (aIsAutomaticBackup) { + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + let files = bookmarksBackupDir.directoryEntries; + while (files.hasMoreElements()) { + let entry = files.nextFile; + if (PlacesBackups.filenamesRegex.test(entry.leafName)) { + profileBookmarksJSONFile = entry; + break; + } + } + } else { + profileBookmarksJSONFile = gProfD.clone(); + profileBookmarksJSONFile.append("bookmarkbackups"); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + } + Assert.ok(profileBookmarksJSONFile.exists()); + return profileBookmarksJSONFile; +} + +/** + * Returns the hidden status of a url. + * + * @param aURI + * The URI or spec to get hidden for. + * @return @return true if the url is hidden, false otherwise. + */ +function isUrlHidden(aURI) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + "SELECT hidden FROM moz_places WHERE url_hash = hash(?1) AND url = ?1" + ); + stmt.bindByIndex(0, url); + if (!stmt.executeStep()) { + throw new Error("No result for hidden."); + } + let hidden = stmt.getInt32(0); + stmt.finalize(); + + return !!hidden; +} + +/** + * Compares two times in usecs, considering eventual platform timers skews. + * + * @param aTimeBefore + * The older time in usecs. + * @param aTimeAfter + * The newer time in usecs. + * @return true if times are ordered, false otherwise. + */ +function is_time_ordered(before, after) { + // Windows has an estimated 16ms timers precision, since Date.now() and + // PR_Now() use different code atm, the results can be unordered by this + // amount of time. See bug 558745 and bug 557406. + let isWindows = "@mozilla.org/windows-registry-key;1" in Cc; + // Just to be safe we consider 20ms. + let skew = isWindows ? 20000000 : 0; + return after - before > -skew; +} + +/** + * Shutdowns Places, invoking the callback when the connection has been closed. + * + * @param aCallback + * Function to be called when done. + */ +function waitForConnectionClosed(aCallback) { + promiseTopicObserved("places-connection-closed").then(aCallback); + shutdownPlaces(); +} + +/** + * Tests if a given guid is valid for use in Places or not. + * + * @param aGuid + * The guid to test. + * @param [optional] aStack + * The stack frame used to report the error. + */ +function do_check_valid_places_guid(aGuid) { + Assert.ok(/^[a-zA-Z0-9\-_]{12}$/.test(aGuid), "Should be a valid GUID"); +} + +/** + * Tests that a guid was set in moz_places for a given uri. + * + * @param aURI + * The uri to check. + * @param [optional] aGUID + * The expected guid in the database. + */ +async function check_guid_for_uri(aURI, aGUID) { + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: aURI, + }); + if (aGUID) { + do_check_valid_places_guid(aGUID); + Assert.equal(guid, aGUID, "Should have a guid in moz_places for the URI"); + } +} + +/** + * Tests that a guid was set in moz_places for a given bookmark. + * + * @param aId + * The bookmark id to check. + * @param [optional] aGUID + * The expected guid in the database. + */ +async function check_guid_for_bookmark(aId, aGUID) { + let guid = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "guid", { + id: aId, + }); + if (aGUID) { + do_check_valid_places_guid(aGUID); + Assert.equal(guid, aGUID, "Should have the correct GUID for the bookmark"); + } +} + +/** + * Compares 2 arrays returning whether they contains the same elements. + * + * @param a1 + * First array to compare. + * @param a2 + * Second array to compare. + * @param [optional] sorted + * Whether the comparison should take in count position of the elements. + * @return true if the arrays contain the same elements, false otherwise. + */ +function do_compare_arrays(a1, a2, sorted) { + if (a1.length != a2.length) { + return false; + } + + if (sorted) { + return a1.every((e, i) => e == a2[i]); + } + return ( + !a1.filter(e => !a2.includes(e)).length && + !a2.filter(e => !a1.includes(e)).length + ); +} + +/** + * Generic nsINavHistoryResultObserver that doesn't implement anything, but + * provides dummy methods to prevent errors about an object not having a certain + * method. + */ +function NavHistoryResultObserver() {} + +NavHistoryResultObserver.prototype = { + batching() {}, + containerStateChanged() {}, + invalidateContainer() {}, + nodeDateAddedChanged() {}, + nodeHistoryDetailsChanged() {}, + nodeIconChanged() {}, + nodeInserted() {}, + nodeKeywordChanged() {}, + nodeLastModifiedChanged() {}, + nodeMoved() {}, + nodeRemoved() {}, + nodeTagsChanged() {}, + nodeTitleChanged() {}, + nodeURIChanged() {}, + sortingChanged() {}, + QueryInterface: ChromeUtils.generateQI(["nsINavHistoryResultObserver"]), +}; + +function checkBookmarkObject(info) { + do_check_valid_places_guid(info.guid); + do_check_valid_places_guid(info.parentGuid); + Assert.ok(typeof info.index == "number", "index should be a number"); + Assert.ok( + info.dateAdded.constructor.name == "Date", + "dateAdded should be a Date" + ); + Assert.ok( + info.lastModified.constructor.name == "Date", + "lastModified should be a Date" + ); + Assert.ok( + info.lastModified >= info.dateAdded, + "lastModified should never be smaller than dateAdded" + ); + Assert.ok(typeof info.type == "number", "type should be a number"); +} + +/** + * Reads foreign_count value for a given url. + */ +async function foreign_count(url) { + if (url instanceof Ci.nsIURI) { + url = url.spec; + } + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT foreign_count FROM moz_places + WHERE url_hash = hash(:url) AND url = :url + `, + { url } + ); + return !rows.length ? 0 : rows[0].getResultByName("foreign_count"); +} + +function compareAscending(a, b) { + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; +} + +function sortBy(array, prop) { + return array.sort((a, b) => compareAscending(a[prop], b[prop])); +} + +/** + * Asynchronously set the favicon associated with a page. + * @param page + * The page's URL + * @param icon + * The URL of the favicon to be set. + * @param [optional] forceReload + * Whether to enforce reloading the icon. + */ +function setFaviconForPage(page, icon, forceReload = true) { + let pageURI = + page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href); + let iconURI = + icon instanceof Ci.nsIURI ? icon : NetUtil.newURI(new URL(icon).href); + return new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + iconURI, + forceReload, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); +} + +function getFaviconUrlForPage(page, width = 0) { + let pageURI = + page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href); + return new Promise((resolve, reject) => { + PlacesUtils.favicons.getFaviconURLForPage( + pageURI, + iconURI => { + if (iconURI) { + resolve(iconURI.spec); + } else { + reject("Unable to find an icon for " + pageURI.spec); + } + }, + width + ); + }); +} + +function getFaviconDataForPage(page, width = 0) { + let pageURI = + page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href); + return new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + pageURI, + (iconUri, len, data, mimeType) => { + resolve({ data, mimeType }); + }, + width + ); + }); +} + +/** + * Asynchronously compares contents from 2 favicon urls. + */ +async function compareFavicons(icon1, icon2, msg) { + icon1 = new URL(icon1 instanceof Ci.nsIURI ? icon1.spec : icon1); + icon2 = new URL(icon2 instanceof Ci.nsIURI ? icon2.spec : icon2); + + function getIconData(icon) { + return new Promise((resolve, reject) => { + NetUtil.asyncFetch( + { + uri: icon.href, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, + }, + function (inputStream, status) { + if (!Components.isSuccessCode(status)) { + reject(); + } + let size = inputStream.available(); + resolve(NetUtil.readInputStreamToString(inputStream, size)); + } + ); + }); + } + + let data1 = await getIconData(icon1); + Assert.ok(!!data1.length, "Should fetch icon data"); + let data2 = await getIconData(icon2); + Assert.ok(!!data2.length, "Should fetch icon data"); + Assert.deepEqual(data1, data2, msg); +} + +/** + * Get the internal "root" folder name for an item, specified by its itemGuid. + * If the itemGuid does not point to a root folder, null is returned. + * + * @param itemGuid + * the item guid. + * @return the internal-root name for the root folder, if itemGuid points + * to such folder, null otherwise. + */ +function mapItemGuidToInternalRootName(itemGuid) { + switch (itemGuid) { + case PlacesUtils.bookmarks.rootGuid: + return "placesRoot"; + case PlacesUtils.bookmarks.menuGuid: + return "bookmarksMenuFolder"; + case PlacesUtils.bookmarks.toolbarGuid: + return "toolbarFolder"; + case PlacesUtils.bookmarks.unfiledGuid: + return "unfiledBookmarksFolder"; + case PlacesUtils.bookmarks.mobileGuid: + return "mobileFolder"; + } + return null; +} + +const DB_FILENAME = "places.sqlite"; + +/** + * Sets the database to use for the given test. This should be the very first + * thing in the test, otherwise this database will not be used! + * + * @param {string|string[]} path + * A filename or path to a database. The database must exist. + * If this is a string, then this is assumed to be a filename in the + * directory where the test calling this is located. + * If this is an array, this is assumed to be a path relative to the + * directory that this file, head_common.js, is located. + * @param {string} destFileName + * The destination filename to copy the database to. + * @return {Promise} the final path to the database + */ +async function setupPlacesDatabase(path, destFileName = DB_FILENAME) { + let currentDir = do_get_cwd().path; + + if (typeof path == "string") { + path = [path]; + } else { + currentDir = PathUtils.parent(currentDir); + } + let src = PathUtils.join(currentDir, ...path); + Assert.ok(await IOUtils.exists(src), "Database file found"); + + // Ensure that our database doesn't already exist. + let dest = PathUtils.join(PathUtils.profileDir, destFileName); + Assert.ok( + !(await IOUtils.exists(dest)), + "Database file should not exist yet" + ); + + await IOUtils.copy(src, dest); + return dest; +} + +/** + * Gets the URLs of pages that have a particular annotation. + * + * @param {String} name The name of the annotation to search for. + * @return An array of URLs found. + */ +function getPagesWithAnnotation(name) { + return PlacesUtils.promiseDBConnection().then(async db => { + let rows = await db.execute( + ` + SELECT h.url FROM moz_anno_attributes n + JOIN moz_annos a ON n.id = a.anno_attribute_id + JOIN moz_places h ON h.id = a.place_id + WHERE n.name = :name + `, + { name } + ); + + return rows.map(row => row.getResultByName("url")); + }); +} + +/** + * Checks there are no orphan page annotations in the database, and no + * orphan anno attribute names. + */ +async function assertNoOrphanPageAnnotations() { + let db = await PlacesUtils.promiseDBConnection(); + + let rows = await db.execute(` + SELECT place_id FROM moz_annos + WHERE place_id NOT IN (SELECT id FROM moz_places) + `); + + Assert.equal(rows.length, 0, "Should not have any orphan page annotations"); + + rows = await db.execute(` + SELECT id FROM moz_anno_attributes + WHERE id NOT IN (SELECT anno_attribute_id FROM moz_annos) AND + id NOT IN (SELECT anno_attribute_id FROM moz_items_annos)`); +} diff --git a/toolkit/components/places/tests/history/head_history.js b/toolkit/components/places/tests/history/head_history.js new file mode 100644 index 0000000000..4adce13cce --- /dev/null +++ b/toolkit/components/places/tests/history/head_history.js @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} diff --git a/toolkit/components/places/tests/history/test_async_history_api.js b/toolkit/components/places/tests/history/test_async_history_api.js new file mode 100644 index 0000000000..d23e947c7e --- /dev/null +++ b/toolkit/components/places/tests/history/test_async_history_api.js @@ -0,0 +1,1343 @@ +/** + * This file tests the async history API exposed by mozIAsyncHistory. + */ + +// Globals + +XPCOMUtils.defineLazyServiceGetter( + this, + "asyncHistory", + "@mozilla.org/browser/history;1", + "mozIAsyncHistory" +); + +const TEST_DOMAIN = "http://mozilla.org/"; +const URI_VISIT_SAVED = "uri-visit-saved"; +const RECENT_EVENT_THRESHOLD = 15 * 60 * 1000000; + +// Helpers +/** + * Object that represents a mozIVisitInfo object. + * + * @param [optional] aTransitionType + * The transition type of the visit. Defaults to TRANSITION_LINK if not + * provided. + * @param [optional] aVisitTime + * The time of the visit. Defaults to now if not provided. + */ +function VisitInfo(aTransitionType, aVisitTime) { + this.transitionType = + aTransitionType === undefined ? TRANSITION_LINK : aTransitionType; + this.visitDate = aVisitTime || Date.now() * 1000; +} + +function promiseUpdatePlaces(aPlaces, aOptions = {}) { + return new Promise((resolve, reject) => { + asyncHistory.updatePlaces( + aPlaces, + Object.assign( + { + _errors: [], + _results: [], + handleError(aResultCode, aPlace) { + this._errors.push({ resultCode: aResultCode, info: aPlace }); + }, + handleResult(aPlace) { + this._results.push(aPlace); + }, + handleCompletion(resultCount) { + resolve({ + errors: this._errors, + results: this._results, + resultCount, + }); + }, + }, + aOptions + ) + ); + }); +} + +/** + * Listens for a title change notification, and calls aCallback when it gets it. + */ +class TitleChangedObserver { + /** + * Constructor. + * + * @param aURI + * The URI of the page we expect a notification for. + * @param aExpectedTitle + * The expected title of the URI we expect a notification for. + * @param aCallback + * The method to call when we have gotten the proper notification about + * the title changing. + */ + constructor(aURI, aExpectedTitle, aCallback) { + this.uri = aURI; + this.expectedTitle = aExpectedTitle; + this.callback = aCallback; + this.handlePlacesEvent = this.handlePlacesEvent.bind(this); + PlacesObservers.addListener(["page-title-changed"], this.handlePlacesEvent); + } + + async handlePlacesEvent(aEvents) { + info("'page-title-changed'!!!"); + Assert.equal(aEvents.length, 1, "Right number of title changed notified"); + Assert.equal(aEvents[0].type, "page-title-changed"); + if (this.uri.spec !== aEvents[0].url) { + return; + } + Assert.equal(aEvents[0].title, this.expectedTitle); + await check_guid_for_uri(this.uri, aEvents[0].pageGuid); + this.callback(); + + PlacesObservers.removeListener( + ["page-title-changed"], + this.handlePlacesEvent + ); + } +} + +/** + * Listens for a visit notification, and calls aCallback when it gets it. + */ +class VisitObserver { + constructor(aURI, aGUID, aCallback) { + this.uri = aURI; + this.guid = aGUID; + this.callback = aCallback; + this.handlePlacesEvent = this.handlePlacesEvent.bind(this); + PlacesObservers.addListener(["page-visited"], this.handlePlacesEvent); + } + + handlePlacesEvent(aEvents) { + info("'page-visited'!!!"); + Assert.equal(aEvents.length, 1, "Right number of visits notified"); + Assert.equal(aEvents[0].type, "page-visited"); + let { + url, + visitId, + visitTime, + referringVisitId, + transitionType, + pageGuid, + hidden, + visitCount, + typedCount, + lastKnownTitle, + } = aEvents[0]; + let args = [ + visitId, + visitTime, + referringVisitId, + transitionType, + pageGuid, + hidden, + visitCount, + typedCount, + lastKnownTitle, + ]; + info("'page-visited' (" + url + args.join(", ") + ")"); + if (this.uri.spec != url || this.guid != pageGuid) { + return; + } + this.callback(visitTime * 1000, transitionType, lastKnownTitle); + + PlacesObservers.removeListener(["page-visited"], this.handlePlacesEvent); + } +} + +/** + * Tests that a title was set properly in the database. + * + * @param aURI + * The uri to check. + * @param aTitle + * The expected title in the database. + */ +function do_check_title_for_uri(aURI, aTitle) { + let stmt = DBConn().createStatement( + `SELECT title + FROM moz_places + WHERE url_hash = hash(:url) AND url = :url` + ); + stmt.params.url = aURI.spec; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.title, aTitle); + stmt.finalize(); +} + +// Test Functions + +add_task(async function test_interface_exists() { + let history = Cc["@mozilla.org/browser/history;1"].getService(Ci.nsISupports); + Assert.ok(history instanceof Ci.mozIAsyncHistory); +}); + +add_task(async function test_invalid_uri_throws() { + // First, test passing in nothing. + let place = { + visits: [new VisitInfo()], + }; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + + // Now, test other bogus things. + const TEST_VALUES = [ + null, + undefined, + {}, + [], + TEST_DOMAIN + "test_invalid_id_throws", + ]; + for (let i = 0; i < TEST_VALUES.length; i++) { + place.uri = TEST_VALUES[i]; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + } +}); + +add_task(async function test_invalid_places_throws() { + // First, test passing in nothing. + try { + asyncHistory.updatePlaces(); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_XPC_NOT_ENOUGH_ARGS); + } + + // Now, test other bogus things. + const TEST_VALUES = [null, undefined, {}, [], ""]; + for (let i = 0; i < TEST_VALUES.length; i++) { + let value = TEST_VALUES[i]; + try { + await promiseUpdatePlaces(value); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + } +}); + +add_task(async function test_invalid_guid_throws() { + // First check invalid length guid. + let place = { + guid: "BAD_GUID", + uri: NetUtil.newURI(TEST_DOMAIN + "test_invalid_guid_throws"), + visits: [new VisitInfo()], + }; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + + // Now check invalid character guid. + place.guid = "__BADGUID+__"; + Assert.equal(place.guid.length, 12); + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } +}); + +add_task(async function test_no_visits_throws() { + const TEST_URI = NetUtil.newURI( + TEST_DOMAIN + "test_no_id_or_guid_no_visits_throws" + ); + const TEST_GUID = "_RANDOMGUID_"; + + let log_test_conditions = function (aPlace) { + let str = + "Testing place with " + + (aPlace.uri ? "uri" : "no uri") + + ", " + + (aPlace.guid ? "guid" : "no guid") + + ", " + + (aPlace.visits ? "visits array" : "no visits array"); + info(str); + }; + + // Loop through every possible case. Note that we don't actually care about + // the case where we have no uri, place id, or guid (covered by another test), + // but it is easier to just make sure it too throws than to exclude it. + let place = {}; + for (let uri = 1; uri >= 0; uri--) { + place.uri = uri ? TEST_URI : undefined; + + for (let guid = 1; guid >= 0; guid--) { + place.guid = guid ? TEST_GUID : undefined; + + for (let visits = 1; visits >= 0; visits--) { + place.visits = visits ? [] : undefined; + + log_test_conditions(place); + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + } + } + } +}); + +add_task(async function test_add_visit_no_date_throws() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit_no_date_throws"), + visits: [new VisitInfo()], + }; + delete place.visits[0].visitDate; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } +}); + +add_task(async function test_add_visit_no_transitionType_throws() { + let place = { + uri: NetUtil.newURI( + TEST_DOMAIN + "test_add_visit_no_transitionType_throws" + ), + visits: [new VisitInfo()], + }; + delete place.visits[0].transitionType; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } +}); + +add_task(async function test_add_visit_invalid_transitionType_throws() { + // First, test something that has a transition type lower than the first one. + let place = { + uri: NetUtil.newURI( + TEST_DOMAIN + "test_add_visit_invalid_transitionType_throws" + ), + visits: [new VisitInfo(TRANSITION_LINK - 1)], + }; + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } + + // Now, test something that has a transition type greater than the last one. + place.visits[0] = new VisitInfo(TRANSITION_RELOAD + 1); + try { + await promiseUpdatePlaces(place); + do_throw("Should have thrown!"); + } catch (e) { + Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG); + } +}); + +add_task(async function test_non_addable_uri_errors() { + // Array of protocols that nsINavHistoryService::canAddURI returns false for. + const URLS = [ + "about:config", + "imap://cyrus.andrew.cmu.edu/archive.imap", + "news://new.mozilla.org/mozilla.dev.apps.firefox", + "mailbox:Inbox", + "moz-anno:favicon:http://mozilla.org/made-up-favicon", + "view-source:http://mozilla.org", + "chrome://browser/content/browser.xhtml", + "resource://gre-resources/hiddenWindow.html", + "data:,Hello%2C%20World!", + "javascript:alert('hello wolrd!');", + "blob:foo", + "moz-extension://f49fb5b3-a1e7-cd41-85e1-d61a3950f5e4/index.html", + ]; + let places = []; + URLS.forEach(function (url) { + try { + let place = { + uri: NetUtil.newURI(url), + title: "test for " + url, + visits: [new VisitInfo()], + }; + places.push(place); + } catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + // NetUtil.newURI() can throw if e.g. our app knows about imap:// + // but the account is not set up and so the URL is invalid for us. + // Note this in the log but ignore as it's not the subject of this test. + info("Could not construct URI for '" + url + "'; ignoring"); + } + }); + + let placesResult = await promiseUpdatePlaces(places); + if (placesResult.results.length) { + do_throw("Unexpected success."); + } + for (let place of placesResult.errors) { + info("Checking '" + place.info.uri.spec + "'"); + Assert.equal(place.resultCode, Cr.NS_ERROR_INVALID_ARG); + Assert.equal(false, await PlacesUtils.history.hasVisits(place.info.uri)); + } + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_duplicate_guid_errors() { + // This test ensures that trying to add a visit, with a guid already found in + // another visit, fails. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"), + visits: [new VisitInfo()], + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri)); + + let badPlace = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"), + visits: [new VisitInfo()], + guid: placeInfo.guid, + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri)); + placesResult = await promiseUpdatePlaces(badPlace); + if (placesResult.results.length) { + do_throw("Unexpected success."); + } + let badPlaceInfo = placesResult.errors[0]; + Assert.equal(badPlaceInfo.resultCode, Cr.NS_ERROR_STORAGE_CONSTRAINT); + Assert.equal( + false, + await PlacesUtils.history.hasVisits(badPlaceInfo.info.uri) + ); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_invalid_referrerURI_ignored() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_invalid_referrerURI_ignored"), + visits: [new VisitInfo()], + }; + place.visits[0].referrerURI = NetUtil.newURI( + place.uri.spec + "_unvisistedURI" + ); + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + Assert.equal( + false, + await PlacesUtils.history.hasVisits(place.visits[0].referrerURI) + ); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri)); + + // Check to make sure we do not visit the invalid referrer. + Assert.equal( + false, + await PlacesUtils.history.hasVisits(place.visits[0].referrerURI) + ); + + // Check to make sure from_visit is zero in database. + let stmt = DBConn().createStatement( + `SELECT from_visit + FROM moz_historyvisits + WHERE id = :visit_id` + ); + stmt.params.visit_id = placeInfo.visits[0].visitId; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.from_visit, 0); + stmt.finalize(); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_nonnsIURI_referrerURI_ignored() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_nonnsIURI_referrerURI_ignored"), + visits: [new VisitInfo()], + }; + place.visits[0].referrerURI = place.uri.spec + "_nonnsIURI"; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri)); + + // Check to make sure from_visit is zero in database. + let stmt = DBConn().createStatement( + `SELECT from_visit + FROM moz_historyvisits + WHERE id = :visit_id` + ); + stmt.params.visit_id = placeInfo.visits[0].visitId; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.from_visit, 0); + stmt.finalize(); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_old_referrer_ignored() { + // This tests that a referrer for a visit which is not recent (specifically, + // older than 15 minutes as per RECENT_EVENT_THRESHOLD) is not saved by + // updatePlaces. + let oldTime = Date.now() * 1000 - (RECENT_EVENT_THRESHOLD + 1); + let referrerPlace = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_referrer"), + visits: [new VisitInfo(TRANSITION_LINK, oldTime)], + }; + + // First we must add our referrer to the history so that it is not ignored + // as being invalid. + Assert.equal(false, await PlacesUtils.history.hasVisits(referrerPlace.uri)); + let placesResult = await promiseUpdatePlaces(referrerPlace); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + + // Now that the referrer is added, we can add a page with a valid + // referrer to determine if the recency of the referrer is taken into + // account. + Assert.ok(await PlacesUtils.history.hasVisits(referrerPlace.uri)); + + let visitInfo = new VisitInfo(); + visitInfo.referrerURI = referrerPlace.uri; + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_page"), + visits: [visitInfo], + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(place.uri)); + + // Though the visit will not contain the referrer, we must examine the + // database to be sure. + Assert.equal(placeInfo.visits[0].referrerURI, null); + let stmt = DBConn().createStatement( + `SELECT COUNT(1) AS count + FROM moz_historyvisits + JOIN moz_places h ON h.id = place_id + WHERE url_hash = hash(:page_url) AND url = :page_url + AND from_visit = 0` + ); + stmt.params.page_url = place.uri.spec; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.count, 1); + stmt.finalize(); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_place_id_ignored() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_first"), + visits: [new VisitInfo()], + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(place.uri)); + + let placeId = placeInfo.placeId; + Assert.notEqual(placeId, 0); + + let badPlace = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_second"), + visits: [new VisitInfo()], + placeId, + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri)); + placesResult = await promiseUpdatePlaces(badPlace); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + placeInfo = placesResult.results[0]; + + Assert.notEqual(placeInfo.placeId, placeId); + Assert.ok(await PlacesUtils.history.hasVisits(badPlace.uri)); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_handleCompletion_called_when_complete() { + // We test a normal visit, and embeded visit, and a uri that would fail + // the canAddURI test to make sure that the notification happens after *all* + // of them have had a callback. + let places = [ + { + uri: NetUtil.newURI( + TEST_DOMAIN + "test_handleCompletion_called_when_complete" + ), + visits: [new VisitInfo(), new VisitInfo(TRANSITION_EMBED)], + }, + { + uri: NetUtil.newURI("data:,Hello%2C%20World!"), + visits: [new VisitInfo()], + }, + ]; + Assert.equal(false, await PlacesUtils.history.hasVisits(places[0].uri)); + Assert.equal(false, await PlacesUtils.history.hasVisits(places[1].uri)); + + const EXPECTED_COUNT_SUCCESS = 2; + const EXPECTED_COUNT_FAILURE = 1; + + let { results, errors } = await promiseUpdatePlaces(places); + + Assert.equal(results.length, EXPECTED_COUNT_SUCCESS); + Assert.equal(errors.length, EXPECTED_COUNT_FAILURE); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_add_visit() { + const VISIT_TIME = Date.now() * 1000; + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit"), + title: "test_add_visit title", + visits: [], + }; + for (let t in PlacesUtils.history.TRANSITIONS) { + if (t == "EMBED") { + continue; + } + let transitionType = PlacesUtils.history.TRANSITIONS[t]; + place.visits.push(new VisitInfo(transitionType, VISIT_TIME)); + } + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let callbackCount = 0; + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + for (let placeInfo of placesResult.results) { + Assert.ok(await PlacesUtils.history.hasVisits(place.uri)); + + // Check mozIPlaceInfo properties. + Assert.ok(place.uri.equals(placeInfo.uri)); + Assert.equal(placeInfo.frecency, -1); // We don't pass frecency here! + Assert.equal(placeInfo.title, place.title); + + // Check mozIVisitInfo properties. + let visits = placeInfo.visits; + Assert.equal(visits.length, 1); + let visit = visits[0]; + Assert.equal(visit.visitDate, VISIT_TIME); + Assert.ok( + Object.values(PlacesUtils.history.TRANSITIONS).includes( + visit.transitionType + ) + ); + Assert.ok(visit.referrerURI === null); + + // For TRANSITION_EMBED visits, many properties will always be zero or + // undefined. + if (visit.transitionType == TRANSITION_EMBED) { + // Check mozIPlaceInfo properties. + Assert.equal(placeInfo.placeId, 0, "//"); + Assert.equal(placeInfo.guid, null); + + // Check mozIVisitInfo properties. + Assert.equal(visit.visitId, 0); + } else { + // But they should be valid for non-embed visits. + // Check mozIPlaceInfo properties. + Assert.ok(placeInfo.placeId > 0); + do_check_valid_places_guid(placeInfo.guid); + + // Check mozIVisitInfo properties. + Assert.ok(visit.visitId > 0); + } + + // If we have had all of our callbacks, continue running tests. + if (++callbackCount == place.visits.length) { + await PlacesTestUtils.promiseAsyncUpdates(); + } + } +}); + +add_task(async function test_properties_saved() { + // Check each transition type to make sure it is saved properly. + let places = []; + for (let t in PlacesUtils.history.TRANSITIONS) { + if (t == "EMBED") { + continue; + } + let transitionType = PlacesUtils.history.TRANSITIONS[t]; + let place = { + uri: NetUtil.newURI( + TEST_DOMAIN + "test_properties_saved/" + transitionType + ), + title: "test_properties_saved test", + visits: [new VisitInfo(transitionType)], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + places.push(place); + } + + let callbackCount = 0; + let placesResult = await promiseUpdatePlaces(places); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + for (let placeInfo of placesResult.results) { + let uri = placeInfo.uri; + Assert.ok(await PlacesUtils.history.hasVisits(uri)); + let visit = placeInfo.visits[0]; + print( + "TEST-INFO | test_properties_saved | updatePlaces callback for " + + "transition type " + + visit.transitionType + ); + + // Note that TRANSITION_EMBED should not be in the database. + const EXPECTED_COUNT = visit.transitionType == TRANSITION_EMBED ? 0 : 1; + + // mozIVisitInfo::date + let stmt = DBConn().createStatement( + `SELECT COUNT(1) AS count + FROM moz_places h + JOIN moz_historyvisits v + ON h.id = v.place_id + WHERE h.url_hash = hash(:page_url) AND h.url = :page_url + AND v.visit_date = :visit_date` + ); + stmt.params.page_url = uri.spec; + stmt.params.visit_date = visit.visitDate; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.count, EXPECTED_COUNT); + stmt.finalize(); + + // mozIVisitInfo::transitionType + stmt = DBConn().createStatement( + `SELECT COUNT(1) AS count + FROM moz_places h + JOIN moz_historyvisits v + ON h.id = v.place_id + WHERE h.url_hash = hash(:page_url) AND h.url = :page_url + AND v.visit_type = :transition_type` + ); + stmt.params.page_url = uri.spec; + stmt.params.transition_type = visit.transitionType; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.count, EXPECTED_COUNT); + stmt.finalize(); + + // mozIPlaceInfo::title + stmt = DBConn().createStatement( + `SELECT COUNT(1) AS count + FROM moz_places h + WHERE h.url_hash = hash(:page_url) AND h.url = :page_url + AND h.title = :title` + ); + stmt.params.page_url = uri.spec; + stmt.params.title = placeInfo.title; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.count, EXPECTED_COUNT); + stmt.finalize(); + + // If we have had all of our callbacks, continue running tests. + if (++callbackCount == places.length) { + await PlacesTestUtils.promiseAsyncUpdates(); + } + } +}); + +add_task(async function test_guid_saved() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_saved"), + guid: "__TESTGUID__", + visits: [new VisitInfo()], + }; + do_check_valid_places_guid(place.guid); + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + let uri = placeInfo.uri; + Assert.ok(await PlacesUtils.history.hasVisits(uri)); + Assert.equal(placeInfo.guid, place.guid); + await check_guid_for_uri(uri, place.guid); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_referrer_saved() { + let places = [ + { + uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/referrer"), + visits: [new VisitInfo()], + }, + { + uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/test"), + visits: [new VisitInfo()], + }, + ]; + places[1].visits[0].referrerURI = places[0].uri; + Assert.equal(false, await PlacesUtils.history.hasVisits(places[0].uri)); + Assert.equal(false, await PlacesUtils.history.hasVisits(places[1].uri)); + + let resultCount = 0; + let placesResult = await promiseUpdatePlaces(places); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + for (let placeInfo of placesResult.results) { + let uri = placeInfo.uri; + Assert.ok(await PlacesUtils.history.hasVisits(uri)); + let visit = placeInfo.visits[0]; + + // We need to insert all of our visits before we can test conditions. + if (++resultCount == places.length) { + Assert.ok(places[0].uri.equals(visit.referrerURI)); + + let stmt = DBConn().createStatement( + `SELECT COUNT(1) AS count + FROM moz_historyvisits + JOIN moz_places h ON h.id = place_id + WHERE url_hash = hash(:page_url) AND url = :page_url + AND from_visit = ( + SELECT v.id + FROM moz_historyvisits v + JOIN moz_places h ON h.id = place_id + WHERE url_hash = hash(:referrer) AND url = :referrer + )` + ); + stmt.params.page_url = uri.spec; + stmt.params.referrer = visit.referrerURI.spec; + Assert.ok(stmt.executeStep()); + Assert.equal(stmt.row.count, 1); + stmt.finalize(); + + await PlacesTestUtils.promiseAsyncUpdates(); + } + } +}); + +add_task(async function test_guid_change_saved() { + // First, add a visit for it. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_change_saved"), + visits: [new VisitInfo()], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + // Then, change the guid with visits. + place.guid = "_GUIDCHANGE_"; + place.visits = [new VisitInfo()]; + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + await check_guid_for_uri(place.uri, place.guid); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_title_change_saved() { + // First, add a visit for it. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_saved"), + title: "original title", + visits: [new VisitInfo()], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + + // Now, make sure the empty string clears the title. + place.title = ""; + place.visits = [new VisitInfo()]; + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + do_check_title_for_uri(place.uri, null); + + // Then, change the title with visits. + place.title = "title change"; + place.visits = [new VisitInfo()]; + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + do_check_title_for_uri(place.uri, place.title); + + // Lastly, check that the title is cleared if we set it to null. + place.title = null; + place.visits = [new VisitInfo()]; + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + do_check_title_for_uri(place.uri, place.title); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_no_title_does_not_clear_title() { + const TITLE = "test title"; + // First, add a visit for it. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_no_title_does_not_clear_title"), + title: TITLE, + visits: [new VisitInfo()], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + // Now, make sure that not specifying a title does not clear it. + delete place.title; + place.visits = [new VisitInfo()]; + placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + do_check_title_for_uri(place.uri, TITLE); + + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_title_change_notifies() { + // There are three cases to test. The first case is to make sure we do not + // get notified if we do not specify a title. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_notifies"), + visits: [new VisitInfo()], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + new TitleChangedObserver(place.uri, "DO NOT WANT", function () { + do_throw("unexpected callback!"); + }); + + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + + // The second case to test is that we don't get the notification when we add + // it for the first time. The first case will fail before our callback if it + // is busted, so we can do this now. + place.uri = NetUtil.newURI(place.uri.spec + "/new-visit-with-title"); + place.title = "title 1"; + let expectedNotification = false; + let titleChangeObserver; + let titleChangePromise = new Promise((resolve, reject) => { + titleChangeObserver = new TitleChangedObserver( + place.uri, + place.title, + function () { + Assert.ok( + expectedNotification, + "Should not get notified for " + + place.uri.spec + + " with title " + + place.title + ); + if (expectedNotification) { + resolve(); + } + } + ); + }); + + let visitPromise = new Promise(resolve => { + function onVisits(events) { + Assert.equal(events.length, 1, "Should only get notified for one visit."); + Assert.equal(events[0].type, "page-visited"); + let { url } = events[0]; + Assert.equal( + url, + place.uri.spec, + "Should get notified for visiting the new URI." + ); + PlacesObservers.removeListener(["page-visited"], onVisits); + resolve(); + } + PlacesObservers.addListener(["page-visited"], onVisits); + }); + asyncHistory.updatePlaces(place); + await visitPromise; + + // The third case to test is to make sure we get a notification when + // we change an existing place. + expectedNotification = true; + titleChangeObserver.expectedTitle = place.title = "title 2"; + place.visits = [new VisitInfo()]; + asyncHistory.updatePlaces(place); + + await titleChangePromise; + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_visit_notifies() { + // There are two observers we need to see for each visit. One is an + // PlacesObservers and the other is the uri-visit-saved observer topic. + let place = { + guid: "abcdefghijkl", + uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_notifies"), + visits: [new VisitInfo()], + }; + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + + function promiseVisitObserver(aPlace) { + return new Promise((resolve, reject) => { + let callbackCount = 0; + let finisher = function () { + if (++callbackCount == 2) { + resolve(); + } + }; + new VisitObserver(place.uri, place.guid, function ( + aVisitDate, + aTransitionType + ) { + let visit = place.visits[0]; + Assert.equal(visit.visitDate, aVisitDate); + Assert.equal(visit.transitionType, aTransitionType); + + finisher(); + }); + let observer = function (aSubject, aTopic, aData) { + info("observe(" + aSubject + ", " + aTopic + ", " + aData + ")"); + Assert.ok(aSubject instanceof Ci.nsIURI); + Assert.ok(aSubject.equals(place.uri)); + + Services.obs.removeObserver(observer, URI_VISIT_SAVED); + finisher(); + }; + Services.obs.addObserver(observer, URI_VISIT_SAVED); + asyncHistory.updatePlaces(place); + }); + } + + await promiseVisitObserver(place); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +// test with empty mozIVisitInfoCallback object +add_task(async function test_callbacks_not_supplied() { + const URLS = [ + "imap://cyrus.andrew.cmu.edu/archive.imap", // bad URI + "http://mozilla.org/", // valid URI + ]; + let places = []; + URLS.forEach(function (url) { + try { + let place = { + uri: NetUtil.newURI(url), + title: "test for " + url, + visits: [new VisitInfo()], + }; + places.push(place); + } catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + // NetUtil.newURI() can throw if e.g. our app knows about imap:// + // but the account is not set up and so the URL is invalid for us. + // Note this in the log but ignore as it's not the subject of this test. + info("Could not construct URI for '" + url + "'; ignoring"); + } + }); + + asyncHistory.updatePlaces(places, {}); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +// Test that we don't wrongly overwrite typed and hidden when adding new visits. +add_task(async function test_typed_hidden_not_overwritten() { + await PlacesUtils.history.clear(); + let places = [ + { + uri: NetUtil.newURI("http://mozilla.org/"), + title: "test", + visits: [new VisitInfo(TRANSITION_TYPED), new VisitInfo(TRANSITION_LINK)], + }, + { + uri: NetUtil.newURI("http://mozilla.org/"), + title: "test", + visits: [new VisitInfo(TRANSITION_FRAMED_LINK)], + }, + ]; + await promiseUpdatePlaces(places); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT hidden, typed FROM moz_places WHERE url_hash = hash(:url) AND url = :url", + { url: "http://mozilla.org/" } + ); + Assert.equal( + rows[0].getResultByName("typed"), + 1, + "The page should be marked as typed" + ); + Assert.equal( + rows[0].getResultByName("hidden"), + 0, + "The page should be marked as not hidden" + ); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_omit_frecency_notifications() { + await PlacesUtils.history.clear(); + let places = [ + { + uri: NetUtil.newURI("http://mozilla.org/"), + title: "test", + visits: [new VisitInfo(TRANSITION_TYPED)], + }, + { + uri: NetUtil.newURI("http://example.org/"), + title: "test", + visits: [new VisitInfo(TRANSITION_TYPED)], + }, + ]; + + const promiseRankingChanged = + PlacesTestUtils.waitForNotification("pages-rank-changed"); + + await promiseUpdatePlaces(places); + await promiseRankingChanged; +}); + +add_task(async function test_ignore_errors() { + await PlacesUtils.history.clear(); + // This test ensures that trying to add a visit, with a guid already found in + // another visit, fails - but doesn't report if we told it not to. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"), + visits: [new VisitInfo()], + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri)); + + let badPlace = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"), + visits: [new VisitInfo()], + guid: placeInfo.guid, + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri)); + placesResult = await promiseUpdatePlaces(badPlace, { ignoreErrors: true }); + if (placesResult.results.length) { + do_throw("Unexpected success."); + } + Assert.equal( + placesResult.errors.length, + 0, + "Should have seen 0 errors because we disabled reporting." + ); + Assert.equal( + placesResult.results.length, + 0, + "Should have seen 0 results because there were none." + ); + Assert.equal( + placesResult.resultCount, + 0, + "Should know that we updated 0 items from the completion callback." + ); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_ignore_results() { + await PlacesUtils.history.clear(); + let place = { + uri: NetUtil.newURI("http://mozilla.org/"), + title: "test", + visits: [new VisitInfo()], + }; + let placesResult = await promiseUpdatePlaces(place, { ignoreResults: true }); + Assert.equal( + placesResult.results.length, + 0, + "Should have seen 0 results because we disabled reporting." + ); + Assert.equal( + placesResult.errors.length, + 0, + "Should have seen 0 errors because there were none." + ); + Assert.equal( + placesResult.resultCount, + 1, + "Should know that we updated 1 item from the completion callback." + ); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_ignore_results_and_errors() { + await PlacesUtils.history.clear(); + // This test ensures that trying to add a visit, with a guid already found in + // another visit, fails - but doesn't report if we told it not to. + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"), + visits: [new VisitInfo()], + }; + + Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri)); + let placesResult = await promiseUpdatePlaces(place); + if (placesResult.errors.length) { + do_throw("Unexpected error."); + } + let placeInfo = placesResult.results[0]; + Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri)); + + let badPlace = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"), + visits: [new VisitInfo()], + guid: placeInfo.guid, + }; + let allPlaces = [ + { + uri: NetUtil.newURI(TEST_DOMAIN + "test_other_successful_item"), + visits: [new VisitInfo()], + }, + badPlace, + ]; + + Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri)); + placesResult = await promiseUpdatePlaces(allPlaces, { + ignoreErrors: true, + ignoreResults: true, + }); + Assert.equal( + placesResult.errors.length, + 0, + "Should have seen 0 errors because we disabled reporting." + ); + Assert.equal( + placesResult.results.length, + 0, + "Should have seen 0 results because we disabled reporting." + ); + Assert.equal( + placesResult.resultCount, + 1, + "Should know that we updated 1 item from the completion callback." + ); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_title_on_initial_visit() { + let place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"), + title: "My title", + visits: [new VisitInfo()], + guid: "mnopqrstuvwx", + }; + let visitPromise = new Promise(resolve => { + new VisitObserver(place.uri, place.guid, function ( + aVisitDate, + aTransitionType, + aLastKnownTitle + ) { + Assert.equal(place.title, aLastKnownTitle); + + resolve(); + }); + }); + await promiseUpdatePlaces(place); + await visitPromise; + + // Now check an empty title doesn't get reported as null + place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"), + title: "", + visits: [new VisitInfo()], + guid: "fghijklmnopq", + }; + visitPromise = new Promise(resolve => { + new VisitObserver(place.uri, place.guid, function ( + aVisitDate, + aTransitionType, + aLastKnownTitle + ) { + Assert.equal(place.title, aLastKnownTitle); + + resolve(); + }); + }); + await promiseUpdatePlaces(place); + await visitPromise; + + // and that a missing title correctly gets reported as null. + place = { + uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"), + visits: [new VisitInfo()], + guid: "fghijklmnopq", + }; + visitPromise = new Promise(resolve => { + new VisitObserver(place.uri, place.guid, function ( + aVisitDate, + aTransitionType, + aLastKnownTitle + ) { + Assert.equal(null, aLastKnownTitle); + + resolve(); + }); + }); + await promiseUpdatePlaces(place); + await visitPromise; +}); diff --git a/toolkit/components/places/tests/history/test_bookmark_unhide.js b/toolkit/components/places/tests/history/test_bookmark_unhide.js new file mode 100644 index 0000000000..1295c6e8c5 --- /dev/null +++ b/toolkit/components/places/tests/history/test_bookmark_unhide.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that bookmarking an hidden page unhides it. + +"use strict"; + +add_task(async function test_hidden() { + const url = "http://moz.com/"; + await PlacesTestUtils.addVisits({ + url, + transition: TRANSITION_FRAMED_LINK, + }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { url }), + 1 + ); + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { url }), + 0 + ); +}); diff --git a/toolkit/components/places/tests/history/test_fetch.js b/toolkit/components/places/tests/history/test_fetch.js new file mode 100644 index 0000000000..899e459403 --- /dev/null +++ b/toolkit/components/places/tests/history/test_fetch.js @@ -0,0 +1,270 @@ +/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function test_fetch_existent() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Populate places and historyvisits. + let uriString = `http://mozilla.com/test_browserhistory/test_fetch`; + let uri = NetUtil.newURI(uriString); + let title = `Test Visit ${Math.random()}`; + let dates = []; + let visits = []; + let transitions = [ + PlacesUtils.history.TRANSITION_LINK, + PlacesUtils.history.TRANSITION_TYPED, + PlacesUtils.history.TRANSITION_BOOKMARK, + PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY, + PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT, + PlacesUtils.history.TRANSITION_DOWNLOAD, + PlacesUtils.history.TRANSITION_FRAMED_LINK, + PlacesUtils.history.TRANSITION_RELOAD, + ]; + let guid = ""; + for (let i = 0; i != transitions.length; i++) { + dates.push(new Date(Date.now() - i * 10000000)); + visits.push({ + uri, + title, + transition: transitions[i], + visitDate: dates[i], + }); + } + await PlacesTestUtils.addVisits(visits); + Assert.ok(await PlacesTestUtils.isPageInDB(uri)); + Assert.equal(await PlacesTestUtils.visitsInDB(uri), visits.length); + + // Store guid for further use in testing. + guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: uri, + }); + Assert.ok(guid, guid); + + // Initialize the objects to compare against. + let idealPageInfo = { + url: new URL(uriString), + guid, + title, + }; + let idealVisits = visits.map(v => { + return { + date: v.visitDate, + transition: v.transition, + }; + }); + + // We should check these 4 cases: + // 1, 2: visits not included, by URL and guid (same result expected). + // 3, 4: visits included, by URL and guid (same result expected). + for (let includeVisits of [true, false]) { + for (let guidOrURL of [uri, guid]) { + let pageInfo = await PlacesUtils.history.fetch(guidOrURL, { + includeVisits, + }); + if (includeVisits) { + idealPageInfo.visits = idealVisits; + } else { + // We need to explicitly delete this property since deepEqual looks at + // the list of properties as well (`visits in pageInfo` is true here). + delete idealPageInfo.visits; + } + + // Since idealPageInfo doesn't contain a frecency, check it and delete. + Assert.ok(typeof pageInfo.frecency === "number"); + delete pageInfo.frecency; + + // Visits should be from newer to older. + if (includeVisits) { + for (let i = 0; i !== pageInfo.visits.length - 1; i++) { + Assert.lessOrEqual( + pageInfo.visits[i + 1].date.getTime(), + pageInfo.visits[i].date.getTime() + ); + } + } + Assert.deepEqual(idealPageInfo, pageInfo); + } + } +}); + +add_task(async function test_fetch_page_meta_info() { + await PlacesUtils.history.clear(); + + let TEST_URI = NetUtil.newURI("http://mozilla.com/test_fetch_page_meta_info"); + await PlacesTestUtils.addVisits(TEST_URI); + Assert.ok(page_in_database(TEST_URI)); + + // Test fetching the null values + let includeMeta = true; + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta }); + Assert.strictEqual( + null, + pageInfo.previewImageURL, + "fetch should return a null previewImageURL" + ); + Assert.strictEqual( + "", + pageInfo.siteName, + "fetch should return a null siteName" + ); + Assert.equal( + "", + pageInfo.description, + "fetch should return a empty string description" + ); + + // Now set the pageMetaInfo for this page + let description = "Test description"; + let siteName = "Mozilla"; + let previewImageURL = "http://mozilla.com/test_preview_image.png"; + await PlacesUtils.history.update({ + url: TEST_URI, + description, + previewImageURL, + siteName, + }); + + includeMeta = true; + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta }); + Assert.equal( + previewImageURL, + pageInfo.previewImageURL.href, + "fetch should return a previewImageURL" + ); + Assert.equal(siteName, pageInfo.siteName, "fetch should return a siteName"); + Assert.equal( + description, + pageInfo.description, + "fetch should return a description" + ); + + includeMeta = false; + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta }); + Assert.ok( + !("description" in pageInfo), + "fetch should not return a description if includeMeta is false" + ); + Assert.ok( + !("siteName" in pageInfo), + "fetch should not return a siteName if includeMeta is false" + ); + Assert.ok( + !("previewImageURL" in pageInfo), + "fetch should not return a previewImageURL if includeMeta is false" + ); +}); + +add_task(async function test_fetch_annotations() { + await PlacesUtils.history.clear(); + + const TEST_URI = "http://mozilla.com/test_fetch_page_meta_info"; + await PlacesTestUtils.addVisits(TEST_URI); + Assert.ok(page_in_database(TEST_URI)); + + let includeAnnotations = true; + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations, + }); + Assert.equal( + pageInfo.annotations.size, + 0, + "fetch should return an empty annotation map" + ); + + await PlacesUtils.history.update({ + url: TEST_URI, + annotations: new Map([["test/annotation", "testContent"]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations }); + Assert.equal( + pageInfo.annotations.size, + 1, + "fetch should have only one annotation" + ); + + Assert.equal( + pageInfo.annotations.get("test/annotation"), + "testContent", + "fetch should return the expected annotation" + ); + + await PlacesUtils.history.update({ + url: TEST_URI, + annotations: new Map([["test/annotation2", 123]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations }); + Assert.equal( + pageInfo.annotations.size, + 2, + "fetch should have returned two annotations" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + "testContent", + "fetch should still have the first annotation" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + 123, + "fetch should have the second annotation" + ); + + includeAnnotations = false; + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations }); + Assert.ok( + !("annotations" in pageInfo), + "fetch should not return annotations if includeAnnotations is false" + ); +}); + +add_task(async function test_fetch_nonexistent() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let uri = NetUtil.newURI("http://doesntexist.in.db"); + let pageInfo = await PlacesUtils.history.fetch(uri); + Assert.equal(pageInfo, null); +}); + +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.fetch("3"), + /TypeError: URL constructor: 3 is not a valid / + ); + Assert.throws( + () => PlacesUtils.history.fetch({ not: "a valid string or guid" }), + /TypeError: Invalid url or guid/ + ); + Assert.throws( + () => PlacesUtils.history.fetch("http://valid.uri.com", "not an object"), + /TypeError: options should be/ + ); + Assert.throws( + () => PlacesUtils.history.fetch("http://valid.uri.com", null), + /TypeError: options should be/ + ); + Assert.throws( + () => + PlacesUtils.history.fetch("http://valid.uri.come", { + includeVisits: "not a boolean", + }), + /TypeError: includeVisits should be a/ + ); + Assert.throws( + () => + PlacesUtils.history.fetch("http://valid.uri.come", { + includeMeta: "not a boolean", + }), + /TypeError: includeMeta should be a/ + ); + Assert.throws( + () => + PlacesUtils.history.fetch("http://valid.url.com", { + includeAnnotations: "not a boolean", + }), + /TypeError: includeAnnotations should be a/ + ); +}); diff --git a/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js b/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js new file mode 100644 index 0000000000..0f487e8090 --- /dev/null +++ b/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.fetchAnnotatedPages(), + /TypeError: annotations should be an Array and not null/, + "Should throw an exception for a null parameter" + ); + Assert.throws( + () => PlacesUtils.history.fetchAnnotatedPages("3"), + /TypeError: annotations should be an Array and not null/, + "Should throw an exception for a parameter of the wrong type" + ); + Assert.throws( + () => PlacesUtils.history.fetchAnnotatedPages([3]), + /TypeError: all annotation values should be strings/, + "Should throw an exception for a non-string annotation name" + ); +}); + +add_task(async function test_fetchAnnotatedPages_no_matching() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + const TEST_URL = "http://example.com/1"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL), + "Should have inserted the page into the database." + ); + + let result = await PlacesUtils.history.fetchAnnotatedPages(["test/anno"]); + + Assert.equal(result.size, 0, "Should be no items returned."); +}); + +add_task(async function test_fetchAnnotatedPages_simple_match() { + await PlacesUtils.history.clear(); + + const TEST_URL = "http://example.com/1"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL), + "Should have inserted the page into the database." + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/anno", "testContent"]]), + }); + + let result = await PlacesUtils.history.fetchAnnotatedPages(["test/anno"]); + + Assert.equal( + result.size, + 1, + "Should have returned one match for the annotation" + ); + + Assert.deepEqual( + result.get("test/anno"), + [ + { + uri: new URL(TEST_URL), + content: "testContent", + }, + ], + "Should have returned the page and its content for the annotation" + ); +}); + +add_task(async function test_fetchAnnotatedPages_multiple_match() { + await PlacesUtils.history.clear(); + + const TEST_URL1 = "http://example.com/1"; + const TEST_URL2 = "http://example.com/2"; + const TEST_URL3 = "http://example.com/3"; + await PlacesTestUtils.addVisits([ + { uri: TEST_URL1 }, + { uri: TEST_URL2 }, + { uri: TEST_URL3 }, + ]); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL1), + "Should have inserted the first page into the database." + ); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL2), + "Should have inserted the second page into the database." + ); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL3), + "Should have inserted the third page into the database." + ); + + await PlacesUtils.history.update({ + url: TEST_URL1, + annotations: new Map([["test/anno", "testContent1"]]), + }); + + await PlacesUtils.history.update({ + url: TEST_URL2, + annotations: new Map([ + ["test/anno", "testContent2"], + ["test/anno2", 1234], + ]), + }); + + let result = await PlacesUtils.history.fetchAnnotatedPages([ + "test/anno", + "test/anno2", + ]); + + Assert.equal( + result.size, + 2, + "Should have returned matches for both annotations" + ); + + Assert.deepEqual( + result.get("test/anno"), + [ + { + uri: new URL(TEST_URL1), + content: "testContent1", + }, + { + uri: new URL(TEST_URL2), + content: "testContent2", + }, + ], + "Should have returned two pages and their content for the first annotation" + ); + + Assert.deepEqual( + result.get("test/anno2"), + [ + { + uri: new URL(TEST_URL2), + content: 1234, + }, + ], + "Should have returned one page for the second annotation" + ); +}); diff --git a/toolkit/components/places/tests/history/test_fetchMany.js b/toolkit/components/places/tests/history/test_fetchMany.js new file mode 100644 index 0000000000..53c3f6847e --- /dev/null +++ b/toolkit/components/places/tests/history/test_fetchMany.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_fetchMany() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let pages = [ + { + url: "https://mozilla.org/test1/", + title: "test 1", + }, + { + url: "https://mozilla.org/test2/", + title: "test 2", + }, + { + url: "https://mozilla.org/test3/", + title: "test 3", + }, + ]; + await PlacesTestUtils.addVisits(pages); + + // Add missing page info from the database. + for (let page of pages) { + page.guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: page.url, + }); + page.frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: page.url } + ); + } + + info("Fetch by url"); + let fetched = await PlacesUtils.history.fetchMany(pages.map(p => p.url)); + Assert.equal(fetched.size, 3, "Map should contain same number of entries"); + for (let page of pages) { + Assert.deepEqual(page, fetched.get(page.url)); + } + info("Fetch by GUID"); + fetched = await PlacesUtils.history.fetchMany(pages.map(p => p.guid)); + Assert.equal(fetched.size, 3, "Map should contain same number of entries"); + for (let page of pages) { + Assert.deepEqual(page, fetched.get(page.guid)); + } + info("Fetch mixed"); + let keys = pages.map((p, i) => (i % 2 == 0 ? p.guid : p.url)); + fetched = await PlacesUtils.history.fetchMany(keys); + Assert.equal(fetched.size, 3, "Map should contain same number of entries"); + for (let key of keys) { + let page = pages.find(p => p.guid == key || p.url == key); + Assert.deepEqual(page, fetched.get(key)); + Assert.ok(URL.isInstance(fetched.get(key).url)); + } +}); + +add_task(async function test_fetch_empty() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let fetched = await PlacesUtils.history.fetchMany([]); + Assert.equal(fetched.size, 0, "Map should contain no entries"); +}); + +add_task(async function test_fetch_nonexistent() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let uri = NetUtil.newURI("http://doesntexist.in.db"); + let fetched = await PlacesUtils.history.fetchMany([uri]); + Assert.equal(fetched.size, 0, "Map should contain no entries"); +}); + +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.fetchMany("3"), + /TypeError: Input is not an array/ + ); + Assert.throws( + () => PlacesUtils.history.fetchMany([{ not: "a valid string or guid" }]), + /TypeError: Invalid url or guid/ + ); + Assert.throws( + () => + PlacesUtils.history.fetchMany(["http://valid.uri.com", "not an object"]), + /TypeError: URL constructor/ + ); + Assert.throws( + () => PlacesUtils.history.fetchMany(["http://valid.uri.com", null]), + /TypeError: Invalid url or guid/ + ); +}); diff --git a/toolkit/components/places/tests/history/test_hasVisits.js b/toolkit/components/places/tests/history/test_hasVisits.js new file mode 100644 index 0000000000..36fc9fd7be --- /dev/null +++ b/toolkit/components/places/tests/history/test_hasVisits.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests for `History.hasVisits` as implemented in History.jsm + +"use strict"; + +add_task(async function test_has_visits_error_cases() { + Assert.throws( + () => PlacesUtils.history.hasVisits(), + /TypeError: Invalid url or guid: undefined/, + "passing a null into History.hasVisits should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.hasVisits(1), + /TypeError: Invalid url or guid: 1/, + "passing an invalid url into History.hasVisits should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.hasVisits({}), + /TypeError: Invalid url or guid: \[object Object\]/, + `passing an invalid (not of type URI or nsIURI) object to History.hasVisits + should throw a TypeError` + ); +}); + +add_task(async function test_history_has_visits() { + const TEST_URL = "http://mozilla.com/"; + await PlacesUtils.history.clear(); + Assert.equal( + await PlacesUtils.history.hasVisits(TEST_URL), + false, + "Test Url should not be in history." + ); + Assert.equal( + await PlacesUtils.history.hasVisits(Services.io.newURI(TEST_URL)), + false, + "Test Url should not be in history." + ); + await PlacesTestUtils.addVisits(TEST_URL); + Assert.equal( + await PlacesUtils.history.hasVisits(TEST_URL), + true, + "Test Url should be in history." + ); + Assert.equal( + await PlacesUtils.history.hasVisits(Services.io.newURI(TEST_URL)), + true, + "Test Url should be in history." + ); + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: TEST_URL, + }); + Assert.equal( + await PlacesUtils.history.hasVisits(guid), + true, + "Test Url should be in history." + ); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/history/test_insert.js b/toolkit/components/places/tests/history/test_insert.js new file mode 100644 index 0000000000..a3a820ade9 --- /dev/null +++ b/toolkit/components/places/tests/history/test_insert.js @@ -0,0 +1,196 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests for `History.insert` as implemented in History.jsm + +"use strict"; + +add_task(async function test_insert_error_cases() { + const TEST_URL = "http://mozilla.com"; + + Assert.throws( + () => PlacesUtils.history.insert(), + /Error: PageInfo: Input should be /, + "passing a null into History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert(1), + /Error: PageInfo: Input should be/, + "passing a non object into History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert({}), + /Error: PageInfo: The following properties were expected/, + "passing an object without a url to History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert({ url: 123 }), + /Error: PageInfo: Invalid value for property/, + "passing an object with an invalid url to History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert({ url: TEST_URL }), + /Error: PageInfo: The following properties were expected/, + "passing an object without a visits property to History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert({ url: TEST_URL, visits: 1 }), + /Error: PageInfo: Invalid value for property/, + "passing an object with a non-array visits property to History.insert should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.insert({ url: TEST_URL, visits: [] }), + /Error: PageInfo: Invalid value for property/, + "passing an object with an empty array as the visits property to History.insert should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK, + date: "a", + }, + ], + }), + /PageInfo: Invalid value for property/, + "passing a visit object with an invalid date to History.insert should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK, + }, + { + transition: TRANSITION_LINK, + date: "a", + }, + ], + }), + /PageInfo: Invalid value for property/, + "passing a second visit object with an invalid date to History.insert should throw an Error" + ); + let futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 1000); + Assert.throws( + () => + PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK, + date: futureDate, + }, + ], + }), + /PageInfo: Invalid value for property/, + "passing a visit object with a future date to History.insert should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.insert({ + url: TEST_URL, + visits: [{ transition: "a" }], + }), + /PageInfo: Invalid value for property/, + "passing a visit object with an invalid transition to History.insert should throw an Error" + ); +}); + +add_task(async function test_history_insert() { + const TEST_URL = "http://mozilla.com/"; + + let inserter = async function (name, filter, referrer, date, transition) { + info(name); + info( + `filter: ${filter}, referrer: ${referrer}, date: ${date}, transition: ${transition}` + ); + + let uri = NetUtil.newURI(TEST_URL + Math.random()); + let title = "Visit " + Math.random(); + + let pageInfo = { + title, + visits: [{ transition, referrer, date }], + }; + + pageInfo.url = await filter(uri); + + let result = await PlacesUtils.history.insert(pageInfo); + + Assert.ok( + PlacesUtils.isValidGuid(result.guid), + "guid for pageInfo object is valid" + ); + Assert.equal( + uri.spec, + result.url.href, + "url is correct for pageInfo object" + ); + Assert.equal(title, result.title, "title is correct for pageInfo object"); + Assert.equal( + TRANSITION_LINK, + result.visits[0].transition, + "transition is correct for pageInfo object" + ); + if (referrer) { + Assert.equal( + referrer, + result.visits[0].referrer.href, + "url of referrer for visit is correct" + ); + } else { + Assert.equal( + null, + result.visits[0].referrer, + "url of referrer for visit is correct" + ); + } + if (date) { + Assert.equal( + Number(date), + Number(result.visits[0].date), + "date of visit is correct" + ); + } + + Assert.ok(await PlacesTestUtils.isPageInDB(uri), "Page was added"); + Assert.ok(await PlacesTestUtils.visitsInDB(uri), "Visit was added"); + }; + + try { + for (let referrer of [TEST_URL, null]) { + for (let date of [new Date(), null]) { + for (let transition of [TRANSITION_LINK, null]) { + await inserter( + "Testing History.insert() with an nsIURI", + x => x, + referrer, + date, + transition + ); + await inserter( + "Testing History.insert() with a string url", + x => x.spec, + referrer, + date, + transition + ); + await inserter( + "Testing History.insert() with a URL object", + x => URL.fromURI(x), + referrer, + date, + transition + ); + } + } + } + } finally { + await PlacesUtils.history.clear(); + } +}); diff --git a/toolkit/components/places/tests/history/test_insertMany.js b/toolkit/components/places/tests/history/test_insertMany.js new file mode 100644 index 0000000000..d261d3eaaa --- /dev/null +++ b/toolkit/components/places/tests/history/test_insertMany.js @@ -0,0 +1,247 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests for `History.insertMany` as implemented in History.jsm + +"use strict"; + +add_task(async function test_error_cases() { + let validPageInfo = { + url: "http://mozilla.com", + visits: [{ transition: TRANSITION_LINK }], + }; + + Assert.throws( + () => PlacesUtils.history.insertMany(), + /TypeError: pageInfos must be an array/, + "passing a null into History.insertMany should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insertMany([]), + /TypeError: pageInfos may not be an empty array/, + "passing an empty array into History.insertMany should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insertMany([validPageInfo, {}]), + /Error: PageInfo: The following properties were expected/, + "passing a second invalid PageInfo object to History.insertMany should throw an Error" + ); +}); + +add_task(async function test_insertMany() { + const BAD_URLS = ["about:config", "chrome://browser/content/browser.xhtml"]; + const GOOD_URLS = [1, 2, 3].map(x => { + return `http://mozilla.com/${x}`; + }); + + let makePageInfos = async function (urls, filter = x => x) { + let pageInfos = []; + for (let url of urls) { + let uri = NetUtil.newURI(url); + + let pageInfo = { + title: `Visit to ${url}`, + visits: [{ transition: TRANSITION_LINK }], + }; + + pageInfo.url = await filter(uri); + pageInfos.push(pageInfo); + } + return pageInfos; + }; + + let inserter = async function (name, filter, useCallbacks) { + info(name); + info(`filter: ${filter}`); + info(`useCallbacks: ${useCallbacks}`); + await PlacesUtils.history.clear(); + + let result; + let allUrls = GOOD_URLS.concat(BAD_URLS); + let pageInfos = await makePageInfos(allUrls, filter); + + if (useCallbacks) { + let onResultUrls = []; + let onErrorUrls = []; + result = await PlacesUtils.history.insertMany( + pageInfos, + pageInfo => { + let url = pageInfo.url.href; + Assert.ok( + GOOD_URLS.includes(url), + "onResult callback called for correct url" + ); + onResultUrls.push(url); + Assert.equal( + `Visit to ${url}`, + pageInfo.title, + "onResult callback provides the correct title" + ); + Assert.ok( + PlacesUtils.isValidGuid(pageInfo.guid), + "onResult callback provides a valid guid" + ); + }, + pageInfo => { + let url = pageInfo.url.href; + Assert.ok( + BAD_URLS.includes(url), + "onError callback called for correct uri" + ); + onErrorUrls.push(url); + Assert.equal( + undefined, + pageInfo.title, + "onError callback provides the correct title" + ); + Assert.equal( + undefined, + pageInfo.guid, + "onError callback provides the expected guid" + ); + } + ); + Assert.equal( + GOOD_URLS.sort().toString(), + onResultUrls.sort().toString(), + "onResult callback was called for each good url" + ); + Assert.equal( + BAD_URLS.sort().toString(), + onErrorUrls.sort().toString(), + "onError callback was called for each bad url" + ); + } else { + const promiseRankingChanged = + PlacesTestUtils.waitForNotification("pages-rank-changed"); + result = await PlacesUtils.history.insertMany(pageInfos); + await promiseRankingChanged; + } + + Assert.equal(undefined, result, "insertMany returned undefined"); + + for (let url of allUrls) { + let expected = GOOD_URLS.includes(url); + Assert.equal( + expected, + await PlacesTestUtils.isPageInDB(url), + `isPageInDB for ${url} is ${expected}` + ); + Assert.equal( + expected, + await PlacesTestUtils.visitsInDB(url), + `visitsInDB for ${url} is ${expected}` + ); + } + }; + + try { + for (let useCallbacks of [false, true]) { + await inserter( + "Testing History.insertMany() with an nsIURI", + x => x, + useCallbacks + ); + await inserter( + "Testing History.insertMany() with a string url", + x => x.spec, + useCallbacks + ); + await inserter( + "Testing History.insertMany() with a URL object", + x => URL.fromURI(x), + useCallbacks + ); + } + // Test rejection when no items added + let pageInfos = await makePageInfos(BAD_URLS); + PlacesUtils.history.insertMany(pageInfos).then( + () => { + Assert.ok( + false, + "History.insertMany rejected promise with all bad URLs" + ); + }, + error => { + Assert.equal( + "No items were added to history.", + error.message, + "History.insertMany rejected promise with all bad URLs" + ); + } + ); + } finally { + await PlacesUtils.history.clear(); + } +}); + +add_task(async function test_transitions() { + const places = Object.keys(PlacesUtils.history.TRANSITIONS).map( + transition => { + return { + url: `http://places.test/${transition}`, + visits: [{ transition: PlacesUtils.history.TRANSITIONS[transition] }], + }; + } + ); + // Should not reject. + await PlacesUtils.history.insertMany(places); + // Check callbacks. + let count = 0; + await PlacesUtils.history.insertMany(places, pageInfo => { + ++count; + }); + Assert.equal(count, Object.keys(PlacesUtils.history.TRANSITIONS).length); +}); + +add_task(async function test_guid() { + const guidA = "aaaaaaaaaaaa"; + const guidB = "bbbbbbbbbbbb"; + const guidC = "cccccccccccc"; + + await PlacesUtils.history.insertMany([ + { + title: "foo", + url: "http://example.com/foo", + guid: guidA, + visits: [{ transition: TRANSITION_LINK, date: new Date() }], + }, + ]); + + Assert.ok( + await PlacesUtils.history.fetch(guidA), + "Record is inserted with correct GUID" + ); + + let expectedGuids = new Set([guidB, guidC]); + await PlacesUtils.history.insertMany( + [ + { + title: "bar", + url: "http://example.com/bar", + guid: guidB, + visits: [{ transition: TRANSITION_LINK, date: new Date() }], + }, + { + title: "baz", + url: "http://example.com/baz", + guid: guidC, + visits: [{ transition: TRANSITION_LINK, date: new Date() }], + }, + ], + pageInfo => { + Assert.ok(expectedGuids.has(pageInfo.guid)); + expectedGuids.delete(pageInfo.guid); + } + ); + Assert.equal(expectedGuids.size, 0); + + Assert.ok( + await PlacesUtils.history.fetch(guidB), + "Record B is fetchable after insertMany" + ); + Assert.ok( + await PlacesUtils.history.fetch(guidC), + "Record C is fetchable after insertMany" + ); +}); diff --git a/toolkit/components/places/tests/history/test_insert_null_title.js b/toolkit/components/places/tests/history/test_insert_null_title.js new file mode 100644 index 0000000000..8cdcddd1e8 --- /dev/null +++ b/toolkit/components/places/tests/history/test_insert_null_title.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that passing a null title to history insert or update doesn't overwrite +// an existing title, while an empty string does. + +"use strict"; + +async function fetchTitle(url) { + let entry; + await TestUtils.waitForCondition(async () => { + entry = await PlacesUtils.history.fetch(url); + return !!entry; + }, "fetch title for entry"); + return entry.title; +} + +add_task(async function () { + const url = "http://mozilla.com"; + let title = "Mozilla"; + + info("Insert a visit with a title"); + let result = await PlacesUtils.history.insert({ + url, + title, + visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }], + }); + Assert.equal(title, result.title, "title should be stored"); + Assert.equal(title, await fetchTitle(url), "title should be stored"); + + // This is shared by the next tests. + let promiseTitleChange = PlacesTestUtils.waitForNotification( + "page-title-changed", + () => (notified = true) + ); + + info("Insert a visit with a null title, should not clear the previous title"); + let notified = false; + result = await PlacesUtils.history.insert({ + url, + title: null, + visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }], + }); + Assert.equal(title, result.title, "title should be unchanged"); + Assert.equal(title, await fetchTitle(url), "title should be unchanged"); + await Promise.race([ + promiseTitleChange, + new Promise(r => do_timeout(1000, r)), + ]); + Assert.ok(!notified, "A title change should not be notified"); + + info( + "Insert a visit without specifying a title, should not clear the previous title" + ); + notified = false; + result = await PlacesUtils.history.insert({ + url, + visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }], + }); + Assert.equal(title, result.title, "title should be unchanged"); + Assert.equal(title, await fetchTitle(url), "title should be unchanged"); + await Promise.race([ + promiseTitleChange, + new Promise(r => do_timeout(1000, r)), + ]); + Assert.ok(!notified, "A title change should not be notified"); + + info("Insert a visit with an empty title, should clear the previous title"); + result = await PlacesUtils.history.insert({ + url, + title: "", + visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }], + }); + info("Waiting for the title change notification"); + await promiseTitleChange; + Assert.equal("", result.title, "title should be empty"); + Assert.equal("", await fetchTitle(url), "title should be empty"); +}); diff --git a/toolkit/components/places/tests/history/test_remove.js b/toolkit/components/places/tests/history/test_remove.js new file mode 100644 index 0000000000..8c5e941fd0 --- /dev/null +++ b/toolkit/components/places/tests/history/test_remove.js @@ -0,0 +1,354 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +// Tests for `History.remove`, as implemented in History.jsm + +"use strict"; + +// Test removing a single page +add_task(async function test_remove_single() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let WITNESS_URI = NetUtil.newURI( + "http://mozilla.com/test_browserhistory/test_remove/" + Math.random() + ); + await PlacesTestUtils.addVisits(WITNESS_URI); + Assert.ok(page_in_database(WITNESS_URI)); + + let remover = async function (name, filter, options) { + info(name); + info(JSON.stringify(options)); + info("Setting up visit"); + + let uri = NetUtil.newURI( + "http://mozilla.com/test_browserhistory/test_remove/" + Math.random() + ); + let title = "Visit " + Math.random(); + await PlacesTestUtils.addVisits({ uri, title }); + Assert.ok(visits_in_database(uri), "History entry created"); + + let removeArg = await filter(uri); + + if (options.addBookmark) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: "test bookmark", + }); + } + + let shouldRemove = !options.addBookmark; + let placesEventListener; + let promiseObserved = new Promise((resolve, reject) => { + placesEventListener = events => { + for (const event of events) { + switch (event.type) { + case "page-title-changed": { + reject( + "Unexpected page-title-changed event happens on " + event.url + ); + break; + } + case "history-cleared": { + reject("Unexpected history-cleared event happens"); + break; + } + case "pages-rank-changed": { + try { + Assert.ok(!shouldRemove, "Observing pages-rank-changed event"); + } finally { + resolve(); + } + break; + } + case "page-removed": { + Assert.equal( + event.isRemovedFromStore, + shouldRemove, + "Observe page-removed event with right removal type" + ); + Assert.equal( + event.url, + uri.spec, + "Observing effect on the right uri" + ); + resolve(); + break; + } + } + } + }; + }); + PlacesObservers.addListener( + [ + "page-title-changed", + "history-cleared", + "pages-rank-changed", + "page-removed", + ], + placesEventListener + ); + + info("Performing removal"); + let removed = false; + if (options.useCallback) { + let onRowCalled = false; + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: uri, + }); + removed = await PlacesUtils.history.remove(removeArg, page => { + Assert.equal(onRowCalled, false, "Callback has not been called yet"); + onRowCalled = true; + Assert.equal( + page.url.href, + uri.spec, + "Callback provides the correct url" + ); + Assert.equal(page.guid, guid, "Callback provides the correct guid"); + Assert.equal(page.title, title, "Callback provides the correct title"); + }); + Assert.ok(onRowCalled, "Callback has been called"); + } else { + removed = await PlacesUtils.history.remove(removeArg); + } + + await promiseObserved; + PlacesObservers.removeListener( + [ + "page-title-changed", + "history-cleared", + "pages-rank-changed", + "page-removed", + ], + placesEventListener + ); + + Assert.equal(visits_in_database(uri), 0, "History entry has disappeared"); + Assert.notEqual( + visits_in_database(WITNESS_URI), + 0, + "Witness URI still has visits" + ); + Assert.notEqual( + page_in_database(WITNESS_URI), + 0, + "Witness URI is still here" + ); + if (shouldRemove) { + Assert.ok(removed, "Something was removed"); + Assert.equal(page_in_database(uri), 0, "Page has disappeared"); + } else { + Assert.ok(!removed, "The page was not removed, as there was a bookmark"); + Assert.notEqual(page_in_database(uri), 0, "The page is still present"); + } + }; + + try { + for (let useCallback of [false, true]) { + for (let addBookmark of [false, true]) { + let options = { useCallback, addBookmark }; + await remover( + "Testing History.remove() with a single URI", + x => x, + options + ); + await remover( + "Testing History.remove() with a single string url", + x => x.spec, + options + ); + await remover( + "Testing History.remove() with a single string guid", + async x => + PlacesTestUtils.getDatabaseValue("moz_places", "guid", { url: x }), + options + ); + await remover( + "Testing History.remove() with a single URI in an array", + x => [x], + options + ); + await remover( + "Testing History.remove() with a single string url in an array", + x => [x.spec], + options + ); + await remover( + "Testing History.remove() with a single string guid in an array", + x => + PlacesTestUtils.getDatabaseValue("moz_places", "guid", { url: x }), + options + ); + } + } + } finally { + await PlacesUtils.history.clear(); + } +}); + +add_task(async function cleanup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +// Test the various error cases +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.remove(), + /TypeError: Invalid url/, + "History.remove with no argument should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove(null), + /TypeError: Invalid url/, + "History.remove with `null` should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove(undefined), + /TypeError: Invalid url/, + "History.remove with `undefined` should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove("not a guid, obviously"), + /TypeError: .* is not a valid URL/, + "History.remove with an ill-formed guid/url argument should throw a TypeError" + ); + Assert.throws( + () => + PlacesUtils.history.remove({ + "not the kind of object we know how to handle": true, + }), + /TypeError: Invalid url/, + "History.remove with an unexpected object should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove([]), + /TypeError: Expected at least one page/, + "History.remove with an empty array should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove([null]), + /TypeError: Invalid url or guid/, + "History.remove with an array containing null should throw a TypeError" + ); + Assert.throws( + () => + PlacesUtils.history.remove([ + "http://example.org", + "not a guid, obviously", + ]), + /TypeError: .* is not a valid URL/, + "History.remove with an array containing an ill-formed guid/url argument should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.remove(["0123456789ab" /* valid guid*/, null]), + /TypeError: Invalid url or guid: null/, + "History.remove with an array containing a guid and a second argument that is null should throw a TypeError" + ); + Assert.throws( + () => + PlacesUtils.history.remove([ + "http://example.org", + { "not the kind of object we know how to handle": true }, + ]), + /TypeError: Invalid url/, + "History.remove with an array containing an unexpected objecgt should throw a TypeError" + ); + Assert.throws( + () => + PlacesUtils.history.remove( + "http://example.org", + "not a function, obviously" + ), + /TypeError: Invalid function/, + "History.remove with a second argument that is not a function argument should throw a TypeError" + ); + try { + PlacesUtils.history.remove( + "http://example.org/I/have/clearly/not/been/added", + null + ); + Assert.ok(true, "History.remove should ignore `null` as a second argument"); + } catch (ex) { + Assert.ok( + false, + "History.remove should ignore `null` as a second argument" + ); + } +}); + +add_task(async function test_orphans() { + let uri = NetUtil.newURI("http://moz.org/"); + await PlacesTestUtils.addVisits({ uri }); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + uri, + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + // Also create a root icon. + let faviconURI = Services.io.newURI(uri.spec + "favicon.ico"); + PlacesUtils.favicons.replaceFaviconDataFromDataURL( + faviconURI, + SMALLPNG_DATA_URI.spec, + 0, + Services.scriptSecurityManager.getSystemPrincipal() + ); + PlacesUtils.favicons.setAndFetchFaviconForPage( + uri, + faviconURI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + await PlacesUtils.history.update({ + url: uri, + annotations: new Map([["test", "restval"]]), + }); + + await PlacesUtils.history.remove(uri); + Assert.ok( + !(await PlacesTestUtils.isPageInDB(uri)), + "Page should have been removed" + ); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(`SELECT (SELECT count(*) FROM moz_annos) + + (SELECT count(*) FROM moz_icons) + + (SELECT count(*) FROM moz_pages_w_icons) + + (SELECT count(*) FROM moz_icons_to_pages) AS count`); + Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans"); +}); + +add_task(async function test_remove_backslash() { + // Backslash is an escape char in Sqlite, we must take care of that when + // removing a url containing a backslash. + const url = "https://www.mozilla.org/?test=\u005C"; + await PlacesTestUtils.addVisits(url); + Assert.ok(await PlacesUtils.history.remove(url), "A page should be removed"); + Assert.deepEqual( + await PlacesUtils.history.fetch(url), + null, + "The page should not be found" + ); +}); + +add_task(async function test_url_with_apices() { + // Apices may confuse code and cause injection if mishandled. + // The ideal test would be with a javascript url, because it would not be + // encoded by URL(), unfortunately it would also not be added to history. + const url = `http://mozilla.org/\u0022\u0027`; + await PlacesTestUtils.addVisits(url); + Assert.ok(await PlacesUtils.history.remove(url), "A page should be removed"); + Assert.deepEqual( + await PlacesUtils.history.fetch(url), + null, + "The page should not be found" + ); +}); diff --git a/toolkit/components/places/tests/history/test_removeByFilter.js b/toolkit/components/places/tests/history/test_removeByFilter.js new file mode 100644 index 0000000000..fb18bf8e74 --- /dev/null +++ b/toolkit/components/places/tests/history/test_removeByFilter.js @@ -0,0 +1,497 @@ +"use strict"; + +/* +This test will ideally test the following cases +(each with and without a callback associated with it) + Case A: Tests which should remove pages (Positives) + Case A 1: Page has multiple visits both in/out of timeframe, all get deleted + Case A 2: Page has single uri, removed by host + Case A 3: Page has random subhost, with same host, removed by wildcard + Case A 4: Page is localhost and localhost:port, removed by host + Case A 5: Page is a `file://` type address, removed by empty host + Cases A 1,2,3 will be tried with and without bookmarks added (which prevent page deletion) + Case B: Tests in which no pages are removed (Inverses) + Case B 1 (inverse): Page has no visits in timeframe, and nothing is deleted + Case B 2: Page has single uri, not removed since hostname is different + Case B 3: Page has multiple subhosts, not removed since wildcard doesn't match + Case C: Combinations tests + Case C 1: Single hostname, multiple visits, at least one in timeframe and hostname + Case C 2: Random subhosts, multiple visits, at least one in timeframe and hostname-wildcard +*/ + +add_task(async function test_removeByFilter() { + // Cleanup + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Adding a witness URI + let witnessURI = NetUtil.newURI( + "http://witnessmozilla.org/test_browserhistory/test_removeByFilter" + + Math.random() + ); + await PlacesTestUtils.addVisits(witnessURI); + Assert.ok( + await PlacesTestUtils.isPageInDB(witnessURI), + "Witness URI is in database" + ); + + let removeByFilterTester = async function ( + visits, + filter, + checkBeforeRemove, + checkAfterRemove, + useCallback, + bookmarkedUri + ) { + // Add visits for URIs + await PlacesTestUtils.addVisits(visits); + if ( + bookmarkedUri !== null && + visits.map(v => v.uri).includes(bookmarkedUri) + ) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmarkedUri, + title: "test bookmark", + }); + } + await checkBeforeRemove(); + + // Take care of any observers (due to bookmarks) + let { placesEventListener, promiseObserved } = + getObserverPromise(bookmarkedUri); + if (placesEventListener) { + PlacesObservers.addListener( + ["page-title-changed", "history-cleared", "page-removed"], + placesEventListener + ); + } + // Perfom delete operation on database + let removed = false; + if (useCallback) { + // The amount of callbacks will be the unique URIs to remove from the database + let netCallbacksRequired = new Set(visits.map(v => v.uri)).size; + removed = await PlacesUtils.history.removeByFilter(filter, pageInfo => { + Assert.ok( + PlacesUtils.validatePageInfo(pageInfo, false), + "pageInfo should follow a basic format" + ); + Assert.ok( + netCallbacksRequired > 0, + "Callback called as many times as required" + ); + netCallbacksRequired--; + }); + } else { + removed = await PlacesUtils.history.removeByFilter(filter); + } + await checkAfterRemove(); + await promiseObserved; + if (placesEventListener) { + await PlacesUtils.bookmarks.eraseEverything(); + PlacesObservers.removeListener( + ["page-title-changed", "history-cleared", "page-removed"], + placesEventListener + ); + } + Assert.ok( + await PlacesTestUtils.isPageInDB(witnessURI), + "Witness URI is still in database" + ); + return removed; + }; + + const remoteUriList = [ + "http://mozilla.org/test_browserhistory/test_removeByFilter/" + + Math.random(), + "http://subdomain1.mozilla.org/test_browserhistory/test_removeByFilter/" + + Math.random(), + "http://subdomain2.mozilla.org/test_browserhistory/test_removeByFilter/" + + Math.random(), + ]; + const localhostUriList = [ + "http://localhost:4500/" + Math.random(), + "http://localhost/" + Math.random(), + ]; + const fileUriList = ["file:///home/user/files" + Math.random()]; + const title = "Title " + Math.random(); + let sameHostVisits = [ + { + uri: remoteUriList[0], + title, + visitDate: new Date(2005, 1, 1) * 1000, + }, + { + uri: remoteUriList[0], + title, + visitDate: new Date(2005, 3, 3) * 1000, + }, + { + uri: remoteUriList[0], + title, + visitDate: new Date(2007, 1, 1) * 1000, + }, + ]; + let randomHostVisits = [ + { + uri: remoteUriList[0], + title, + visitDate: new Date(2005, 1, 1) * 1000, + }, + { + uri: remoteUriList[1], + title, + visitDate: new Date(2005, 3, 3) * 1000, + }, + { + uri: remoteUriList[2], + title, + visitDate: new Date(2007, 1, 1) * 1000, + }, + ]; + let localhostVisits = [ + { + uri: localhostUriList[0], + title, + }, + { + uri: localhostUriList[1], + title, + }, + ]; + let fileVisits = [ + { + uri: fileUriList[0], + title, + }, + ]; + let assertInDB = async function (aUri) { + Assert.ok(await PlacesTestUtils.isPageInDB(aUri)); + }; + let assertNotInDB = async function (aUri) { + Assert.ok(!(await PlacesTestUtils.isPageInDB(aUri))); + }; + for (let callbackUse of [true, false]) { + // Case A Positives + for (let bookmarkUse of [true, false]) { + let bookmarkedUri = arr => undefined; + let checkableArray = arr => arr; + let checkClosure = assertNotInDB; + if (bookmarkUse) { + bookmarkedUri = arr => arr[0]; + checkableArray = arr => arr.slice(1); + checkClosure = function (aUri) {}; + } + // Case A 1: Dates + await removeByFilterTester( + sameHostVisits, + { beginDate: new Date(2004, 1, 1), endDate: new Date(2006, 1, 1) }, + () => assertInDB(remoteUriList[0]), + () => checkClosure(remoteUriList[0]), + callbackUse, + bookmarkedUri(remoteUriList) + ); + // Case A 2: Single Sub-host + await removeByFilterTester( + sameHostVisits, + { host: "mozilla.org" }, + () => assertInDB(remoteUriList[0]), + () => checkClosure(remoteUriList[0]), + callbackUse, + bookmarkedUri(remoteUriList) + ); + // Case A 3: Multiple subhost + await removeByFilterTester( + randomHostVisits, + { host: ".mozilla.org" }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of checkableArray(remoteUriList)) { + await checkClosure(uri); + } + }, + callbackUse, + bookmarkedUri(remoteUriList) + ); + } + + // Case A 4: Localhost + await removeByFilterTester( + localhostVisits, + { host: "localhost" }, + async () => { + for (let uri of localhostUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of localhostUriList) { + await assertNotInDB(uri); + } + }, + callbackUse + ); + // Case A 5: Local Files + await removeByFilterTester( + fileVisits, + { host: "." }, + async () => { + for (let uri of fileUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of fileUriList) { + await assertNotInDB(uri); + } + }, + callbackUse + ); + + // Case B: Tests which do not remove anything (inverses) + // Case B 1: Date + await removeByFilterTester( + sameHostVisits, + { beginDate: new Date(2001, 1, 1), endDate: new Date(2002, 1, 1) }, + () => assertInDB(remoteUriList[0]), + () => assertInDB(remoteUriList[0]), + callbackUse + ); + // Case B 2 : Single subhost + await removeByFilterTester( + sameHostVisits, + { host: "notthere.org" }, + () => assertInDB(remoteUriList[0]), + () => assertInDB(remoteUriList[0]), + callbackUse + ); + // Case B 3 : Multiple subhosts + await removeByFilterTester( + randomHostVisits, + { host: ".notthere.org" }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + callbackUse + ); + // Case B 4 : invalid local subhost + await removeByFilterTester( + randomHostVisits, + { host: ".org" }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + callbackUse + ); + + // Case C: Combination Cases + // Case C 1: single subhost + await removeByFilterTester( + sameHostVisits, + { + host: "mozilla.org", + beginDate: new Date(2004, 1, 1), + endDate: new Date(2006, 1, 1), + }, + () => assertInDB(remoteUriList[0]), + () => assertNotInDB(remoteUriList[0]), + callbackUse + ); + // Case C 2: multiple subhost + await removeByFilterTester( + randomHostVisits, + { + host: ".mozilla.org", + beginDate: new Date(2005, 1, 1), + endDate: new Date(2017, 1, 1), + }, + async () => { + for (let uri of remoteUriList) { + await assertInDB(uri); + } + }, + async () => { + for (let uri of remoteUriList) { + await assertNotInDB(uri); + } + }, + callbackUse + ); + } +}); + +// Test various error cases +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.removeByFilter(), + /TypeError: Expected a filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter("obviously, not a filter"), + /TypeError: Expected a filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({}), + /TypeError: Expected a non-empty filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ beginDate: "now" }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ beginDate: Date.now() }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ beginDate: new Date(NaN) }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => + PlacesUtils.history.removeByFilter( + { beginDate: new Date() }, + "obviously, not a callback" + ), + /TypeError: Invalid function/ + ); + Assert.throws( + () => + PlacesUtils.history.removeByFilter({ + beginDate: new Date(1000), + endDate: new Date(0), + }), + /TypeError: `beginDate` should be at least as old/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "#" }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "www..org" }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: {} }), + /TypeError: `host` should be a string/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "*.mozilla.org" }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "*" }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "local.host." }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "(local files)" }), + /TypeError: Expected well formed hostname string for/ + ); + Assert.throws( + () => PlacesUtils.history.removeByFilter({ host: "" }), + /TypeError: Expected a non-empty filter/ + ); +}); + +add_task(async function test_chunking() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + info("Insert many visited pages"); + let pages = []; + for (let i = 1; i <= 1500; i++) { + let visits = [ + { + date: new Date(Date.now() - (86400 + i) * 1000), + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]; + pages.push( + { + url: `http://example.com/${i}`, + title: `Page ${i}`, + visits, + }, + { + url: `http://subdomain.example.com/${i}`, + title: `Subdomain ${i}`, + visits, + } + ); + } + await PlacesUtils.history.insertMany(pages); + + info("Remove all visited pages"); + await PlacesUtils.history.removeByFilter({ + host: ".example.com", + }); +}); + +// Helper functions + +function getObserverPromise(bookmarkedUri) { + if (!bookmarkedUri) { + return { promiseObserved: Promise.resolve() }; + } + let placesEventListener; + let promiseObserved = new Promise((resolve, reject) => { + placesEventListener = events => { + for (const event of events) { + switch (event.type) { + case "page-title-changed": { + reject(new Error("Unexpected page-title-changed event happens")); + break; + } + case "history-cleared": { + reject(new Error("Unexpected history-cleared event happens")); + break; + } + case "page-removed": { + if (event.isRemovedFromStore) { + Assert.notEqual( + event.url, + bookmarkedUri, + "Bookmarked URI should not be deleted" + ); + } else { + Assert.equal( + event.isPartialVisistsRemoval, + false, + "Observing page-removed deletes all visits" + ); + Assert.equal( + event.url, + bookmarkedUri, + "Bookmarked URI should have all visits removed but not the page itself" + ); + } + resolve(); + break; + } + } + } + }; + }); + return { placesEventListener, promiseObserved }; +} diff --git a/toolkit/components/places/tests/history/test_removeMany.js b/toolkit/components/places/tests/history/test_removeMany.js new file mode 100644 index 0000000000..ff8c3a21ee --- /dev/null +++ b/toolkit/components/places/tests/history/test_removeMany.js @@ -0,0 +1,206 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +// Tests for `History.remove` with removing many urls, as implemented in +// History.jsm. + +"use strict"; + +// Test removing a list of pages +add_task(async function test_remove_many() { + // This is set so that we are guaranteed to trigger REMOVE_PAGES_CHUNKLEN. + const SIZE = 310; + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + info("Adding a witness page"); + let WITNESS_URI = NetUtil.newURI( + "http://mozilla.com/test_browserhistory/test_remove/" + Math.random() + ); + await PlacesTestUtils.addVisits(WITNESS_URI); + Assert.ok(page_in_database(WITNESS_URI), "Witness page added"); + + info("Generating samples"); + let pages = []; + for (let i = 0; i < SIZE; ++i) { + let uri = NetUtil.newURI( + "http://mozilla.com/test_browserhistory/test_remove?sample=" + + i + + "&salt=" + + Math.random() + ); + let title = "Visit " + i + ", " + Math.random(); + let hasBookmark = i % 3 == 0; + let page = { + uri, + title, + hasBookmark, + // `true` once `onResult` has been called for this page + onResultCalled: false, + // `true` once page-removed for store has been fired for this page + pageRemovedFromStore: false, + // `true` once page-removed for all visits has been fired for this page + pageRemovedAllVisits: false, + }; + info("Pushing: " + uri.spec); + pages.push(page); + + await PlacesTestUtils.addVisits(page); + page.guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: uri, + }); + if (hasBookmark) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: "test bookmark " + i, + }); + } + Assert.ok(page_in_database(uri), "Page added"); + } + + info("Mixing key types and introducing dangling keys"); + let keys = []; + for (let i = 0; i < SIZE; ++i) { + if (i % 4 == 0) { + keys.push(pages[i].uri); + keys.push(NetUtil.newURI("http://example.org/dangling/nsIURI/" + i)); + } else if (i % 4 == 1) { + keys.push(new URL(pages[i].uri.spec)); + keys.push(new URL("http://example.org/dangling/URL/" + i)); + } else if (i % 4 == 2) { + keys.push(pages[i].uri.spec); + keys.push("http://example.org/dangling/stringuri/" + i); + } else { + keys.push(pages[i].guid); + keys.push(("guid_" + i + "_01234567890").substr(0, 12)); + } + } + + let onPageRankingChanged = false; + const placesEventListener = events => { + for (const event of events) { + switch (event.type) { + case "page-title-changed": { + Assert.ok( + false, + "Unexpected page-title-changed event happens on " + event.url + ); + break; + } + case "history-cleared": { + Assert.ok(false, "Unexpected history-cleared event happens"); + break; + } + case "pages-rank-changed": { + onPageRankingChanged = true; + break; + } + case "page-removed": { + const origin = pages.find(x => x.uri.spec === event.url); + Assert.ok(origin); + + if (event.isRemovedFromStore) { + Assert.ok( + !origin.hasBookmark, + "Observing page-removed event on a page without a bookmark" + ); + Assert.ok( + !origin.pageRemovedFromStore, + "Observing page-removed for store for the first time" + ); + origin.pageRemovedFromStore = true; + } else { + Assert.ok( + !origin.pageRemovedAllVisits, + "Observing page-removed for all visits for the first time" + ); + origin.pageRemovedAllVisits = true; + } + break; + } + } + } + }; + + PlacesObservers.addListener( + [ + "page-title-changed", + "history-cleared", + "pages-rank-changed", + "page-removed", + ], + placesEventListener + ); + + info("Removing the pages and checking the callbacks"); + + let removed = await PlacesUtils.history.remove(keys, page => { + let origin = pages.find(candidate => candidate.uri.spec == page.url.href); + + Assert.ok(origin, "onResult has a valid page"); + Assert.ok(!origin.onResultCalled, "onResult has not seen this page yet"); + origin.onResultCalled = true; + Assert.equal(page.guid, origin.guid, "onResult has the right guid"); + Assert.equal(page.title, origin.title, "onResult has the right title"); + }); + Assert.ok(removed, "Something was removed"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + PlacesObservers.removeListener( + [ + "page-title-changed", + "history-cleared", + "pages-rank-changed", + "page-removed", + ], + placesEventListener + ); + + info("Checking out results"); + // By now the observers should have been called. + for (let i = 0; i < pages.length; ++i) { + let page = pages[i]; + Assert.ok( + page.onResultCalled, + `We have reached the page #${i} from the callback` + ); + Assert.ok( + visits_in_database(page.uri) == 0, + "History entry has disappeared" + ); + Assert.equal( + page_in_database(page.uri) != 0, + page.hasBookmark, + "Page is present only if it also has bookmarks" + ); + Assert.notEqual( + page.pageRemovedFromStore, + page.pageRemovedAllVisits, + "Either only page-removed event for store or all visits should be called" + ); + } + + Assert.equal( + onPageRankingChanged, + pages.some(p => p.pageRemovedFromStore || p.pageRemovedAllVisits), + "page-rank-changed was fired" + ); + + Assert.notEqual( + visits_in_database(WITNESS_URI), + 0, + "Witness URI still has visits" + ); + Assert.notEqual( + page_in_database(WITNESS_URI), + 0, + "Witness URI is still here" + ); +}); + +add_task(async function cleanup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/history/test_removeVisits.js b/toolkit/components/places/tests/history/test_removeVisits.js new file mode 100644 index 0000000000..3a82132bd8 --- /dev/null +++ b/toolkit/components/places/tests/history/test_removeVisits.js @@ -0,0 +1,376 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const JS_NOW = Date.now(); +const DB_NOW = JS_NOW * 1000; +const TEST_URI = uri("http://example.com/"); + +async function cleanup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + // This is needed to remove place: entries. + DBConn().executeSimpleSQL("DELETE FROM moz_places"); +} + +add_task(async function remove_visits_outside_unbookmarked_uri() { + info( + "*** TEST: Remove some visits outside valid timeframe from an unbookmarked URI" + ); + + info("Add 10 visits for the URI from way in the past."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + + info("Remove visits using timerange outside the URI's visits."); + let filter = { + beginDate: new Date(JS_NOW - 10), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + + info("Run a history query and check that all visits still exist."); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 10); + for (let i = 0; i < root.childCount; i++) { + let visitTime = root.getChild(i).time; + Assert.equal(visitTime, DB_NOW - 100000 - i * 1000); + } + root.containerOpen = false; + + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should exist" + ); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Frecency should be positive."); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); + + await cleanup(); +}); + +add_task(async function remove_visits_outside_bookmarked_uri() { + info( + "*** TEST: Remove some visits outside valid timeframe from a bookmarked URI" + ); + + info("Add 10 visits for the URI from way in the past."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + info("Bookmark the URI."); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URI, + title: "bookmark title", + }); + + info("Remove visits using timerange outside the URI's visits."); + let filter = { + beginDate: new Date(JS_NOW - 10), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + + info("Run a history query and check that all visits still exist."); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 10); + for (let i = 0; i < root.childCount; i++) { + let visitTime = root.getChild(i).time; + Assert.equal(visitTime, DB_NOW - 100000 - i * 1000); + } + root.containerOpen = false; + + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should exist" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Frecency should be positive."); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); + + await cleanup(); +}); + +add_task(async function remove_visits_unbookmarked_uri() { + info("*** TEST: Remove some visits from an unbookmarked URI"); + + info("Add 10 visits for the URI from now to 9 usecs in the past."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + + info("Remove the 5 most recent visits."); + let filter = { + beginDate: new Date(JS_NOW - 4), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + + info( + "Run a history query and check that only the older 5 visits still exist." + ); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 5); + for (let i = 0; i < root.childCount; i++) { + let visitTime = root.getChild(i).time; + Assert.equal(visitTime, DB_NOW - i * 1000 - 5000); + } + root.containerOpen = false; + + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should exist" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Frecency should be positive."); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); + + await cleanup(); +}); + +add_task(async function remove_visits_bookmarked_uri() { + info("*** TEST: Remove some visits from a bookmarked URI"); + + info("Add 10 visits for the URI from now to 9 usecs in the past."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + info("Bookmark the URI."); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URI, + title: "bookmark title", + }); + + info("Remove the 5 most recent visits."); + let filter = { + beginDate: new Date(JS_NOW - 4), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + + info( + "Run a history query and check that only the older 5 visits still exist." + ); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 5); + for (let i = 0; i < root.childCount; i++) { + let visitTime = root.getChild(i).time; + Assert.equal(visitTime, DB_NOW - i * 1000 - 5000); + } + root.containerOpen = false; + + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should exist" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Frecency should be positive."); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); + + await cleanup(); +}); + +add_task(async function remove_all_visits_unbookmarked_uri() { + info("*** TEST: Remove all visits from an unbookmarked URI"); + + info("Add some visits for the URI."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + + info("Remove all visits."); + let filter = { + beginDate: new Date(JS_NOW - 10), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should no longer exist in moz_places."); + Assert.ok(!page_in_database(TEST_URI.spec)); + + info("Run a history query and check that no visits exist."); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 0); + root.containerOpen = false; + + Assert.equal( + false, + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should not exist" + ); + + await cleanup(); +}); + +add_task(async function remove_all_visits_bookmarked_uri() { + info("*** TEST: Remove all visits from a bookmarked URI"); + + info("Add some visits for the URI."); + let visits = []; + for (let i = 0; i < 10; i++) { + visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 }); + } + await PlacesTestUtils.addVisits(visits); + info("Bookmark the URI."); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URI, + title: "bookmark title", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let initialFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: TEST_URI } + ); + + info("Remove all visits."); + let filter = { + beginDate: new Date(JS_NOW - 10), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + + info("Run a history query and check that no visits exist."); + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let root = PlacesUtils.history.executeQuery(query, opts).root; + root.containerOpen = true; + Assert.equal(root.childCount, 0); + root.containerOpen = false; + + Assert.equal( + false, + await PlacesUtils.history.hasVisits(TEST_URI), + "visit should not exist" + ); + + info("URI should be bookmarked"); + Assert.ok(await PlacesUtils.bookmarks.fetch({ url: TEST_URI })); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Frecency should be smaller."); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) < initialFrecency + ); + + await cleanup(); +}); + +add_task(async function remove_all_visits_bookmarked_uri() { + info( + "*** TEST: Remove some visits from a zero frecency URI retains zero frecency" + ); + + info("Add some visits for the URI."); + await PlacesTestUtils.addVisits([ + { + uri: TEST_URI, + transition: TRANSITION_FRAMED_LINK, + visitDate: DB_NOW - 86400000000000, + }, + { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: DB_NOW }, + ]); + + info("Remove newer visit."); + let filter = { + beginDate: new Date(JS_NOW - 10), + endDate: new Date(JS_NOW), + }; + await PlacesUtils.history.removeVisitsByFilter(filter); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI should still exist in moz_places."); + Assert.ok(page_in_database(TEST_URI.spec)); + info("Frecency should be zero."); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await cleanup(); +}); diff --git a/toolkit/components/places/tests/history/test_removeVisitsByFilter.js b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js new file mode 100644 index 0000000000..3d71c7348a --- /dev/null +++ b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js @@ -0,0 +1,409 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +// Tests for `History.removeVisitsByFilter`, as implemented in History.jsm + +"use strict"; + +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +add_task(async function test_removeVisitsByFilter() { + let referenceDate = new Date(1999, 9, 9, 9, 9); + + // Populate a database with 20 entries, remove a subset of entries, + // ensure consistency. + let remover = async function (options) { + info("Remover with options " + JSON.stringify(options)); + let SAMPLE_SIZE = options.sampleSize; + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Populate the database. + // Create `SAMPLE_SIZE` visits, from the oldest to the newest. + + let bookmarkIndices = new Set(options.bookmarks); + let visits = []; + let rankingChangePromises = []; + let uriDeletePromises = new Map(); + let getURL = options.url + ? i => + "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/byurl/" + + Math.floor(i / (SAMPLE_SIZE / 5)) + + "/" + : i => + "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/" + + i + + "/" + + Math.random(); + for (let i = 0; i < SAMPLE_SIZE; ++i) { + let spec = getURL(i); + let uri = NetUtil.newURI(spec); + let jsDate = new Date(Number(referenceDate) + 3600 * 1000 * i); + let dbDate = jsDate * 1000; + let hasBookmark = bookmarkIndices.has(i); + let hasOwnBookmark = hasBookmark; + if (!hasOwnBookmark && options.url) { + // Also mark as bookmarked if one of the earlier bookmarked items has the same URL. + hasBookmark = options.bookmarks + .filter(n => n < i) + .some(n => visits[n].uri.spec == spec && visits[n].test.hasBookmark); + } + info("Generating " + uri.spec + ", " + dbDate); + let visit = { + uri, + title: "visit " + i, + visitDate: dbDate, + test: { + // `visitDate`, as a Date + jsDate, + // `true` if we expect that the visit will be removed + toRemove: false, + // `true` if `onRow` informed of the removal of this visit + announcedByOnRow: false, + // `true` if there is a bookmark for this URI, i.e. of the page + // should not be entirely removed. + hasBookmark, + }, + }; + visits.push(visit); + if (hasOwnBookmark) { + info("Adding a bookmark to visit " + i); + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test bookmark", + }); + info("Bookmark added"); + } + } + + info("Adding visits"); + await PlacesTestUtils.addVisits(visits); + + info("Preparing filters"); + let filter = {}; + let beginIndex = 0; + let endIndex = visits.length - 1; + if ("begin" in options) { + let ms = Number(visits[options.begin].test.jsDate) - 1000; + filter.beginDate = new Date(ms); + beginIndex = options.begin; + } + if ("end" in options) { + let ms = Number(visits[options.end].test.jsDate) + 1000; + filter.endDate = new Date(ms); + endIndex = options.end; + } + if ("limit" in options) { + endIndex = beginIndex + options.limit - 1; // -1 because the start index is inclusive. + filter.limit = options.limit; + } + let removedItems = visits.slice(beginIndex); + endIndex -= beginIndex; + if (options.url) { + let rawURL = ""; + switch (options.url) { + case 1: + filter.url = new URL(removedItems[0].uri.spec); + rawURL = filter.url.href; + break; + case 2: + filter.url = removedItems[0].uri; + rawURL = filter.url.spec; + break; + case 3: + filter.url = removedItems[0].uri.spec; + rawURL = filter.url; + break; + } + endIndex = Math.min( + endIndex, + removedItems.findIndex((v, index) => v.uri.spec != rawURL) - 1 + ); + } + removedItems.splice(endIndex + 1); + let remainingItems = visits.filter(v => !removedItems.includes(v)); + for (let i = 0; i < removedItems.length; i++) { + let test = removedItems[i].test; + info("Marking visit " + (beginIndex + i) + " as expecting removal"); + test.toRemove = true; + if ( + test.hasBookmark || + (options.url && + remainingItems.some(v => v.uri.spec == removedItems[i].uri.spec)) + ) { + rankingChangePromises.push(PromiseUtils.defer()); + } else if (!options.url || i == 0) { + uriDeletePromises.set(removedItems[i].uri.spec, PromiseUtils.defer()); + } + } + + const placesEventListener = events => { + for (const event of events) { + switch (event.type) { + case "page-title-changed": { + this.deferred.reject( + "Unexpected page-title-changed event happens on " + event.url + ); + break; + } + case "history-cleared": { + info("history-cleared"); + this.deferred.reject("Unexpected history-cleared event happens"); + break; + } + case "pages-rank-changed": { + info("pages-rank-changed"); + for (const deferred of rankingChangePromises) { + deferred.resolve(); + } + break; + } + } + } + }; + PlacesObservers.addListener( + ["page-title-changed", "history-cleared", "pages-rank-changed"], + placesEventListener + ); + + let cbarg; + if (options.useCallback) { + info("Setting up callback"); + cbarg = [ + info => { + for (let visit of visits) { + info("Comparing " + info.date + " and " + visit.test.jsDate); + if (Math.abs(visit.test.jsDate - info.date) < 100) { + // Assume rounding errors + Assert.ok( + !visit.test.announcedByOnRow, + "This is the first time we announce the removal of this visit" + ); + Assert.ok( + visit.test.toRemove, + "This is a visit we intended to remove" + ); + visit.test.announcedByOnRow = true; + return; + } + } + Assert.ok(false, "Could not find the visit we attempt to remove"); + }, + ]; + } else { + info("No callback"); + cbarg = []; + } + let result = await PlacesUtils.history.removeVisitsByFilter( + filter, + ...cbarg + ); + + Assert.ok(result, "Removal succeeded"); + + // Make sure that we have eliminated exactly the entries we expected + // to eliminate. + for (let i = 0; i < visits.length; ++i) { + let visit = visits[i]; + info("Controlling the results on visit " + i); + let remainingVisitsForURI = remainingItems.filter( + v => visit.uri.spec == v.uri.spec + ).length; + Assert.equal( + visits_in_database(visit.uri), + remainingVisitsForURI, + "Visit is still present iff expected" + ); + if (options.useCallback) { + Assert.equal( + visit.test.toRemove, + visit.test.announcedByOnRow, + "Visit removal has been announced by onResult iff expected" + ); + } + if (visit.test.hasBookmark || remainingVisitsForURI) { + Assert.notEqual( + page_in_database(visit.uri), + 0, + "The page should still appear in the db" + ); + } else { + Assert.equal( + page_in_database(visit.uri), + 0, + "The page should have been removed from the db" + ); + } + } + + // Make sure that the observer has been called wherever applicable. + info("Checking URI delete promises."); + await Promise.all(Array.from(uriDeletePromises.values())); + info("Checking frecency change promises."); + await Promise.all(rankingChangePromises); + PlacesObservers.removeListener( + ["page-title-changed", "history-cleared", "pages-rank-changed"], + placesEventListener + ); + }; + + let size = 20; + for (let range of [ + { begin: 0 }, + { end: 19 }, + { begin: 0, end: 10 }, + { begin: 3, end: 4 }, + { begin: 5, end: 8, limit: 2 }, + { begin: 10, end: 18, limit: 5 }, + ]) { + for (let bookmarks of [[], [5, 6]]) { + let options = { + sampleSize: size, + bookmarks, + }; + if ("begin" in range) { + options.begin = range.begin; + } + if ("end" in range) { + options.end = range.end; + } + if ("limit" in range) { + options.limit = range.limit; + } + await remover(options); + options.url = 1; + await remover(options); + options.url = 2; + await remover(options); + options.url = 3; + await remover(options); + } + } + await PlacesUtils.history.clear(); +}); + +// Test the various error cases +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter(), + /TypeError: Expected a filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter("obviously, not a filter"), + /TypeError: Expected a filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({}), + /TypeError: Expected a non-empty filter/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ beginDate: "now" }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ beginDate: Date.now() }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => + PlacesUtils.history.removeVisitsByFilter({ beginDate: new Date(NaN) }), + /TypeError: Expected a valid Date/ + ); + Assert.throws( + () => + PlacesUtils.history.removeVisitsByFilter( + { beginDate: new Date() }, + "obviously, not a callback" + ), + /TypeError: Invalid function/ + ); + Assert.throws( + () => + PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(1000), + endDate: new Date(0), + }), + /TypeError: `beginDate` should be at least as old/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ limit: {} }), + /Expected a non-zero positive integer as a limit/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ limit: -1 }), + /Expected a non-zero positive integer as a limit/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ limit: 0.1 }), + /Expected a non-zero positive integer as a limit/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ limit: Infinity }), + /Expected a non-zero positive integer as a limit/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ url: {} }), + /Expected a valid URL for `url`/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ url: 0 }), + /Expected a valid URL for `url`/ + ); + Assert.throws( + () => + PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(1000), + endDate: new Date(0), + }), + /TypeError: `beginDate` should be at least as old/ + ); + Assert.throws( + () => + PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(1000), + endDate: new Date(0), + }), + /TypeError: `beginDate` should be at least as old/ + ); + Assert.throws( + () => PlacesUtils.history.removeVisitsByFilter({ transition: -1 }), + /TypeError: `transition` should be valid/ + ); +}); + +add_task(async function test_orphans() { + let uri = NetUtil.newURI("http://moz.org/"); + await PlacesTestUtils.addVisits({ uri }); + + PlacesUtils.favicons.setAndFetchFaviconForPage( + uri, + SMALLPNG_DATA_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await PlacesUtils.history.update({ + url: uri, + annotations: new Map([["test", "restval"]]), + }); + + await PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(1999, 9, 9, 9, 9), + endDate: new Date(), + }); + Assert.ok( + !(await PlacesTestUtils.isPageInDB(uri)), + "Page should have been removed" + ); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(`SELECT (SELECT count(*) FROM moz_annos) + + (SELECT count(*) FROM moz_icons) + + (SELECT count(*) FROM moz_pages_w_icons) + + (SELECT count(*) FROM moz_icons_to_pages) AS count`); + Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans"); +}); diff --git a/toolkit/components/places/tests/history/test_sameUri_titleChanged.js b/toolkit/components/places/tests/history/test_sameUri_titleChanged.js new file mode 100644 index 0000000000..87fb3f455c --- /dev/null +++ b/toolkit/components/places/tests/history/test_sameUri_titleChanged.js @@ -0,0 +1,54 @@ +// Test that repeated additions of the same URI to history, properly +// update from_visit and notify titleChanged. + +add_task(async function test() { + let uri = "http://test.com/"; + + const promiseTitleChangedNotifications = + PlacesTestUtils.waitForNotification("page-title-changed"); + + // This repeats the url on purpose, don't merge it into a single place entry. + await PlacesTestUtils.addVisits([ + { uri, title: "test" }, + { uri, referrer: uri, title: "test2" }, + ]); + + const events = await promiseTitleChangedNotifications; + Assert.equal(events.length, 1, "Right number of title changed notified"); + Assert.equal(events[0].url, uri, "Should notify the proper url"); + + let options = PlacesUtils.history.getNewQueryOptions(); + let query = PlacesUtils.history.getNewQuery(); + query.uri = NetUtil.newURI(uri); + options.resultType = options.RESULTS_AS_VISIT; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + Assert.equal(root.childCount, 2); + + let child = root.getChild(0); + Assert.equal( + child.visitType, + TRANSITION_LINK, + "Visit type should be TRANSITION_LINK" + ); + Assert.equal(child.visitId, 1, "Visit ID should be 1"); + Assert.equal(child.fromVisitId, -1, "Should have no referrer visit ID"); + Assert.equal(child.title, "test2", "Should have the correct title"); + + child = root.getChild(1); + Assert.equal( + child.visitType, + TRANSITION_LINK, + "Visit type should be TRANSITION_LINK" + ); + Assert.equal(child.visitId, 2, "Visit ID should be 2"); + Assert.equal( + child.fromVisitId, + 1, + "First visit should be the referring visit" + ); + Assert.equal(child.title, "test2", "Should have the correct title"); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/history/test_update.js b/toolkit/components/places/tests/history/test_update.js new file mode 100644 index 0000000000..d7beafd368 --- /dev/null +++ b/toolkit/components/places/tests/history/test_update.js @@ -0,0 +1,700 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests for `History.update` as implemented in History.jsm + +"use strict"; + +add_task(async function test_error_cases() { + Assert.throws( + () => PlacesUtils.history.update("not an object"), + /Error: PageInfo: Input should be a valid object/, + "passing a string as pageInfo should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.update(null), + /Error: PageInfo: Input should be/, + "passing a null as pageInfo should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + description: "Test description", + }), + /Error: PageInfo: The following properties were expected: url, guid/, + "not included a url or a guid should throw" + ); + Assert.throws( + () => PlacesUtils.history.update({ url: "not a valid url string" }), + /Error: PageInfo: Invalid value for property/, + "passing an invalid url should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + description: 123, + }), + /Error: PageInfo: Invalid value for property/, + "passing a non-string description in pageInfo should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + guid: "invalid guid", + description: "Test description", + }), + /Error: PageInfo: Invalid value for property/, + "passing a invalid guid in pageInfo should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + previewImageURL: "not a valid url string", + }), + /Error: PageInfo: Invalid value for property/, + "passing an invlid preview image url in pageInfo should throw an Error" + ); + Assert.throws( + () => { + let imageName = "a-very-long-string".repeat(10000); + let previewImageURL = `http://valid.uri.com/${imageName}.png`; + PlacesUtils.history.update({ + url: "http://valid.uri.com", + previewImageURL, + }); + }, + /Error: PageInfo: Invalid value for property/, + "passing an oversized previewImageURL in pageInfo should throw an Error" + ); + Assert.throws( + () => PlacesUtils.history.update({ url: "http://valid.uri.com" }), + /TypeError: pageInfo object must at least/, + "passing a pageInfo with neither description, previewImageURL, nor annotations should throw a TypeError" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + annotations: "asd", + }), + /Error: PageInfo: Invalid value for property/, + "passing a pageInfo with incorrect annotations type should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + annotations: new Map(), + }), + /Error: PageInfo: Invalid value for property/, + "passing a pageInfo with an empty annotations type should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + annotations: new Map([[1234, "value"]]), + }), + /Error: PageInfo: Invalid value for property/, + "passing a pageInfo with an invalid key type should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + annotations: new Map([["test", ["myarray"]]]), + }), + /Error: PageInfo: Invalid value for property/, + "passing a pageInfo with an invalid key type should throw an Error" + ); + Assert.throws( + () => + PlacesUtils.history.update({ + url: "http://valid.uri.com", + annotations: new Map([["test", { anno: "value" }]]), + }), + /Error: PageInfo: Invalid value for property/, + "passing a pageInfo with an invalid key type should throw an Error" + ); +}); + +add_task(async function test_description_change_saved() { + await PlacesUtils.history.clear(); + + let TEST_URL = "http://mozilla.org/test_description_change_saved"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL)); + + let description = "Test description"; + await PlacesUtils.history.update({ url: TEST_URL, description }); + let descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { url: TEST_URL } + ); + Assert.equal( + description, + descriptionInDB, + "description should be updated via URL as expected" + ); + + description = ""; + await PlacesUtils.history.update({ url: TEST_URL, description }); + descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { url: TEST_URL } + ); + Assert.strictEqual( + null, + descriptionInDB, + "an empty description should set it to null in the database" + ); + + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: TEST_URL, + }); + description = "Test description"; + await PlacesUtils.history.update({ url: TEST_URL, guid, description }); + descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { url: TEST_URL } + ); + Assert.equal( + description, + descriptionInDB, + "description should be updated via GUID as expected" + ); + + description = "Test descipriton".repeat(1000); + await PlacesUtils.history.update({ url: TEST_URL, description }); + descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { url: TEST_URL } + ); + Assert.ok( + !!descriptionInDB.length < description.length, + "a long description should be truncated" + ); + + description = null; + await PlacesUtils.history.update({ url: TEST_URL, description }); + descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { url: TEST_URL } + ); + Assert.strictEqual( + description, + descriptionInDB, + "a null description should set it to null in the database" + ); +}); + +add_task(async function test_siteName_change_saved() { + await PlacesUtils.history.clear(); + + let TEST_URL = "http://mozilla.org/test_siteName_change_saved"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL)); + + let siteName = "Test site name"; + await PlacesUtils.history.update({ url: TEST_URL, siteName }); + let siteNameInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "site_name", + { url: TEST_URL } + ); + Assert.equal( + siteName, + siteNameInDB, + "siteName should be updated via URL as expected" + ); + + siteName = ""; + await PlacesUtils.history.update({ url: TEST_URL, siteName }); + siteNameInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "site_name", + { url: TEST_URL } + ); + Assert.strictEqual( + null, + siteNameInDB, + "an empty siteName should set it to null in the database" + ); + + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: TEST_URL, + }); + siteName = "Test site name"; + await PlacesUtils.history.update({ url: TEST_URL, guid, siteName }); + siteNameInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "site_name", + { url: TEST_URL } + ); + Assert.equal( + siteName, + siteNameInDB, + "siteName should be updated via GUID as expected" + ); + + siteName = "Test site name".repeat(1000); + await PlacesUtils.history.update({ url: TEST_URL, siteName }); + siteNameInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "site_name", + { + url: TEST_URL, + } + ); + Assert.ok( + !!siteNameInDB.length < siteName.length, + "a long siteName should be truncated" + ); + + siteName = null; + await PlacesUtils.history.update({ url: TEST_URL, siteName }); + siteNameInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "site_name", + { + url: TEST_URL, + } + ); + Assert.strictEqual( + siteName, + siteNameInDB, + "a null siteName should set it to null in the database" + ); +}); + +add_task(async function test_previewImageURL_change_saved() { + await PlacesUtils.history.clear(); + + let TEST_URL = "http://mozilla.org/test_previewImageURL_change_saved"; + let IMAGE_URL = "http://mozilla.org/test_preview_image.png"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL)); + + let previewImageURL = IMAGE_URL; + await PlacesUtils.history.update({ url: TEST_URL, previewImageURL }); + let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.equal( + previewImageURL, + previewImageURLInDB, + "previewImageURL should be updated via URL as expected" + ); + + previewImageURL = null; + await PlacesUtils.history.update({ url: TEST_URL, previewImageURL }); + previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.strictEqual( + null, + previewImageURLInDB, + "a null previewImageURL should set it to null in the database" + ); + + let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: TEST_URL, + }); + previewImageURL = IMAGE_URL; + await PlacesUtils.history.update({ guid, previewImageURL }); + previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.equal( + previewImageURL, + previewImageURLInDB, + "previewImageURL should be updated via GUID as expected" + ); + + previewImageURL = ""; + await PlacesUtils.history.update({ url: TEST_URL, previewImageURL }); + previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.strictEqual( + null, + previewImageURLInDB, + "an empty previewImageURL should set it to null in the database" + ); +}); + +add_task(async function test_change_description_and_preview_saved() { + await PlacesUtils.history.clear(); + + let TEST_URL = "http://mozilla.org/test_change_both_saved"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL)); + + let description = "Test description"; + let previewImageURL = "http://mozilla.org/test_preview_image.png"; + + await PlacesUtils.history.update({ + url: TEST_URL, + description, + previewImageURL, + }); + let descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { + url: TEST_URL, + } + ); + let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.equal( + description, + descriptionInDB, + "description should be updated via URL as expected" + ); + Assert.equal( + previewImageURL, + previewImageURLInDB, + "previewImageURL should be updated via URL as expected" + ); + + // Update description should not touch other fields + description = null; + await PlacesUtils.history.update({ url: TEST_URL, description }); + descriptionInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "description", + { + url: TEST_URL, + } + ); + previewImageURLInDB = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "preview_image_url", + { + url: TEST_URL, + } + ); + Assert.strictEqual( + description, + descriptionInDB, + "description should be updated via URL as expected" + ); + Assert.equal( + previewImageURL, + previewImageURLInDB, + "previewImageURL should not be updated" + ); +}); + +/** + * Gets annotation information from the database for the specified URL and + * annotation name. + * + * @param {String} pageUrl The URL to search for. + * @param {String} annoName The name of the annotation to search for. + * @return {Array} An array of objects containing the annotations found. + */ +async function getAnnotationInfoFromDB(pageUrl, annoName) { + let db = await PlacesUtils.promiseDBConnection(); + + let rows = await db.execute( + ` + SELECT a.content, a.flags, a.expiration, a.type FROM moz_anno_attributes n + JOIN moz_annos a ON n.id = a.anno_attribute_id + JOIN moz_places h ON h.id = a.place_id + WHERE h.url_hash = hash(:pageUrl) AND h.url = :pageUrl + AND n.name = :annoName + `, + { annoName, pageUrl } + ); + + let result = rows.map(row => { + return { + content: row.getResultByName("content"), + flags: row.getResultByName("flags"), + expiration: row.getResultByName("expiration"), + type: row.getResultByName("type"), + }; + }); + + return result; +} + +add_task(async function test_simple_change_annotations() { + await PlacesUtils.history.clear(); + + const TEST_URL = "http://mozilla.org/test_change_both_saved"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL), + "Should have inserted the page into the database." + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/annotation", "testContent"]]), + }); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 1, + "Should have one annotation for the page" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + "testContent", + "Should have the correct annotation" + ); + + let annotationInfo = await getAnnotationInfoFromDB( + TEST_URL, + "test/annotation" + ); + Assert.deepEqual( + { + content: "testContent", + flags: 0, + type: PlacesUtils.history.ANNOTATION_TYPE_STRING, + expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER, + }, + annotationInfo[0], + "Should have stored the correct annotation data in the db" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/annotation2", "testAnno"]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 2, + "Should have two annotations for the page" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + "testContent", + "Should have the correct value for the first annotation" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + "testAnno", + "Should have the correct value for the second annotation" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/annotation", 1234]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 2, + "Should still have two annotations for the page" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + 1234, + "Should have the updated the first annotation value" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + "testAnno", + "Should have kept the value for the second annotation" + ); + + annotationInfo = await getAnnotationInfoFromDB(TEST_URL, "test/annotation"); + Assert.deepEqual( + { + content: 1234, + flags: 0, + type: PlacesUtils.history.ANNOTATION_TYPE_INT64, + expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER, + }, + annotationInfo[0], + "Should have updated the annotation data in the db" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/annotation", null]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 1, + "Should have removed only the first annotation" + ); + Assert.strictEqual( + pageInfo.annotations.get("test/annotation"), + undefined, + "Should have removed only the first annotation" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + "testAnno", + "Should have kept the value for the second annotation" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([["test/annotation2", null]]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left"); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT * FROM moz_annos + `); + Assert.equal(rows.length, 0, "Should be no annotations left in the db"); +}); + +add_task(async function test_change_multiple_annotations() { + await PlacesUtils.history.clear(); + + const TEST_URL = "http://mozilla.org/test_change_both_saved"; + await PlacesTestUtils.addVisits(TEST_URL); + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL), + "Should have inserted the page into the database." + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([ + ["test/annotation", "testContent"], + ["test/annotation2", "testAnno"], + ]), + }); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 2, + "Should have inserted the two annotations for the page." + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + "testContent", + "Should have the correct value for the first annotation" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + "testAnno", + "Should have the correct value for the second annotation" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([ + ["test/annotation", 123456], + ["test/annotation2", 135246], + ]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.size, + 2, + "Should have two annotations for the page" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation"), + 123456, + "Should have the correct value for the first annotation" + ); + Assert.equal( + pageInfo.annotations.get("test/annotation2"), + 135246, + "Should have the correct value for the second annotation" + ); + + await PlacesUtils.history.update({ + url: TEST_URL, + annotations: new Map([ + ["test/annotation", null], + ["test/annotation2", null], + ]), + }); + + pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + + Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left"); +}); + +add_task(async function test_annotations_nonexisting_page() { + info("Adding annotations to a non existing page should be silent"); + await PlacesUtils.history.update({ + url: "http://nonexisting.moz/", + annotations: new Map([["test/annotation", null]]), + }); +}); + +add_task(async function test_annotations_nonexisting_page() { + info("Adding annotations to a non existing page should be silent"); + await PlacesUtils.history.update({ + url: "http://nonexisting.moz/", + annotations: new Map([["test/annotation", null]]), + }); +}); diff --git a/toolkit/components/places/tests/history/test_updatePlaces_embed.js b/toolkit/components/places/tests/history/test_updatePlaces_embed.js new file mode 100644 index 0000000000..a2831f2f58 --- /dev/null +++ b/toolkit/components/places/tests/history/test_updatePlaces_embed.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that updatePlaces properly handled callbacks for embed visits. + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "asyncHistory", + "@mozilla.org/browser/history;1", + "mozIAsyncHistory" +); + +add_task(async function test_embed_visit() { + let place = { + uri: NetUtil.newURI("http://places.test/"), + visits: [ + { + transitionType: PlacesUtils.history.TRANSITIONS.EMBED, + visitDate: PlacesUtils.toPRTime(new Date()), + }, + ], + }; + let errors = 0; + let results = 0; + let updated = await new Promise(resolve => { + asyncHistory.updatePlaces(place, { + ignoreErrors: true, + ignoreResults: true, + handleError(aResultCode, aPlace) { + errors++; + }, + handleResult(aPlace) { + results++; + }, + handleCompletion(resultCount) { + resolve(resultCount); + }, + }); + }); + Assert.equal(errors, 0, "There should be no error callback"); + Assert.equal(results, 0, "There should be no result callback"); + Assert.equal(updated, 1, "The visit should have been added"); +}); + +add_task(async function test_misc_visits() { + let place = { + uri: NetUtil.newURI("http://places.test/"), + visits: [ + { + transitionType: PlacesUtils.history.TRANSITIONS.EMBED, + visitDate: PlacesUtils.toPRTime(new Date()), + }, + { + transitionType: PlacesUtils.history.TRANSITIONS.LINK, + visitDate: PlacesUtils.toPRTime(new Date()), + }, + ], + }; + let errors = 0; + let results = 0; + let updated = await new Promise(resolve => { + asyncHistory.updatePlaces(place, { + ignoreErrors: true, + ignoreResults: true, + handleError(aResultCode, aPlace) { + errors++; + }, + handleResult(aPlace) { + results++; + }, + handleCompletion(resultCount) { + resolve(resultCount); + }, + }); + }); + Assert.equal(errors, 0, "There should be no error callback"); + Assert.equal(results, 0, "There should be no result callback"); + Assert.equal(updated, 2, "The visit should have been added"); +}); diff --git a/toolkit/components/places/tests/history/xpcshell.ini b/toolkit/components/places/tests/history/xpcshell.ini new file mode 100644 index 0000000000..b4a017e15d --- /dev/null +++ b/toolkit/components/places/tests/history/xpcshell.ini @@ -0,0 +1,20 @@ +[DEFAULT] +head = head_history.js + +[test_async_history_api.js] +[test_bookmark_unhide.js] +[test_fetch.js] +[test_fetchAnnotatedPages.js] +[test_fetchMany.js] +[test_hasVisits.js] +[test_insert.js] +[test_insert_null_title.js] +[test_insertMany.js] +[test_remove.js] +[test_removeMany.js] +[test_removeVisits.js] +[test_removeByFilter.js] +[test_removeVisitsByFilter.js] +[test_sameUri_titleChanged.js] +[test_update.js] +[test_updatePlaces_embed.js] diff --git a/toolkit/components/places/tests/legacy/head_legacy.js b/toolkit/components/places/tests/legacy/head_legacy.js new file mode 100644 index 0000000000..06e7fda560 --- /dev/null +++ b/toolkit/components/places/tests/legacy/head_legacy.js @@ -0,0 +1,14 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. diff --git a/toolkit/components/places/tests/legacy/test_bookmarks.js b/toolkit/components/places/tests/legacy/test_bookmarks.js new file mode 100644 index 0000000000..3f331b56cb --- /dev/null +++ b/toolkit/components/places/tests/legacy/test_bookmarks.js @@ -0,0 +1,519 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var bs = PlacesUtils.bookmarks; +var hs = PlacesUtils.history; +var os = PlacesUtils.observers; + +var bookmarksObserver = { + handlePlacesEvents(events) { + Assert.equal(events.length, 1); + let event = events[0]; + switch (event.type) { + case "bookmark-added": + bookmarksObserver._itemAddedId = event.id; + bookmarksObserver._itemAddedParent = event.parentId; + bookmarksObserver._itemAddedIndex = event.index; + bookmarksObserver._itemAddedURI = event.url + ? Services.io.newURI(event.url) + : null; + bookmarksObserver._itemAddedTitle = event.title; + + // Ensure that we've created a guid for this item. + let stmt = DBConn().createStatement( + `SELECT guid + FROM moz_bookmarks + WHERE id = :item_id` + ); + stmt.params.item_id = event.id; + Assert.ok(stmt.executeStep()); + Assert.ok(!stmt.getIsNull(0)); + do_check_valid_places_guid(stmt.row.guid); + Assert.equal(stmt.row.guid, event.guid); + stmt.finalize(); + break; + case "bookmark-removed": + bookmarksObserver._itemRemovedId = event.id; + bookmarksObserver._itemRemovedFolder = event.parentId; + bookmarksObserver._itemRemovedIndex = event.index; + break; + case "bookmark-title-changed": + bookmarksObserver._itemTitleChangedId = event.id; + bookmarksObserver._itemTitleChangedTitle = event.title; + break; + } + }, +}; + +var root; +// Index at which items should begin. +var bmStartIndex = 0; + +add_task(async function setup() { + // Get bookmarks menu folder id. + root = await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.menuGuid); +}); + +add_task(async function test_bookmarks() { + os.addListener( + ["bookmark-added", "bookmark-removed", "bookmark-title-changed"], + bookmarksObserver.handlePlacesEvents + ); + + // test special folders + Assert.ok(bs.tagsFolder > 0); + + // create a folder to hold all the tests + // this makes the tests more tolerant of changes to default_places.html + let testRoot = bs.createFolder( + root, + "places bookmarks xpcshell tests", + bs.DEFAULT_INDEX + ); + let testRootGuid = await PlacesUtils.promiseItemGuid(testRoot); + Assert.equal(bookmarksObserver._itemAddedId, testRoot); + Assert.equal(bookmarksObserver._itemAddedParent, root); + Assert.equal(bookmarksObserver._itemAddedIndex, bmStartIndex); + Assert.equal(bookmarksObserver._itemAddedURI, null); + let testStartIndex = 0; + + // insert a bookmark. + // the time before we insert, in microseconds + let beforeInsert = Date.now() * 1000; + Assert.ok(beforeInsert > 0); + + let newId = bs.insertBookmark( + testRoot, + uri("http://google.com/"), + bs.DEFAULT_INDEX, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId); + Assert.equal(bookmarksObserver._itemAddedParent, testRoot); + Assert.equal(bookmarksObserver._itemAddedIndex, testStartIndex); + Assert.ok(bookmarksObserver._itemAddedURI.equals(uri("http://google.com/"))); + + // after just inserting, modified should not be set + let lastModified = PlacesUtils.toPRTime( + ( + await PlacesUtils.bookmarks.fetch( + await PlacesUtils.promiseItemGuid(newId) + ) + ).lastModified + ); + + // The time before we set the title, in microseconds. + let beforeSetTitle = Date.now() * 1000; + Assert.ok(beforeSetTitle >= beforeInsert); + + // Workaround possible VM timers issues moving lastModified and dateAdded + // to the past. + lastModified -= 1000; + bs.setItemLastModified(newId, lastModified); + + // set bookmark title + bs.setItemTitle(newId, "Google"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "Google"); + + // check lastModified after we set the title + let lastModified2 = PlacesUtils.toPRTime( + ( + await PlacesUtils.bookmarks.fetch( + await PlacesUtils.promiseItemGuid(newId) + ) + ).lastModified + ); + info("test setItemTitle"); + info("beforeSetTitle = " + beforeSetTitle); + info("lastModified = " + lastModified); + info("lastModified2 = " + lastModified2); + Assert.ok(is_time_ordered(lastModified, lastModified2)); + + // get item title + let title = bs.getItemTitle(newId); + Assert.equal(title, "Google"); + + // get item title bad input + try { + bs.getItemTitle(-3); + do_throw("getItemTitle accepted bad input"); + } catch (ex) {} + + // create a folder at a specific index + let workFolder = bs.createFolder(testRoot, "Work", 0); + Assert.equal(bookmarksObserver._itemAddedId, workFolder); + Assert.equal(bookmarksObserver._itemAddedParent, testRoot); + Assert.equal(bookmarksObserver._itemAddedIndex, 0); + Assert.equal(bookmarksObserver._itemAddedURI, null); + + Assert.equal(bs.getItemTitle(workFolder), "Work"); + bs.setItemTitle(workFolder, "Work #"); + Assert.equal(bs.getItemTitle(workFolder), "Work #"); + + // add item into subfolder, specifying index + let newId2 = bs.insertBookmark( + workFolder, + uri("http://developer.mozilla.org/"), + 0, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId2); + Assert.equal(bookmarksObserver._itemAddedParent, workFolder); + Assert.equal(bookmarksObserver._itemAddedIndex, 0); + + // change item + bs.setItemTitle(newId2, "DevMo"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId2); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "DevMo"); + + // insert item into subfolder + let newId3 = bs.insertBookmark( + workFolder, + uri("http://msdn.microsoft.com/"), + bs.DEFAULT_INDEX, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId3); + Assert.equal(bookmarksObserver._itemAddedParent, workFolder); + Assert.equal(bookmarksObserver._itemAddedIndex, 1); + + // change item + bs.setItemTitle(newId3, "MSDN"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId3); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "MSDN"); + + // remove item + bs.removeItem(newId2); + Assert.equal(bookmarksObserver._itemRemovedId, newId2); + Assert.equal(bookmarksObserver._itemRemovedFolder, workFolder); + Assert.equal(bookmarksObserver._itemRemovedIndex, 0); + + // insert item into subfolder + let newId4 = bs.insertBookmark( + workFolder, + uri("http://developer.mozilla.org/"), + bs.DEFAULT_INDEX, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId4); + Assert.equal(bookmarksObserver._itemAddedParent, workFolder); + Assert.equal(bookmarksObserver._itemAddedIndex, 1); + + // create folder + let homeFolder = bs.createFolder(testRoot, "Home", bs.DEFAULT_INDEX); + Assert.equal(bookmarksObserver._itemAddedId, homeFolder); + Assert.equal(bookmarksObserver._itemAddedParent, testRoot); + Assert.equal(bookmarksObserver._itemAddedIndex, 2); + + // insert item + let newId5 = bs.insertBookmark( + homeFolder, + uri("http://espn.com/"), + bs.DEFAULT_INDEX, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId5); + Assert.equal(bookmarksObserver._itemAddedParent, homeFolder); + Assert.equal(bookmarksObserver._itemAddedIndex, 0); + + // change item + bs.setItemTitle(newId5, "ESPN"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId5); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "ESPN"); + + // insert query item + let uri6 = uri( + "place:domain=google.com&type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY + ); + let newId6 = bs.insertBookmark(testRoot, uri6, bs.DEFAULT_INDEX, ""); + Assert.equal(bookmarksObserver._itemAddedParent, testRoot); + Assert.equal(bookmarksObserver._itemAddedIndex, 3); + + // change item + bs.setItemTitle(newId6, "Google Sites"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId6); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "Google Sites"); + + // test bookmark id in query output + try { + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + query.setParents([testRootGuid]); + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + info("bookmark itemId test: CC = " + cc); + Assert.ok(cc > 0); + for (let i = 0; i < cc; ++i) { + let node = rootNode.getChild(i); + if ( + node.type == node.RESULT_TYPE_FOLDER || + node.type == node.RESULT_TYPE_URI || + node.type == node.RESULT_TYPE_SEPARATOR || + node.type == node.RESULT_TYPE_QUERY + ) { + Assert.ok(node.itemId > 0); + } else { + Assert.equal(node.itemId, -1); + } + } + rootNode.containerOpen = false; + } catch (ex) { + do_throw("bookmarks query: " + ex); + } + + // test that multiple bookmarks with same URI show up right in bookmark + // folder queries, todo: also to do for complex folder queries + try { + // test uri + let mURI = uri("http://multiple.uris.in.query"); + + let testFolder = bs.createFolder(testRoot, "test Folder", bs.DEFAULT_INDEX); + let testFolderGuid = await PlacesUtils.promiseItemGuid(testFolder); + // add 2 bookmarks + bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 1"); + bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 2"); + + // query + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + query.setParents([testFolderGuid]); + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.equal(cc, 2); + Assert.equal(rootNode.getChild(0).title, "title 1"); + Assert.equal(rootNode.getChild(1).title, "title 2"); + rootNode.containerOpen = false; + } catch (ex) { + do_throw("bookmarks query: " + ex); + } + + // test change bookmark uri + let newId10 = bs.insertBookmark( + testRoot, + uri("http://foo10.com/"), + bs.DEFAULT_INDEX, + "" + ); + + // Workaround possible VM timers issues moving lastModified and dateAdded + // to the past. + lastModified -= 1000; + bs.setItemLastModified(newId10, lastModified); + + // insert a bookmark with title ZZZXXXYYY and then search for it. + // this test confirms that we can find bookmarks that we haven't visited + // (which are "hidden") and that we can find by title. + // see bug #369887 for more details + let newId13 = bs.insertBookmark( + testRoot, + uri("http://foobarcheese.com/"), + bs.DEFAULT_INDEX, + "" + ); + Assert.equal(bookmarksObserver._itemAddedId, newId13); + Assert.equal(bookmarksObserver._itemAddedParent, testRoot); + Assert.equal(bookmarksObserver._itemAddedIndex, 6); + + // set bookmark title + bs.setItemTitle(newId13, "ZZZXXXYYY"); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId13); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, "ZZZXXXYYY"); + + // test search on bookmark title ZZZXXXYYY + try { + let options = hs.getNewQueryOptions(); + options.excludeQueries = 1; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + let query = hs.getNewQuery(); + query.searchTerms = "ZZZXXXYYY"; + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.equal(cc, 1); + let node = rootNode.getChild(0); + Assert.equal(node.title, "ZZZXXXYYY"); + Assert.ok(node.itemId > 0); + rootNode.containerOpen = false; + } catch (ex) { + do_throw("bookmarks query: " + ex); + } + + // test dateAdded and lastModified properties + // for a search query + try { + let options = hs.getNewQueryOptions(); + options.excludeQueries = 1; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + let query = hs.getNewQuery(); + query.searchTerms = "ZZZXXXYYY"; + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.equal(cc, 1); + let node = rootNode.getChild(0); + + Assert.equal(typeof node.dateAdded, "number"); + Assert.ok(node.dateAdded > 0); + + Assert.equal(typeof node.lastModified, "number"); + Assert.ok(node.lastModified > 0); + + rootNode.containerOpen = false; + } catch (ex) { + do_throw("bookmarks query: " + ex); + } + + // test dateAdded and lastModified properties + // for a folder query + try { + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + query.setParents([testRootGuid]); + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + let cc = rootNode.childCount; + Assert.ok(cc > 0); + for (let i = 0; i < cc; i++) { + let node = rootNode.getChild(i); + + if (node.type == node.RESULT_TYPE_URI) { + Assert.equal(typeof node.dateAdded, "number"); + Assert.ok(node.dateAdded > 0); + + Assert.equal(typeof node.lastModified, "number"); + Assert.ok(node.lastModified > 0); + break; + } + } + rootNode.containerOpen = false; + } catch (ex) { + do_throw("bookmarks query: " + ex); + } + + // check setItemLastModified() + let newId14 = bs.insertBookmark( + testRoot, + uri("http://bar.tld/"), + bs.DEFAULT_INDEX, + "" + ); + bs.setItemLastModified(newId14, 1234000000000000); + let fakeLastModified = PlacesUtils.toPRTime( + ( + await PlacesUtils.bookmarks.fetch( + await PlacesUtils.promiseItemGuid(newId14) + ) + ).lastModified + ); + Assert.equal(fakeLastModified, 1234000000000000); + + // bug 378820 + let uri1 = uri("http://foo.tld/a"); + bs.insertBookmark(testRoot, uri1, bs.DEFAULT_INDEX, ""); + await PlacesTestUtils.addVisits(uri1); + + // bug 646993 - test bookmark titles longer than the maximum allowed length + let title15 = Array(TITLE_LENGTH_MAX + 5).join("X"); + let title15expected = title15.substring(0, TITLE_LENGTH_MAX); + let newId15 = bs.insertBookmark( + testRoot, + uri("http://evil.com/"), + bs.DEFAULT_INDEX, + title15 + ); + + Assert.equal(bs.getItemTitle(newId15).length, title15expected.length); + Assert.equal(bookmarksObserver._itemAddedTitle, title15expected); + // test title length after updates + bs.setItemTitle(newId15, title15 + " updated"); + Assert.equal(bs.getItemTitle(newId15).length, title15expected.length); + Assert.equal(bookmarksObserver._itemTitleChangedId, newId15); + Assert.equal(bookmarksObserver._itemTitleChangedTitle, title15expected); + + await testSimpleFolderResult(); +}); + +async function testSimpleFolderResult() { + // the time before we create a folder, in microseconds + // Workaround possible VM timers issues subtracting 1us. + let beforeCreate = Date.now() * 1000 - 1; + Assert.ok(beforeCreate > 0); + + // create a folder + let parent = bs.createFolder(root, "test", bs.DEFAULT_INDEX); + let parentGuid = await PlacesUtils.promiseItemGuid(parent); + + // the time before we insert, in microseconds + // Workaround possible VM timers issues subtracting 1ms. + let beforeInsert = Date.now() * 1000 - 1; + Assert.ok(beforeInsert > 0); + + // re-set item title separately so can test nodes' last modified + let item = bs.insertBookmark( + parent, + uri("about:blank"), + bs.DEFAULT_INDEX, + "" + ); + bs.setItemTitle(item, "test bookmark"); + + // see above + let folder = bs.createFolder(parent, "test folder", bs.DEFAULT_INDEX); + bs.setItemTitle(folder, "test folder"); + + let longName = Array(TITLE_LENGTH_MAX + 5).join("A"); + let folderLongName = bs.createFolder(parent, longName, bs.DEFAULT_INDEX); + Assert.equal( + bookmarksObserver._itemAddedTitle, + longName.substring(0, TITLE_LENGTH_MAX) + ); + + let options = hs.getNewQueryOptions(); + let query = hs.getNewQuery(); + query.setParents([parentGuid]); + let result = hs.executeQuery(query, options); + let rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 3); + + let node = rootNode.getChild(0); + Assert.equal(node.itemId, item); + Assert.ok(node.dateAdded > 0); + Assert.ok(node.lastModified > 0); + Assert.equal(node.title, "test bookmark"); + node = rootNode.getChild(1); + Assert.equal(node.itemId, folder); + Assert.equal(node.title, "test folder"); + Assert.ok(node.dateAdded > 0); + Assert.ok(node.lastModified > 0); + node = rootNode.getChild(2); + Assert.equal(node.itemId, folderLongName); + Assert.equal(node.title, longName.substring(0, TITLE_LENGTH_MAX)); + Assert.ok(node.dateAdded > 0); + Assert.ok(node.lastModified > 0); + + // update with another long title + bs.setItemTitle(folderLongName, longName + " updated"); + Assert.equal(bookmarksObserver._itemTitleChangedId, folderLongName); + Assert.equal( + bookmarksObserver._itemTitleChangedTitle, + longName.substring(0, TITLE_LENGTH_MAX) + ); + + node = rootNode.getChild(2); + Assert.equal(node.title, longName.substring(0, TITLE_LENGTH_MAX)); + + rootNode.containerOpen = false; +} diff --git a/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js b/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js new file mode 100644 index 0000000000..ff224c3402 --- /dev/null +++ b/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Both setItemTitle and insertBookmark should default to the empty string + * for null titles. + */ + +const bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].getService( + Ci.nsINavBookmarksService +); + +const TEST_URL = "http://www.mozilla.org"; + +function run_test() { + // Insert a bookmark with an empty title. + var itemId = bs.insertBookmark( + bs.tagsFolder, + uri(TEST_URL), + bs.DEFAULT_INDEX, + "" + ); + // Check returned title is an empty string. + Assert.equal(bs.getItemTitle(itemId), ""); + // Set title to null. + bs.setItemTitle(itemId, null); + // Check returned title defaults to an empty string. + Assert.equal(bs.getItemTitle(itemId), ""); + // Cleanup. + bs.removeItem(itemId); + + // Insert a bookmark with a null title. + itemId = bs.insertBookmark( + bs.tagsFolder, + uri(TEST_URL), + bs.DEFAULT_INDEX, + null + ); + // Check returned title defaults to an empty string. + Assert.equal(bs.getItemTitle(itemId), ""); + // Set title to an empty string. + bs.setItemTitle(itemId, ""); + // Check returned title is an empty string. + Assert.equal(bs.getItemTitle(itemId), ""); + // Cleanup. + bs.removeItem(itemId); +} diff --git a/toolkit/components/places/tests/legacy/test_protectRoots.js b/toolkit/components/places/tests/legacy/test_protectRoots.js new file mode 100644 index 0000000000..7c3c9d31dc --- /dev/null +++ b/toolkit/components/places/tests/legacy/test_protectRoots.js @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async () => { + const ROOTS = [ + PlacesUtils.bookmarks.rootGuid, + ...PlacesUtils.bookmarks.userContentRoots, + PlacesUtils.bookmarks.tagsGuid, + ]; + + for (let guid of ROOTS) { + Assert.ok(PlacesUtils.isRootItem(guid)); + + let id = await PlacesUtils.promiseItemId(guid); + + try { + PlacesUtils.bookmarks.removeItem(id); + do_throw("Trying to remove a root should throw"); + } catch (ex) {} + } +}); diff --git a/toolkit/components/places/tests/legacy/xpcshell.ini b/toolkit/components/places/tests/legacy/xpcshell.ini new file mode 100644 index 0000000000..54c9c6d3f5 --- /dev/null +++ b/toolkit/components/places/tests/legacy/xpcshell.ini @@ -0,0 +1,10 @@ +# This directory is for tests for the legacy, sync APIs as somewhere to put them +# until we remove the APIs themselves. + +[DEFAULT] +head = head_legacy.js +firefox-appdir = browser + +[test_bookmarks.js] +[test_bookmarks_setNullTitle.js] +[test_protectRoots.js] diff --git a/toolkit/components/places/tests/maintenance/corruptDB.sqlite b/toolkit/components/places/tests/maintenance/corruptDB.sqlite Binary files differnew file mode 100644 index 0000000000..b234246cac --- /dev/null +++ b/toolkit/components/places/tests/maintenance/corruptDB.sqlite diff --git a/toolkit/components/places/tests/maintenance/corruptPayload.sqlite b/toolkit/components/places/tests/maintenance/corruptPayload.sqlite Binary files differnew file mode 100644 index 0000000000..16717bda80 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/corruptPayload.sqlite diff --git a/toolkit/components/places/tests/maintenance/head.js b/toolkit/components/places/tests/maintenance/head.js new file mode 100644 index 0000000000..3117ab323d --- /dev/null +++ b/toolkit/components/places/tests/maintenance/head.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +ChromeUtils.defineESModuleGetters(this, { + PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", +}); + +async function createCorruptDb(filename) { + let path = PathUtils.join(PathUtils.profileDir, filename); + await IOUtils.remove(path, { ignoreAbsent: true }); + // Create a corrupt database. + let dir = do_get_cwd().path; + let src = PathUtils.join(dir, "corruptDB.sqlite"); + await IOUtils.copy(src, path); +} + +/** + * Used in _replaceOnStartup_ tests as common test code. It checks whether we + * are properly cloning or replacing a corrupt database. + * + * @param {string[]} src + * Array of strings which form a path to a test database, relative to + * the parent of this test folder. + * @param {string} filename + * Database file name + * @param {boolean} shouldClone + * Whether we expect the database to be cloned + * @param {boolean} dbStatus + * The expected final database status + */ +async function test_database_replacement(src, filename, shouldClone, dbStatus) { + registerCleanupFunction(() => { + Services.prefs.clearUserPref("places.database.cloneOnCorruption"); + }); + Services.prefs.setBoolPref("places.database.cloneOnCorruption", shouldClone); + + // Only the main database file (places.sqlite) will be cloned, because + // attached databased would break due to OS file lockings. + let willClone = shouldClone && filename == DB_FILENAME; + + // Ensure that our databases don't exist yet. + let dest = PathUtils.join(PathUtils.profileDir, filename); + Assert.ok( + !(await IOUtils.exists(dest)), + `"${filename} should not exist initially` + ); + let corrupt = PathUtils.join(PathUtils.profileDir, `${filename}.corrupt`); + Assert.ok( + !(await IOUtils.exists(corrupt)), + `${filename}.corrupt should not exist initially` + ); + + let dir = PathUtils.parent(do_get_cwd().path); + src = PathUtils.join(dir, ...src); + await IOUtils.copy(src, dest); + + // Create some unique stuff to check later. + let db = await Sqlite.openConnection({ path: dest }); + await db.execute(`CREATE TABLE moz_cloned (id INTEGER PRIMARY KEY)`); + await db.execute(`CREATE TABLE not_cloned (id INTEGER PRIMARY KEY)`); + await db.execute(`DELETE FROM moz_cloned`); // Shouldn't throw. + await db.close(); + + // Open the database with Places. + Services.prefs.setCharPref( + "places.database.replaceDatabaseOnStartup", + filename + ); + Assert.equal(PlacesUtils.history.databaseStatus, dbStatus); + + Assert.ok(await IOUtils.exists(dest), "The database should exist"); + + // Check the new database still contains our special data. + db = await Sqlite.openConnection({ path: dest }); + if (willClone) { + await db.execute(`DELETE FROM moz_cloned`); // Shouldn't throw. + } + + // Check the new database is really a new one. + await Assert.rejects( + db.execute(`DELETE FROM not_cloned`), + /no such table/, + "The database should have been replaced" + ); + await db.close(); + + if (willClone) { + Assert.ok( + !(await IOUtils.exists(corrupt)), + "The corrupt db should not exist" + ); + Assert.ok( + !(await IOUtils.exists(corrupt + "-wal")), + "The corrupt db wal should not exist" + ); + Assert.ok( + !(await IOUtils.exists(corrupt + "-shm")), + "The corrupt db shm should not exist" + ); + } else { + Assert.ok(await IOUtils.exists(corrupt), "The corrupt db should exist"); + } + + Assert.equal( + Services.prefs.getCharPref("places.database.replaceDatabaseOnStartup", ""), + "", + "The replaceDatabaseOnStartup pref should have been unset" + ); +} diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js b/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js new file mode 100644 index 0000000000..2021428a62 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a corrupt favicons file +// that can't be opened. + +add_task(async function () { + await createCorruptDb("favicons.sqlite"); + + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + let db = await PlacesUtils.promiseDBConnection(); + await db.execute("SELECT * FROM moz_icons"); // Should not fail. +}); diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js b/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js new file mode 100644 index 0000000000..299bbca65d --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a corrupt places schema. + +add_task(async function () { + let path = await setupPlacesDatabase(["migration", "favicons_v41.sqlite"]); + + // Ensure the database will go through a migration that depends on moz_places + // and break the schema by dropping that table. + let db = await Sqlite.openConnection({ path }); + await db.setSchemaVersion(38); + await db.execute("DROP TABLE moz_icons"); + await db.close(); + + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute("SELECT 1 FROM moz_icons"); + Assert.equal(rows.length, 0, "Found no icons"); +}); diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js b/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js new file mode 100644 index 0000000000..9af7863ca2 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a corrupt places schema. + +add_task(async function () { + let path = await setupPlacesDatabase(["migration", "places_v43.sqlite"]); + + // Ensure the database will go through a migration that depends on moz_places + // and break the schema by dropping that table. + let db = await Sqlite.openConnection({ path }); + await db.setSchemaVersion(43); + await db.execute("DROP TABLE moz_places"); + await db.close(); + + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + db = await PlacesUtils.promiseDBConnection(); + await db.execute("SELECT * FROM moz_places LIMIT 1"); // Should not fail. +}); diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js b/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js new file mode 100644 index 0000000000..d6659267da --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a request to forcibly +// replace the current database. + +add_task(async function () { + await createCorruptDb("places.sqlite"); + + let count = Services.telemetry + .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE") + .snapshot().values[3]; + Assert.equal(count, undefined, "There should be no telemetry"); + + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + count = Services.telemetry + .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE") + .snapshot().values[3]; + Assert.equal(count, 1, "Telemetry should have been added"); +}); diff --git a/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js new file mode 100644 index 0000000000..d48b32f5d6 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a request to forcibly +// replace the current database. + +add_task(async function () { + await test_database_replacement( + ["migration", "favicons_v41.sqlite"], + "favicons.sqlite", + false, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); +}); diff --git a/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js new file mode 100644 index 0000000000..f6ff2379a0 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a request to forcibly +// replace the current database. + +add_task(async function () { + // In reality, this won't try to clone the database, because attached + // databases cannot be supported when cloning. This test also verifies that. + await test_database_replacement( + ["migration", "favicons_v41.sqlite"], + "favicons.sqlite", + true, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); +}); diff --git a/toolkit/components/places/tests/maintenance/test_integrity_replacement.js b/toolkit/components/places/tests/maintenance/test_integrity_replacement.js new file mode 100644 index 0000000000..dde8fd16a3 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_integrity_replacement.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that integrity check will replace a corrupt database. + +add_task(async function () { + await setupPlacesDatabase("corruptPayload.sqlite"); + await Assert.rejects( + PlacesDBUtils.checkIntegrity(), + /will be replaced on next startup/, + "Should reject on corruption" + ); + Assert.equal( + Services.prefs.getCharPref("places.database.replaceDatabaseOnStartup"), + DB_FILENAME + ); +}); diff --git a/toolkit/components/places/tests/maintenance/test_places_purge_caches.js b/toolkit/components/places/tests/maintenance/test_places_purge_caches.js new file mode 100644 index 0000000000..dc3e8452f1 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_places_purge_caches.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test whether purge-caches event works collectry when maintenance the places. + +add_task(async function test_history() { + await PlacesTestUtils.addVisits({ uri: "http://example.com/" }); + await assertPurgingCaches(); +}); + +add_task(async function test_bookmark() { + await PlacesTestUtils.addBookmarkWithDetails({ uri: "http://example.com/" }); + await assertPurgingCaches(); +}); + +async function assertPurgingCaches() { + const query = PlacesUtils.history.getNewQuery(); + const options = PlacesUtils.history.getNewQueryOptions(); + const result = PlacesUtils.history.executeQuery(query, options); + result.root.containerOpen = true; + + const onInvalidateContainer = new Promise(resolve => { + const resultObserver = new NavHistoryResultObserver(); + resultObserver.invalidateContainer = resolve; + result.addObserver(resultObserver, false); + }); + + await PlacesDBUtils.maintenanceOnIdle(); + await onInvalidateContainer; + ok(true, "InvalidateContainer is called"); +} diff --git a/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js new file mode 100644 index 0000000000..dae5154df4 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a request to forcibly +// replace the current database. + +add_task(async function () { + await test_database_replacement( + ["migration", "places_v43.sqlite"], + "places.sqlite", + false, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); +}); diff --git a/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js new file mode 100644 index 0000000000..d2ef1374e9 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that history initialization correctly handles a request to forcibly +// replace the current database. + +add_task(async function () { + await test_database_replacement( + ["migration", "places_v43.sqlite"], + "places.sqlite", + true, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); +}); diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js new file mode 100644 index 0000000000..a1ae830d8e --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js @@ -0,0 +1,2823 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test preventive maintenance + * For every maintenance query create an uncoherent db and check that we take + * correct fix steps, without polluting valid data. + */ + +// ------------------------------------------------------------------------------ +// Helpers + +var defaultBookmarksMaxId = 0; +async function cleanDatabase() { + // First clear any bookmarks the "proper way" to ensure caches like GuidHelper + // are properly cleared. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.withConnectionWrapper("cleanDatabase", async db => { + await db.executeTransaction(async () => { + await db.executeCached("DELETE FROM moz_places"); + await db.executeCached("DELETE FROM moz_origins"); + await db.executeCached("DELETE FROM moz_historyvisits"); + await db.executeCached("DELETE FROM moz_anno_attributes"); + await db.executeCached("DELETE FROM moz_annos"); + await db.executeCached("DELETE FROM moz_inputhistory"); + await db.executeCached("DELETE FROM moz_keywords"); + await db.executeCached("DELETE FROM moz_icons"); + await db.executeCached("DELETE FROM moz_pages_w_icons"); + await db.executeCached( + "DELETE FROM moz_bookmarks WHERE id > " + defaultBookmarksMaxId + ); + await db.executeCached("DELETE FROM moz_bookmarks_deleted"); + await db.executeCached("DELETE FROM moz_places_metadata_search_queries"); + }); + }); + // Since we're doing raw deletes, we must invalidate the guids cache. + await PlacesUtils.invalidateCachedGuids(); +} + +async function addPlace( + aUrl, + aFavicon, + aGuid = PlacesUtils.history.makeGuid(), + aHash = null +) { + let href = new URL( + aUrl || `http://www.mozilla.org/${encodeURIComponent(aGuid)}` + ).href; + let id; + await PlacesUtils.withConnectionWrapper("cleanDatabase", async db => { + await db.executeTransaction(async () => { + id = ( + await db.executeCached( + `INSERT INTO moz_places (url, url_hash, guid) + VALUES (:url, IFNULL(:hash, hash(:url)), :guid) + RETURNING id`, + { + url: href, + hash: aHash, + guid: aGuid, + } + ) + )[0].getResultByIndex(0); + await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp"); + + if (aFavicon) { + await db.executeCached( + `INSERT INTO moz_pages_w_icons (page_url, page_url_hash) + VALUES (:url, IFNULL(:hash, hash(:url)))`, + { + url: href, + hash: aHash, + } + ); + await db.executeCached( + `INSERT INTO moz_icons_to_pages (page_id, icon_id) + VALUES ( + (SELECT id FROM moz_pages_w_icons WHERE page_url_hash = IFNULL(:hash, hash(:url))), + :favicon + )`, + { + url: href, + hash: aHash, + favicon: aFavicon, + } + ); + } + }); + }); + return id; +} + +async function addBookmark( + aPlaceId, + aType, + aParentGuid = PlacesUtils.bookmarks.unfiledGuid, + aKeywordId, + aTitle, + aGuid = PlacesUtils.history.makeGuid(), + aSyncStatus = PlacesUtils.bookmarks.SYNC_STATUS.NEW, + aSyncChangeCounter = 0 +) { + return PlacesUtils.withConnectionWrapper("addBookmark", async db => { + return ( + await db.executeCached( + `INSERT INTO moz_bookmarks (fk, type, parent, keyword_id, + title, guid, syncStatus, syncChangeCounter) + VALUES (:place_id, :type, + (SELECT id FROM moz_bookmarks WHERE guid = :parent), :keyword_id, + :title, :guid, :sync_status, :change_counter) + RETURNING id`, + { + place_id: aPlaceId || null, + type: aType || null, + parent: aParentGuid, + keyword_id: aKeywordId || null, + title: typeof aTitle == "string" ? aTitle : null, + guid: aGuid, + sync_status: aSyncStatus, + change_counter: aSyncChangeCounter, + } + ) + )[0].getResultByIndex(0); + }); +} + +// ------------------------------------------------------------------------------ +// Tests + +var tests = []; + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "A.1", + desc: "Remove obsolete annotations from moz_annos", + + _obsoleteWeaveAttribute: "weave/test", + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid. + this._placeId = await addPlace(); + // Add an obsolete attribute. + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + db.executeCached( + "INSERT INTO moz_anno_attributes (name) VALUES (:anno)", + { anno: this._obsoleteWeaveAttribute } + ); + + db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES (:place_id, + (SELECT id FROM moz_anno_attributes WHERE name = :anno) + )`, + { + place_id: this._placeId, + anno: this._obsoleteWeaveAttribute, + } + ); + }); + }); + }, + + async check() { + // Check that the obsolete annotation has been removed. + Assert.strictEqual( + await PlacesTestUtils.getDatabaseValue("moz_anno_attributes", "id", { + name: this._obsoleteWeaveAttribute, + }), + undefined + ); + }, +}); + +tests.push({ + name: "A.3", + desc: "Remove unused attributes", + + _usedPageAttribute: "usedPage", + _unusedAttribute: "unused", + _placeId: null, + _bookmarkId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // add a bookmark + this._bookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + // Add a used attribute and an unused one. + await db.executeCached( + `INSERT INTO moz_anno_attributes (name) + VALUES (:anno1), (:anno2)`, + { + anno1: this._usedPageAttribute, + anno2: this._unusedAttribute, + } + ); + await db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`, + { + place_id: this._placeId, + anno: this._usedPageAttribute, + } + ); + }); + }); + }, + + async check() { + // Check that used attributes are still there + let value = await PlacesTestUtils.getDatabaseValue( + "moz_anno_attributes", + "id", + { + name: this._usedPageAttribute, + } + ); + Assert.notStrictEqual(value, undefined); + // Check that unused attribute has been removed + value = await PlacesTestUtils.getDatabaseValue( + "moz_anno_attributes", + "id", + { + name: this._unusedAttribute, + } + ); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "B.1", + desc: "Remove annotations with an invalid attribute", + + _usedPageAttribute: "usedPage", + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + // Add a used attribute. + await db.executeCached( + "INSERT INTO moz_anno_attributes (name) VALUES (:anno)", + { anno: this._usedPageAttribute } + ); + await db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`, + { + place_id: this._placeId, + anno: this._usedPageAttribute, + } + ); + // Add an annotation with a nonexistent attribute + await db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES(:place_id, 1337)`, + { place_id: this._placeId } + ); + }); + }); + }, + + async check() { + // Check that used attribute is still there + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + "SELECT id FROM moz_anno_attributes WHERE name = :anno", + { anno: this._usedPageAttribute } + ); + Assert.equal(rows.length, 1); + // check that annotation with valid attribute is still there + rows = await db.executeCached( + `SELECT id FROM moz_annos + WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)`, + { anno: this._usedPageAttribute } + ); + Assert.equal(rows.length, 1); + // Check that annotation with bogus attribute has been removed + let value = await PlacesTestUtils.getDatabaseValue("moz_annos", "id", { + anno_attribute_id: 1337, + }); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "B.2", + desc: "Remove orphan page annotations", + + _usedPageAttribute: "usedPage", + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + // Add a used attribute. + await db.executeCached( + "INSERT INTO moz_anno_attributes (name) VALUES (:anno)", + { anno: this._usedPageAttribute } + ); + await db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`, + { place_id: this._placeId, anno: this._usedPageAttribute } + ); + // Add an annotation to a nonexistent page + await db.executeCached( + `INSERT INTO moz_annos (place_id, anno_attribute_id) + VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`, + { place_id: 1337, anno: this._usedPageAttribute } + ); + }); + }); + }, + + async check() { + // Check that used attribute is still there + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + "SELECT id FROM moz_anno_attributes WHERE name = :anno", + { anno: this._usedPageAttribute } + ); + Assert.equal(rows.length, 1); + // check that annotation with valid attribute is still there + rows = await db.executeCached( + `SELECT id FROM moz_annos + WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)`, + { anno: this._usedPageAttribute } + ); + Assert.equal(rows.length, 1); + // Check that an annotation to a nonexistent page has been removed + let value = await PlacesTestUtils.getDatabaseValue("moz_annos", "id", { + place_id: 1337, + }); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.9", + desc: "Remove items without a valid place", + + _validItemId: null, + _invalidItemId: null, + _invalidSyncedItemId: null, + placeId: null, + + _changeCounterStmt: null, + _menuChangeCounter: -1, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this.placeId = await addPlace(); + // Insert a valid bookmark + this._validItemId = await addBookmark( + this.placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + // Insert a bookmark with an invalid place + this._invalidItemId = await addBookmark( + 1337, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + // Insert a synced bookmark with an invalid place. We should write a + // tombstone when we remove it. + this._invalidSyncedItemId = await addBookmark( + 1337, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.menuGuid, + null, + null, + "bookmarkAAAA", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + // Insert a folder referencing a nonexistent place ID. D.5 should convert + // it to a bookmark; D.9 should remove it. + this._invalidWrongTypeItemId = await addBookmark( + 1337, + PlacesUtils.bookmarks.TYPE_FOLDER + ); + + let value = await PlacesTestUtils.getDatabaseValue( + "moz_bookmarks", + "syncChangeCounter", + { + guid: PlacesUtils.bookmarks.menuGuid, + } + ); + Assert.equal(value, 0); + this._menuChangeCounter = value; + }, + + async check() { + // Check that valid bookmark is still there + let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + id: this._validItemId, + }); + Assert.notStrictEqual(value, undefined); + // Check that invalid bookmarks have been removed + for (let id of [ + this._invalidItemId, + this._invalidSyncedItemId, + this._invalidWrongTypeItemId, + ]) { + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + id, + }); + Assert.strictEqual(value, undefined); + } + + value = await PlacesTestUtils.getDatabaseValue( + "moz_bookmarks", + "syncChangeCounter", + { guid: PlacesUtils.bookmarks.menuGuid } + ); + Assert.equal(value, 1); + Assert.equal(value, this._menuChangeCounter + 1); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["bookmarkAAAA"] + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.1", + desc: "Remove items that are not uri bookmarks from tag containers", + + _tagId: null, + _bookmarkId: null, + _separatorId: null, + _folderId: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Create a tag + this._tagId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.tagsGuid + ); + let tagGuid = await PlacesUtils.promiseItemGuid(this._tagId); + // Insert a bookmark in the tag + this._bookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + tagGuid + ); + // Insert a separator in the tag + this._separatorId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + tagGuid + ); + // Insert a folder in the tag + this._folderId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + tagGuid + ); + }, + + async check() { + // Check that valid bookmark is still there + let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parent: this._tagId, + }); + Assert.notStrictEqual(value, undefined); + // Check that separator is no more there + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parent: this._tagId, + }); + Assert.equal(value, undefined); + // Check that folder is no more there + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parent: this._tagId, + }); + Assert.equal(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.2", + desc: "Remove empty tags", + + _tagId: null, + _bookmarkId: null, + _emptyTagId: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Create a tag + this._tagId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.tagsGuid + ); + let tagGuid = await PlacesUtils.promiseItemGuid(this._tagId); + // Insert a bookmark in the tag + this._bookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + tagGuid + ); + // Create another tag (empty) + this._emptyTagId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.tagsGuid + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + // Check that valid bookmark is still there + let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", { + id: this._bookmarkId, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parent: this._tagId, + }); + Assert.notStrictEqual(value, undefined); + let rows = await db.executeCached( + `SELECT b.id FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE b.id = :id AND b.type = :type AND p.guid = :parent`, + { + id: this._tagId, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parent: PlacesUtils.bookmarks.tagsGuid, + } + ); + Assert.equal(rows.length, 1); + rows = await db.executeCached( + `SELECT b.id FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE b.id = :id AND b.type = :type AND p.guid = :parent`, + { + id: this._emptyTagId, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parent: PlacesUtils.bookmarks.tagsGuid, + } + ); + Assert.equal(rows.length, 0); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.3", + desc: "Move orphan items to unsorted folder", + + _orphanBookmarkId: null, + _orphanSeparatorId: null, + _orphanFolderId: null, + _bookmarkId: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Insert an orphan bookmark + this._orphanBookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + 8888 + ); + // Insert an orphan separator + this._orphanSeparatorId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + 8888 + ); + // Insert a orphan folder + this._orphanFolderId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + 8888 + ); + let folderGuid = await PlacesUtils.promiseItemGuid(this._orphanFolderId); + // Create a child of the last created folder + this._bookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + folderGuid + ); + }, + + async check() { + // Check that bookmarks are now children of a real folder (unfiled) + let expectedInfos = [ + { + id: this._orphanBookmarkId, + parent: PlacesUtils.bookmarks.unfiledGuid, + syncChangeCounter: 1, + }, + { + id: this._orphanSeparatorId, + parent: PlacesUtils.bookmarks.unfiledGuid, + syncChangeCounter: 1, + }, + { + id: this._orphanFolderId, + parent: PlacesUtils.bookmarks.unfiledGuid, + syncChangeCounter: 1, + }, + { + id: this._bookmarkId, + parent: await PlacesUtils.promiseItemGuid(this._orphanFolderId), + syncChangeCounter: 0, + }, + { + id: await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.unfiledGuid), + parent: PlacesUtils.bookmarks.rootGuid, + syncChangeCounter: 3, + }, + ]; + let db = await PlacesUtils.promiseDBConnection(); + for (let { id, parent, syncChangeCounter } of expectedInfos) { + let rows = await db.executeCached( + ` + SELECT b.id, b.syncChangeCounter + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE b.id = :item_id + AND p.guid = :parent`, + { item_id: id, parent } + ); + Assert.equal(rows.length, 1); + + let actualChangeCounter = rows[0].getResultByName("syncChangeCounter"); + Assert.equal(actualChangeCounter, syncChangeCounter); + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.5", + desc: "Fix wrong item types | folders and separators", + + _separatorId: null, + _separatorGuid: null, + _folderId: null, + _folderGuid: null, + _syncedFolderId: null, + _syncedFolderGuid: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Add a separator with a fk + this._separatorId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_SEPARATOR + ); + this._separatorGuid = await PlacesUtils.promiseItemGuid(this._separatorId); + // Add a folder with a fk + this._folderId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_FOLDER + ); + this._folderGuid = await PlacesUtils.promiseItemGuid(this._folderId); + // Add a synced folder with a fk + this._syncedFolderId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.toolbarGuid, + null, + null, + "itemAAAAAAAA", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + this._syncedFolderGuid = await PlacesUtils.promiseItemGuid( + this._syncedFolderId + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + // Check that items with an fk have been converted to bookmarks + let rows = await db.executeCached( + `SELECT id, guid, syncChangeCounter + FROM moz_bookmarks + WHERE id = :item_id AND type = :type`, + { item_id: this._separatorId, type: PlacesUtils.bookmarks.TYPE_BOOKMARK } + ); + Assert.equal(rows.length, 1); + let expected = [ + { + id: this._folderId, + oldGuid: this._folderGuid, + }, + { + id: this._syncedFolderId, + oldGuid: this._syncedFolderGuid, + }, + ]; + for (let { id, oldGuid } of expected) { + rows = await db.executeCached( + `SELECT id, guid, syncChangeCounter + FROM moz_bookmarks + WHERE id = :item_id AND type = :type`, + { item_id: id, type: PlacesUtils.bookmarks.TYPE_BOOKMARK } + ); + Assert.equal(rows.length, 1); + Assert.notEqual(rows[0].getResultByName("guid"), oldGuid); + Assert.equal(rows[0].getResultByName("syncChangeCounter"), 1); + await Assert.rejects( + PlacesUtils.promiseItemId(oldGuid), + /no item found for the given GUID/ + ); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["itemAAAAAAAA"] + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.6", + desc: "Fix wrong item types | bookmarks", + + _validBookmarkId: null, + _validBookmarkGuid: null, + _invalidBookmarkId: null, + _invalidBookmarkGuid: null, + _invalidSyncedBookmarkId: null, + _invalidSyncedBookmarkGuid: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Add a bookmark with a valid place id + this._validBookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + this._validBookmarkGuid = await PlacesUtils.promiseItemGuid( + this._validBookmarkId + ); + // Add a bookmark with a null place id + this._invalidBookmarkId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + this._invalidBookmarkGuid = await PlacesUtils.promiseItemGuid( + this._invalidBookmarkId + ); + // Add a synced bookmark with a null place id + this._invalidSyncedBookmarkId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.toolbarGuid, + null, + null, + "bookmarkAAAA", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + this._invalidSyncedBookmarkGuid = await PlacesUtils.promiseItemGuid( + this._invalidSyncedBookmarkId + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + // Check valid bookmark + let rows = await db.executeCached( + `SELECT id, guid, syncChangeCounter + FROM moz_bookmarks + WHERE id = :item_id AND type = :type`, + { + item_id: this._validBookmarkId, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + } + ); + Assert.equal(rows.length, 1); + Assert.equal(rows[0].getResultByName("syncChangeCounter"), 0); + Assert.equal( + await PlacesUtils.promiseItemId(this._validBookmarkGuid), + this._validBookmarkId + ); + + // Check invalid bookmarks have been converted to folders + let expected = [ + { + id: this._invalidBookmarkId, + oldGuid: this._invalidBookmarkGuid, + }, + { + id: this._invalidSyncedBookmarkId, + oldGuid: this._invalidSyncedBookmarkGuid, + }, + ]; + for (let { id, oldGuid } of expected) { + rows = await db.executeCached( + `SELECT id, guid, syncChangeCounter + FROM moz_bookmarks + WHERE id = :item_id AND type = :type`, + { item_id: id, type: PlacesUtils.bookmarks.TYPE_FOLDER } + ); + Assert.equal(rows.length, 1); + Assert.notEqual(rows[0].getResultByName("guid"), oldGuid); + Assert.equal(rows[0].getResultByName("syncChangeCounter"), 1); + await Assert.rejects( + PlacesUtils.promiseItemId(oldGuid), + /no item found for the given GUID/ + ); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["bookmarkAAAA"] + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.7", + desc: "Fix missing item types", + + _placeId: null, + _bookmarkId: null, + _bookmarkGuid: null, + _syncedBookmarkId: null, + _syncedBookmarkGuid: null, + _folderId: null, + _folderGuid: null, + _syncedFolderId: null, + _syncedFolderGuid: null, + + async setup() { + // Item without a type but with a place ID; should be converted to a + // bookmark. The synced bookmark should be handled the same way, but with + // a tombstone. + this._placeId = await addPlace(); + this._bookmarkId = await addBookmark(this._placeId); + this._bookmarkGuid = await PlacesUtils.promiseItemGuid(this._bookmarkId); + this._syncedBookmarkId = await addBookmark( + this._placeId, + null, + PlacesUtils.bookmarks.toolbarGuid, + null, + null, + "bookmarkAAAA", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + this._syncedBookmarkGuid = await PlacesUtils.promiseItemGuid( + this._syncedBookmarkId + ); + + // Item without a type and without a place ID; should be converted to a + // folder. + this._folderId = await addBookmark(); + this._folderGuid = await PlacesUtils.promiseItemGuid(this._folderId); + this._syncedFolderId = await addBookmark( + null, + null, + PlacesUtils.bookmarks.toolbarGuid, + null, + null, + "folderBBBBBB", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + this._syncedFolderGuid = await PlacesUtils.promiseItemGuid( + this._syncedFolderId + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let expected = [ + { + id: this._bookmarkId, + oldGuid: this._bookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + syncChangeCounter: 1, + }, + { + id: this._syncedBookmarkId, + oldGuid: this._syncedBookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + syncChangeCounter: 1, + }, + { + id: this._folderId, + oldGuid: this._folderGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + syncChangeCounter: 1, + }, + { + id: this._syncedFolderId, + oldGuid: this._syncedFolderGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + syncChangeCounter: 1, + }, + ]; + for (let { id, oldGuid, type, syncChangeCounter } of expected) { + let rows = await db.executeCached( + `SELECT id, guid, type, syncChangeCounter + FROM moz_bookmarks + WHERE id = :item_id`, + { item_id: id } + ); + Assert.equal(rows.length, 1); + Assert.equal(rows[0].getResultByName("type"), type); + Assert.equal( + rows[0].getResultByName("syncChangeCounter"), + syncChangeCounter + ); + Assert.notEqual(rows[0].getResultByName("guid"), oldGuid); + await Assert.rejects( + PlacesUtils.promiseItemId(oldGuid), + /no item found for the given GUID/ + ); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["bookmarkAAAA", "folderBBBBBB"] + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.8", + desc: "Fix wrong parents", + + _bookmarkId: null, + _separatorId: null, + _bookmarkId1: null, + _bookmarkId2: null, + _placeId: null, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Insert a bookmark + this._bookmarkId = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK + ); + // Insert a separator + this._separatorId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_SEPARATOR + ); + // Create 3 children of these items + let bookmarkGuid = await PlacesUtils.promiseItemGuid(this._bookmarkId); + this._bookmarkId1 = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + bookmarkGuid + ); + let separatorGuid = await PlacesUtils.promiseItemGuid(this._separatorId); + this._bookmarkId2 = await addBookmark( + this._placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + separatorGuid + ); + }, + + async check() { + // Check that bookmarks are now children of a real folder (unfiled) + let expectedInfos = [ + { + id: this._bookmarkId1, + parent: PlacesUtils.bookmarks.unfiledGuid, + syncChangeCounter: 1, + }, + { + id: this._bookmarkId2, + parent: PlacesUtils.bookmarks.unfiledGuid, + syncChangeCounter: 1, + }, + { + id: await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.unfiledGuid), + parent: PlacesUtils.bookmarks.rootGuid, + syncChangeCounter: 2, + }, + ]; + let db = await PlacesUtils.promiseDBConnection(); + for (let { id, parent, syncChangeCounter } of expectedInfos) { + let rows = await db.executeCached( + ` + SELECT b.id, b.syncChangeCounter + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE b.id = :item_id AND p.guid = :parent`, + { item_id: id, parent } + ); + Assert.equal(rows.length, 1); + + let actualChangeCounter = rows[0].getResultByName("syncChangeCounter"); + Assert.equal(actualChangeCounter, syncChangeCounter); + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.10", + desc: "Recalculate positions", + + _unfiledBookmarks: [], + _toolbarBookmarks: [], + + async setup() { + const NUM_BOOKMARKS = 20; + let children = []; + for (let i = 0; i < NUM_BOOKMARKS; i++) { + children.push({ + title: "testbookmark", + url: "http://example.com", + }); + } + + // Add bookmarks to two folders to better perturbate the table. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + + async function randomize_positions(aParent, aResultArray) { + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + for (let i = 0; i < NUM_BOOKMARKS / 2; i++) { + await db.executeCached( + `UPDATE moz_bookmarks SET position = :rand + WHERE id IN ( + SELECT b.id FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE p.guid = :parent + ORDER BY RANDOM() LIMIT 1 + )`, + { + parent: aParent, + rand: Math.round(Math.random() * (NUM_BOOKMARKS - 1)), + } + ); + } + + // Build the expected ordered list of bookmarks. + let rows = await db.executeCached( + `SELECT b.id + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE p.guid = :parent + ORDER BY b.position ASC, b.ROWID ASC`, + { parent: aParent } + ); + rows.forEach(r => { + aResultArray.push(r.getResultByName("id")); + }); + await PlacesTestUtils.dumpTable({ + db, + table: "moz_bookmarks", + columns: ["id", "parent", "position"], + }); + }); + }); + } + + // Set random positions for the added bookmarks. + await randomize_positions( + PlacesUtils.bookmarks.unfiledGuid, + this._unfiledBookmarks + ); + await randomize_positions( + PlacesUtils.bookmarks.toolbarGuid, + this._toolbarBookmarks + ); + + let syncInfos = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid + ); + Assert.ok(syncInfos.every(info => info.syncChangeCounter === 0)); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + + async function check_order(aParent, aResultArray) { + // Build the expected ordered list of bookmarks. + let childRows = await db.executeCached( + `SELECT b.id, b.position, b.syncChangeCounter + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE p.guid = :parent + ORDER BY b.position ASC`, + { parent: aParent } + ); + for (let row of childRows) { + let id = row.getResultByName("id"); + let position = row.getResultByName("position"); + if (aResultArray.indexOf(id) != position) { + info("Expected order: " + aResultArray); + await PlacesTestUtils.dumpTable({ + db, + table: "moz_bookmarks", + columns: ["id", "parent", "position"], + }); + do_throw(`Unexpected bookmarks order for ${aParent}.`); + } + } + + let parentRows = await db.executeCached( + `SELECT syncChangeCounter FROM moz_bookmarks + WHERE guid = :parent`, + { parent: aParent } + ); + for (let row of parentRows) { + let actualChangeCounter = row.getResultByName("syncChangeCounter"); + Assert.ok(actualChangeCounter > 0); + } + } + + await check_order( + PlacesUtils.bookmarks.unfiledGuid, + this._unfiledBookmarks + ); + await check_order( + PlacesUtils.bookmarks.toolbarGuid, + this._toolbarBookmarks + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "D.13", + desc: "Fix empty-named tags", + _taggedItemIds: {}, + + async setup() { + // Add a place to ensure place_id = 1 is valid + let placeId = await addPlace(); + // Create a empty-named tag. + this._untitledTagId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.tagsGuid, + null, + "" + ); + // Insert a bookmark in the tag, otherwise it will be removed. + let untitledTagGuid = await PlacesUtils.promiseItemGuid( + this._untitledTagId + ); + await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + untitledTagGuid + ); + // Create a empty-named folder. + this._untitledFolderId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.toolbarGuid, + null, + "" + ); + // Create a titled tag. + this._titledTagId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.tagsGuid, + null, + "titledTag" + ); + // Insert a bookmark in the tag, otherwise it will be removed. + let titledTagGuid = await PlacesUtils.promiseItemGuid(this._titledTagId); + await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + titledTagGuid + ); + // Create a titled folder. + this._titledFolderId = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.toolbarGuid, + null, + "titledFolder" + ); + + // Create two tagged bookmarks in different folders. + this._taggedItemIds.inMenu = await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.menuGuid, + null, + "Tagged bookmark in menu" + ); + this._taggedItemIds.inToolbar = await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.toolbarGuid, + null, + "Tagged bookmark in toolbar" + ); + }, + + async check() { + // Check that valid bookmark is still there + let value = await PlacesTestUtils.getDatabaseValue( + "moz_bookmarks", + "title", + { id: this._untitledTagId } + ); + Assert.equal(value, "(notitle)"); + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", { + id: this._untitledFolderId, + }); + Assert.equal(value, ""); + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", { + id: this._titledTagId, + }); + Assert.equal(value, "titledTag"); + value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", { + id: this._titledFolderId, + }); + Assert.equal(value, "titledFolder"); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT syncChangeCounter FROM moz_bookmarks + WHERE id IN (:taggedInMenu, :taggedInToolbar)`, + { + taggedInMenu: this._taggedItemIds.inMenu, + taggedInToolbar: this._taggedItemIds.inToolbar, + } + ); + for (let row of rows) { + Assert.greaterOrEqual(row.getResultByName("syncChangeCounter"), 1); + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "E.1", + desc: "Remove orphan icon entries", + + _placeId: null, + + async setup() { + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeTransaction(async () => { + // Insert favicon entries + await db.executeCached( + `INSERT INTO moz_icons (id, icon_url, fixed_icon_url_hash, root) VALUES(:favicon_id, :url, hash(fixup_url(:url)), :root)`, + [ + { + favicon_id: 1, + url: "http://www1.mozilla.org/favicon.ico", + root: 0, + }, + { + favicon_id: 2, + url: "http://www2.mozilla.org/favicon.ico", + root: 0, + }, + { + favicon_id: 3, + url: "http://www3.mozilla.org/favicon.ico", + root: 1, + }, + ] + ); + + // Insert orphan page. + await db.executeCached( + `INSERT INTO moz_pages_w_icons (id, page_url, page_url_hash) + VALUES(:page_id, :url, hash(:url))`, + { page_id: 99, url: "http://w99.mozilla.org/" } + ); + }); + }); + + // Insert a place using the existing favicon entry + this._placeId = await addPlace("http://www.mozilla.org", 1); + }, + + async check() { + // Check that used icon is still there + let value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", { + id: 1, + }); + Assert.notStrictEqual(value, undefined); + // Check that unused icon has been removed + value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", { + id: 2, + }); + Assert.strictEqual(value, undefined); + // Check that unused icon has been removed + value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", { + id: 3, + }); + Assert.strictEqual(value, undefined); + // Check that the orphan page is gone. + value = await PlacesTestUtils.getDatabaseValue("moz_pages_w_icons", "id", { + id: 99, + }); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "F.1", + desc: "Remove orphan visits", + + _placeId: null, + _invalidPlaceId: 1337, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Add a valid visit and an invalid one + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeCached( + `INSERT INTO moz_historyvisits(place_id) + VALUES (:place_id_1), (:place_id_2)`, + { place_id_1: this._placeId, place_id_2: this._invalidPlaceId } + ); + }); + }, + + async check() { + // Check that valid visit is still there + let value = await PlacesTestUtils.getDatabaseValue( + "moz_historyvisits", + "id", + { + place_id: this._placeId, + } + ); + Assert.notStrictEqual(value, undefined); + // Check that invalid visit has been removed + value = await PlacesTestUtils.getDatabaseValue("moz_historyvisits", "id", { + place_id: this._invalidPlaceId, + }); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "G.1", + desc: "Remove orphan input history", + + _placeId: null, + _invalidPlaceId: 1337, + + async setup() { + // Add a place to ensure place_id = 1 is valid + this._placeId = await addPlace(); + // Add input history entries + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeCached( + `INSERT INTO moz_inputhistory (place_id, input) + VALUES (:place_id_1, :input_1), (:place_id_2, :input_2)`, + { + place_id_1: this._placeId, + input_1: "moz", + place_id_2: this._invalidPlaceId, + input_2: "moz", + } + ); + }); + }, + + async check() { + // Check that inputhistory on valid place is still there + let value = await PlacesTestUtils.getDatabaseValue( + "moz_inputhistory", + "place_id", + { place_id: this._placeId } + ); + Assert.notStrictEqual(value, undefined); + // Check that inputhistory on invalid place has gone + value = await PlacesTestUtils.getDatabaseValue( + "moz_inputhistory", + "place_id", + { place_id: this._invalidPlaceId } + ); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "I.1", + desc: "Remove unused keywords", + + _bookmarkId: null, + _placeId: null, + + async setup() { + // Insert 2 keywords + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://testkw.moz.org/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + url: bm.url, + keyword: "used", + }); + + await PlacesUtils.withConnectionWrapper("setup", async db => { + await db.executeCached( + `INSERT INTO moz_keywords (id, keyword, place_id) + VALUES(NULL, :keyword, :place_id)`, + { keyword: "unused", place_id: 100 } + ); + }); + }, + + async check() { + // Check that "used" keyword is still there + let value = await PlacesTestUtils.getDatabaseValue("moz_keywords", "id", { + keyword: "used", + }); + Assert.notStrictEqual(value, undefined); + // Check that "unused" keyword has gone + value = await PlacesTestUtils.getDatabaseValue("moz_keywords", "id", { + keyword: "unused", + }); + Assert.strictEqual(value, undefined); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.1", + desc: "remove duplicate URLs", + _placeA: -1, + _placeD: -1, + _placeE: -1, + _bookmarkIds: [], + + async setup() { + // Place with visits, an autocomplete history entry, anno, and a bookmark. + this._placeA = await addPlace("http://example.com", null, "placeAAAAAAA"); + + // Duplicate Place with different visits and a keyword. + let placeB = await addPlace("http://example.com", null, "placeBBBBBBB"); + + // Another duplicate with conflicting autocomplete history entry and + // two more bookmarks. + let placeC = await addPlace("http://example.com", null, "placeCCCCCCC"); + + // Unrelated, unique Place. + this._placeD = await addPlace( + "http://example.net", + null, + "placeDDDDDDD", + 1234 + ); + + // Another unrelated Place, with the same hash as D, but different URL. + this._placeE = await addPlace( + "http://example.info", + null, + "placeEEEEEEE", + 1234 + ); + + let visits = [ + { + placeId: this._placeA, + date: new Date(2017, 1, 2), + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + placeId: this._placeA, + date: new Date(2018, 3, 4), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + placeId: placeB, + date: new Date(2016, 5, 6), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + // Duplicate visit; should keep both when we merge. + placeId: placeB, + date: new Date(2018, 3, 4), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + placeId: this._placeD, + date: new Date(2018, 7, 8), + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + placeId: this._placeE, + date: new Date(2018, 8, 9), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]; + + let inputs = [ + { + placeId: this._placeA, + input: "exam", + count: 4, + }, + { + placeId: placeC, + input: "exam", + count: 3, + }, + { + placeId: placeC, + input: "ex", + count: 5, + }, + { + placeId: this._placeD, + input: "amp", + count: 3, + }, + ]; + + let annos = [ + { + name: "anno", + placeId: this._placeA, + content: "splish", + }, + { + // Anno that's already set on A; should be ignored when we merge. + name: "anno", + placeId: placeB, + content: "oops", + }, + { + name: "other-anno", + placeId: placeB, + content: "splash", + }, + { + name: "other-anno", + placeId: this._placeD, + content: "sploosh", + }, + ]; + + let bookmarks = [ + { + placeId: this._placeA, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "A", + guid: "bookmarkAAAA", + }, + { + placeId: placeB, + parentGuid: PlacesUtils.bookmarks.mobileGuid, + title: "B", + guid: "bookmarkBBBB", + }, + { + placeId: placeC, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "C1", + guid: "bookmarkCCC1", + }, + { + placeId: placeC, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "C2", + guid: "bookmarkCCC2", + }, + { + placeId: this._placeD, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "D", + guid: "bookmarkDDDD", + }, + { + placeId: this._placeE, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "E", + guid: "bookmarkEEEE", + }, + ]; + + let keywords = [ + { + placeId: placeB, + keyword: "hi", + }, + { + placeId: this._placeD, + keyword: "bye", + }, + ]; + + for (let { placeId, parentGuid, title, guid } of bookmarks) { + let itemId = await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid, + null, + title, + guid + ); + this._bookmarkIds.push(itemId); + } + + await PlacesUtils.withConnectionWrapper( + "L.1: Insert foreign key refs", + function (db) { + return db.executeTransaction(async function () { + for (let { placeId, date, type } of visits) { + await db.executeCached( + `INSERT INTO moz_historyvisits(place_id, visit_date, visit_type) + VALUES(:placeId, :date, :type)`, + { placeId, date: PlacesUtils.toPRTime(date), type } + ); + } + + for (let params of inputs) { + await db.executeCached( + `INSERT INTO moz_inputhistory(place_id, input, use_count) + VALUES(:placeId, :input, :count)`, + params + ); + } + + for (let { name, placeId, content } of annos) { + await db.executeCached( + `INSERT OR IGNORE INTO moz_anno_attributes(name) + VALUES(:name)`, + { name } + ); + + await db.executeCached( + `INSERT INTO moz_annos(place_id, anno_attribute_id, content) + VALUES(:placeId, (SELECT id FROM moz_anno_attributes + WHERE name = :name), :content)`, + { placeId, name, content } + ); + } + + for (let param of keywords) { + await db.executeCached( + `INSERT INTO moz_keywords(keyword, place_id) + VALUES(:keyword, :placeId)`, + param + ); + } + }); + } + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + + let placeRows = await db.execute(` + SELECT id, guid, foreign_count FROM moz_places + ORDER BY guid`); + let placeInfos = placeRows.map(row => ({ + id: row.getResultByName("id"), + guid: row.getResultByName("guid"), + foreignCount: row.getResultByName("foreign_count"), + })); + Assert.deepEqual( + placeInfos, + [ + { + id: this._placeA, + guid: "placeAAAAAAA", + foreignCount: 5, // 4 bookmarks + 1 keyword + }, + { + id: this._placeD, + guid: "placeDDDDDDD", + foreignCount: 2, // 1 bookmark + 1 keyword + }, + { + id: this._placeE, + guid: "placeEEEEEEE", + foreignCount: 1, // 1 bookmark + }, + ], + "Should remove duplicate Places B and C" + ); + + let visitRows = await db.execute(` + SELECT place_id, visit_date, visit_type FROM moz_historyvisits + ORDER BY visit_date`); + let visitInfos = visitRows.map(row => ({ + placeId: row.getResultByName("place_id"), + date: PlacesUtils.toDate(row.getResultByName("visit_date")), + type: row.getResultByName("visit_type"), + })); + Assert.deepEqual( + visitInfos, + [ + { + placeId: this._placeA, + date: new Date(2016, 5, 6), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + placeId: this._placeA, + date: new Date(2017, 1, 2), + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + placeId: this._placeA, + date: new Date(2018, 3, 4), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + placeId: this._placeA, + date: new Date(2018, 3, 4), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + placeId: this._placeD, + date: new Date(2018, 7, 8), + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + placeId: this._placeE, + date: new Date(2018, 8, 9), + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ], + "Should merge history visits" + ); + + let inputRows = await db.execute(` + SELECT place_id, input, use_count FROM moz_inputhistory + ORDER BY use_count ASC`); + let inputInfos = inputRows.map(row => ({ + placeId: row.getResultByName("place_id"), + input: row.getResultByName("input"), + count: row.getResultByName("use_count"), + })); + Assert.deepEqual( + inputInfos, + [ + { + placeId: this._placeD, + input: "amp", + count: 3, + }, + { + placeId: this._placeA, + input: "ex", + count: 5, + }, + { + placeId: this._placeA, + input: "exam", + count: 7, + }, + ], + "Should merge autocomplete history" + ); + + let annoRows = await db.execute(` + SELECT a.place_id, n.name, a.content FROM moz_annos a + JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id + ORDER BY n.name, a.content ASC`); + let annoInfos = annoRows.map(row => ({ + placeId: row.getResultByName("place_id"), + name: row.getResultByName("name"), + content: row.getResultByName("content"), + })); + Assert.deepEqual( + annoInfos, + [ + { + placeId: this._placeA, + name: "anno", + content: "splish", + }, + { + placeId: this._placeA, + name: "other-anno", + content: "splash", + }, + { + placeId: this._placeD, + name: "other-anno", + content: "sploosh", + }, + ], + "Should merge page annos" + ); + + let itemRows = await db.execute( + ` + SELECT guid, fk, syncChangeCounter FROM moz_bookmarks + WHERE id IN (${new Array(this._bookmarkIds.length).fill("?").join(",")}) + ORDER BY guid ASC`, + this._bookmarkIds + ); + let itemInfos = itemRows.map(row => ({ + guid: row.getResultByName("guid"), + placeId: row.getResultByName("fk"), + syncChangeCounter: row.getResultByName("syncChangeCounter"), + })); + Assert.deepEqual( + itemInfos, + [ + { + guid: "bookmarkAAAA", + placeId: this._placeA, + syncChangeCounter: 1, + }, + { + guid: "bookmarkBBBB", + placeId: this._placeA, + syncChangeCounter: 1, + }, + { + guid: "bookmarkCCC1", + placeId: this._placeA, + syncChangeCounter: 1, + }, + { + guid: "bookmarkCCC2", + placeId: this._placeA, + syncChangeCounter: 1, + }, + { + guid: "bookmarkDDDD", + placeId: this._placeD, + syncChangeCounter: 0, + }, + { + guid: "bookmarkEEEE", + placeId: this._placeE, + syncChangeCounter: 0, + }, + ], + "Should merge bookmarks and bump change counter" + ); + + let keywordRows = await db.execute(` + SELECT keyword, place_id FROM moz_keywords + ORDER BY keyword ASC`); + let keywordInfos = keywordRows.map(row => ({ + keyword: row.getResultByName("keyword"), + placeId: row.getResultByName("place_id"), + })); + Assert.deepEqual( + keywordInfos, + [ + { + keyword: "bye", + placeId: this._placeD, + }, + { + keyword: "hi", + placeId: this._placeA, + }, + ], + "Should merge all keywords" + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.2", + desc: "Recalculate visit_count and last_visit_date", + + async setup() { + async function setVisitCount(aURL, aValue) { + await PlacesUtils.withConnectionWrapper("setVisitCount", async db => { + await db.executeCached( + `UPDATE moz_places SET visit_count = :count + WHERE url_hash = hash(:url) AND url = :url`, + { count: aValue, url: aURL } + ); + }); + } + async function setLastVisitDate(aURL, aValue) { + await PlacesUtils.withConnectionWrapper("setVisitCount", async db => { + await db.executeCached( + `UPDATE moz_places SET last_visit_date = :date + WHERE url_hash = hash(:url) AND url = :url`, + { date: aValue, url: aURL } + ); + }); + } + + let now = Date.now() * 1000; + // Add a page with 1 visit. + let url = "http://1.moz.org/"; + await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ }); + // Add a page with 1 visit and set wrong visit_count. + url = "http://2.moz.org/"; + await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ }); + await setVisitCount(url, 10); + // Add a page with 1 visit and set wrong last_visit_date. + url = "http://3.moz.org/"; + await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ }); + await setLastVisitDate(url, now++); + // Add a page with 1 visit and set wrong stats. + url = "http://4.moz.org/"; + await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ }); + await setVisitCount(url, 10); + await setLastVisitDate(url, now++); + + // Add a page without visits. + url = "http://5.moz.org/"; + await addPlace(url); + // Add a page without visits and set wrong visit_count. + url = "http://6.moz.org/"; + await addPlace(url); + await setVisitCount(url, 10); + // Add a page without visits and set wrong last_visit_date. + url = "http://7.moz.org/"; + await addPlace(url); + await setLastVisitDate(url, now++); + // Add a page without visits and set wrong stats. + url = "http://8.moz.org/"; + await addPlace(url); + await setVisitCount(url, 10); + await setLastVisitDate(url, now++); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT h.id, h.last_visit_date as v_date + FROM moz_places h + LEFT JOIN moz_historyvisits v ON v.place_id = h.id AND visit_type NOT IN (0,4,7,8,9) + GROUP BY h.id HAVING h.visit_count <> count(v.id) + UNION ALL + SELECT h.id, MAX(v.visit_date) as v_date + FROM moz_places h + LEFT JOIN moz_historyvisits v ON v.place_id = h.id + GROUP BY h.id HAVING h.last_visit_date IS NOT v_date` + ); + Assert.equal(rows.length, 0); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.3", + desc: "recalculate hidden for redirects.", + + async setup() { + await PlacesTestUtils.addVisits([ + { + uri: NetUtil.newURI("http://l3.moz.org/"), + transition: TRANSITION_TYPED, + }, + { + uri: NetUtil.newURI("http://l3.moz.org/redirecting/"), + transition: TRANSITION_TYPED, + }, + { + uri: NetUtil.newURI("http://l3.moz.org/redirecting2/"), + transition: TRANSITION_REDIRECT_TEMPORARY, + referrer: NetUtil.newURI("http://l3.moz.org/redirecting/"), + }, + { + uri: NetUtil.newURI("http://l3.moz.org/target/"), + transition: TRANSITION_REDIRECT_PERMANENT, + referrer: NetUtil.newURI("http://l3.moz.org/redirecting2/"), + }, + ]); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + "SELECT h.url FROM moz_places h WHERE h.hidden = 1" + ); + Assert.equal(rows.length, 2); + for (let row of rows) { + let url = row.getResultByIndex(0); + Assert.ok(/redirecting/.test(url)); + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.4", + desc: "recalculate foreign_count.", + + async setup() { + this._pageGuid = ( + await PlacesUtils.history.insert({ + url: "http://l4.moz.org/", + visits: [{ date: new Date() }], + }) + ).guid; + await PlacesUtils.bookmarks.insert({ + url: "http://l4.moz.org/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + url: "http://l4.moz.org/", + keyword: "kw", + }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "foreign_count", { + guid: this._pageGuid, + }), + 2 + ); + }, + + async check() { + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "foreign_count", { + guid: this._pageGuid, + }), + 2 + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.5", + desc: "recalculate hashes when missing.", + + async setup() { + this._pageGuid = ( + await PlacesUtils.history.insert({ + url: "http://l5.moz.org/", + visits: [{ date: new Date() }], + }) + ).guid; + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", { + guid: this._pageGuid, + }), + 0 + ); + await PlacesUtils.withConnectionWrapper( + "change url hash", + async function (db) { + await db.execute(`UPDATE moz_places SET url_hash = 0`); + } + ); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", { + guid: this._pageGuid, + }), + 0 + ); + }, + + async check() { + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", { + guid: this._pageGuid, + }), + 0 + ); + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "L.6", + desc: "fix invalid Place GUIDs", + _placeIds: [], + + async setup() { + let placeWithValidGuid = await addPlace( + "http://example.com/a", + null, + "placeAAAAAAA" + ); + this._placeIds.push(placeWithValidGuid); + + let placeWithEmptyGuid = await addPlace("http://example.com/b", null, ""); + this._placeIds.push(placeWithEmptyGuid); + + let placeWithoutGuid = await addPlace("http://example.com/c", null, null); + this._placeIds.push(placeWithoutGuid); + + let placeWithInvalidGuid = await addPlace( + "http://example.com/c", + null, + "{123456}" + ); + this._placeIds.push(placeWithInvalidGuid); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let updatedRows = await db.execute( + ` + SELECT id, guid + FROM moz_places + WHERE id IN (?, ?, ?, ?)`, + this._placeIds + ); + + for (let row of updatedRows) { + let id = row.getResultByName("id"); + let guid = row.getResultByName("guid"); + if (id == this._placeIds[0]) { + Assert.equal(guid, "placeAAAAAAA"); + } else { + Assert.ok(PlacesUtils.isValidGuid(guid)); + } + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "S.1", + desc: "fix invalid GUIDs for synced bookmarks", + _bookmarkInfos: [], + + async setup() { + let folderWithInvalidGuid = await addBookmark( + null, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.menuGuid, + /* aKeywordId */ null, + "NORMAL folder with invalid GUID", + "{123456}", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + + let placeIdForBookmarkWithoutGuid = await addPlace(); + let bookmarkWithoutGuid = await addBookmark( + placeIdForBookmarkWithoutGuid, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "{123456}", + /* aKeywordId */ null, + "NEW bookmark without GUID", + /* aGuid */ null + ); + + let placeIdForBookmarkWithInvalidGuid = await addPlace(); + let bookmarkWithInvalidGuid = await addBookmark( + placeIdForBookmarkWithInvalidGuid, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "{123456}", + /* aKeywordId */ null, + "NORMAL bookmark with invalid GUID", + "bookmarkAAAA\n", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + + let placeIdForBookmarkWithValidGuid = await addPlace(); + let bookmarkWithValidGuid = await addBookmark( + placeIdForBookmarkWithValidGuid, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "{123456}", + /* aKeywordId */ null, + "NORMAL bookmark with valid GUID", + "bookmarkBBBB", + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ); + + this._bookmarkInfos.push( + { + id: await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.menuGuid), + syncChangeCounter: 1, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }, + { + id: folderWithInvalidGuid, + syncChangeCounter: 3, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + }, + { + id: bookmarkWithoutGuid, + syncChangeCounter: 1, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + }, + { + id: bookmarkWithInvalidGuid, + syncChangeCounter: 1, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + }, + { + id: bookmarkWithValidGuid, + syncChangeCounter: 0, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + } + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let updatedRows = await db.execute( + `SELECT id, guid, syncChangeCounter, syncStatus + FROM moz_bookmarks + WHERE id IN (?, ?, ?, ?, ?)`, + this._bookmarkInfos.map(info => info.id) + ); + + for (let row of updatedRows) { + let id = row.getResultByName("id"); + let guid = row.getResultByName("guid"); + Assert.ok(PlacesUtils.isValidGuid(guid)); + + let cachedGuid = await PlacesUtils.promiseItemGuid(id); + Assert.equal(cachedGuid, guid); + + let expectedInfo = this._bookmarkInfos.find(info => info.id == id); + + let syncChangeCounter = row.getResultByName("syncChangeCounter"); + Assert.equal(syncChangeCounter, expectedInfo.syncChangeCounter); + + let syncStatus = row.getResultByName("syncStatus"); + Assert.equal(syncStatus, expectedInfo.syncStatus); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["bookmarkAAAA\n", "{123456}"] + ); + }, +}); + +tests.push({ + name: "S.2", + desc: "drop tombstones for bookmarks that aren't deleted", + + async setup() { + let placeId = await addPlace(); + await addBookmark( + placeId, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.menuGuid, + null, + "", + "bookmarkAAAA" + ); + + await PlacesUtils.withConnectionWrapper("Insert tombstones", db => + db.executeTransaction(async function () { + for (let guid of ["bookmarkAAAA", "bookmarkBBBB"]) { + await db.executeCached( + `INSERT INTO moz_bookmarks_deleted(guid) + VALUES(:guid)`, + { guid } + ); + } + }) + ); + }, + + async check() { + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + Assert.deepEqual( + tombstones.map(info => info.guid), + ["bookmarkBBBB"] + ); + }, +}); + +tests.push({ + name: "S.3", + desc: "set missing added and last modified dates", + _placeVisits: [], + _bookmarksWithDates: [], + + async setup() { + let placeIdWithVisits = await addPlace(); + let placeIdWithZeroVisit = await addPlace(); + this._placeVisits.push( + { + placeId: placeIdWithVisits, + visitDate: PlacesUtils.toPRTime(new Date(2017, 9, 4)), + }, + { + placeId: placeIdWithVisits, + visitDate: PlacesUtils.toPRTime(new Date(2017, 9, 8)), + }, + { + placeId: placeIdWithZeroVisit, + visitDate: 0, + } + ); + + this._bookmarksWithDates.push( + { + guid: "bookmarkAAAA", + placeId: null, + parent: PlacesUtils.bookmarks.menuGuid, + dateAdded: null, + lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 1)), + }, + { + guid: "bookmarkBBBB", + placeId: null, + parent: PlacesUtils.bookmarks.menuGuid, + dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 2)), + lastModified: null, + }, + { + guid: "bookmarkCCCC", + placeId: null, + parent: PlacesUtils.bookmarks.unfiledGuid, + dateAdded: null, + lastModified: null, + }, + { + guid: "bookmarkDDDD", + placeId: placeIdWithVisits, + parent: PlacesUtils.bookmarks.mobileGuid, + dateAdded: null, + lastModified: null, + }, + { + guid: "bookmarkEEEE", + placeId: placeIdWithVisits, + parent: PlacesUtils.bookmarks.unfiledGuid, + dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 3)), + lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 6)), + }, + { + guid: "bookmarkFFFF", + placeId: placeIdWithZeroVisit, + parent: PlacesUtils.bookmarks.unfiledGuid, + dateAdded: 0, + lastModified: 0, + } + ); + + await PlacesUtils.withConnectionWrapper( + "S.3: Insert bookmarks and visits", + db => + db.executeTransaction(async () => { + await db.execute( + `INSERT INTO moz_historyvisits(place_id, visit_date) + VALUES(:placeId, :visitDate)`, + this._placeVisits + ); + + await db.execute( + `INSERT INTO moz_bookmarks(fk, type, parent, guid, dateAdded, + lastModified) + VALUES(:placeId, 1, (SELECT id FROM moz_bookmarks WHERE guid = :parent), + :guid, :dateAdded, :lastModified)`, + this._bookmarksWithDates + ); + + await db.execute( + `UPDATE moz_bookmarks SET dateAdded = 0, lastModified = NULL + WHERE guid = :toolbarFolder`, + { toolbarFolder: PlacesUtils.bookmarks.toolbarGuid } + ); + }) + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let updatedRows = await db.execute( + `SELECT guid, dateAdded, lastModified + FROM moz_bookmarks + WHERE guid = :guid`, + [ + { guid: PlacesUtils.bookmarks.toolbarGuid }, + ...this._bookmarksWithDates.map(({ guid }) => ({ guid })), + ] + ); + + for (let row of updatedRows) { + let guid = row.getResultByName("guid"); + + let dateAdded = row.getResultByName("dateAdded"); + Assert.ok(Number.isInteger(dateAdded)); + + let lastModified = row.getResultByName("lastModified"); + Assert.ok(Number.isInteger(lastModified)); + + switch (guid) { + // Last modified date exists, so we should use it for date added. + case "bookmarkAAAA": { + let expectedInfo = this._bookmarksWithDates[0]; + Assert.equal(dateAdded, expectedInfo.lastModified); + Assert.equal(lastModified, expectedInfo.lastModified); + break; + } + + // Date added exists, so we should use it for last modified date. + case "bookmarkBBBB": { + let expectedInfo = this._bookmarksWithDates[1]; + Assert.equal(dateAdded, expectedInfo.dateAdded); + Assert.equal(lastModified, expectedInfo.dateAdded); + break; + } + + // C has no visits, date added, or last modified time, F has zeros for + // all, and the toolbar has a zero date added and no last modified time. + // In all cases, we should fall back to the current time. + case "bookmarkCCCC": + case "bookmarkFFFF": + case PlacesUtils.bookmarks.toolbarGuid: { + let nowAsPRTime = PlacesUtils.toPRTime(new Date()); + Assert.greater(dateAdded, 0); + Assert.equal(dateAdded, lastModified); + Assert.ok(dateAdded <= nowAsPRTime); + break; + } + + // Neither date added nor last modified exists, but we have two + // visits, so we should fall back to the earliest and latest visit + // dates. + case "bookmarkDDDD": { + let oldestVisit = this._placeVisits[0]; + Assert.equal(dateAdded, oldestVisit.visitDate); + let newestVisit = this._placeVisits[1]; + Assert.equal(lastModified, newestVisit.visitDate); + break; + } + + // We have two visits, but both date added and last modified exist, + // so we shouldn't update them. + case "bookmarkEEEE": { + let expectedInfo = this._bookmarksWithDates[4]; + Assert.equal(dateAdded, expectedInfo.dateAdded); + Assert.equal(lastModified, expectedInfo.lastModified); + break; + } + + default: + throw new Error(`Unexpected row for bookmark ${guid}`); + } + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "S.4", + desc: "reset added dates that are ahead of last modified dates", + _bookmarksWithDates: [], + + async setup() { + this._bookmarksWithDates.push({ + guid: "bookmarkGGGG", + parent: PlacesUtils.bookmarks.unfiledGuid, + dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 6)), + lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 3)), + }); + + await PlacesUtils.withConnectionWrapper( + "S.4: Insert bookmarks and visits", + db => + db.executeTransaction(async () => { + await db.execute( + `INSERT INTO moz_bookmarks(type, parent, guid, dateAdded, + lastModified) + VALUES(1, (SELECT id FROM moz_bookmarks WHERE guid = :parent), + :guid, :dateAdded, :lastModified)`, + this._bookmarksWithDates + ); + }) + ); + }, + + async check() { + let db = await PlacesUtils.promiseDBConnection(); + let updatedRows = await db.execute( + `SELECT guid, dateAdded, lastModified + FROM moz_bookmarks + WHERE guid = :guid`, + this._bookmarksWithDates.map(({ guid }) => ({ guid })) + ); + + for (let row of updatedRows) { + let guid = row.getResultByName("guid"); + let dateAdded = row.getResultByName("dateAdded"); + let lastModified = row.getResultByName("lastModified"); + switch (guid) { + case "bookmarkGGGG": { + let expectedInfo = this._bookmarksWithDates[0]; + Assert.equal(dateAdded, expectedInfo.lastModified); + Assert.equal(lastModified, expectedInfo.lastModified); + break; + } + + default: + throw new Error(`Unexpected row for bookmark ${guid}`); + } + } + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "T.1", + desc: "history.recalculateOriginFrecencyStats() is called", + + async setup() { + let urls = [ + "http://example1.com/", + "http://example2.com/", + "http://example3.com/", + ]; + await PlacesTestUtils.addVisits(urls.map(u => ({ uri: u }))); + + this._frecencies = []; + for (let url of urls) { + this._frecencies.push( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url, + }) + ); + } + + let stats = await this._promiseStats(); + Assert.equal(stats.count, this._frecencies.length, "Sanity check"); + Assert.equal(stats.sum, this._sum(this._frecencies), "Sanity check"); + Assert.equal( + stats.squares, + this._squares(this._frecencies), + "Sanity check" + ); + + await PlacesUtils.withConnectionWrapper("T.1", db => + db.execute(` + INSERT OR REPLACE INTO moz_meta VALUES + ('origin_frecency_count', 99), + ('origin_frecency_sum', 99999), + ('origin_frecency_sum_of_squares', 99999 * 99999); + `) + ); + + stats = await this._promiseStats(); + Assert.equal(stats.count, 99); + Assert.equal(stats.sum, 99999); + Assert.equal(stats.squares, 99999 * 99999); + }, + + async check() { + let stats = await this._promiseStats(); + Assert.equal(stats.count, this._frecencies.length); + Assert.equal(stats.sum, this._sum(this._frecencies)); + Assert.equal(stats.squares, this._squares(this._frecencies)); + }, + + _sum(frecs) { + return frecs.reduce((memo, f) => memo + f, 0); + }, + + _squares(frecs) { + return frecs.reduce((memo, f) => memo + f * f, 0); + }, + + async _promiseStats() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_count'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares'), 0) + `); + return { + count: rows[0].getResultByIndex(0), + sum: rows[0].getResultByIndex(1), + squares: rows[0].getResultByIndex(2), + }; + }, +}); + +// ------------------------------------------------------------------------------ + +tests.push({ + name: "Z", + desc: "Sanity: Preventive maintenance does not touch valid items", + + _uri1: uri("http://www1.mozilla.org"), + _uri2: uri("http://www2.mozilla.org"), + _folder: null, + _bookmark: null, + _bookmarkId: null, + _separator: null, + + async setup() { + // use valid api calls to create a bunch of items + await PlacesTestUtils.addVisits([{ uri: this._uri1 }, { uri: this._uri2 }]); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "testfolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "testbookmark", + url: this._uri1, + }, + ], + }, + ], + }); + + this._folder = bookmarks[0]; + this._bookmark = bookmarks[1]; + this._bookmarkId = await PlacesUtils.promiseItemId(bookmarks[1].guid); + + this._separator = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + + PlacesUtils.tagging.tagURI(this._uri1, ["testtag"]); + PlacesUtils.favicons.setAndFetchFaviconForPage( + this._uri2, + SMALLPNG_DATA_URI, + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + await PlacesUtils.keywords.insert({ + url: this._uri1.spec, + keyword: "testkeyword", + }); + await PlacesUtils.history.update({ + url: this._uri2, + annotations: new Map([["anno", "anno"]]), + }); + }, + + async check() { + // Check that all items are correct + let isVisited = await PlacesUtils.history.hasVisits(this._uri1); + Assert.ok(isVisited); + isVisited = await PlacesUtils.history.hasVisits(this._uri2); + Assert.ok(isVisited); + + Assert.equal( + (await PlacesUtils.bookmarks.fetch(this._bookmark.guid)).url, + this._uri1.spec + ); + let folder = await PlacesUtils.bookmarks.fetch(this._folder.guid); + Assert.equal(folder.index, 0); + Assert.equal(folder.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(this._separator.guid)).type, + PlacesUtils.bookmarks.TYPE_SEPARATOR + ); + + Assert.equal(PlacesUtils.tagging.getTagsForURI(this._uri1).length, 1); + Assert.equal( + (await PlacesUtils.keywords.fetch({ url: this._uri1.spec })).keyword, + "testkeyword" + ); + let pageInfo = await PlacesUtils.history.fetch(this._uri2, { + includeAnnotations: true, + }); + Assert.equal(pageInfo.annotations.get("anno"), "anno"); + + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconURLForPage(this._uri2, aFaviconURI => { + Assert.ok(aFaviconURI.equals(SMALLPNG_DATA_URI)); + resolve(); + }); + }); + }, +}); + +// ------------------------------------------------------------------------------ + +add_task(async function test_preventive_maintenance() { + let db = await PlacesUtils.promiseDBConnection(); + // Get current bookmarks max ID for cleanup + defaultBookmarksMaxId = ( + await db.executeCached("SELECT MAX(id) FROM moz_bookmarks") + )[0].getResultByIndex(0); + Assert.ok(defaultBookmarksMaxId > 0); + + for (let test of tests) { + await PlacesTestUtils.markBookmarksAsSynced(); + + info("\nExecuting test: " + test.name + "\n*** " + test.desc + "\n"); + await test.setup(); + + Services.prefs.clearUserPref("places.database.lastMaintenance"); + await PlacesDBUtils.maintenanceOnIdle(); + + // Check the lastMaintenance time has been saved. + Assert.notEqual( + Services.prefs.getIntPref("places.database.lastMaintenance"), + null + ); + + await test.check(); + + await cleanDatabase(); + } + + // Sanity check: all roots should be intact + Assert.strictEqual( + (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.rootGuid)) + .parentGuid, + undefined + ); + Assert.deepEqual( + (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.menuGuid)) + .parentGuid, + PlacesUtils.bookmarks.rootGuid + ); + Assert.deepEqual( + (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.tagsGuid)) + .parentGuid, + PlacesUtils.bookmarks.rootGuid + ); + Assert.deepEqual( + (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.unfiledGuid)) + .parentGuid, + PlacesUtils.bookmarks.rootGuid + ); + Assert.deepEqual( + (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid)) + .parentGuid, + PlacesUtils.bookmarks.rootGuid + ); +}); + +// ------------------------------------------------------------------------------ + +add_task(async function test_idle_daily() { + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + const sandbox = sinon.createSandbox(); + sandbox.stub(PlacesDBUtils, "maintenanceOnIdle"); + Services.prefs.clearUserPref("places.database.lastMaintenance"); + Cc["@mozilla.org/places/databaseUtilsIdleMaintenance;1"] + .getService(Ci.nsIObserver) + .observe(null, "idle-daily", ""); + Assert.ok( + PlacesDBUtils.maintenanceOnIdle.calledOnce, + "maintenanceOnIdle was invoked" + ); + sandbox.restore(); +}); diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js new file mode 100644 index 0000000000..db9466b784 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js @@ -0,0 +1,36 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test preventive maintenance checkAndFixDatabase. + */ + +add_task(async function () { + // We must initialize places first, or we won't have a db to check. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + + let tasksStatusMap = await PlacesDBUtils.checkAndFixDatabase(); + let numberOfTasksRun = tasksStatusMap.size; + let successfulTasks = []; + let failedTasks = []; + tasksStatusMap.forEach(val => { + if (val.succeeded && val.logs) { + successfulTasks.push(val); + } else { + failedTasks.push(val); + } + }); + Assert.equal(numberOfTasksRun, 8, "Check that we have run all tasks."); + Assert.equal( + successfulTasks.length, + 8, + "Check that we have run all tasks successfully" + ); + Assert.equal(failedTasks.length, 0, "Check that no task is failing"); +}); diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js new file mode 100644 index 0000000000..6740acae57 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js @@ -0,0 +1,31 @@ +/* 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/. */ + +/** + * Test preventive maintenance runTasks. + */ + +add_task(async function () { + let tasksStatusMap = await PlacesDBUtils.runTasks([ + PlacesDBUtils.invalidateCaches, + ]); + let numberOfTasksRun = tasksStatusMap.size; + let successfulTasks = []; + let failedTasks = []; + tasksStatusMap.forEach(val => { + if (val.succeeded) { + successfulTasks.push(val); + } else { + failedTasks.push(val); + } + }); + + Assert.equal(numberOfTasksRun, 1, "Check that we have run all tasks."); + Assert.equal( + successfulTasks.length, + 1, + "Check that we have run all tasks successfully" + ); + Assert.equal(failedTasks.length, 0, "Check that no task is failing"); +}); diff --git a/toolkit/components/places/tests/maintenance/xpcshell.ini b/toolkit/components/places/tests/maintenance/xpcshell.ini new file mode 100644 index 0000000000..f6e2148024 --- /dev/null +++ b/toolkit/components/places/tests/maintenance/xpcshell.ini @@ -0,0 +1,20 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +support-files = + corruptDB.sqlite + corruptPayload.sqlite + +[test_corrupt_favicons.js] +[test_corrupt_favicons_schema.js] +[test_corrupt_places_schema.js] +[test_corrupt_telemetry.js] +[test_favicons_replaceOnStartup.js] +[test_favicons_replaceOnStartup_clone.js] +[test_integrity_replacement.js] +[test_places_purge_caches.js] +[test_places_replaceOnStartup.js] +[test_places_replaceOnStartup_clone.js] +[test_preventive_maintenance.js] +[test_preventive_maintenance_checkAndFixDatabase.js] +[test_preventive_maintenance_runTasks.js] diff --git a/toolkit/components/places/tests/migration/favicons_v41.sqlite b/toolkit/components/places/tests/migration/favicons_v41.sqlite Binary files differnew file mode 100644 index 0000000000..a59d9d286f --- /dev/null +++ b/toolkit/components/places/tests/migration/favicons_v41.sqlite diff --git a/toolkit/components/places/tests/migration/head_migration.js b/toolkit/components/places/tests/migration/head_migration.js new file mode 100644 index 0000000000..27fb06a3ef --- /dev/null +++ b/toolkit/components/places/tests/migration/head_migration.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +const CURRENT_SCHEMA_VERSION = 74; +const FIRST_UPGRADABLE_SCHEMA_VERSION = 43; + +async function assertAnnotationsRemoved(db, expectedAnnos) { + for (let anno of expectedAnnos) { + let rows = await db.execute( + ` + SELECT id FROM moz_anno_attributes + WHERE name = :anno + `, + { anno } + ); + + Assert.equal(rows.length, 0, `${anno} should not exist in the database`); + } +} + +async function assertNoOrphanAnnotations(db) { + let rows = await db.execute(` + SELECT item_id FROM moz_items_annos + WHERE item_id NOT IN (SELECT id from moz_bookmarks) + `); + + Assert.equal(rows.length, 0, `Should have no orphan annotations.`); + + rows = await db.execute(` + SELECT id FROM moz_anno_attributes + WHERE id NOT IN (SELECT id from moz_items_annos) + `); + + Assert.equal(rows.length, 0, `Should have no orphan annotation attributes.`); +} diff --git a/toolkit/components/places/tests/migration/places_outdated.sqlite b/toolkit/components/places/tests/migration/places_outdated.sqlite Binary files differnew file mode 100644 index 0000000000..2852a4cf97 --- /dev/null +++ b/toolkit/components/places/tests/migration/places_outdated.sqlite diff --git a/toolkit/components/places/tests/migration/places_v43.sqlite b/toolkit/components/places/tests/migration/places_v43.sqlite Binary files differnew file mode 100644 index 0000000000..9210f215fa --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v43.sqlite diff --git a/toolkit/components/places/tests/migration/places_v54.sqlite b/toolkit/components/places/tests/migration/places_v54.sqlite Binary files differnew file mode 100644 index 0000000000..a203b28c10 --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v54.sqlite diff --git a/toolkit/components/places/tests/migration/places_v66.sqlite b/toolkit/components/places/tests/migration/places_v66.sqlite Binary files differnew file mode 100644 index 0000000000..9578ee11e6 --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v66.sqlite diff --git a/toolkit/components/places/tests/migration/places_v68.sqlite b/toolkit/components/places/tests/migration/places_v68.sqlite Binary files differnew file mode 100644 index 0000000000..414fa170ec --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v68.sqlite diff --git a/toolkit/components/places/tests/migration/places_v69.sqlite b/toolkit/components/places/tests/migration/places_v69.sqlite Binary files differnew file mode 100644 index 0000000000..bc3053c18e --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v69.sqlite diff --git a/toolkit/components/places/tests/migration/places_v70.sqlite b/toolkit/components/places/tests/migration/places_v70.sqlite Binary files differnew file mode 100644 index 0000000000..907e7f5046 --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v70.sqlite diff --git a/toolkit/components/places/tests/migration/places_v72.sqlite b/toolkit/components/places/tests/migration/places_v72.sqlite Binary files differnew file mode 100644 index 0000000000..59d0d8fdab --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v72.sqlite diff --git a/toolkit/components/places/tests/migration/places_v74.sqlite b/toolkit/components/places/tests/migration/places_v74.sqlite Binary files differnew file mode 100644 index 0000000000..e7078a054f --- /dev/null +++ b/toolkit/components/places/tests/migration/places_v74.sqlite diff --git a/toolkit/components/places/tests/migration/test_current_from_downgraded.js b/toolkit/components/places/tests/migration/test_current_from_downgraded.js new file mode 100644 index 0000000000..5daec14e2f --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_downgraded.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures we can pass twice through migration methods without +// failing, that is what happens in case of a downgrade followed by an upgrade. + +add_task(async function setup() { + let dbFile = PathUtils.join( + do_get_cwd().path, + `places_v${CURRENT_SCHEMA_VERSION}.sqlite` + ); + Assert.ok(await IOUtils.exists(dbFile)); + await setupPlacesDatabase(`places_v${CURRENT_SCHEMA_VERSION}.sqlite`); + // Downgrade the schema version to the first supported one. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + await db.setSchemaVersion(FIRST_UPGRADABLE_SCHEMA_VERSION); + await db.close(); +}); + +add_task(async function database_is_valid() { + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_outdated.js b/toolkit/components/places/tests/migration/test_current_from_outdated.js new file mode 100644 index 0000000000..e7fad5b3a4 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_outdated.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests migration from a preliminary schema version 6 that + * lacks frecency column and moz_inputhistory table. + */ + +add_task(async function setup() { + await setupPlacesDatabase("places_outdated.sqlite"); +}); + +add_task(async function corrupt_database_not_exists() { + let corruptPath = PathUtils.join( + PathUtils.profileDir, + "places.sqlite.corrupt" + ); + Assert.ok( + !(await IOUtils.exists(corruptPath)), + "Corrupt file should not exist" + ); +}); + +add_task(async function database_is_valid() { + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function check_columns() { + // Check the database has been replaced, these would throw otherwise. + let db = await PlacesUtils.promiseDBConnection(); + await db.execute("SELECT frecency from moz_places"); + await db.execute("SELECT 1 from moz_inputhistory"); +}); + +add_task(async function corrupt_database_exists() { + let corruptPath = PathUtils.join( + PathUtils.profileDir, + "places.sqlite.corrupt" + ); + Assert.ok(await IOUtils.exists(corruptPath), "Corrupt file should exist"); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v43.js b/toolkit/components/places/tests/migration/test_current_from_v43.js new file mode 100644 index 0000000000..70a383bb2e --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v43.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const EXPECTED_REMAINING_ROOTS = [ + ...PlacesUtils.bookmarks.userContentRoots, + PlacesUtils.bookmarks.tagsGuid, +]; + +const EXPECTED_REMOVED_BOOKMARK_GUIDS = [ + // These first ones are the old left-pane folder queries + "SNLmwJH6GtW9", // Root Query + "r0dY_2_y4mlx", // History + "xGGhZK3b6GnW", // Downloads + "EJG6I1nKkQFQ", // Tags + "gSyHo5oNSUJV", // All Bookmarks + // These are simulated add-on injections that we expect to be removed. + "exaddon_____", + "exaddon1____", + "exaddon2____", + "exaddon3____", + "test________", +]; + +const EXPECTED_REMOVED_ANNOTATIONS = [ + "PlacesOrganizer/OrganizerFolder", + "PlacesOrganizer/OrganizerQuery", +]; + +const EXPECTED_REMOVED_PLACES_ENTRIES = ["exaddonh____", "exaddonh3___"]; +const EXPECTED_KEPT_PLACES_ENTRY = "exaddonh2___"; +const EXPECTED_REMOVED_KEYWORDS = ["exaddon", "exaddon2"]; + +async function assertItemIn(db, table, field, expectedItems) { + let rows = await db.execute(`SELECT ${field} from ${table}`); + + Assert.ok( + rows.length >= expectedItems.length, + "Should be at least the number of annotations we expect to be removed." + ); + + let fieldValues = rows.map(row => row.getResultByName(field)); + + for (let item of expectedItems) { + Assert.ok( + fieldValues.includes(item), + `${table} should have ${expectedItems}` + ); + } +} + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); + + // Setup database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + + let rows = await db.execute(`SELECT * FROM moz_bookmarks_deleted`); + Assert.equal(rows.length, 0, "Should be nothing in moz_bookmarks_deleted"); + + // Break roots parenting, to test for Bug 1472127. + await db.execute(`INSERT INTO moz_bookmarks (title, parent, position, guid) + VALUES ("test", 1, 0, "test________")`); + await db.execute(`UPDATE moz_bookmarks + SET parent = (SELECT id FROM moz_bookmarks WHERE guid = "test________") + WHERE guid = "menu________"`); + + await assertItemIn( + db, + "moz_anno_attributes", + "name", + EXPECTED_REMOVED_ANNOTATIONS + ); + await assertItemIn( + db, + "moz_bookmarks", + "guid", + EXPECTED_REMOVED_BOOKMARK_GUIDS + ); + await assertItemIn(db, "moz_keywords", "keyword", EXPECTED_REMOVED_KEYWORDS); + await assertItemIn(db, "moz_places", "guid", EXPECTED_REMOVED_PLACES_ENTRIES); + + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function test_roots_removed() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + ` + SELECT id FROM moz_bookmarks + WHERE guid = :guid + `, + { guid: PlacesUtils.bookmarks.rootGuid } + ); + Assert.equal(rows.length, 1, "Should have exactly one root row."); + let rootId = rows[0].getResultByName("id"); + + rows = await db.execute( + ` + SELECT guid FROM moz_bookmarks + WHERE parent = :rootId`, + { rootId } + ); + + Assert.equal( + rows.length, + EXPECTED_REMAINING_ROOTS.length, + "Should only have the built-in folder roots." + ); + + for (let row of rows) { + let guid = row.getResultByName("guid"); + Assert.ok( + EXPECTED_REMAINING_ROOTS.includes(guid), + `Should have only the expected guids remaining, unexpected guid: ${guid}` + ); + } + + // Check the reparented menu now. + rows = await db.execute( + ` + SELECT id, parent FROM moz_bookmarks + WHERE guid = :guid + `, + { guid: PlacesUtils.bookmarks.menuGuid } + ); + Assert.equal(rows.length, 1, "Should have found the menu root."); + Assert.equal( + rows[0].getResultByName("parent"), + await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.rootGuid), + "Should have moved the menu back to the Places root." + ); +}); + +add_task(async function test_tombstones_added() { + let db = await PlacesUtils.promiseDBConnection(); + + let rows = await db.execute(` + SELECT guid FROM moz_bookmarks_deleted + `); + + for (let row of rows) { + let guid = row.getResultByName("guid"); + Assert.ok( + EXPECTED_REMOVED_BOOKMARK_GUIDS.includes(guid), + `Should have tombstoned the expected guids, unexpected guid: ${guid}` + ); + } + + Assert.equal( + rows.length, + EXPECTED_REMOVED_BOOKMARK_GUIDS.length, + "Should have removed all the expected bookmarks." + ); +}); + +add_task(async function test_annotations_removed() { + let db = await PlacesUtils.promiseDBConnection(); + + await assertAnnotationsRemoved(db, EXPECTED_REMOVED_ANNOTATIONS); +}); + +add_task(async function test_check_history_entries() { + let db = await PlacesUtils.promiseDBConnection(); + + for (let entry of EXPECTED_REMOVED_PLACES_ENTRIES) { + let rows = await db.execute(` + SELECT id FROM moz_places + WHERE guid = '${entry}'`); + + Assert.equal( + rows.length, + 0, + `Should have removed an orphaned history entry ${EXPECTED_REMOVED_PLACES_ENTRIES}.` + ); + } + + let rows = await db.execute( + ` + SELECT foreign_count FROM moz_places + WHERE guid = :guid + `, + { guid: EXPECTED_KEPT_PLACES_ENTRY } + ); + + Assert.equal( + rows.length, + 1, + `Should have kept visited history entry ${EXPECTED_KEPT_PLACES_ENTRY}` + ); + + let foreignCount = rows[0].getResultByName("foreign_count"); + Assert.equal( + foreignCount, + 0, + `Should have updated the foreign_count for ${EXPECTED_KEPT_PLACES_ENTRY}` + ); +}); + +add_task(async function test_check_keyword_removed() { + let db = await PlacesUtils.promiseDBConnection(); + + for (let keyword of EXPECTED_REMOVED_KEYWORDS) { + let rows = await db.execute( + ` + SELECT keyword FROM moz_keywords + WHERE keyword = :keyword + `, + { keyword } + ); + + Assert.equal( + rows.length, + 0, + `Should have removed the expected keyword: ${keyword}.` + ); + } +}); + +add_task(async function test_no_orphan_annotations() { + let db = await PlacesUtils.promiseDBConnection(); + + await assertNoOrphanAnnotations(db); +}); + +add_task(async function test_no_orphan_keywords() { + let db = await PlacesUtils.promiseDBConnection(); + + let rows = await db.execute(` + SELECT place_id FROM moz_keywords + WHERE place_id NOT IN (SELECT id from moz_places) + `); + + Assert.equal(rows.length, 0, `Should have no orphan keywords.`); +}); + +add_task(async function test_meta_exists() { + let db = await PlacesUtils.promiseDBConnection(); + await db.execute(`SELECT 1 FROM moz_meta`); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v45.js b/toolkit/components/places/tests/migration/test_current_from_v45.js new file mode 100644 index 0000000000..af940d75d4 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v45.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gTags = [ + { + folder: 123456, + url: "place:folder=123456&type=7&queryType=1", + title: "tag1", + hash: "268505532566465", + }, + { + folder: 234567, + url: "place:folder=234567&type=7&queryType=1&somethingelse", + title: "tag2", + hash: "268506675127932", + }, + { + folder: 345678, + url: "place:type=7&folder=345678&queryType=1", + title: "tag3", + hash: "268506471927988", + }, + // This will point to an invalid folder id. + { + folder: 456789, + url: "place:type=7&folder=456789&queryType=1", + expectedUrl: + "place:type=7&invalidOldParentId=456789&queryType=1&excludeItems=1", + title: "invalid", + hash: "268505972797836", + }, +]; +gTags.forEach(t => (t.guid = t.title.padEnd(12, "_"))); + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); + + // Setup database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + + for (let tag of gTags) { + // We can reuse the same guid, it doesn't matter for this test. + await db.execute( + `INSERT INTO moz_places (url, guid, url_hash) + VALUES (:url, :guid, :hash) + `, + { url: tag.url, guid: tag.guid, hash: tag.hash } + ); + if (tag.title != "invalid") { + await db.execute( + `INSERT INTO moz_bookmarks (id, fk, guid, title) + VALUES (:id, (SELECT id FROM moz_places WHERE guid = :guid), :guid, :title) + `, + { id: tag.folder, guid: tag.guid, title: tag.title } + ); + } + } + + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function test_queries_converted() { + for (let tag of gTags) { + let url = + tag.title == "invalid" ? tag.expectedUrl : "place:tag=" + tag.title; + let page = await PlacesUtils.history.fetch(tag.guid); + Assert.equal(page.url.href, url); + } +}); + +add_task(async function test_sync_fields() { + let db = await PlacesUtils.promiseDBConnection(); + for (let tag of gTags) { + if (tag.title != "invalid") { + let rows = await db.execute( + ` + SELECT syncChangeCounter + FROM moz_bookmarks + WHERE guid = :guid + `, + { guid: tag.guid } + ); + Assert.equal(rows[0].getResultByIndex(0), 2); + } + } +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v46.js b/toolkit/components/places/tests/migration/test_current_from_v46.js new file mode 100644 index 0000000000..a613a3027e --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v46.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let guid = "null".padEnd(12, "_"); + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); + + // Setup database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + // We can reuse the same guid, it doesn't matter for this test. + + await db.execute( + `INSERT INTO moz_places (url, guid, url_hash) + VALUES (NULL, :guid, "123456")`, + { guid } + ); + await db.execute( + `INSERT INTO moz_bookmarks (fk, guid) + VALUES ((SELECT id FROM moz_places WHERE guid = :guid), :guid) + `, + { guid } + ); + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + let page = await PlacesUtils.history.fetch(guid); + Assert.equal(page.url.href, "place:excludeItems=1"); + + let rows = await db.execute( + ` + SELECT syncChangeCounter + FROM moz_bookmarks + WHERE guid = :guid + `, + { guid } + ); + Assert.equal(rows[0].getResultByIndex(0), 2); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v47.js b/toolkit/components/places/tests/migration/test_current_from_v47.js new file mode 100644 index 0000000000..b3d5f47211 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v47.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); +}); + +// Accessing the database for the first time should trigger migration, and the +// schema version should be updated. +add_task(async function database_is_valid() { + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + // Now wait for moz_origins.frecency to be populated before continuing with + // other test tasks. + await TestUtils.waitForCondition( + () => { + return !Services.prefs.getBoolPref( + "places.database.migrateV52OriginFrecencies", + false + ); + }, + "Waiting for v52 origin frecencies to be migrated", + 100, + 3000 + ); +}); + +// moz_origins should be populated. +add_task(async function test_origins() { + let db = await PlacesUtils.promiseDBConnection(); + + // Collect origins. + let rows = await db.execute(` + SELECT id, prefix, host, frecency + FROM moz_origins + ORDER BY id ASC; + `); + Assert.notEqual(rows.length, 0); + let origins = rows.map(r => ({ + id: r.getResultByName("id"), + prefix: r.getResultByName("prefix"), + host: r.getResultByName("host"), + frecency: r.getResultByName("frecency"), + })); + + // Get moz_places. + rows = await db.execute(` + SELECT get_prefix(url) AS prefix, get_host_and_port(url) AS host, + origin_id, frecency + FROM moz_places; + `); + Assert.notEqual(rows.length, 0); + + let seenOriginIDs = []; + let frecenciesByOriginID = {}; + + // Make sure moz_places.origin_id refers to the right origins. + for (let row of rows) { + let originID = row.getResultByName("origin_id"); + let origin = origins.find(o => o.id == originID); + Assert.ok(origin); + Assert.equal(origin.prefix, row.getResultByName("prefix")); + Assert.equal(origin.host, row.getResultByName("host")); + + seenOriginIDs.push(originID); + + let frecency = row.getResultByName("frecency"); + frecenciesByOriginID[originID] = frecenciesByOriginID[originID] || 0; + frecenciesByOriginID[originID] += frecency; + } + + for (let origin of origins) { + // Make sure each origin corresponds to at least one moz_place. + Assert.ok(seenOriginIDs.includes(origin.id)); + + // moz_origins.frecency should be the sum of frecencies of all moz_places + // with the origin. + Assert.equal(origin.frecency, frecenciesByOriginID[origin.id]); + } + + // Make sure moz_hosts was emptied. + rows = await db.execute(` + SELECT * + FROM moz_hosts; + `); + Assert.equal(rows.length, 0); +}); + +// Frecency stats should have been collected. +add_task(async function test_frecency_stats() { + let db = await PlacesUtils.promiseDBConnection(); + + // Collect positive frecencies from moz_origins. + let rows = await db.execute(` + SELECT frecency FROM moz_origins WHERE frecency > 0 + `); + Assert.notEqual(rows.length, 0); + let frecencies = rows.map(r => r.getResultByName("frecency")); + + // Collect stats. + rows = await db.execute(` + SELECT + (SELECT value FROM moz_meta WHERE key = "origin_frecency_count"), + (SELECT value FROM moz_meta WHERE key = "origin_frecency_sum"), + (SELECT value FROM moz_meta WHERE key = "origin_frecency_sum_of_squares") + `); + let count = rows[0].getResultByIndex(0); + let sum = rows[0].getResultByIndex(1); + let squares = rows[0].getResultByIndex(2); + + Assert.equal(count, frecencies.length); + Assert.equal( + sum, + frecencies.reduce((memo, f) => memo + f, 0) + ); + Assert.equal( + squares, + frecencies.reduce((memo, f) => memo + f * f, 0) + ); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v48.js b/toolkit/components/places/tests/migration/test_current_from_v48.js new file mode 100644 index 0000000000..f2c7c683ed --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v48.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const gCreatedParentGuid = "m47___FOLDER"; + +const gTestItems = [ + { + // Folder shortcuts to built-in folders. + guid: "m47_____ROOT", + url: "place:folder=PLACES_ROOT", + targetParentGuid: "rootGuid", + }, + { + guid: "m47_____MENU", + url: "place:folder=BOOKMARKS_MENU", + targetParentGuid: "menuGuid", + }, + { + guid: "m47_____TAGS", + url: "place:folder=TAGS", + targetParentGuid: "tagsGuid", + }, + { + guid: "m47____OTHER", + url: "place:folder=UNFILED_BOOKMARKS", + targetParentGuid: "unfiledGuid", + }, + { + guid: "m47__TOOLBAR", + url: "place:folder=TOOLBAR", + targetParentGuid: "toolbarGuid", + }, + { + guid: "m47___MOBILE", + url: "place:folder=MOBILE_BOOKMARKS", + targetParentGuid: "mobileGuid", + }, + { + // Folder shortcut to using id. + guid: "m47_______ID", + url: "place:folder=%id%", + expectedUrl: "place:parent=%guid%", + }, + { + // Folder shortcut to multiple folders. + guid: "m47____MULTI", + url: "place:folder=TOOLBAR&folder=%id%&sort=1", + expectedUrl: "place:parent=%toolbarGuid%&parent=%guid%&sort=1", + }, + { + // Folder shortcut to non-existent folder. + guid: "m47______NON", + url: "place:folder=454554545", + expectedUrl: "place:invalidOldParentId=454554545&excludeItems=1", + }, +]; + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); + + // Setup database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + + let rows = await db.execute( + `SELECT id FROM moz_bookmarks + WHERE guid = :guid`, + { guid: PlacesUtils.bookmarks.unfiledGuid } + ); + + let unfiledId = rows[0].getResultByName("id"); + + // Insert a test folder. + await db.execute( + `INSERT INTO moz_bookmarks (guid, title, parent) + VALUES (:guid, "Folder", :parent)`, + { guid: gCreatedParentGuid, parent: unfiledId } + ); + + rows = await db.execute( + `SELECT id FROM moz_bookmarks + WHERE guid = :guid`, + { guid: gCreatedParentGuid } + ); + + let createdFolderId = rows[0].getResultByName("id"); + + for (let item of gTestItems) { + item.url = item.url.replace("%id%", createdFolderId); + + // We can reuse the same guid, it doesn't matter for this test. + await db.execute( + `INSERT INTO moz_places (url, guid, url_hash) + VALUES (:url, :guid, :hash) + `, + { + url: item.url, + guid: item.guid, + hash: PlacesUtils.history.hashURL(item.url), + } + ); + await db.execute( + `INSERT INTO moz_bookmarks (id, fk, guid, title, parent) + VALUES (:id, (SELECT id FROM moz_places WHERE guid = :guid), + :guid, :title, + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)) + `, + { + id: item.folder, + guid: item.guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: item.guid, + } + ); + } + + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function test_correct_folder_queries() { + for (let item of gTestItems) { + let bm = await PlacesUtils.bookmarks.fetch(item.guid); + + if (item.targetParentGuid) { + Assert.equal( + bm.url, + `place:parent=${PlacesUtils.bookmarks[item.targetParentGuid]}`, + `Should have updated the URL for ${item.guid}` + ); + } else { + let expected = item.expectedUrl + .replace("%guid%", gCreatedParentGuid) + .replace("%toolbarGuid%", PlacesUtils.bookmarks.toolbarGuid); + + Assert.equal( + bm.url, + expected, + `Should have updated the URL for ${item.guid}` + ); + } + } +}); + +add_task(async function test_hashes_valid() { + let db = await PlacesUtils.promiseDBConnection(); + // Ensure all the hashes in moz_places are valid. + let rows = await db.execute(`SELECT url, url_hash FROM moz_places`); + + for (let row of rows) { + let url = row.getResultByName("url"); + let url_hash = row.getResultByName("url_hash"); + Assert.equal( + url_hash, + PlacesUtils.history.hashURL(url), + `url hash should be correct for ${url}` + ); + } +}); + +add_task(async function test_sync_counters_updated() { + let db = await PlacesUtils.promiseDBConnection(); + + for (let test of gTestItems) { + let rows = await db.execute( + `SELECT syncChangeCounter FROM moz_bookmarks + WHERE guid = :guid`, + { guid: test.guid } + ); + + Assert.equal(rows.length, 1, `Should only be one record for ${test.guid}`); + Assert.equal( + rows[0].getResultByName("syncChangeCounter"), + 2, + `Should have bumped the syncChangeCounter for ${test.guid}` + ); + } +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v50.js b/toolkit/components/places/tests/migration/test_current_from_v50.js new file mode 100644 index 0000000000..af181091c0 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v50.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BASE_GUID = "null".padEnd(11, "_"); +const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed"; +const LAST_USED_META_DATA = "places/bookmarks/edit/lastusedfolder"; + +let expectedGuids = []; + +async function adjustIndices(db, itemGuid) { + await db.execute( + ` + UPDATE moz_bookmarks SET + position = position - 1 + WHERE parent = (SELECT parent FROM moz_bookmarks + WHERE guid = :itemGuid) AND + position >= (SELECT position FROM moz_bookmarks + WHERE guid = :itemGuid)`, + { itemGuid } + ); +} + +async function fetchChildInfos(db, parentGuid) { + let rows = await db.execute( + ` + SELECT b.guid, b.position, b.syncChangeCounter + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE p.guid = :parentGuid + ORDER BY b.position`, + { parentGuid } + ); + return rows.map(row => ({ + guid: row.getResultByName("guid"), + position: row.getResultByName("position"), + syncChangeCounter: row.getResultByName("syncChangeCounter"), + })); +} + +add_task(async function setup() { + await setupPlacesDatabase("places_v43.sqlite"); + + // Setup database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + // We can reuse the same guid, it doesn't matter for this test. + await db.execute( + `INSERT INTO moz_anno_attributes (name) + VALUES (:last_used_anno)`, + { last_used_anno: LAST_USED_ANNO } + ); + + for (let i = 0; i < 3; i++) { + let guid = `${BASE_GUID}${i}`; + await db.execute( + `INSERT INTO moz_bookmarks (guid, type) + VALUES (:guid, :type) + `, + { guid, type: PlacesUtils.bookmarks.TYPE_FOLDER } + ); + await db.execute( + `INSERT INTO moz_items_annos (item_id, anno_attribute_id, content) + VALUES ((SELECT id FROM moz_bookmarks WHERE guid = :guid), + (SELECT id FROM moz_anno_attributes WHERE name = :last_used_anno), + :content)`, + { + guid, + content: new Date(1517318477569) - (3 - i) * 60 * 60 * 1000, + last_used_anno: LAST_USED_ANNO, + } + ); + expectedGuids.unshift(guid); + } + + info("Move menu into unfiled"); + await adjustIndices(db, "menu________"); + await db.execute( + ` + UPDATE moz_bookmarks SET + parent = (SELECT id FROM moz_bookmarks WHERE guid = :newParentGuid), + position = IFNULL((SELECT MAX(position) + 1 FROM moz_bookmarks + WHERE guid = :newParentGuid), 0) + WHERE guid = :itemGuid`, + { newParentGuid: "unfiled_____", itemGuid: "menu________" } + ); + + info("Move toolbar into mobile"); + let mobileChildren = [ + "bookmarkAAAA", + "bookmarkBBBB", + "toolbar_____", + "bookmarkCCCC", + "bookmarkDDDD", + ]; + await adjustIndices(db, "toolbar_____"); + for (let position = 0; position < mobileChildren.length; position++) { + await db.execute( + ` + INSERT INTO moz_bookmarks(guid, parent, position) + VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = 'mobile______'), + :position) + ON CONFLICT(guid) DO UPDATE SET + parent = excluded.parent, + position = excluded.position`, + { guid: mobileChildren[position], position } + ); + } + + info("Reset Sync change counters"); + await db.execute(`UPDATE moz_bookmarks SET syncChangeCounter = 0`); + + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function test_folders_migrated() { + let metaData = await PlacesUtils.metadata.get(LAST_USED_META_DATA); + + Assert.deepEqual(JSON.parse(metaData), expectedGuids); +}); + +add_task(async function test_annotations_removed() { + let db = await PlacesUtils.promiseDBConnection(); + + await assertAnnotationsRemoved(db, [LAST_USED_ANNO]); +}); + +add_task(async function test_no_orphan_annotations() { + let db = await PlacesUtils.promiseDBConnection(); + + await assertNoOrphanAnnotations(db); +}); + +add_task(async function test_roots_fixed() { + let db = await PlacesUtils.promiseDBConnection(); + + let expectedRootInfos = [ + { + guid: PlacesUtils.bookmarks.tagsGuid, + position: 0, + syncChangeCounter: 0, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + position: 1, + syncChangeCounter: 1, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + position: 2, + syncChangeCounter: 1, + }, + { + guid: PlacesUtils.bookmarks.menuGuid, + position: 3, + syncChangeCounter: 1, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + position: 4, + syncChangeCounter: 1, + }, + ]; + Assert.deepEqual( + expectedRootInfos, + await fetchChildInfos(db, PlacesUtils.bookmarks.rootGuid), + "All roots should be reparented to the Places root" + ); + + let expectedMobileInfos = [ + { + guid: "bookmarkAAAA", + position: 0, + syncChangeCounter: 0, + }, + { + guid: "bookmarkBBBB", + position: 1, + syncChangeCounter: 0, + }, + { + guid: "bookmarkCCCC", + position: 2, + syncChangeCounter: 0, + }, + { + guid: "bookmarkDDDD", + position: 3, + syncChangeCounter: 0, + }, + ]; + Assert.deepEqual( + expectedMobileInfos, + await fetchChildInfos(db, PlacesUtils.bookmarks.mobileGuid), + "Should fix misparented root sibling positions" + ); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v53.js b/toolkit/components/places/tests/migration/test_current_from_v53.js new file mode 100644 index 0000000000..f872dea5d5 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v53.js @@ -0,0 +1,23 @@ +add_task(async function setup() { + // Since this migration doesn't affect places.sqlite, we can reuse v43. + await setupPlacesDatabase("places_v43.sqlite"); + await setupPlacesDatabase("favicons_v41.sqlite", "favicons.sqlite"); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + let count = ( + await db.execute( + `SELECT count(*) FROM moz_icons_to_pages WHERE expire_ms = 0` + ) + )[0].getResultByIndex(0); + Assert.equal(count, 0, "All the expirations should be set"); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v54.js b/toolkit/components/places/tests/migration/test_current_from_v54.js new file mode 100644 index 0000000000..94c8a26474 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v54.js @@ -0,0 +1,58 @@ +add_task(async function setup() { + // Since this migration doesn't affect places.sqlite, we can reuse v43. + await setupPlacesDatabase("places_v54.sqlite"); + await setupPlacesDatabase("favicons_v41.sqlite", "favicons.sqlite"); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + for (let table of [ + "moz_places_metadata", + "moz_places_metadata_search_queries", + ]) { + let count = ( + await db.execute(`SELECT count(*) FROM ${table}`) + )[0].getResultByIndex(0); + Assert.equal(count, 0, `Empty table ${table}`); + } + + for (let table of [ + "moz_places_metadata_snapshots", + "moz_places_metadata_snapshots_extra", + "moz_places_metadata_snapshots_groups", + "moz_places_metadata_groups_to_snapshots", + "moz_session_metadata", + "moz_session_to_places", + ]) { + await Assert.rejects( + db.execute(`SELECT count(*) FROM ${table}`), + /no such table/, + `Table ${table} should not exist` + ); + } +}); + +add_task(async function scrolling_fields_in_database() { + let db = await PlacesUtils.promiseDBConnection(); + await db.execute( + `SELECT scrolling_time,scrolling_distance FROM moz_places_metadata` + ); +}); + +add_task(async function site_name_field_in_database() { + let db = await PlacesUtils.promiseDBConnection(); + await db.execute(`SELECT site_name FROM moz_places`); +}); + +add_task(async function previews_tombstones_in_database() { + let db = await PlacesUtils.promiseDBConnection(); + await db.execute(`SELECT hash FROM moz_previews_tombstones`); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v66.js b/toolkit/components/places/tests/migration/test_current_from_v66.js new file mode 100644 index 0000000000..5ea14f3b9d --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v66.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + const path = await setupPlacesDatabase("places_v66.sqlite"); + + const db = await Sqlite.openConnection({ path }); + await db.execute(` + INSERT INTO moz_inputhistory (input, use_count, place_id) + VALUES + ('abc', 1, 1), + ('aBc', 0.9, 1), + ('ABC', 5, 1), + ('ABC', 1, 2), + ('DEF', 1, 3) + `); + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function moz_inputhistory() { + await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => { + const rows = await db.execute( + "SELECT * FROM moz_inputhistory ORDER BY place_id" + ); + + Assert.equal(rows.length, 3); + + Assert.equal(rows[0].getResultByName("place_id"), 1); + Assert.equal(rows[0].getResultByName("input"), "abc"); + Assert.equal(rows[0].getResultByName("use_count"), 5); + + Assert.equal(rows[1].getResultByName("place_id"), 2); + Assert.equal(rows[1].getResultByName("input"), "abc"); + Assert.equal(rows[1].getResultByName("use_count"), 1); + + Assert.equal(rows[2].getResultByName("place_id"), 3); + Assert.equal(rows[2].getResultByName("input"), "def"); + Assert.equal(rows[2].getResultByName("use_count"), 1); + }); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v68.js b/toolkit/components/places/tests/migration/test_current_from_v68.js new file mode 100644 index 0000000000..689fcbfd40 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v68.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + const path = await setupPlacesDatabase("places_v68.sqlite"); + + const db = await Sqlite.openConnection({ path }); + await db.execute("INSERT INTO moz_historyvisits (from_visit) VALUES (-1)"); + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + const db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function moz_historyvisits() { + await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => { + const rows = await db.execute( + "SELECT * FROM moz_historyvisits WHERE from_visit=-1" + ); + + Assert.equal(rows.length, 1); + Assert.equal(rows[0].getResultByName("source"), 0); + Assert.equal(rows[0].getResultByName("triggeringPlaceId"), null); + }); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v69.js b/toolkit/components/places/tests/migration/test_current_from_v69.js new file mode 100644 index 0000000000..09c66fb66e --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v69.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + const path = await setupPlacesDatabase("places_v69.sqlite"); + + const db = await Sqlite.openConnection({ path }); + await db.execute(` + INSERT INTO moz_places (url, guid, url_hash, origin_id, frecency) + VALUES + ('https://test1.com', '___________1', '123456', 100, 0), + ('https://test2.com', '___________2', '123456', 101, -1), + ('https://test3.com', '___________3', '123456', 102, -1234) + `); + await db.execute(` + INSERT INTO moz_origins (id, prefix, host, frecency) + VALUES + (100, 'https://', 'test1.com', 0), + (101, 'https://', 'test2.com', 0), + (102, 'https://', 'test3.com', 0) + `); + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + const db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); +}); + +add_task(async function moz_historyvisits() { + await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => { + function expectedFrecency(guid) { + switch (guid) { + case "___________1": + return 0; + case "___________2": + return -1; + case "___________3": + return 1234; + default: + throw new Error("Unknown guid"); + } + } + const rows = await db.execute( + "SELECT guid, frecency FROM moz_places WHERE url_hash = '123456'" + ); + for (let row of rows) { + Assert.equal( + row.getResultByName("frecency"), + expectedFrecency(row.getResultByName("guid")), + "Check expected frecency" + ); + } + const origins = new Map( + (await db.execute("SELECT host, frecency FROM moz_origins")).map(r => [ + r.getResultByName("host"), + r.getResultByName("frecency"), + ]) + ); + Assert.equal(origins.get("test1.com"), 0); + Assert.equal(origins.get("test2.com"), 0); + Assert.equal(origins.get("test3.com"), 1234); + + const statSum = ( + await db.execute( + "SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'" + ) + )[0].getResultByName("value"); + const sum = ( + await db.execute( + "SELECT SUM(frecency) AS sum from moz_origins WHERE frecency > 0" + ) + )[0].getResultByName("sum"); + Assert.equal(sum, statSum, "Check stats were updated"); + }); +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v70.js b/toolkit/components/places/tests/migration/test_current_from_v70.js new file mode 100644 index 0000000000..e5e41852e3 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v70.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + let path = await setupPlacesDatabase("places_v70.sqlite"); + + let db = await Sqlite.openConnection({ path }); + await db.execute(` + INSERT INTO moz_places (url, guid, url_hash, origin_id, frecency, foreign_count) + VALUES + ('https://test1.com', '___________1', '123456', 100, 0, 2), + ('https://test2.com', '___________2', '123456', 101, -1, 2), + ('https://test3.com', '___________3', '123456', 102, -1234, 1) + `); + await db.execute(` + INSERT INTO moz_origins (id, prefix, host, frecency) + VALUES + (100, 'https://', 'test1.com', 0), + (101, 'https://', 'test2.com', 0), + (102, 'https://', 'test3.com', 0) + `); + await db.execute( + `INSERT INTO moz_session_metadata + (id, guid) + VALUES (0, "0") + ` + ); + + await db.execute( + `INSERT INTO moz_places_metadata_snapshots + (place_id, created_at, first_interaction_at, last_interaction_at) + VALUES ((SELECT id FROM moz_places WHERE guid = :guid), 0, 0, 0) + `, + { guid: "___________1" } + ); + await db.execute( + `INSERT INTO moz_bookmarks + (fk, guid) + VALUES ((SELECT id FROM moz_places WHERE guid = :guid), :guid) + `, + { guid: "___________1" } + ); + + await db.execute( + `INSERT INTO moz_places_metadata_snapshots + (place_id, created_at, first_interaction_at, last_interaction_at) + VALUES ((SELECT id FROM moz_places WHERE guid = :guid), 0, 0, 0) + `, + { guid: "___________2" } + ); + await db.execute( + `INSERT INTO moz_session_to_places + (session_id, place_id) + VALUES (0, (SELECT id FROM moz_places WHERE guid = :guid)) + `, + { guid: "___________2" } + ); + + await db.execute( + `INSERT INTO moz_session_to_places + (session_id, place_id) + VALUES (0, (SELECT id FROM moz_places WHERE guid = :guid)) + `, + { guid: "___________3" } + ); + + await db.close(); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + const db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + let rows = await db.execute("SELECT guid, foreign_count FROM moz_places"); + for (let row of rows) { + let guid = row.getResultByName("guid"); + let count = row.getResultByName("foreign_count"); + if (guid == "___________1") { + Assert.equal(count, 1, "test1 should have the correct foreign_count"); + } + if (guid == "___________2") { + Assert.equal(count, 0, "test2 should have the correct foreign_count"); + } + if (guid == "___________3") { + Assert.equal(count, 0, "test3 should have the correct foreign_count"); + } + } +}); diff --git a/toolkit/components/places/tests/migration/test_current_from_v72.js b/toolkit/components/places/tests/migration/test_current_from_v72.js new file mode 100644 index 0000000000..626279fce4 --- /dev/null +++ b/toolkit/components/places/tests/migration/test_current_from_v72.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + await setupPlacesDatabase("places_v72.sqlite"); +}); + +add_task(async function database_is_valid() { + // Accessing the database for the first time triggers migration. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_UPGRADED + ); + + const db = await PlacesUtils.promiseDBConnection(); + Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION); + + await db.execute( + "SELECT recalc_frecency, alt_frecency, recalc_alt_frecency FROM moz_origins" + ); + + await db.execute("SELECT alt_frecency, recalc_alt_frecency FROM moz_places"); + Assert.ok( + await db.indexExists("moz_places_altfrecencyindex"), + "Should have created an index" + ); +}); diff --git a/toolkit/components/places/tests/migration/xpcshell.ini b/toolkit/components/places/tests/migration/xpcshell.ini new file mode 100644 index 0000000000..6f864171fc --- /dev/null +++ b/toolkit/components/places/tests/migration/xpcshell.ini @@ -0,0 +1,33 @@ +[DEFAULT] +head = head_migration.js +tags = condprof + +support-files = + favicons_v41.sqlite + places_outdated.sqlite + places_v43.sqlite + places_v54.sqlite + places_v66.sqlite + places_v68.sqlite + places_v69.sqlite + places_v70.sqlite + places_v72.sqlite + places_v74.sqlite + +[test_current_from_downgraded.js] +[test_current_from_outdated.js] +[test_current_from_v43.js] +[test_current_from_v45.js] +[test_current_from_v46.js] +[test_current_from_v47.js] +[test_current_from_v48.js] +[test_current_from_v50.js] +[test_current_from_v53.js] +skip-if = condprof # Bug 1769154 - not supported +[test_current_from_v54.js] +skip-if = condprof # Bug 1769154 - not supported +[test_current_from_v66.js] +[test_current_from_v68.js] +[test_current_from_v69.js] +[test_current_from_v70.js] +[test_current_from_v72.js] diff --git a/toolkit/components/places/tests/moz.build b/toolkit/components/places/tests/moz.build new file mode 100644 index 0000000000..97662e2a4b --- /dev/null +++ b/toolkit/components/places/tests/moz.build @@ -0,0 +1,77 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +TEST_DIRS += ["gtest"] + +TESTING_JS_MODULES += [ + "PlacesTestUtils.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "bookmarks/xpcshell.ini", + "expiration/xpcshell.ini", + "favicons/xpcshell.ini", + "history/xpcshell.ini", + "legacy/xpcshell.ini", + "maintenance/xpcshell.ini", + "migration/xpcshell.ini", + "queries/xpcshell.ini", + "sync/xpcshell.ini", + "unit/xpcshell.ini", +] + +BROWSER_CHROME_MANIFESTS += [ + "browser/browser.ini", + "browser/previews/browser.ini", +] + +MOCHITEST_CHROME_MANIFESTS += [ + "chrome/chrome.ini", +] + +TEST_HARNESS_FILES.xpcshell.toolkit.components.places.tests += [ + "head_common.js", +] + +TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.browser += [ + "browser/1601563-1.html", + "browser/1601563-2.html", + "browser/399606-history.go-0.html", + "browser/399606-httprefresh.html", + "browser/399606-location.reload.html", + "browser/399606-location.replace.html", + "browser/399606-window.location.href.html", + "browser/399606-window.location.html", + "browser/461710_link_page-2.html", + "browser/461710_link_page-3.html", + "browser/461710_link_page.html", + "browser/461710_visited_page.html", + "browser/begin.html", + "browser/favicon-normal16.png", + "browser/favicon-normal32.png", + "browser/favicon.html", + "browser/final.html", + "browser/history_post.html", + "browser/history_post.sjs", + "browser/redirect-target.html", + "browser/redirect.sjs", + "browser/redirect_once.sjs", + "browser/redirect_self.sjs", + "browser/redirect_thrice.sjs", + "browser/redirect_twice.sjs", + "browser/redirect_twice_perma.sjs", + "browser/title1.html", + "browser/title2.html", +] + +TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.chrome += [ + "chrome/bad_links.atom", + "chrome/link-less-items-no-site-uri.rss", + "chrome/link-less-items.rss", + "chrome/rss_as_html.rss", + "chrome/rss_as_html.rss^headers^", + "chrome/sample_feed.atom", +] diff --git a/toolkit/components/places/tests/queries/head_queries.js b/toolkit/components/places/tests/queries/head_queries.js new file mode 100644 index 0000000000..d31d50d252 --- /dev/null +++ b/toolkit/components/places/tests/queries/head_queries.js @@ -0,0 +1,354 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +// Some Useful Date constants - PRTime uses microseconds, so convert +const DAY_MICROSEC = 86400000000; +const today = PlacesUtils.toPRTime(Date.now()); +const yesterday = today - DAY_MICROSEC; +const lastweek = today - DAY_MICROSEC * 7; +const daybefore = today - DAY_MICROSEC * 2; +const old = today - DAY_MICROSEC * 3; +const futureday = today + DAY_MICROSEC * 3; +const olderthansixmonths = today - DAY_MICROSEC * 31 * 7; + +/** + * Generalized function to pull in an array of objects of data and push it into + * the database. It does NOT do any checking to see that the input is + * appropriate. This function is an asynchronous task, it can be called using + * "Task.spawn" or using the "yield" function inside another task. + */ +async function task_populateDB(aArray) { + // Iterate over aArray and execute all instructions. + for (let arrayItem of aArray) { + try { + // make the data object into a query data object in order to create proper + // default values for anything left unspecified + var qdata = new queryData(arrayItem); + if (qdata.isVisit) { + // Then we should add a visit for this node + await PlacesTestUtils.addVisits({ + uri: uri(qdata.uri), + transition: qdata.transType, + visitDate: qdata.lastVisit, + referrer: qdata.referrer ? uri(qdata.referrer) : null, + title: qdata.title, + }); + if (qdata.visitCount && !qdata.isDetails) { + // Set a fake visit_count, this is not a real count but can be used + // to test sorting by visit_count. + let stmt = DBConn().createAsyncStatement( + "UPDATE moz_places SET visit_count = :vc WHERE url_hash = hash(:url) AND url = :url" + ); + stmt.params.vc = qdata.visitCount; + stmt.params.url = qdata.uri; + try { + stmt.executeAsync(); + } catch (ex) { + print("Error while setting visit_count."); + } finally { + stmt.finalize(); + } + } + } + + if (qdata.isRedirect) { + // This must be async to properly enqueue after the updateFrecency call + // done by the visit addition. + let stmt = DBConn().createAsyncStatement( + "UPDATE moz_places SET hidden = 1 WHERE url_hash = hash(:url) AND url = :url" + ); + stmt.params.url = qdata.uri; + try { + stmt.executeAsync(); + } catch (ex) { + print("Error while setting hidden."); + } finally { + stmt.finalize(); + } + } + + if (qdata.isDetails) { + // Then we add extraneous page details for testing + await PlacesTestUtils.addVisits({ + uri: uri(qdata.uri), + visitDate: qdata.lastVisit, + title: qdata.title, + }); + } + + if (qdata.markPageAsTyped) { + PlacesUtils.history.markPageAsTyped(uri(qdata.uri)); + } + + if (qdata.isPageAnnotation) { + await PlacesUtils.history.update({ + url: qdata.uri, + annotations: new Map([ + [qdata.annoName, qdata.removeAnnotation ? null : qdata.annoVal], + ]), + }); + } + + if (qdata.isFolder) { + await PlacesUtils.bookmarks.insert({ + parentGuid: qdata.parentGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: qdata.title, + index: qdata.index, + }); + } + + if (qdata.isBookmark) { + let data = { + parentGuid: qdata.parentGuid, + index: qdata.index, + title: qdata.title, + url: qdata.uri, + }; + + if (qdata.dateAdded) { + data.dateAdded = new Date(qdata.dateAdded / 1000); + } + + if (qdata.lastModified) { + data.lastModified = new Date(qdata.lastModified / 1000); + } + + await PlacesUtils.bookmarks.insert(data); + + if (qdata.keyword) { + await PlacesUtils.keywords.insert({ + url: qdata.uri, + keyword: qdata.keyword, + }); + } + } + + if (qdata.isTag) { + PlacesUtils.tagging.tagURI(uri(qdata.uri), qdata.tagArray); + } + + if (qdata.isSeparator) { + await PlacesUtils.bookmarks.insert({ + parentGuid: qdata.parentGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: qdata.index, + }); + } + } catch (ex) { + // use the arrayItem object here in case instantiation of qdata failed + info("Problem with this URI: " + arrayItem.uri); + do_throw("Error creating database: " + ex + "\n"); + } + } +} + +/** + * The Query Data Object - this object encapsulates data for our queries and is + * used to parameterize our calls to the Places APIs to put data into the + * database. It also has some interesting meta functions to determine which APIs + * should be called, and to determine if this object should show up in the + * resulting query. + * Its parameter is an object specifying which attributes you want to set. + * For ex: + * var myobj = new queryData({isVisit: true, uri:"http://mozilla.com", title="foo"}); + * Note that it doesn't do any input checking on that object. + */ +function queryData(obj) { + this.isVisit = obj.isVisit ? obj.isVisit : false; + this.isBookmark = obj.isBookmark ? obj.isBookmark : false; + this.uri = obj.uri ? obj.uri : ""; + this.lastVisit = obj.lastVisit ? obj.lastVisit : today; + this.referrer = obj.referrer ? obj.referrer : null; + this.transType = obj.transType + ? obj.transType + : Ci.nsINavHistoryService.TRANSITION_TYPED; + this.isRedirect = obj.isRedirect ? obj.isRedirect : false; + this.isDetails = obj.isDetails ? obj.isDetails : false; + this.title = obj.title ? obj.title : ""; + this.markPageAsTyped = obj.markPageAsTyped ? obj.markPageAsTyped : false; + this.isPageAnnotation = obj.isPageAnnotation ? obj.isPageAnnotation : false; + this.removeAnnotation = !!obj.removeAnnotation; + this.annoName = obj.annoName ? obj.annoName : ""; + this.annoVal = obj.annoVal ? obj.annoVal : ""; + this.itemId = obj.itemId ? obj.itemId : 0; + this.annoMimeType = obj.annoMimeType ? obj.annoMimeType : ""; + this.isTag = obj.isTag ? obj.isTag : false; + this.tagArray = obj.tagArray ? obj.tagArray : null; + this.parentGuid = obj.parentGuid || PlacesUtils.bookmarks.unfiledGuid; + this.feedURI = obj.feedURI ? obj.feedURI : ""; + this.index = obj.index ? obj.index : PlacesUtils.bookmarks.DEFAULT_INDEX; + this.isFolder = obj.isFolder ? obj.isFolder : false; + this.contractId = obj.contractId ? obj.contractId : ""; + this.lastModified = obj.lastModified ? obj.lastModified : null; + this.dateAdded = obj.dateAdded ? obj.dateAdded : null; + this.keyword = obj.keyword ? obj.keyword : ""; + this.visitCount = obj.visitCount ? obj.visitCount : 0; + this.isSeparator = obj.hasOwnProperty("isSeparator") && obj.isSeparator; + + // And now, the attribute for whether or not this object should appear in the + // resulting query + this.isInQuery = obj.isInQuery ? obj.isInQuery : false; +} + +// All attributes are set in the constructor above +queryData.prototype = {}; + +/** + * Helper function to compare an array of query objects with a result set. + * It assumes the array of query objects contains the SAME SORT as the result + * set. It checks the the uri, title, time, and bookmarkIndex properties of + * the results, where appropriate. + */ +function compareArrayToResult(aArray, aRoot) { + info("Comparing Array to Results"); + + var wasOpen = aRoot.containerOpen; + if (!wasOpen) { + aRoot.containerOpen = true; + } + + // check expected number of results against actual + var expectedResultCount = aArray.filter(function (aEl) { + return aEl.isInQuery; + }).length; + if (expectedResultCount != aRoot.childCount) { + // Debugging code for failures. + dump_table("moz_places"); + dump_table("moz_historyvisits"); + info("Found children:"); + for (let i = 0; i < aRoot.childCount; i++) { + info(aRoot.getChild(i).uri); + } + info("Expected:"); + for (let i = 0; i < aArray.length; i++) { + if (aArray[i].isInQuery) { + info(aArray[i].uri); + } + } + } + Assert.equal(expectedResultCount, aRoot.childCount); + + var inQueryIndex = 0; + for (var i = 0; i < aArray.length; i++) { + if (aArray[i].isInQuery) { + var child = aRoot.getChild(inQueryIndex); + // do_print("testing testData[" + i + "] vs result[" + inQueryIndex + "]"); + if (!aArray[i].isFolder && !aArray[i].isSeparator) { + info( + "testing testData[" + aArray[i].uri + "] vs result[" + child.uri + "]" + ); + if (aArray[i].uri != child.uri) { + dump_table("moz_places"); + do_throw("Expected " + aArray[i].uri + " found " + child.uri); + } + } + if (!aArray[i].isSeparator && aArray[i].title != child.title) { + do_throw("Expected " + aArray[i].title + " found " + child.title); + } + if ( + aArray[i].hasOwnProperty("lastVisit") && + aArray[i].lastVisit != child.time + ) { + do_throw("Expected " + aArray[i].lastVisit + " found " + child.time); + } + if ( + aArray[i].hasOwnProperty("index") && + aArray[i].index != PlacesUtils.bookmarks.DEFAULT_INDEX && + aArray[i].index != child.bookmarkIndex + ) { + do_throw( + "Expected " + aArray[i].index + " found " + child.bookmarkIndex + ); + } + + inQueryIndex++; + } + } + + if (!wasOpen) { + aRoot.containerOpen = false; + } + info("Comparing Array to Results passes"); +} + +/** + * Helper function to check to see if one object either is or is not in the + * result set. It can accept either a queryData object or an array of queryData + * objects. If it gets an array, it only compares the first object in the array + * to see if it is in the result set. + * Returns: True if item is in query set, and false if item is not in query set + * If input is an array, returns True if FIRST object in array is in + * query set. To compare entire array, use the function above. + */ +function isInResult(aQueryData, aRoot) { + var rv = false; + var uri; + var wasOpen = aRoot.containerOpen; + if (!wasOpen) { + aRoot.containerOpen = true; + } + + // If we have an array, pluck out the first item. If an object, pluc out the + // URI, we just compare URI's here. + if ("uri" in aQueryData) { + uri = aQueryData.uri; + } else { + uri = aQueryData[0].uri; + } + + for (var i = 0; i < aRoot.childCount; i++) { + if (uri == aRoot.getChild(i).uri) { + rv = true; + break; + } + } + if (!wasOpen) { + aRoot.containerOpen = false; + } + return rv; +} + +/** + * A nice helper function for debugging things. It prints the contents of a + * result set. + */ +function displayResultSet(aRoot) { + var wasOpen = aRoot.containerOpen; + if (!wasOpen) { + aRoot.containerOpen = true; + } + + if (!aRoot.hasChildren) { + // Something wrong? Empty result set? + info("Result Set Empty"); + return; + } + + for (var i = 0; i < aRoot.childCount; ++i) { + info( + "Result Set URI: " + + aRoot.getChild(i).uri + + " Title: " + + aRoot.getChild(i).title + + " Visit Time: " + + aRoot.getChild(i).time + ); + } + if (!wasOpen) { + aRoot.containerOpen = false; + } +} diff --git a/toolkit/components/places/tests/queries/readme.txt b/toolkit/components/places/tests/queries/readme.txt new file mode 100644 index 0000000000..19414f96ed --- /dev/null +++ b/toolkit/components/places/tests/queries/readme.txt @@ -0,0 +1,16 @@ +These are tests specific to the Places Query API. + +We are tracking the coverage of these tests here: +http://wiki.mozilla.org/QA/TDAI/Projects/Places_Tests + +When creating one of these tests, you need to update those tables so that we +know how well our test coverage is of this area. Furthermore, when adding tests +ensure to cover live update (changing the query set) by performing the following +operations on the query set you get after running the query: +* Adding a new item to the query set +* Updating an existing item so that it matches the query set +* Change an existing item so that it does not match the query set +* Do multiple of the above inside an Update Batch transaction. +* Try these transactions in different orders. + +Use the stub test to help you create a test with the proper structure. diff --git a/toolkit/components/places/tests/queries/test_415716.js b/toolkit/components/places/tests/queries/test_415716.js new file mode 100644 index 0000000000..a39c278024 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_415716.js @@ -0,0 +1,109 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function modHistoryTypes(val) { + switch (val % 8) { + case 0: + case 1: + return TRANSITION_LINK; + case 2: + return TRANSITION_TYPED; + case 3: + return TRANSITION_BOOKMARK; + case 4: + return TRANSITION_EMBED; + case 5: + return TRANSITION_REDIRECT_PERMANENT; + case 6: + return TRANSITION_REDIRECT_TEMPORARY; + case 7: + return TRANSITION_DOWNLOAD; + case 8: + return TRANSITION_FRAMED_LINK; + } + return TRANSITION_TYPED; +} + +/** + * Builds a test database by hand using various times, annotations and + * visit numbers for this test + */ +add_task(async function test_buildTestDatabase() { + // This is the set of visits that we will match - our min visit is 2 so that's + // why we add more visits to the same URIs. + let testURI = "http://www.foo.com"; + let places = []; + + for (let i = 0; i < 12; ++i) { + places.push({ + uri: testURI, + transition: modHistoryTypes(i), + visitDate: today, + }); + } + + testURI = "http://foo.com/youdontseeme.html"; + let testAnnoName = "moz-test-places/testing123"; + let testAnnoVal = "test"; + for (let i = 0; i < 12; ++i) { + places.push({ + uri: testURI, + transition: modHistoryTypes(i), + visitDate: today, + }); + } + + await PlacesTestUtils.addVisits(places); + + await PlacesUtils.history.update({ + url: testURI, + annotations: new Map([[testAnnoName, testAnnoVal]]), + }); +}); + +/** + * This test will test Queries that use relative Time Range, minVists, maxVisits, + * annotation. + * The Query: + * Annotation == "moz-test-places/testing123" && + * TimeRange == "now() - 2d" && + * minVisits == 2 && + * maxVisits == 10 + */ +add_task(function test_execute() { + let query = PlacesUtils.history.getNewQuery(); + query.annotation = "moz-test-places/testing123"; + query.beginTime = daybefore * 1000; + query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_NOW; + query.endTime = today * 1000; + query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_NOW; + query.minVisits = 2; + query.maxVisits = 10; + + // Options + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_VISIT; + + // Results + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + let cc = root.childCount; + dump("----> cc is: " + cc + "\n"); + for (let i = 0; i < root.childCount; ++i) { + let resultNode = root.getChild(i); + let accesstime = Date(resultNode.time / 1000); + dump( + "----> result: " + + resultNode.uri + + " Date: " + + accesstime.toLocaleString() + + "\n" + ); + } + Assert.equal(cc, 0); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js b/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js new file mode 100644 index 0000000000..3f563ea7d8 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js @@ -0,0 +1,321 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const DAY_MSEC = 86400000; +const MIN_MSEC = 60000; +const HOUR_MSEC = 3600000; +// Jan 6 2008 at 8am is our begin edge of the query +var beginTimeDate = new Date(2008, 0, 6, 8, 0, 0, 0); +// Jan 15 2008 at 9:30pm is our ending edge of the query +var endTimeDate = new Date(2008, 0, 15, 21, 30, 0, 0); + +// These as millisecond values +var beginTime = beginTimeDate.getTime(); +var endTime = endTimeDate.getTime(); + +// Some range dates inside our query - mult by 1000 to convert to PRTIME +var jan7_800 = (beginTime + DAY_MSEC) * 1000; +var jan6_815 = (beginTime + MIN_MSEC * 15) * 1000; +var jan11_800 = (beginTime + DAY_MSEC * 5) * 1000; +var jan14_2130 = (endTime - DAY_MSEC) * 1000; +var jan15_2045 = (endTime - MIN_MSEC * 45) * 1000; +var jan12_1730 = (endTime - DAY_MSEC * 3 - HOUR_MSEC * 4) * 1000; + +// Dates outside our query - mult by 1000 to convert to PRTIME +var jan6_700 = (beginTime - HOUR_MSEC) * 1000; +var dec27_800 = (beginTime - DAY_MSEC * 10) * 1000; + +// So that we can easily use these too, convert them to PRTIME +beginTime *= 1000; +endTime *= 1000; + +/** + * Array of objects to build our test database + */ +var goodAnnoName = "moz-test-places/testing123"; +var val = "test"; +var badAnnoName = "text/foo"; + +// The test data for our database, note that the ordering of the results that +// will be returned by the query (the isInQuery: true objects) is IMPORTANT. +// see compareArrayToResult in head_queries.js for more info. +var testData = [ + // Test ftp protocol - vary the title length + { + isInQuery: true, + isVisit: true, + isDetails: true, + uri: "ftp://foo.com/ftp", + lastVisit: jan12_1730, + title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo", + }, + + // Test flat domain with annotation + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://foo.com/", + annoName: goodAnnoName, + annoVal: val, + lastVisit: jan14_2130, + title: "moz", + }, + + // Test subdomain included with isRedirect=true, different transtype + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz", + isRedirect: true, + uri: "http://mail.foo.com/redirect", + lastVisit: jan11_800, + transType: PlacesUtils.history.TRANSITION_LINK, + }, + + // Test subdomain inclued at the leading time edge + { + isInQuery: true, + isVisit: true, + isDetails: true, + uri: "http://mail.foo.com/yiihah", + title: "moz", + lastVisit: jan6_815, + }, + + // Test www. style URI is included, with an annotation + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://www.foo.com/yiihah", + annoName: goodAnnoName, + annoVal: val, + lastVisit: jan7_800, + title: "moz", + }, + + // Test https protocol + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz", + uri: "https://foo.com/", + lastVisit: jan15_2045, + }, + + // Test begin edge of time + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz mozilla", + uri: "https://foo.com/begin.html", + lastVisit: beginTime, + }, + + // Test end edge of time + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz mozilla", + uri: "https://foo.com/end.html", + lastVisit: endTime, + }, + + // Test an image link, with annotations + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + title: "mozzie the dino", + uri: "https://foo.com/mozzie.png", + annoName: goodAnnoName, + annoVal: val, + lastVisit: jan14_2130, + }, + + // Begin the invalid queries: Test too early + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://foo.com/tooearly.php", + lastVisit: jan6_700, + }, + + // Test Bad Annotation + { + isInQuery: false, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + title: "moz", + uri: "http://foo.com/badanno.htm", + lastVisit: jan12_1730, + annoName: badAnnoName, + annoVal: val, + }, + + // Test bad URI + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://somefoo.com/justwrong.htm", + lastVisit: jan11_800, + }, + + // Test afterward, one to update + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "changeme", + uri: "http://foo.com/changeme1.htm", + lastVisit: jan12_1730, + }, + + // Test invalid title + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "changeme2", + uri: "http://foo.com/changeme2.htm", + lastVisit: jan7_800, + }, + + // Test changing the lastVisit + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://foo.com/changeme3.htm", + lastVisit: dec27_800, + }, +]; + +/** + * This test will test a Query using several terms and do a bit of negative + * testing for items that should be ignored while querying over history. + * The Query:WHERE absoluteTime(matches) AND searchTerms AND URI + * AND annotationIsNot(match) GROUP BY Domain, Day SORT BY uri,ascending + * excludeITems(should be ignored) + */ +add_task(async function test_abstime_annotation_domain() { + // Initialize database + await task_populateDB(testData); + + // Query + var query = PlacesUtils.history.getNewQuery(); + query.beginTime = beginTime; + query.endTime = endTime; + query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH; + query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH; + query.searchTerms = "moz"; + query.domain = "foo.com"; + query.domainIsHost = false; + query.annotation = "text/foo"; + query.annotationIsNot = true; + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_URI_ASCENDING; + options.resultType = options.RESULTS_AS_URI; + // The next two options should be ignored + // can't use this one, breaks test - bug 419779 + // options.excludeItems = true; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + // Ensure the result set is correct + compareArrayToResult(testData, root); + + // Make some changes to the result set + // Let's add something first + var addItem = [ + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://www.foo.com/i-am-added.html", + lastVisit: jan11_800, + }, + ]; + await task_populateDB(addItem); + info("Adding item foo.com/i-am-added.html"); + Assert.equal(isInResult(addItem, root), true); + + // Let's update something by title + var change1 = [ + { + isDetails: true, + uri: "http://foo.com/changeme1", + lastVisit: jan12_1730, + title: "moz moz mozzie", + }, + ]; + await task_populateDB(change1); + info("LiveUpdate by changing title"); + Assert.equal(isInResult(change1, root), true); + + // Let's update something by annotation + // Updating a page by removing an annotation does not cause it to join this + // query set. I tend to think that it should cause that page to join this + // query set, because this visit fits all theother specified criteria once the + // annotation is removed. Uncommenting this will fail the test. + // Bug 424050 + /* var change2 = [{isPageAnnotation: true, uri: "http://foo.com/badannotaion.html", + annoName: "text/mozilla", annoVal: "test"}]; + yield task_populateDB(change2); + do_print("LiveUpdate by removing annotation"); + do_check_eq(isInResult(change2, root), true);*/ + + // Let's update by adding a visit in the time range for an existing URI + var change3 = [ + { + isDetails: true, + uri: "http://foo.com/changeme3.htm", + title: "moz", + lastVisit: jan15_2045, + }, + ]; + await task_populateDB(change3); + info("LiveUpdate by adding visit within timerange"); + Assert.equal(isInResult(change3, root), true); + + // And delete something from the result set - using annotation + // Once again, bug 424050 prevents this from passing + /* var change4 = [{isPageAnnotation: true, uri: "ftp://foo.com/ftp", + annoVal: "test", annoName: badAnnoName}]; + yield task_populateDB(change4); + do_print("LiveUpdate by deleting item from set by adding annotation"); + do_check_eq(isInResult(change4, root), false);*/ + + // Delete something by changing the title + var change5 = [ + { isDetails: true, uri: "http://foo.com/end.html", title: "deleted" }, + ]; + await task_populateDB(change5); + info("LiveUpdate by deleting item by changing title"); + Assert.equal(isInResult(change5, root), false); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js b/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js new file mode 100644 index 0000000000..9ad1478726 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js @@ -0,0 +1,226 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const DAY_MSEC = 86400000; +const MIN_MSEC = 60000; +const HOUR_MSEC = 3600000; +// Jan 6 2008 at 8am is our begin edge of the query +var beginTimeDate = new Date(2008, 0, 6, 8, 0, 0, 0); +// Jan 15 2008 at 9:30pm is our ending edge of the query +var endTimeDate = new Date(2008, 0, 15, 21, 30, 0, 0); + +// These as millisecond values +var beginTime = beginTimeDate.getTime(); +var endTime = endTimeDate.getTime(); + +// Some range dates inside our query - mult by 1000 to convert to PRTIME +var jan7_800 = (beginTime + DAY_MSEC) * 1000; +var jan6_815 = (beginTime + MIN_MSEC * 15) * 1000; +var jan14_2130 = (endTime - DAY_MSEC) * 1000; +var jan15_2045 = (endTime - MIN_MSEC * 45) * 1000; +var jan12_1730 = (endTime - DAY_MSEC * 3 - HOUR_MSEC * 4) * 1000; + +// Dates outside our query - mult by 1000 to convert to PRTIME +var jan6_700 = (beginTime - HOUR_MSEC) * 1000; +var dec27_800 = (beginTime - DAY_MSEC * 10) * 1000; + +// So that we can easily use these too, convert them to PRTIME +beginTime *= 1000; +endTime *= 1000; + +/** + * Array of objects to build our test database + */ +var goodAnnoName = "moz-test-places/testing123"; +var val = "test"; +var badAnnoName = "text/foo"; + +// The test data for our database, note that the ordering of the results that +// will be returned by the query (the isInQuery: true objects) is IMPORTANT. +// see compareArrayToResult in head_queries.js for more info. +var testData = [ + // Test flat domain with annotation + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://foo.com/", + annoName: goodAnnoName, + annoVal: val, + lastVisit: jan14_2130, + title: "moz", + }, + + // Begin the invalid queries: + // Test www. style URI is not included, with an annotation + { + isInQuery: false, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://www.foo.com/yiihah", + annoName: goodAnnoName, + annoVal: val, + lastVisit: jan7_800, + title: "moz", + }, + + // Test subdomain not inclued at the leading time edge + { + isInQuery: false, + isVisit: true, + isDetails: true, + uri: "http://mail.foo.com/yiihah", + title: "moz", + lastVisit: jan6_815, + }, + + // Test https protocol + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "https://foo.com/", + lastVisit: jan15_2045, + }, + + // Test ftp protocol + { + isInQuery: false, + isVisit: true, + isDetails: true, + uri: "ftp://foo.com/ftp", + lastVisit: jan12_1730, + title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo", + }, + + // Test too early + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://foo.com/tooearly.php", + lastVisit: jan6_700, + }, + + // Test Bad Annotation + { + isInQuery: false, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + title: "moz", + uri: "http://foo.com/badanno.htm", + lastVisit: jan12_1730, + annoName: badAnnoName, + annoVal: val, + }, + + // Test afterward, one to update + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "changeme", + uri: "http://foo.com/changeme1.htm", + lastVisit: jan12_1730, + }, + + // Test invalid title + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "changeme2", + uri: "http://foo.com/changeme2.htm", + lastVisit: jan7_800, + }, + + // Test changing the lastVisit + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://foo.com/changeme3.htm", + lastVisit: dec27_800, + }, +]; + +/** + * This test will test a Query using several terms and do a bit of negative + * testing for items that should be ignored while querying over history. + * The Query:WHERE absoluteTime(matches) AND searchTerms AND URI + * AND annotationIsNot(match) GROUP BY Domain, Day SORT BY uri,ascending + * excludeITems(should be ignored) + */ +add_task(async function test_abstime_annotation_uri() { + // Initialize database + await task_populateDB(testData); + + // Query + var query = PlacesUtils.history.getNewQuery(); + query.beginTime = beginTime; + query.endTime = endTime; + query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH; + query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH; + query.searchTerms = "moz"; + query.uri = uri("http://foo.com"); + query.annotation = "text/foo"; + query.annotationIsNot = true; + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_URI_ASCENDING; + options.resultType = options.RESULTS_AS_URI; + // The next two options should be ignored + // can't use this one, breaks test - bug 419779 + // options.excludeItems = true; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + // Ensure the result set is correct + compareArrayToResult(testData, root); + + // live update. + info("change title"); + var change1 = [{ isDetails: true, uri: "http://foo.com/", title: "mo" }]; + await task_populateDB(change1); + Assert.ok(!isInResult({ uri: "http://foo.com/" }, root)); + + var change2 = [ + { + isDetails: true, + uri: "http://foo.com/", + title: "moz", + lastvisit: endTime, + }, + ]; + await task_populateDB(change2); + dump_table("moz_places"); + Assert.ok(!isInResult({ uri: "http://foo.com/" }, root)); + + // Let's delete something from the result set - using annotation + var change3 = [ + { + isPageAnnotation: true, + uri: "http://foo.com/", + annoName: badAnnoName, + annoVal: "test", + }, + ]; + await task_populateDB(change3); + info("LiveUpdate by removing annotation"); + Assert.ok(!isInResult({ uri: "http://foo.com/" }, root)); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_async.js b/toolkit/components/places/tests/queries/test_async.js new file mode 100644 index 0000000000..acdb0eef48 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_async.js @@ -0,0 +1,379 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var tests = [ + { + desc: + "nsNavHistoryFolderResultNode: Basic test, asynchronously open and " + + "close container with a single child", + + loading(node, newState, oldState) { + this.checkStateChanged("loading", 1); + this.checkArgs("loading", node, oldState, node.STATE_CLOSED); + }, + + opened(node, newState, oldState) { + this.checkStateChanged("opened", 1); + this.checkState("loading", 1); + this.checkArgs("opened", node, oldState, node.STATE_LOADING); + + print("Checking node children"); + compareArrayToResult(this.data, node); + + print("Closing container"); + node.containerOpen = false; + }, + + closed(node, newState, oldState) { + this.checkStateChanged("closed", 1); + this.checkState("opened", 1); + this.checkArgs("closed", node, oldState, node.STATE_OPENED); + this.success(); + }, + }, + + { + desc: + "nsNavHistoryFolderResultNode: After async open and no changes, " + + "second open should be synchronous", + + loading(node, newState, oldState) { + this.checkStateChanged("loading", 1); + this.checkState("closed", 0); + this.checkArgs("loading", node, oldState, node.STATE_CLOSED); + }, + + opened(node, newState, oldState) { + let cnt = this.checkStateChanged("opened", 1, 2); + let expectOldState = cnt === 1 ? node.STATE_LOADING : node.STATE_CLOSED; + this.checkArgs("opened", node, oldState, expectOldState); + + print("Checking node children"); + compareArrayToResult(this.data, node); + + print("Closing container"); + node.containerOpen = false; + }, + + closed(node, newState, oldState) { + let cnt = this.checkStateChanged("closed", 1, 2); + this.checkArgs("closed", node, oldState, node.STATE_OPENED); + + switch (cnt) { + case 1: + node.containerOpen = true; + break; + case 2: + this.success(); + break; + } + }, + }, + + { + desc: + "nsNavHistoryFolderResultNode: After closing container in " + + "loading(), opened() should not be called", + + loading(node, newState, oldState) { + this.checkStateChanged("loading", 1); + this.checkArgs("loading", node, oldState, node.STATE_CLOSED); + print("Closing container"); + node.containerOpen = false; + }, + + opened(node, newState, oldState) { + do_throw("opened should not be called"); + }, + + closed(node, newState, oldState) { + this.checkStateChanged("closed", 1); + this.checkState("loading", 1); + this.checkArgs("closed", node, oldState, node.STATE_LOADING); + this.success(); + }, + }, +]; + +/** + * Instances of this class become the prototypes of the test objects above. + * Each test can therefore use the methods of this class, or they can override + * them if they want. To run a test, call setup() and then run(). + */ +function Test() { + // This maps a state name to the number of times it's been observed. + this.stateCounts = {}; + // Promise object resolved when the next test can be run. + this.deferNextTest = PromiseUtils.defer(); +} + +Test.prototype = { + /** + * Call this when an observer observes a container state change to sanity + * check the arguments. + * + * @param aNewState + * The name of the new state. Used only for printing out helpful info. + * @param aNode + * The node argument passed to containerStateChanged. + * @param aOldState + * The old state argument passed to containerStateChanged. + * @param aExpectOldState + * The expected old state. + */ + checkArgs(aNewState, aNode, aOldState, aExpectOldState) { + print("Node passed on " + aNewState + " should be result.root"); + Assert.equal(this.result.root, aNode); + print("Old state passed on " + aNewState + " should be " + aExpectOldState); + + // aOldState comes from xpconnect and will therefore be defined. It may be + // zero, though, so use strict equality just to make sure aExpectOldState is + // also defined. + Assert.ok(aOldState === aExpectOldState); + }, + + /** + * Call this when an observer observes a container state change. It registers + * the state change and ensures that it has been observed the given number + * of times. See checkState for parameter explanations. + * + * @return The number of times aState has been observed, including the new + * observation. + */ + checkStateChanged(aState, aExpectedMin, aExpectedMax) { + print(aState + " state change observed"); + if (!this.stateCounts.hasOwnProperty(aState)) { + this.stateCounts[aState] = 0; + } + this.stateCounts[aState]++; + return this.checkState(aState, aExpectedMin, aExpectedMax); + }, + + /** + * Ensures that the state has been observed the given number of times. + * + * @param aState + * The name of the state. + * @param aExpectedMin + * The state must have been observed at least this number of times. + * @param aExpectedMax + * The state must have been observed at most this number of times. + * This parameter is optional. If undefined, it's set to + * aExpectedMin. + * @return The number of times aState has been observed, including the new + * observation. + */ + checkState(aState, aExpectedMin, aExpectedMax) { + let cnt = this.stateCounts[aState] || 0; + if (aExpectedMax === undefined) { + aExpectedMax = aExpectedMin; + } + if (aExpectedMin === aExpectedMax) { + print( + aState + + " should be observed only " + + aExpectedMin + + " times (actual = " + + cnt + + ")" + ); + } else { + print( + aState + + " should be observed at least " + + aExpectedMin + + " times and at most " + + aExpectedMax + + " times (actual = " + + cnt + + ")" + ); + } + Assert.ok(cnt >= aExpectedMin && cnt <= aExpectedMax); + return cnt; + }, + + /** + * Asynchronously opens the root of the test's result. + */ + openContainer() { + // Set up the result observer. It delegates to this object's callbacks and + // wraps them in a try-catch so that errors don't get eaten. + let self = this; + this.observer = { + containerStateChanged(container, oldState, newState) { + print( + "New state passed to containerStateChanged() should equal the " + + "container's current state" + ); + Assert.equal(newState, container.state); + + try { + switch (newState) { + case Ci.nsINavHistoryContainerResultNode.STATE_LOADING: + self.loading(container, newState, oldState); + break; + case Ci.nsINavHistoryContainerResultNode.STATE_OPENED: + self.opened(container, newState, oldState); + break; + case Ci.nsINavHistoryContainerResultNode.STATE_CLOSED: + self.closed(container, newState, oldState); + break; + default: + do_throw("Unexpected new state! " + newState); + } + } catch (err) { + do_throw(err); + } + }, + }; + this.result.addObserver(this.observer); + + print("Opening container"); + this.result.root.containerOpen = true; + }, + + /** + * Starts the test and returns a promise resolved when the test completes. + */ + run() { + this.openContainer(); + return this.deferNextTest.promise; + }, + + /** + * This must be called before run(). It adds a bookmark and sets up the + * test's result. Override if need be. + */ + async setup() { + // Populate the database with different types of bookmark items. + this.data = DataHelper.makeDataArray([ + { type: "bookmark" }, + { type: "separator" }, + { type: "folder" }, + { type: "bookmark", uri: "place:terms=foo" }, + ]); + await task_populateDB(this.data); + + // Make a query. + this.query = PlacesUtils.history.getNewQuery(); + this.query.setParents([DataHelper.defaults.bookmark.parentGuid]); + this.opts = PlacesUtils.history.getNewQueryOptions(); + this.opts.asyncEnabled = true; + this.result = PlacesUtils.history.executeQuery(this.query, this.opts); + }, + + /** + * Call this when the test has succeeded. It cleans up resources and starts + * the next test. + */ + success() { + this.result.removeObserver(this.observer); + + // Resolve the promise object that indicates that the next test can be run. + this.deferNextTest.resolve(); + }, +}; + +/** + * This makes it a little bit easier to use the functions of head_queries.js. + */ +var DataHelper = { + defaults: { + bookmark: { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + uri: "http://example.com/", + title: "test bookmark", + }, + + folder: { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test folder", + }, + + separator: { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + }, + + /** + * Converts an array of simple bookmark item descriptions to the more verbose + * format required by task_populateDB() in head_queries.js. + * + * @param aData + * An array of objects, each of which describes a bookmark item. + * @return An array of objects suitable for passing to populateDB(). + */ + makeDataArray: function DH_makeDataArray(aData) { + let self = this; + return aData.map(function (dat) { + let type = dat.type; + dat = self._makeDataWithDefaults(dat, self.defaults[type]); + switch (type) { + case "bookmark": + return { + isBookmark: true, + uri: dat.uri, + parentGuid: dat.parentGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: dat.title, + isInQuery: true, + }; + case "separator": + return { + isSeparator: true, + parentGuid: dat.parentGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + isInQuery: true, + }; + case "folder": + return { + isFolder: true, + parentGuid: dat.parentGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: dat.title, + isInQuery: true, + }; + default: + do_throw("Unknown data type when populating DB: " + type); + return undefined; + } + }); + }, + + /** + * Returns a copy of aData, except that any properties that are undefined but + * defined in aDefaults are set to the corresponding values in aDefaults. + * + * @param aData + * An object describing a bookmark item. + * @param aDefaults + * An object describing the default bookmark item. + * @return A copy of aData with defaults values set. + */ + _makeDataWithDefaults: function DH__makeDataWithDefaults(aData, aDefaults) { + let dat = {}; + for (let [prop, val] of Object.entries(aDefaults)) { + dat[prop] = aData.hasOwnProperty(prop) ? aData[prop] : val; + } + return dat; + }, +}; + +add_task(async function test_async() { + for (let test of tests) { + await PlacesUtils.bookmarks.eraseEverything(); + + Object.setPrototypeOf(test, new Test()); + await test.setup(); + + print("------ Running test: " + test.desc); + await test.run(); + } + + await PlacesUtils.bookmarks.eraseEverything(); + print("All tests done, exiting"); +}); diff --git a/toolkit/components/places/tests/queries/test_bookmarks.js b/toolkit/components/places/tests/queries/test_bookmarks.js new file mode 100644 index 0000000000..b5f2ef754f --- /dev/null +++ b/toolkit/components/places/tests/queries/test_bookmarks.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_eraseEverything() { + info("Test folder with eraseEverything"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "remove-folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { url: "http://mozilla.org/", title: "title 1" }, + { url: "http://mozilla.org/", title: "title 2" }, + { title: "sub-folder", type: PlacesUtils.bookmarks.TYPE_FOLDER }, + { type: PlacesUtils.bookmarks.TYPE_SEPARATOR }, + ], + }, + ], + }); + + let unfiled = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + Assert.equal(unfiled.childCount, 1, "There should be 1 folder"); + let folder = unfiled.getChild(0); + // Test dateAdded and lastModified properties. + Assert.equal(typeof folder.dateAdded, "number"); + Assert.ok(folder.dateAdded > 0); + Assert.equal(typeof folder.lastModified, "number"); + Assert.ok(folder.lastModified > 0); + + let root = PlacesUtils.getFolderContents(folder.bookmarkGuid).root; + Assert.equal(root.childCount, 4, "The folder should have 4 children"); + for (let i = 0; i < root.childCount; ++i) { + let node = root.getChild(i); + Assert.greater(node.itemId, 0, "The node should have an itemId"); + } + Assert.equal(root.getChild(0).title, "title 1"); + Assert.equal(root.getChild(1).title, "title 2"); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Refetch the guid to refresh the data. + unfiled = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + Assert.equal(unfiled.childCount, 0); + unfiled.containerOpen = false; +}); + +add_task(async function test_search_title() { + let title = "ZZZXXXYYY"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://mozilla.org/", + title, + }); + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "ZZZXXXYYY"; + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + let node = root.getChild(0); + Assert.equal(node.title, title); + + // Test dateAdded and lastModified properties. + Assert.equal(typeof node.dateAdded, "number"); + Assert.ok(node.dateAdded > 0); + Assert.equal(typeof node.lastModified, "number"); + Assert.ok(node.lastModified > 0); + Assert.equal(node.bookmarkGuid, bm.guid); + + await PlacesUtils.bookmarks.remove(bm); + Assert.equal(root.childCount, 0); + root.containerOpen = false; +}); + +add_task(async function test_long_title() { + let title = Array(TITLE_LENGTH_MAX + 5).join("A"); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://mozilla.org/", + title, + }); + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + let node = root.getChild(0); + Assert.equal(node.title, title.substr(0, TITLE_LENGTH_MAX)); + + // Update with another long title. + let newTitle = Array(TITLE_LENGTH_MAX + 5).join("B"); + bm.title = newTitle; + await PlacesUtils.bookmarks.update(bm); + Assert.equal(node.title, newTitle.substr(0, TITLE_LENGTH_MAX)); + + await PlacesUtils.bookmarks.remove(bm); + Assert.equal(root.childCount, 0); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_containersQueries_sorting.js b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js new file mode 100644 index 0000000000..9cdc0f2a52 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js @@ -0,0 +1,492 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Testing behavior of bug 473157 + * "Want to sort history in container view without sorting the containers" + * and regression bug 488783 + * Tags list no longer sorted (alphabetized). + * This test is for global testing sorting containers queries. + */ + +// Globals and Constants + +var resultTypes = [ + { + value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY, + name: "RESULTS_AS_DATE_QUERY", + }, + { + value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY, + name: "RESULTS_AS_SITE_QUERY", + }, + { + value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY, + name: "RESULTS_AS_DATE_SITE_QUERY", + }, + { + value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT, + name: "RESULTS_AS_TAGS_ROOT", + }, +]; + +var sortingModes = [ + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING, + name: "SORT_BY_TITLE_ASCENDING", + }, + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING, + name: "SORT_BY_TITLE_DESCENDING", + }, + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING, + name: "SORT_BY_DATE_ASCENDING", + }, + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING, + name: "SORT_BY_DATE_DESCENDING", + }, + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING, + name: "SORT_BY_DATEADDED_ASCENDING", + }, + { + value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING, + name: "SORT_BY_DATEADDED_DESCENDING", + }, +]; + +// These pages will be added from newest to oldest and from less visited to most +// visited. +var pages = [ + "http://www.mozilla.org/c/", + "http://www.mozilla.org/a/", + "http://www.mozilla.org/b/", + "http://www.mozilla.com/c/", + "http://www.mozilla.com/a/", + "http://www.mozilla.com/b/", +]; + +var tags = ["mozilla", "Development", "test"]; + +// Test Runner + +/** + * Enumerates all the sequences of the cartesian product of the arrays contained + * in aSequences. Examples: + * + * cartProd([[1, 2, 3], ["a", "b"]], callback); + * // callback is called 3 * 2 = 6 times with the following arrays: + * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"] + * + * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback); + * // callback is called 1 * 3 * 2 = 6 times with the following arrays: + * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"], + * // ["a", 3, "X"], ["a", 3, "Y"] + * + * cartProd([[1], [2], [3], [4]], callback); + * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array: + * // [1, 2, 3, 4] + * + * cartProd([], callback); + * // callback is 0 times + * + * cartProd([[1, 2, 3, 4]], callback); + * // callback is called 4 times with the following arrays: + * // [1], [2], [3], [4] + * + * @param aSequences + * an array that contains an arbitrary number of arrays + * @param aCallback + * a function that is passed each sequence of the product as it's + * computed + * @return the total number of sequences in the product + */ +function cartProd(aSequences, aCallback) { + if (aSequences.length === 0) { + return 0; + } + + // For each sequence in aSequences, we maintain a pointer (an array index, + // really) to the element we're currently enumerating in that sequence + var seqEltPtrs = aSequences.map(i => 0); + + var numProds = 0; + var done = false; + while (!done) { + numProds++; + + // prod = sequence in product we're currently enumerating + var prod = []; + for (var i = 0; i < aSequences.length; i++) { + prod.push(aSequences[i][seqEltPtrs[i]]); + } + aCallback(prod); + + // The next sequence in the product differs from the current one by just a + // single element. Determine which element that is. We advance the + // "rightmost" element pointer to the "right" by one. If we move past the + // end of that pointer's sequence, reset the pointer to the first element + // in its sequence and then try the sequence to the "left", and so on. + + // seqPtr = index of rightmost input sequence whose element pointer is not + // past the end of the sequence + var seqPtr = aSequences.length - 1; + while (!done) { + // Advance the rightmost element pointer. + seqEltPtrs[seqPtr]++; + + // The rightmost element pointer is past the end of its sequence. + if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) { + seqEltPtrs[seqPtr] = 0; + seqPtr--; + + // All element pointers are past the ends of their sequences. + if (seqPtr < 0) { + done = true; + } + } else { + break; + } + } + } + return numProds; +} + +/** + * Test a query based on passed-in options. + * + * @param aSequence + * array of options we will use to query. + */ +function test_query_callback(aSequence) { + Assert.equal(aSequence.length, 2); + var resultType = aSequence[0]; + var sortingMode = aSequence[1]; + print( + "\n\n*** Testing default sorting for resultType (" + + resultType.name + + ") and sortingMode (" + + sortingMode.name + + ")" + ); + + // Skip invalid combinations sorting queries by none. + if ( + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT && + (sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING || + sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) + ) { + // This is a bookmark query, we can't sort by visit date. + sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + } + if ( + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY || + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ) { + // This is an history query, we can't sort by date added. + if ( + sortingMode.value == + Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING || + sortingMode.value == + Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING + ) { + sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + } + } + + // Create a new query with required options. + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = resultType.value; + options.sortingMode = sortingMode.value; + + // Compare resultset with expectedData. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + if ( + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ) { + // Date containers are always sorted by date descending. + check_children_sorting( + root, + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING + ); + } else { + check_children_sorting(root, sortingMode.value); + } + + // Now Check sorting of the first child container. + var container = root + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + container.containerOpen = true; + + if ( + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ) { + // Has more than one level of containers, first we check the sorting of + // the first level (site containers), those won't inherit sorting... + check_children_sorting( + container, + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING + ); + // ...then we check sorting of the contained urls, we can't inherit sorting + // since the above level does not inherit it, so they will be sorted by + // title ascending. + let innerContainer = container + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + innerContainer.containerOpen = true; + check_children_sorting( + innerContainer, + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING + ); + innerContainer.containerOpen = false; + } else if ( + resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT + ) { + // Sorting mode for tag contents is hardcoded for now, to allow for faster + // duplicates filtering. + check_children_sorting( + container, + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + } else { + check_children_sorting(container, sortingMode.value); + } + + container.containerOpen = false; + root.containerOpen = false; + + test_result_sortingMode_change(result, resultType, sortingMode); +} + +/** + * Sets sortingMode on aResult and checks for correct sorting of children. + * Containers should not change their sorting, while contained uri nodes should. + * + * @param aResult + * nsINavHistoryResult generated by our query. + * @param aResultType + * required result type. + * @param aOriginalSortingMode + * the sorting mode from query's options. + */ +function test_result_sortingMode_change( + aResult, + aResultType, + aOriginalSortingMode +) { + var root = aResult.root; + // Now we set sortingMode on the result and check that containers are not + // sorted while children are. + sortingModes.forEach(function sortingModeChecker(aForcedSortingMode) { + print( + "\n* Test setting sortingMode (" + + aForcedSortingMode.name + + ") " + + "on result with resultType (" + + aResultType.name + + ") " + + "currently sorted as (" + + aOriginalSortingMode.name + + ")" + ); + + aResult.sortingMode = aForcedSortingMode.value; + root.containerOpen = true; + + if ( + aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + aResultType.value == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ) { + // Date containers are always sorted by date descending. + check_children_sorting( + root, + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING + ); + } else if ( + aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY && + (aOriginalSortingMode.value == + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING || + aOriginalSortingMode.value == + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) + ) { + // Site containers don't have a good time property to sort by. + check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE); + } else { + check_children_sorting(root, aOriginalSortingMode.value); + } + + // Now Check sorting of the first child container. + var container = root + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + container.containerOpen = true; + + if ( + aResultType.value == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ) { + // Has more than one level of containers, first we check the sorting of + // the first level (site containers), those won't be sorted... + check_children_sorting( + container, + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING + ); + // ...then we check sorting of the second level of containers, result + // will sort them through recursiveSort. + let innerContainer = container + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + innerContainer.containerOpen = true; + check_children_sorting(innerContainer, aForcedSortingMode.value); + innerContainer.containerOpen = false; + } else { + if ( + aResultType.value == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + aResultType.value == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY || + aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY + ) { + // Date containers are always sorted by date descending. + check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE); + } else { + check_children_sorting(root, aOriginalSortingMode.value); + } + + // Children should always be sorted. + check_children_sorting(container, aForcedSortingMode.value); + } + + container.containerOpen = false; + root.containerOpen = false; + }); +} + +/** + * Test if children of aRootNode are correctly sorted. + * @param aRootNode + * already opened root node from our query's result. + * @param aExpectedSortingMode + * The sortingMode we expect results to be. + */ +function check_children_sorting(aRootNode, aExpectedSortingMode) { + var results = []; + print("Found children:"); + for (let i = 0; i < aRootNode.childCount; i++) { + results[i] = aRootNode.getChild(i); + print(i + " " + results[i].title); + } + + // Helper for case insensitive string comparison. + function caseInsensitiveStringComparator(a, b) { + var aLC = a.toLowerCase(); + var bLC = b.toLowerCase(); + if (aLC < bLC) { + return -1; + } + if (aLC > bLC) { + return 1; + } + return 0; + } + + // Get a comparator based on expected sortingMode. + var comparator; + switch (aExpectedSortingMode) { + case Ci.nsINavHistoryQueryOptions.SORT_BY_NONE: + comparator = function (a, b) { + return 0; + }; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING: + comparator = function (a, b) { + return caseInsensitiveStringComparator(a.title, b.title); + }; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING: + comparator = function (a, b) { + return -caseInsensitiveStringComparator(a.title, b.title); + }; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING: + comparator = function (a, b) { + return a.time - b.time; + }; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING: + comparator = function (a, b) { + return b.time - a.time; + }; + // fall through - we shouldn't do this, see bug 1572437. + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING: + comparator = function (a, b) { + return a.dateAdded - b.dateAdded; + }; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING: + comparator = function (a, b) { + return b.dateAdded - a.dateAdded; + }; + break; + default: + do_throw("Unknown sorting type: " + aExpectedSortingMode); + } + + // Make an independent copy of the results array and sort it. + var sortedResults = results.slice(); + sortedResults.sort(comparator); + // Actually compare returned children with our sorted array. + for (let i = 0; i < sortedResults.length; i++) { + if (sortedResults[i].title != results[i].title) { + print( + i + + " index wrong, expected " + + sortedResults[i].title + + " found " + + results[i].title + ); + } + Assert.equal(sortedResults[i].title, results[i].title); + } +} + +// Main + +add_task(async function test_containersQueries_sorting() { + // Add visits, bookmarks and tags to our database. + var timeInMilliseconds = Date.now(); + var visitCount = 0; + var dayOffset = 0; + var visits = []; + pages.forEach(aPageUrl => + visits.push({ + isVisit: true, + isBookmark: true, + transType: Ci.nsINavHistoryService.TRANSITION_TYPED, + uri: aPageUrl, + title: aPageUrl, + // subtract 5 hours per iteration, to expose more than one day container. + lastVisit: (timeInMilliseconds - 18000 * 1000 * dayOffset++) * 1000, + visitCount: visitCount++, + isTag: true, + tagArray: tags, + isInQuery: true, + }) + ); + await task_populateDB(visits); + + cartProd([resultTypes, sortingModes], test_query_callback); +}); diff --git a/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js b/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js new file mode 100644 index 0000000000..ba0f528b62 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that download history (filtered by transition) queries +// don't invalidate (and requery) too often. + +function accumulateNotifications(result) { + let notifications = []; + let resultObserver = new Proxy(NavHistoryResultObserver, { + get(target, name) { + if (name == "check") { + result.removeObserver(resultObserver, false); + return expectedNotifications => + Assert.deepEqual(notifications, expectedNotifications); + } + // ignore a few uninteresting notifications. + if (["QueryInterface", "containerStateChanged"].includes(name)) { + return () => {}; + } + return () => { + notifications.push(name); + }; + }, + }); + result.addObserver(resultObserver, false); + return resultObserver; +} + +add_task(async function test_downloadhistory_query_notifications() { + const MAX_RESULTS = 5; + let query = PlacesUtils.history.getNewQuery(); + query.setTransitions([PlacesUtils.history.TRANSITIONS.DOWNLOAD]); + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + options.maxResults = MAX_RESULTS; + let result = PlacesUtils.history.executeQuery(query, options); + let notifications = accumulateNotifications(result); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + // Add more maxResults downloads in order. + let transitions = Object.values(PlacesUtils.history.TRANSITIONS); + for (let transition of transitions) { + let uri = "http://fx-search.com/" + transition; + await PlacesTestUtils.addVisits({ + uri, + transition, + title: "test " + transition, + }); + // For each visit also set apart: + // - a bookmark + // - an annotation + // - an icon + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.history.update({ + url: uri, + annotations: new Map([["test/anno", "testValue"]]), + }); + await PlacesTestUtils.addFavicons(new Map([[uri, SMALLPNG_DATA_URI.spec]])); + } + // Remove all the visits one by one. + for (let transition of transitions) { + let uri = Services.io.newURI("http://fx-search.com/" + transition); + await PlacesUtils.history.remove(uri); + } + root.containerOpen = false; + // We pretty much don't want to see invalidateContainer here, because that + // means we requeried. + // We also don't want to see changes caused by filtered-out transition types. + notifications.check([ + "nodeHistoryDetailsChanged", + "nodeInserted", + "nodeTitleChanged", + "nodeIconChanged", + "nodeRemoved", + ]); +}); + +add_task(async function test_downloadhistory_query_filtering() { + const MAX_RESULTS = 3; + let query = PlacesUtils.history.getNewQuery(); + query.setTransitions([PlacesUtils.history.TRANSITIONS.DOWNLOAD]); + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + options.maxResults = MAX_RESULTS; + let result = PlacesUtils.history.executeQuery(query, options); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + Assert.equal(root.childCount, 0, "No visits found"); + // Add more than maxResults downloads. + let uris = []; + // Define a monotonic visit date to ensure results order stability. + let visitDate = Date.now() * 1000; + for (let i = 0; i < MAX_RESULTS + 1; ++i, visitDate += 1000) { + let uri = `http://fx-search.com/download/${i}`; + await PlacesTestUtils.addVisits({ + uri, + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + visitDate, + }); + uris.push(uri); + } + // Add an older download visit out of the maxResults timeframe. + await PlacesTestUtils.addVisits({ + uri: `http://fx-search.com/download/unordered`, + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + visitDate: new Date(Date.now() - 7200000), + }); + + Assert.equal(root.childCount, MAX_RESULTS, "Result should be limited"); + // Invert the uris array because we are sorted by date descending. + uris.reverse(); + for (let i = 0; i < root.childCount; ++i) { + let node = root.getChild(i); + Assert.equal(node.uri, uris[i], "Found the expected uri"); + } + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_excludeQueries.js b/toolkit/components/places/tests/queries/test_excludeQueries.js new file mode 100644 index 0000000000..c48f84c7f4 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_excludeQueries.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var bm; +var fakeQuery; +var folderShortcut; + +add_task(async function setup() { + await PlacesUtils.bookmarks.eraseEverything(); + + bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + title: "a bookmark", + }); + fakeQuery = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "place:terms=foo", + title: "a bookmark", + }); + folderShortcut = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`, + title: "a bookmark", + }); + + checkBookmarkObject(bm); + checkBookmarkObject(fakeQuery); + checkBookmarkObject(folderShortcut); +}); + +add_task(async function test_bookmarks_url_query_implicit_exclusions() { + // When we run bookmarks url queries, we implicity filter out queries and + // folder shortcuts regardless of excludeQueries. They don't make sense to + // include in the results. + let expectedGuids = [bm.guid]; + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + options.excludeQueries = true; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + Assert.equal( + root.childCount, + expectedGuids.length, + "Checking root child count" + ); + for (let i = 0; i < expectedGuids.length; i++) { + Assert.equal( + root.getChild(i).bookmarkGuid, + expectedGuids[i], + "should have got the expected item" + ); + } + + root.containerOpen = false; +}); + +add_task(async function test_bookmarks_excludeQueries() { + // When excluding queries, we exclude actual queries, but not folder shortcuts. + let expectedGuids = [bm.guid, folderShortcut.guid]; + let query = {}, + options = {}; + let queryString = `place:parent=${PlacesUtils.bookmarks.unfiledGuid}&excludeQueries=1`; + PlacesUtils.history.queryStringToQuery(queryString, query, options); + + let root = PlacesUtils.history.executeQuery(query.value, options.value).root; + root.containerOpen = true; + + Assert.equal( + root.childCount, + expectedGuids.length, + "Checking root child count" + ); + for (let i = 0; i < expectedGuids.length; i++) { + Assert.equal( + root.getChild(i).bookmarkGuid, + expectedGuids[i], + "should have got the expected item" + ); + } + + root.containerOpen = false; +}); + +add_task(async function test_search_excludesQueries() { + // Searching implicity removes queries and folder shortcuts even if excludeQueries + // is not specified. + let expectedGuids = [bm.guid]; + + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "bookmark"; + + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + Assert.equal( + root.childCount, + expectedGuids.length, + "Checking root child count" + ); + for (let i = 0; i < expectedGuids.length; i++) { + Assert.equal( + root.getChild(i).bookmarkGuid, + expectedGuids[i], + "should have got the expected item" + ); + } + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js new file mode 100644 index 0000000000..53f680bf53 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that tags changes are correctly live-updated in a history +// query. + +let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000); + +function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; +} + +var gTestData = [ + { + isVisit: true, + uri: "http://example.com/1/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "example1", + }, + { + isVisit: true, + uri: "http://example.com/2/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "example2", + }, + { + isVisit: true, + uri: "http://example.com/3/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "example3", + }, +]; + +function newQueryWithOptions() { + return [ + PlacesUtils.history.getNewQuery(), + PlacesUtils.history.getNewQueryOptions(), + ]; +} + +function testQueryContents(aQuery, aOptions, aCallback) { + let root = PlacesUtils.history.executeQuery(aQuery, aOptions).root; + root.containerOpen = true; + aCallback(root); + root.containerOpen = false; +} + +add_task(async function test_initialize() { + await task_populateDB(gTestData); +}); + +add_task(function pages_query() { + let [query, options] = newQueryWithOptions(); + testQueryContents(query, options, function (root) { + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.tags, null); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + Assert.equal(node.tags, "test-tag"); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + Assert.equal(node.tags, null); + } + }); +}); + +add_task(function visits_query() { + let [query, options] = newQueryWithOptions(); + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + testQueryContents(query, options, function (root) { + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.tags, null); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + Assert.equal(node.tags, "test-tag"); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + Assert.equal(node.tags, null); + } + }); +}); + +add_task(function bookmarks_query() { + let [query, options] = newQueryWithOptions(); + query.setParents([PlacesUtils.bookmarks.unfiledGuid]); + testQueryContents(query, options, function (root) { + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.tags, null); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + Assert.equal(node.tags, "test-tag"); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + Assert.equal(node.tags, null); + } + }); +}); + +add_task(function pages_searchterm_query() { + let [query, options] = newQueryWithOptions(); + query.searchTerms = "example"; + testQueryContents(query, options, function (root) { + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.tags, null); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + Assert.equal(node.tags, "test-tag"); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + Assert.equal(node.tags, null); + } + }); +}); + +add_task(function visits_searchterm_query() { + let [query, options] = newQueryWithOptions(); + query.searchTerms = "example"; + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + testQueryContents(query, options, function (root) { + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.tags, null); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + Assert.equal(node.tags, "test-tag"); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + Assert.equal(node.tags, null); + } + }); +}); + +add_task(async function pages_searchterm_is_tag_query() { + let [query, options] = newQueryWithOptions(); + query.searchTerms = "test-tag"; + let root; + testQueryContents(query, options, rv => (root = rv)); + compareArrayToResult([], root); + for (let data of gTestData) { + let uri = NetUtil.newURI(data.uri); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: data.title, + }); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + compareArrayToResult([data], root); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + compareArrayToResult([], root); + } +}); + +add_task(async function visits_searchterm_is_tag_query() { + let [query, options] = newQueryWithOptions(); + query.searchTerms = "test-tag"; + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + let root; + testQueryContents(query, options, rv => (root = rv)); + compareArrayToResult([], root); + for (let data of gTestData) { + let uri = NetUtil.newURI(data.uri); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: data.title, + }); + PlacesUtils.tagging.tagURI(uri, ["test-tag"]); + compareArrayToResult([data], root); + PlacesUtils.tagging.untagURI(uri, ["test-tag"]); + compareArrayToResult([], root); + } +}); diff --git a/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js new file mode 100644 index 0000000000..ac3931892f --- /dev/null +++ b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js @@ -0,0 +1,217 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that tags changes are correctly live-updated in a history +// query. + +let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000); + +function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; +} + +var gTestData = [ + { + isVisit: true, + uri: "http://example.com/1/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + title: "title1", + }, + { + isVisit: true, + uri: "http://example.com/2/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + title: "title2", + }, + { + isVisit: true, + uri: "http://example.com/3/", + lastVisit: newTimeInMicroseconds(), + isInQuery: true, + title: "title3", + }, +]; + +function searchNodeHavingUrl(aRoot, aUrl) { + for (let i = 0; i < aRoot.childCount; i++) { + if (aRoot.getChild(i).uri == aUrl) { + return aRoot.getChild(i); + } + } + return undefined; +} + +function newQueryWithOptions() { + return [ + PlacesUtils.history.getNewQuery(), + PlacesUtils.history.getNewQueryOptions(), + ]; +} + +add_task(async function pages_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + Assert.equal(node.title, gTestData[i].title); + let uri = NetUtil.newURI(node.uri); + await PlacesTestUtils.addVisits({ uri, title: "changedTitle" }); + Assert.equal(node.title, "changedTitle"); + await PlacesTestUtils.addVisits({ uri, title: gTestData[i].title }); + Assert.equal(node.title, gTestData[i].title); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function visits_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + + for (let testData of gTestData) { + let uri = NetUtil.newURI(testData.uri); + let node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, testData.title); + await PlacesTestUtils.addVisits({ uri, title: "changedTitle" }); + node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, "changedTitle"); + await PlacesTestUtils.addVisits({ uri, title: testData.title }); + node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, testData.title); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function pages_searchterm_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + query.searchTerms = "example"; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = NetUtil.newURI(node.uri); + Assert.equal(node.title, gTestData[i].title); + await PlacesTestUtils.addVisits({ uri, title: "changedTitle" }); + Assert.equal(node.title, "changedTitle"); + await PlacesTestUtils.addVisits({ uri, title: gTestData[i].title }); + Assert.equal(node.title, gTestData[i].title); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function visits_searchterm_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + query.searchTerms = "example"; + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root); + for (let testData of gTestData) { + let uri = NetUtil.newURI(testData.uri); + let node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, testData.title); + await PlacesTestUtils.addVisits({ uri, title: "changedTitle" }); + node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, "changedTitle"); + await PlacesTestUtils.addVisits({ uri, title: testData.title }); + node = searchNodeHavingUrl(root, testData.uri); + Assert.equal(node.title, testData.title); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function pages_searchterm_is_title_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + query.searchTerms = "match"; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult([], root); + for (let data of gTestData) { + let uri = NetUtil.newURI(data.uri); + let origTitle = data.title; + data.title = "match"; + await PlacesTestUtils.addVisits({ + uri, + title: data.title, + visitDate: data.lastVisit, + }); + compareArrayToResult([data], root); + data.title = origTitle; + await PlacesTestUtils.addVisits({ + uri, + title: data.title, + visitDate: data.lastVisit, + }); + compareArrayToResult([], root); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function visits_searchterm_is_title_query() { + await task_populateDB(gTestData); + + let [query, options] = newQueryWithOptions(); + query.searchTerms = "match"; + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult([], root); + for (let data of gTestData) { + let uri = NetUtil.newURI(data.uri); + let origTitle = data.title; + data.title = "match"; + + info("Adding " + uri.spec); + await PlacesTestUtils.addVisits({ + uri, + title: data.title, + visitDate: data.lastVisit, + }); + + compareArrayToResult([data], root); + data.title = origTitle; + info("Clobbering " + uri.spec); + await PlacesTestUtils.addVisits({ + uri, + title: data.title, + visitDate: data.lastVisit, + }); + + compareArrayToResult([], root); + } + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/queries/test_onlyBookmarked.js b/toolkit/components/places/tests/queries/test_onlyBookmarked.js new file mode 100644 index 0000000000..28c42c190c --- /dev/null +++ b/toolkit/components/places/tests/queries/test_onlyBookmarked.js @@ -0,0 +1,110 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * The next thing we do is create a test database for us. Each test runs with + * its own database (tail_queries.js will clear it after the run). Take a look + * at the queryData object in head_queries.js, and you'll see how this object + * works. You can call it anything you like, but I usually use "testData". + * I'll include a couple of example entries in the database. + * + * Note that to use the compareArrayToResult API, you need to put all the + * results that are in the query set at the top of the testData list, and those + * results MUST be in the same sort order as the items in the resulting query. + */ + +var testData = [ + // Add a bookmark that should be in the results + { + isBookmark: true, + uri: "http://bookmarked.com/", + title: "", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + isInQuery: true, + }, + + // Add a bookmark that should not be in the results + { + isBookmark: true, + uri: "http://bookmarked-elsewhere.com/", + title: "", + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + isInQuery: false, + }, + + // Add an un-bookmarked visit + { + isVisit: true, + uri: "http://notbookmarked.com/", + title: "", + isInQuery: false, + }, +]; + +add_task(async function test_onlyBookmarked() { + // This function in head_queries.js creates our database with the above data + await task_populateDB(testData); + + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_HISTORY; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + // You can use this to compare the data in the array with the result set, + // if the array's isInQuery: true items are sorted the same way as the result + // set. + info("begin first test"); + compareArrayToResult(testData, root); + info("end first test"); + + // Test live-update + var liveUpdateTestData = [ + // Add a bookmark that should show up + { + isBookmark: true, + uri: "http://bookmarked2.com/", + title: "", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + isInQuery: true, + }, + + // Add a bookmark that should not show up + { + isBookmark: true, + uri: "http://bookmarked-elsewhere2.com/", + title: "", + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + isInQuery: false, + }, + ]; + + await task_populateDB(liveUpdateTestData); // add to the db + + // add to the test data + testData.push(liveUpdateTestData[0]); + testData.push(liveUpdateTestData[1]); + + // re-query and test + info("begin live-update test"); + compareArrayToResult(testData, root); + info("end live-update test"); + + // Close the container when finished + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_options_inherit.js b/toolkit/components/places/tests/queries/test_options_inherit.js new file mode 100644 index 0000000000..ae43350eda --- /dev/null +++ b/toolkit/components/places/tests/queries/test_options_inherit.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests inheritance of certain query options like: + * excludeItems, excludeQueries, expandQueries. + */ + +"use strict"; + +add_task(async function () { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "query", + url: + "place:queryType=" + + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS, + }, + { title: "bm", url: "http://example.com" }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + ], + }, + { title: "bm", url: "http://example.com" }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + ], + }); + + await test_query({}, 3, 3, 2); + await test_query({ expandQueries: false }, 3, 3, 0); + await test_query({ excludeItems: true }, 1, 1, 0); + await test_query({ excludeItems: true, expandQueries: false }, 1, 1, 0); + await test_query({ excludeItems: true, excludeQueries: true }, 1, 0, 0); +}); + +async function test_query( + opts, + expectedRootCc, + expectedFolderCc, + expectedQueryCc +) { + let query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.unfiledGuid]); + let options = PlacesUtils.history.getNewQueryOptions(); + for (const [o, v] of Object.entries(opts)) { + info(`Setting ${o} to ${v}`); + options[o] = v; + } + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + Assert.equal(root.childCount, expectedRootCc, "Checking root child count"); + if (root.childCount > 0) { + let folder = root.getChild(0); + Assert.equal(folder.title, "folder", "Found the expected folder"); + + // Check the folder uri doesn't reflect the root options, since those + // options are inherited and not part of this node declaration. + checkURIOptions(folder.uri); + + PlacesUtils.asContainer(folder).containerOpen = true; + Assert.equal( + folder.childCount, + expectedFolderCc, + "Checking folder child count" + ); + if (folder.childCount) { + let placeQuery = folder.getChild(0); + PlacesUtils.asQuery(placeQuery).containerOpen = true; + Assert.equal( + placeQuery.childCount, + expectedQueryCc, + "Checking query child count" + ); + placeQuery.containerOpen = false; + } + folder.containerOpen = false; + } + let f = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + checkURIOptions(root.getChild(root.childCount - 1).uri); + await PlacesUtils.bookmarks.remove(f); + + root.containerOpen = false; +} + +function checkURIOptions(uri) { + info("Checking options for uri " + uri); + let folderOptions = {}; + PlacesUtils.history.queryStringToQuery(uri, {}, folderOptions); + folderOptions = folderOptions.value; + Assert.equal( + folderOptions.excludeItems, + false, + "ExcludeItems should not be changed" + ); + Assert.equal( + folderOptions.excludeQueries, + false, + "ExcludeQueries should not be changed" + ); + Assert.equal( + folderOptions.expandQueries, + true, + "ExpandQueries should not be changed" + ); +} diff --git a/toolkit/components/places/tests/queries/test_queryMultipleFolder.js b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js new file mode 100644 index 0000000000..7c24bef74e --- /dev/null +++ b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var folderGuids = []; +var bookmarkGuids = []; + +add_task(async function setup() { + // adding bookmarks in the folders + for (let i = 0; i < 3; ++i) { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: `Folder${i}`, + }); + folderGuids.push(folder.guid); + + for (let j = 0; j < 7; ++j) { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuids[i], + url: `http://Bookmark${i}_${j}.com`, + title: "", + }); + bookmarkGuids.push(bm.guid); + } + } +}); + +add_task(async function test_queryMultipleFolders_ids() { + // using queryStringToQuery + let query = {}, + options = {}; + let maxResults = 20; + let queryString = `place:${folderGuids + .map(guid => "parent=" + guid) + .join("&")}&sort=5&maxResults=${maxResults}`; + PlacesUtils.history.queryStringToQuery(queryString, query, options); + let rootNode = PlacesUtils.history.executeQuery( + query.value, + options.value + ).root; + rootNode.containerOpen = true; + let resultLength = rootNode.childCount; + Assert.equal(resultLength, maxResults); + for (let i = 0; i < resultLength; ++i) { + let node = rootNode.getChild(i); + Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri); + } + rootNode.containerOpen = false; + + // using getNewQuery and getNewQueryOptions + query = PlacesUtils.history.getNewQuery(); + options = PlacesUtils.history.getNewQueryOptions(); + query.setParents(folderGuids); + options.sortingMode = options.SORT_BY_URI_ASCENDING; + options.maxResults = maxResults; + rootNode = PlacesUtils.history.executeQuery(query, options).root; + rootNode.containerOpen = true; + resultLength = rootNode.childCount; + Assert.equal(resultLength, maxResults); + for (let i = 0; i < resultLength; ++i) { + let node = rootNode.getChild(i); + Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri); + } + rootNode.containerOpen = false; +}); + +add_task(async function test_queryMultipleFolders_guids() { + // using queryStringToQuery + let query = {}, + options = {}; + let maxResults = 20; + let queryString = `place:${folderGuids + .map(guid => "parent=" + guid) + .join("&")}&sort=5&maxResults=${maxResults}`; + PlacesUtils.history.queryStringToQuery(queryString, query, options); + let rootNode = PlacesUtils.history.executeQuery( + query.value, + options.value + ).root; + rootNode.containerOpen = true; + let resultLength = rootNode.childCount; + Assert.equal(resultLength, maxResults); + for (let i = 0; i < resultLength; ++i) { + let node = rootNode.getChild(i); + Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri); + } + rootNode.containerOpen = false; + + // using getNewQuery and getNewQueryOptions + query = PlacesUtils.history.getNewQuery(); + options = PlacesUtils.history.getNewQueryOptions(); + query.setParents(folderGuids); + options.sortingMode = options.SORT_BY_URI_ASCENDING; + options.maxResults = maxResults; + rootNode = PlacesUtils.history.executeQuery(query, options).root; + rootNode.containerOpen = true; + resultLength = rootNode.childCount; + Assert.equal(resultLength, maxResults); + for (let i = 0; i < resultLength; ++i) { + let node = rootNode.getChild(i); + Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri); + } + rootNode.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_querySerialization.js b/toolkit/components/places/tests/queries/test_querySerialization.js new file mode 100644 index 0000000000..071b2b40c2 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_querySerialization.js @@ -0,0 +1,746 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests Places query serialization. Associated bug is + * https://bugzilla.mozilla.org/show_bug.cgi?id=370197 + * + * The simple idea behind this test is to try out different combinations of + * query switches and ensure that queries are the same before serialization + * as they are after de-serialization. + * + * In the code below, "switch" refers to a query option -- "option" in a broad + * sense, not nsINavHistoryQueryOptions specifically (which is why we refer to + * them as switches, not options). Both nsINavHistoryQuery and + * nsINavHistoryQueryOptions allow you to specify switches that affect query + * strings. nsINavHistoryQuery instances have attributes hasBeginTime, + * hasEndTime, hasSearchTerms, and so on. nsINavHistoryQueryOptions instances + * have attributes sortingMode, resultType, excludeItems, etc. + * + * Ideally we would like to test all 2^N subsets of switches, where N is the + * total number of switches; switches might interact in erroneous or other ways + * we do not expect. However, since N is large (21 at this time), that's + * impractical for a single test in a suite. + * + * Instead we choose all possible subsets of a certain, smaller size. In fact + * we begin by choosing CHOOSE_HOW_MANY_SWITCHES_LO and ramp up to + * CHOOSE_HOW_MANY_SWITCHES_HI. + * + * There are two more wrinkles. First, for some switches we'd like to be able to + * test multiple values. For example, it seems like a good idea to test both an + * empty string and a non-empty string for switch nsINavHistoryQuery.searchTerms. + * When switches have more than one value for a test run, we use the Cartesian + * product of their values to generate all possible combinations of values. + * + * To summarize, here's how this test works: + * + * - For n = CHOOSE_HOW_MANY_SWITCHES_LO to CHOOSE_HOW_MANY_SWITCHES_HI: + * - From the total set of switches choose all possible subsets of size n. + * For each of those subsets s: + * - Collect the test runs of each switch in subset s and take their + * Cartesian product. For each sequence in the product: + * - Create nsINavHistoryQuery and nsINavHistoryQueryOptions objects + * with the chosen switches and test run values. + * - Serialize the query. + * - De-serialize and ensure that the de-serialized query objects equal + * the originals. + */ + +const CHOOSE_HOW_MANY_SWITCHES_LO = 1; +const CHOOSE_HOW_MANY_SWITCHES_HI = 2; + +// The switches are represented by objects below, in arrays querySwitches and +// queryOptionSwitches. Use them to set up test runs. +// +// Some switches have special properties (where noted), but all switches must +// have the following properties: +// +// matches: A function that takes two nsINavHistoryQuery objects (in the case +// of nsINavHistoryQuery switches) or two nsINavHistoryQueryOptions +// objects (for nsINavHistoryQueryOptions switches) and returns true +// if the values of the switch in the two objects are equal. This is +// the foundation of how we determine if two queries are equal. +// runs: An array of functions. Each function takes an nsINavHistoryQuery +// object and an nsINavHistoryQueryOptions object. The functions +// should set the attributes of one of the two objects as appropriate +// to their switches. This is how switch values are set for each test +// run. +// +// The following properties are optional: +// +// desc: An informational string to print out during runs when the switch +// is chosen. Hopefully helpful if the test fails. + +// nsINavHistoryQuery switches +const querySwitches = [ + // hasBeginTime + { + // flag and subswitches are used by the flagSwitchMatches function. Several + // of the nsINavHistoryQuery switches (like this one) are really guard flags + // that indicate if other "subswitches" are enabled. + flag: "hasBeginTime", + subswitches: ["beginTime", "beginTimeReference", "absoluteBeginTime"], + desc: "nsINavHistoryQuery.hasBeginTime", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.beginTime = Date.now() * 1000; + aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH; + }, + function (aQuery, aQueryOptions) { + aQuery.beginTime = Date.now() * 1000; + aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY; + }, + ], + }, + // hasEndTime + { + flag: "hasEndTime", + subswitches: ["endTime", "endTimeReference", "absoluteEndTime"], + desc: "nsINavHistoryQuery.hasEndTime", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.endTime = Date.now() * 1000; + aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH; + }, + function (aQuery, aQueryOptions) { + aQuery.endTime = Date.now() * 1000; + aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY; + }, + ], + }, + // hasSearchTerms + { + flag: "hasSearchTerms", + subswitches: ["searchTerms"], + desc: "nsINavHistoryQuery.hasSearchTerms", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.searchTerms = "shrimp and white wine"; + }, + function (aQuery, aQueryOptions) { + aQuery.searchTerms = ""; + }, + ], + }, + // hasDomain + { + flag: "hasDomain", + subswitches: ["domain", "domainIsHost"], + desc: "nsINavHistoryQuery.hasDomain", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.domain = "mozilla.com"; + aQuery.domainIsHost = false; + }, + function (aQuery, aQueryOptions) { + aQuery.domain = "www.mozilla.com"; + aQuery.domainIsHost = true; + }, + function (aQuery, aQueryOptions) { + aQuery.domain = ""; + }, + ], + }, + // hasUri + { + flag: "hasUri", + subswitches: ["uri"], + desc: "nsINavHistoryQuery.hasUri", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.uri = uri("http://mozilla.com"); + }, + ], + }, + // hasAnnotation + { + flag: "hasAnnotation", + subswitches: ["annotation", "annotationIsNot"], + desc: "nsINavHistoryQuery.hasAnnotation", + matches: flagSwitchMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.annotation = "bookmarks/toolbarFolder"; + aQuery.annotationIsNot = false; + }, + function (aQuery, aQueryOptions) { + aQuery.annotation = "bookmarks/toolbarFolder"; + aQuery.annotationIsNot = true; + }, + ], + }, + // minVisits + { + // property is used by function simplePropertyMatches. + property: "minVisits", + desc: "nsINavHistoryQuery.minVisits", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.minVisits = 0x7fffffff; // 2^31 - 1 + }, + ], + }, + // maxVisits + { + property: "maxVisits", + desc: "nsINavHistoryQuery.maxVisits", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.maxVisits = 0x7fffffff; // 2^31 - 1 + }, + ], + }, + // onlyBookmarked + { + property: "onlyBookmarked", + desc: "nsINavHistoryQuery.onlyBookmarked", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.onlyBookmarked = true; + }, + ], + }, + // getFolders + { + desc: "nsINavHistoryQuery.getParents", + matches(aQuery1, aQuery2) { + var q1Parents = aQuery1.getParents(); + var q2Parents = aQuery2.getParents(); + if (q1Parents.length !== q2Parents.length) { + return false; + } + for (let i = 0; i < q1Parents.length; i++) { + if (!q2Parents.includes(q1Parents[i])) { + return false; + } + } + for (let i = 0; i < q2Parents.length; i++) { + if (!q1Parents.includes(q2Parents[i])) { + return false; + } + } + return true; + }, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.setParents([]); + }, + function (aQuery, aQueryOptions) { + aQuery.setParents([PlacesUtils.bookmarks.rootGuid]); + }, + function (aQuery, aQueryOptions) { + aQuery.setParents([ + PlacesUtils.bookmarks.rootGuid, + PlacesUtils.bookmarks.tagsGuid, + ]); + }, + ], + }, + // tags + { + desc: "nsINavHistoryQuery.getTags", + matches(aQuery1, aQuery2) { + if (aQuery1.tagsAreNot !== aQuery2.tagsAreNot) { + return false; + } + var q1Tags = aQuery1.tags; + var q2Tags = aQuery2.tags; + if (q1Tags.length !== q2Tags.length) { + return false; + } + for (let i = 0; i < q1Tags.length; i++) { + if (!q2Tags.includes(q1Tags[i])) { + return false; + } + } + for (let i = 0; i < q2Tags.length; i++) { + if (!q1Tags.includes(q2Tags[i])) { + return false; + } + } + return true; + }, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.tags = []; + }, + function (aQuery, aQueryOptions) { + aQuery.tags = [""]; + }, + function (aQuery, aQueryOptions) { + aQuery.tags = [ + "foo", + "七難", + "", + "いっぱいおっぱい", + "Abracadabra", + "123", + "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!", + "アスキーでございません", + "あいうえお", + ]; + }, + function (aQuery, aQueryOptions) { + aQuery.tags = [ + "foo", + "七難", + "", + "いっぱいおっぱい", + "Abracadabra", + "123", + "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!", + "アスキーでございません", + "あいうえお", + ]; + aQuery.tagsAreNot = true; + }, + ], + }, + // transitions + { + desc: "tests nsINavHistoryQuery.getTransitions", + matches(aQuery1, aQuery2) { + var q1Trans = aQuery1.getTransitions(); + var q2Trans = aQuery2.getTransitions(); + if (q1Trans.length !== q2Trans.length) { + return false; + } + for (let i = 0; i < q1Trans.length; i++) { + if (!q2Trans.includes(q1Trans[i])) { + return false; + } + } + for (let i = 0; i < q2Trans.length; i++) { + if (!q1Trans.includes(q2Trans[i])) { + return false; + } + } + return true; + }, + runs: [ + function (aQuery, aQueryOptions) { + aQuery.setTransitions([]); + }, + function (aQuery, aQueryOptions) { + aQuery.setTransitions([Ci.nsINavHistoryService.TRANSITION_DOWNLOAD]); + }, + function (aQuery, aQueryOptions) { + aQuery.setTransitions([ + Ci.nsINavHistoryService.TRANSITION_TYPED, + Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + ]); + }, + ], + }, +]; + +// nsINavHistoryQueryOptions switches +const queryOptionSwitches = [ + // sortingMode + { + desc: "nsINavHistoryQueryOptions.sortingMode", + matches(aOptions1, aOptions2) { + if (aOptions1.sortingMode === aOptions2.sortingMode) { + return true; + } + return false; + }, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.sortingMode = aQueryOptions.SORT_BY_DATE_ASCENDING; + }, + ], + }, + // resultType + { + // property is used by function simplePropertyMatches. + property: "resultType", + desc: "nsINavHistoryQueryOptions.resultType", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.resultType = aQueryOptions.RESULTS_AS_URI; + }, + ], + }, + // excludeItems + { + property: "excludeItems", + desc: "nsINavHistoryQueryOptions.excludeItems", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.excludeItems = true; + }, + ], + }, + // excludeQueries + { + property: "excludeQueries", + desc: "nsINavHistoryQueryOptions.excludeQueries", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.excludeQueries = true; + }, + ], + }, + // expandQueries + { + property: "expandQueries", + desc: "nsINavHistoryQueryOptions.expandQueries", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.expandQueries = true; + }, + ], + }, + // includeHidden + { + property: "includeHidden", + desc: "nsINavHistoryQueryOptions.includeHidden", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.includeHidden = true; + }, + ], + }, + // maxResults + { + property: "maxResults", + desc: "nsINavHistoryQueryOptions.maxResults", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.maxResults = 0xffffffff; // 2^32 - 1 + }, + ], + }, + // queryType + { + property: "queryType", + desc: "nsINavHistoryQueryOptions.queryType", + matches: simplePropertyMatches, + runs: [ + function (aQuery, aQueryOptions) { + aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_HISTORY; + }, + function (aQuery, aQueryOptions) { + aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_UNIFIED; + }, + ], + }, +]; + +/** + * Enumerates all the sequences of the cartesian product of the arrays contained + * in aSequences. Examples: + * + * cartProd([[1, 2, 3], ["a", "b"]], callback); + * // callback is called 3 * 2 = 6 times with the following arrays: + * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"] + * + * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback); + * // callback is called 1 * 3 * 2 = 6 times with the following arrays: + * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"], + * // ["a", 3, "X"], ["a", 3, "Y"] + * + * cartProd([[1], [2], [3], [4]], callback); + * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array: + * // [1, 2, 3, 4] + * + * cartProd([], callback); + * // callback is 0 times + * + * cartProd([[1, 2, 3, 4]], callback); + * // callback is called 4 times with the following arrays: + * // [1], [2], [3], [4] + * + * @param aSequences + * an array that contains an arbitrary number of arrays + * @param aCallback + * a function that is passed each sequence of the product as it's + * computed + * @return the total number of sequences in the product + */ +function cartProd(aSequences, aCallback) { + if (aSequences.length === 0) { + return 0; + } + + // For each sequence in aSequences, we maintain a pointer (an array index, + // really) to the element we're currently enumerating in that sequence + var seqEltPtrs = aSequences.map(i => 0); + + var numProds = 0; + var done = false; + while (!done) { + numProds++; + + // prod = sequence in product we're currently enumerating + let prod = []; + for (let i = 0; i < aSequences.length; i++) { + prod.push(aSequences[i][seqEltPtrs[i]]); + } + aCallback(prod); + + // The next sequence in the product differs from the current one by just a + // single element. Determine which element that is. We advance the + // "rightmost" element pointer to the "right" by one. If we move past the + // end of that pointer's sequence, reset the pointer to the first element + // in its sequence and then try the sequence to the "left", and so on. + + // seqPtr = index of rightmost input sequence whose element pointer is not + // past the end of the sequence + let seqPtr = aSequences.length - 1; + while (!done) { + // Advance the rightmost element pointer. + seqEltPtrs[seqPtr]++; + + // The rightmost element pointer is past the end of its sequence. + if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) { + seqEltPtrs[seqPtr] = 0; + seqPtr--; + + // All element pointers are past the ends of their sequences. + if (seqPtr < 0) { + done = true; + } + } else { + break; + } + } + } + return numProds; +} + +/** + * Enumerates all the subsets in aSet of size aHowMany. There are + * C(aSet.length, aHowMany) such subsets. aCallback will be passed each subset + * as it is generated. Note that aSet and the subsets enumerated are -- even + * though they're arrays -- not sequences; the ordering of their elements is not + * important. Example: + * + * choose([1, 2, 3, 4], 2, callback); + * // callback is called C(4, 2) = 6 times with the following sets (arrays): + * // [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4] + * + * @param aSet + * an array from which to choose elements, aSet.length > 0 + * @param aHowMany + * the number of elements to choose, > 0 and <= aSet.length + * @return the total number of sets chosen + */ +function choose(aSet, aHowMany, aCallback) { + // ptrs = indices of the elements in aSet we're currently choosing + var ptrs = []; + for (let i = 0; i < aHowMany; i++) { + ptrs.push(i); + } + + var numFound = 0; + var done = false; + while (!done) { + numFound++; + aCallback(ptrs.map(p => aSet[p])); + + // The next subset to be chosen differs from the current one by just a + // single element. Determine which element that is. Advance the "rightmost" + // pointer to the "right" by one. If we move past the end of set, move the + // next non-adjacent rightmost pointer to the right by one, and reset all + // succeeding pointers so that they're adjacent to it. When all pointers + // are clustered all the way to the right, we're done. + + // Advance the rightmost pointer. + ptrs[ptrs.length - 1]++; + + // The rightmost pointer has gone past the end of set. + if (ptrs[ptrs.length - 1] >= aSet.length) { + // Find the next rightmost pointer that is not adjacent to the current one. + let si = aSet.length - 2; // aSet index + let pi = ptrs.length - 2; // ptrs index + while (pi >= 0 && ptrs[pi] === si) { + pi--; + si--; + } + + // All pointers are adjacent and clustered all the way to the right. + if (pi < 0) { + done = true; + } else { + // pi = index of rightmost pointer with a gap between it and its + // succeeding pointer. Move it right and reset all succeeding pointers + // so that they're adjacent to it. + ptrs[pi]++; + for (let i = 0; i < ptrs.length - pi - 1; i++) { + ptrs[i + pi + 1] = ptrs[pi] + i + 1; + } + } + } + } + return numFound; +} + +/** + * Convenience function for nsINavHistoryQuery switches that act as flags. This + * is attached to switch objects. See querySwitches array above. + * + * @param aQuery1 + * an nsINavHistoryQuery object + * @param aQuery2 + * another nsINavHistoryQuery object + * @return true if this switch is the same in both aQuery1 and aQuery2 + */ +function flagSwitchMatches(aQuery1, aQuery2) { + if (aQuery1[this.flag] && aQuery2[this.flag]) { + for (let p in this.subswitches) { + if (p in aQuery1 && p in aQuery2) { + if (aQuery1[p] instanceof Ci.nsIURI) { + if (!aQuery1[p].equals(aQuery2[p])) { + return false; + } + } else if (aQuery1[p] !== aQuery2[p]) { + return false; + } + } + } + } else if (aQuery1[this.flag] || aQuery2[this.flag]) { + return false; + } + + return true; +} + +/** + * Tests if aObj1 and aObj2 are equal. This function is general and may be used + * for either nsINavHistoryQuery or nsINavHistoryQueryOptions objects. aSwitches + * determines which set of switches is used for comparison. Pass in either + * querySwitches or queryOptionSwitches. + * + * @param aSwitches + * determines which set of switches applies to aObj1 and aObj2, either + * querySwitches or queryOptionSwitches + * @param aObj1 + * an nsINavHistoryQuery or nsINavHistoryQueryOptions object + * @param aObj2 + * another nsINavHistoryQuery or nsINavHistoryQueryOptions object + * @return true if aObj1 and aObj2 are equal + */ +function queryObjsEqual(aSwitches, aObj1, aObj2) { + for (let i = 0; i < aSwitches.length; i++) { + if (!aSwitches[i].matches(aObj1, aObj2)) { + return false; + } + } + return true; +} + +/** + * This drives the test runs. See the comment at the top of this file. + * + * @param aHowManyLo + * the size of the switch subsets to start with + * @param aHowManyHi + * the size of the switch subsets to end with (inclusive) + */ +function runQuerySequences(aHowManyLo, aHowManyHi) { + var allSwitches = querySwitches.concat(queryOptionSwitches); + + // Choose aHowManyLo switches up to aHowManyHi switches. + for (let howMany = aHowManyLo; howMany <= aHowManyHi; howMany++) { + let numIters = 0; + print("CHOOSING " + howMany + " SWITCHES"); + + // Choose all subsets of size howMany from allSwitches. + choose(allSwitches, howMany, function (chosenSwitches) { + print(numIters); + numIters++; + + // Collect the runs. + // runs = [ [runs from switch 1], ..., [runs from switch howMany] ] + var runs = chosenSwitches.map(function (s) { + if (s.desc) { + print(" " + s.desc); + } + return s.runs; + }); + + // cartProd(runs) => [ + // [switch 1 run 1, switch 2 run 1, ..., switch howMany run 1 ], + // ..., + // [switch 1 run 1, switch 2 run 1, ..., switch howMany run N ], + // ..., ..., + // [switch 1 run N, switch 2 run N, ..., switch howMany run 1 ], + // ..., + // [switch 1 run N, switch 2 run N, ..., switch howMany run N ], + // ] + cartProd(runs, function (runSet) { + // Create a new query, apply the switches in runSet, and test it. + var query = PlacesUtils.history.getNewQuery(); + var opts = PlacesUtils.history.getNewQueryOptions(); + for (let i = 0; i < runSet.length; i++) { + runSet[i](query, opts); + } + serializeDeserialize(query, opts); + }); + }); + } + print("\n"); +} + +/** + * Serializes the nsINavHistoryQuery objects in aQuery and the + * nsINavHistoryQueryOptions object aQueryOptions, de-serializes the + * serialization, and ensures (using do_check_* functions) that the + * de-serialized objects equal the originals. + * + * @param aQuery + * an nsINavHistoryQuery object + * @param aQueryOptions + * an nsINavHistoryQueryOptions object + */ +function serializeDeserialize(aQuery, aQueryOptions) { + let queryStr = PlacesUtils.history.queryToQueryString(aQuery, aQueryOptions); + print(" " + queryStr); + let query2 = {}, + opts2 = {}; + PlacesUtils.history.queryStringToQuery(queryStr, query2, opts2); + query2 = query2.value; + opts2 = opts2.value; + + Assert.ok(queryObjsEqual(querySwitches, aQuery, query2)); + + // Finally check the query options objects. + Assert.ok(queryObjsEqual(queryOptionSwitches, aQueryOptions, opts2)); +} + +/** + * Convenience function for switches that have simple values. This is attached + * to switch objects. See querySwitches and queryOptionSwitches arrays above. + * + * @param aObj1 + * an nsINavHistoryQuery or nsINavHistoryQueryOptions object + * @param aObj2 + * another nsINavHistoryQuery or nsINavHistoryQueryOptions object + * @return true if this switch is the same in both aObj1 and aObj2 + */ +function simplePropertyMatches(aObj1, aObj2) { + return aObj1[this.property] === aObj2[this.property]; +} + +function run_test() { + runQuerySequences(CHOOSE_HOW_MANY_SWITCHES_LO, CHOOSE_HOW_MANY_SWITCHES_HI); +} diff --git a/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js b/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js new file mode 100644 index 0000000000..5ada4a84d4 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_results_as_tag_query() { + let bms = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { url: "http://tag1.moz.com/", tags: ["tag1"] }, + { url: "http://tag2.moz.com/", tags: ["tag2"] }, + { url: "place:tag=tag1" }, + ], + }); + + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid, + false, + true + ).root; + Assert.equal(root.childCount, 3, "We should get 3 results"); + let queryRoot = root.getChild(2); + PlacesUtils.asContainer(queryRoot).containerOpen = true; + + Assert.equal(queryRoot.uri, "place:tag=tag1", "Found the query"); + Assert.equal(queryRoot.childCount, 1, "We should get 1 result"); + Assert.equal( + queryRoot.getChild(0).uri, + "http://tag1.moz.com/", + "Found the tagged bookmark" + ); + + await PlacesUtils.bookmarks.update({ + guid: bms[2].guid, + url: "place:tag=tag2", + }); + Assert.equal(queryRoot.uri, "place:tag=tag2", "Found the query"); + Assert.equal(queryRoot.childCount, 1, "We should get 1 result"); + Assert.equal( + queryRoot.getChild(0).uri, + "http://tag2.moz.com/", + "Found the tagged bookmark" + ); + + queryRoot.containerOpen = false; + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_redirects.js b/toolkit/components/places/tests/queries/test_redirects.js new file mode 100644 index 0000000000..ee23f949a8 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_redirects.js @@ -0,0 +1,351 @@ +/* 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/. */ + +// Array of visits we will add to the database, will be populated later +// in the test. +var visits = []; + +/** + * Takes a sequence of query options, and compare query results obtained through + * them with a custom filtered array of visits, based on the values we are + * expecting from the query. + * + * @param aSequence + * an array that contains query options in the form: + * [includeHidden, maxResults, sortingMode] + */ +function check_results_callback(aSequence) { + // Sanity check: we should receive 3 parameters. + Assert.equal(aSequence.length, 3); + let includeHidden = aSequence[0]; + let maxResults = aSequence[1]; + let sortingMode = aSequence[2]; + print( + "\nTESTING: includeHidden(" + + includeHidden + + ")," + + " maxResults(" + + maxResults + + ")," + + " sortingMode(" + + sortingMode + + ")." + ); + + function isHidden(aVisit) { + return ( + aVisit.transType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK || + aVisit.isRedirect + ); + } + + // Build expectedData array. + let expectedData = visits.filter(function (aVisit, aIndex, aArray) { + // Embed visits never appear in results. + if (aVisit.transType == Ci.nsINavHistoryService.TRANSITION_EMBED) { + return false; + } + + if (!includeHidden && isHidden(aVisit)) { + // If the page has any non-hidden visit, then it's visible. + if ( + !visits.filter(function (refVisit) { + return refVisit.uri == aVisit.uri && !isHidden(refVisit); + }).length + ) { + return false; + } + } + + return true; + }); + + // Remove duplicates, since queries are RESULTS_AS_URI (unique pages). + let seen = []; + expectedData = expectedData.filter(function (aData) { + if (seen.includes(aData.uri)) { + return false; + } + seen.push(aData.uri); + return true; + }); + + // Sort expectedData. + function getFirstIndexFor(aEntry) { + for (let i = 0; i < visits.length; i++) { + if (visits[i].uri == aEntry.uri) { + return i; + } + } + return undefined; + } + function comparator(a, b) { + if (sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) { + return b.lastVisit - a.lastVisit; + } + if ( + sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING + ) { + return b.visitCount - a.visitCount; + } + return getFirstIndexFor(a) - getFirstIndexFor(b); + } + expectedData.sort(comparator); + + // Crop results to maxResults if it's defined. + if (maxResults) { + expectedData = expectedData.slice(0, maxResults); + } + + // Create a new query with required options. + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + options.includeHidden = includeHidden; + options.sortingMode = sortingMode; + if (maxResults) { + options.maxResults = maxResults; + } + + // Compare resultset with expectedData. + let result = PlacesUtils.history.executeQuery(query, options); + let root = result.root; + root.containerOpen = true; + compareArrayToResult(expectedData, root); + root.containerOpen = false; +} + +/** + * Enumerates all the sequences of the cartesian product of the arrays contained + * in aSequences. Examples: + * + * cartProd([[1, 2, 3], ["a", "b"]], callback); + * // callback is called 3 * 2 = 6 times with the following arrays: + * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"] + * + * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback); + * // callback is called 1 * 3 * 2 = 6 times with the following arrays: + * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"], + * // ["a", 3, "X"], ["a", 3, "Y"] + * + * cartProd([[1], [2], [3], [4]], callback); + * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array: + * // [1, 2, 3, 4] + * + * cartProd([], callback); + * // callback is 0 times + * + * cartProd([[1, 2, 3, 4]], callback); + * // callback is called 4 times with the following arrays: + * // [1], [2], [3], [4] + * + * @param aSequences + * an array that contains an arbitrary number of arrays + * @param aCallback + * a function that is passed each sequence of the product as it's + * computed + * @return the total number of sequences in the product + */ +function cartProd(aSequences, aCallback) { + if (aSequences.length === 0) { + return 0; + } + + // For each sequence in aSequences, we maintain a pointer (an array index, + // really) to the element we're currently enumerating in that sequence + let seqEltPtrs = aSequences.map(i => 0); + + let numProds = 0; + let done = false; + while (!done) { + numProds++; + + // prod = sequence in product we're currently enumerating + let prod = []; + for (let i = 0; i < aSequences.length; i++) { + prod.push(aSequences[i][seqEltPtrs[i]]); + } + aCallback(prod); + + // The next sequence in the product differs from the current one by just a + // single element. Determine which element that is. We advance the + // "rightmost" element pointer to the "right" by one. If we move past the + // end of that pointer's sequence, reset the pointer to the first element + // in its sequence and then try the sequence to the "left", and so on. + + // seqPtr = index of rightmost input sequence whose element pointer is not + // past the end of the sequence + let seqPtr = aSequences.length - 1; + while (!done) { + // Advance the rightmost element pointer. + seqEltPtrs[seqPtr]++; + + // The rightmost element pointer is past the end of its sequence. + if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) { + seqEltPtrs[seqPtr] = 0; + seqPtr--; + + // All element pointers are past the ends of their sequences. + if (seqPtr < 0) { + done = true; + } + } else { + break; + } + } + } + return numProds; +} + +/** + * Populate the visits array and add visits to the database. + * We will generate visit-chains like: + * visit -> redirect_temp -> redirect_perm + */ +add_task(async function test_add_visits_to_database() { + await PlacesUtils.bookmarks.eraseEverything(); + + // We don't really bother on this, but we need a time to add visits. + let timeInMicroseconds = Date.now() * 1000; + let visitCount = 1; + + // Array of all possible transition types we could be redirected from. + let t = [ + Ci.nsINavHistoryService.TRANSITION_LINK, + Ci.nsINavHistoryService.TRANSITION_TYPED, + Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + // Embed visits are not added to the database and we don't want redirects + // to them, thus just avoid addition. + // Ci.nsINavHistoryService.TRANSITION_EMBED, + Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK, + // Would make hard sorting by visit date because last_visit_date is actually + // calculated excluding download transitions, but the query includes + // downloads. + // TODO: Bug 488966 could fix this behavior. + // Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + ]; + + function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds - 1000; + return timeInMicroseconds; + } + + // we add a visit for each of the above transition types. + t.forEach(transition => + visits.push({ + isVisit: true, + transType: transition, + uri: "http://" + transition + ".example.com/", + title: transition + "-example", + isRedirect: true, + lastVisit: newTimeInMicroseconds(), + visitCount: + transition == Ci.nsINavHistoryService.TRANSITION_EMBED || + transition == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK + ? 0 + : visitCount++, + isInQuery: true, + }) + ); + + // Add a REDIRECT_TEMPORARY layer of visits for each of the above visits. + t.forEach(transition => + visits.push({ + isVisit: true, + transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY, + uri: "http://" + transition + ".redirect.temp.example.com/", + title: transition + "-redirect-temp-example", + lastVisit: newTimeInMicroseconds(), + isRedirect: true, + referrer: "http://" + transition + ".example.com/", + visitCount: visitCount++, + isInQuery: true, + }) + ); + + // Add a REDIRECT_PERMANENT layer of visits for each of the above redirects. + t.forEach(transition => + visits.push({ + isVisit: true, + transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT, + uri: "http://" + transition + ".redirect.perm.example.com/", + title: transition + "-redirect-perm-example", + lastVisit: newTimeInMicroseconds(), + isRedirect: true, + referrer: "http://" + transition + ".redirect.temp.example.com/", + visitCount: visitCount++, + isInQuery: true, + }) + ); + + // Add a REDIRECT_PERMANENT layer of visits that loop to the first visit. + // These entries should not change visitCount or lastVisit, otherwise + // guessing an order would be a nightmare. + function getLastValue(aURI, aProperty) { + for (let i = 0; i < visits.length; i++) { + if (visits[i].uri == aURI) { + return visits[i][aProperty]; + } + } + do_throw("Unknown uri."); + return null; + } + t.forEach(transition => + visits.push({ + isVisit: true, + transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT, + uri: "http://" + transition + ".example.com/", + title: getLastValue("http://" + transition + ".example.com/", "title"), + lastVisit: getLastValue( + "http://" + transition + ".example.com/", + "lastVisit" + ), + isRedirect: true, + referrer: "http://" + transition + ".redirect.perm.example.com/", + visitCount: getLastValue( + "http://" + transition + ".example.com/", + "visitCount" + ), + isInQuery: true, + }) + ); + + // Add an unvisited bookmark in the database, it should never appear. + visits.push({ + isBookmark: true, + uri: "http://unvisited.bookmark.com/", + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "Unvisited Bookmark", + isInQuery: false, + }); + + // Put visits in the database. + await task_populateDB(visits); +}); + +add_task(async function test_redirects() { + // Frecency and hidden are updated asynchronously, wait for them. + await PlacesTestUtils.promiseAsyncUpdates(); + + // This array will be used by cartProd to generate a matrix of all possible + // combinations. + let includeHidden_options = [true, false]; + let maxResults_options = [5, 10, 20, null]; + // These sortingMode are choosen to toggle using special queries for history + // menu. + let sorting_options = [ + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, + Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING, + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING, + ]; + // Will execute check_results_callback() for each generated combination. + cartProd( + [includeHidden_options, maxResults_options, sorting_options], + check_results_callback + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js b/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js new file mode 100644 index 0000000000..83531ee2c4 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that skipHistoryDetailsNotifications works as expected. + +function accumulateNotifications( + result, + skipHistoryDetailsNotifications = false +) { + let notifications = []; + let resultObserver = new Proxy(NavHistoryResultObserver, { + get(target, name) { + if (name == "check") { + result.removeObserver(resultObserver, false); + return expectedNotifications => + Assert.deepEqual(notifications, expectedNotifications); + } + if (name == "skipHistoryDetailsNotifications") { + return skipHistoryDetailsNotifications; + } + // ignore a few uninteresting notifications. + if (["QueryInterface", "containerStateChanged"].includes(name)) { + return () => {}; + } + return () => { + notifications.push(name); + }; + }, + }); + result.addObserver(resultObserver, false); + return resultObserver; +} + +add_task(async function test_history_query_observe() { + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + let result = PlacesUtils.history.executeQuery(query, options); + let notifications = accumulateNotifications(result); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org", + title: "test", + }); + notifications.check([ + "nodeHistoryDetailsChanged", + "nodeInserted", + "nodeTitleChanged", + ]); + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function test_history_query_no_observe() { + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + let result = PlacesUtils.history.executeQuery(query, options); + // Even if we opt-out of notifications, this is an history query, thus the + // setting is pretty much ignored. + let notifications = accumulateNotifications(result, true); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org", + title: "test", + }); + await PlacesTestUtils.addVisits({ + uri: "http://mozilla2.org", + title: "test", + }); + + notifications.check([ + "nodeHistoryDetailsChanged", + "nodeInserted", + "nodeTitleChanged", + "nodeHistoryDetailsChanged", + "nodeInserted", + "nodeTitleChanged", + ]); + + root.containerOpen = false; + await PlacesUtils.history.clear(); +}); + +add_task(async function test_bookmarks_query_observe() { + let query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let result = PlacesUtils.history.executeQuery(query, options); + let notifications = accumulateNotifications(result); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + + await PlacesUtils.bookmarks.insert({ + url: "http://mozilla.org", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "test", + }); + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org", + title: "title", + }); + + notifications.check([ + "nodeHistoryDetailsChanged", + "nodeInserted", + "nodeHistoryDetailsChanged", + ]); + + root.containerOpen = false; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_bookmarks_query_no_observe() { + let query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let result = PlacesUtils.history.executeQuery(query, options); + let notifications = accumulateNotifications(result, true); + let root = PlacesUtils.asContainer(result.root); + root.containerOpen = true; + + await PlacesUtils.bookmarks.insert({ + url: "http://mozilla.org", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "test", + }); + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org", + title: "title", + }); + + notifications.check(["nodeInserted"]); + + info("Change the sorting mode to one that is based on history"); + notifications = accumulateNotifications(result, true); + result.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING; + notifications.check(["invalidateContainer"]); + + notifications = accumulateNotifications(result, true); + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org", + title: "title", + }); + notifications.check(["nodeHistoryDetailsChanged"]); + + root.containerOpen = false; + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/queries/test_results-as-left-pane.js b/toolkit/components/places/tests/queries/test_results-as-left-pane.js new file mode 100644 index 0000000000..6cec733758 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_results-as-left-pane.js @@ -0,0 +1,83 @@ +"use strict"; + +const expectedRoots = [ + { + title: "OrganizerQueryHistory", + uri: `place:sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}&type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY}`, + guid: "history____v", + }, + { + title: "OrganizerQueryDownloads", + uri: `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`, + guid: "downloads__v", + }, + { + title: "TagsFolderTitle", + uri: `place:sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING}&type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT}`, + guid: "tags_______v", + }, + { + title: "OrganizerQueryAllBookmarks", + uri: `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY}`, + guid: "allbms_____v", + }, +]; + +const placesStrings = Services.strings.createBundle( + "chrome://places/locale/places.properties" +); + +function getLeftPaneQuery() { + var query = PlacesUtils.history.getNewQuery(); + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_LEFT_PANE_QUERY; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + return result.root; +} + +function assertExpectedChildren(root, expectedChildren) { + Assert.equal( + root.childCount, + expectedChildren.length, + "Should have the expected number of children." + ); + + for (let i = 0; i < root.childCount; i++) { + Assert.ok( + PlacesTestUtils.ComparePlacesURIs( + root.getChild(i).uri, + expectedChildren[i].uri + ), + "Should have the correct uri for root ${i}" + ); + Assert.equal( + root.getChild(i).title, + placesStrings.GetStringFromName(expectedChildren[i].title), + "Should have the correct title for root ${i}" + ); + Assert.equal(root.getChild(i).bookmarkGuid, expectedChildren[i].guid); + } +} + +/** + * This test will test the basic RESULTS_AS_ROOTS_QUERY, that simply returns, + * the existing bookmark roots. + */ +add_task(async function test_results_as_root() { + let root = getLeftPaneQuery(); + root.containerOpen = true; + + Assert.equal( + PlacesUtils.asQuery(root).queryOptions.queryType, + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS, + "Should have a query type of QUERY_TYPE_BOOKMARKS" + ); + + assertExpectedChildren(root, expectedRoots); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_results-as-roots.js b/toolkit/components/places/tests/queries/test_results-as-roots.js new file mode 100644 index 0000000000..2f082d3e0b --- /dev/null +++ b/toolkit/components/places/tests/queries/test_results-as-roots.js @@ -0,0 +1,114 @@ +"use strict"; + +const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks"; + +const expectedRoots = [ + { + title: "BookmarksToolbarFolderTitle", + uri: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`, + guid: PlacesUtils.bookmarks.virtualToolbarGuid, + }, + { + title: "BookmarksMenuFolderTitle", + uri: `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + guid: PlacesUtils.bookmarks.virtualMenuGuid, + }, + { + title: "OtherBookmarksFolderTitle", + uri: `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`, + guid: PlacesUtils.bookmarks.virtualUnfiledGuid, + }, +]; + +const expectedRootsWithMobile = [ + ...expectedRoots, + { + title: "MobileBookmarksFolderTitle", + uri: `place:parent=${PlacesUtils.bookmarks.mobileGuid}`, + guid: PlacesUtils.bookmarks.virtualMobileGuid, + }, +]; + +const placesStrings = Services.strings.createBundle( + "chrome://places/locale/places.properties" +); + +function getAllBookmarksQuery() { + var query = PlacesUtils.history.getNewQuery(); + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_VISITCOUNT_ASCENDING; + options.resultType = options.RESULTS_AS_ROOTS_QUERY; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + return result.root; +} + +function assertExpectedChildren(root, expectedChildren) { + Assert.equal( + root.childCount, + expectedChildren.length, + "Should have the expected number of children." + ); + + for (let i = 0; i < root.childCount; i++) { + Assert.equal( + root.getChild(i).uri, + expectedChildren[i].uri, + "Should have the correct uri for root ${i}" + ); + Assert.equal( + root.getChild(i).title, + placesStrings.GetStringFromName(expectedChildren[i].title), + "Should have the correct title for root ${i}" + ); + Assert.equal(root.getChild(i).bookmarkGuid, expectedChildren[i].guid); + } +} + +/** + * This test will test the basic RESULTS_AS_ROOTS_QUERY, that simply returns, + * the existing bookmark roots. + */ +add_task(async function test_results_as_root() { + let root = getAllBookmarksQuery(); + root.containerOpen = true; + + Assert.equal( + PlacesUtils.asQuery(root).queryOptions.queryType, + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS, + "Should have a query type of QUERY_TYPE_BOOKMARKS" + ); + + assertExpectedChildren(root, expectedRoots); + + root.containerOpen = false; +}); + +add_task(async function test_results_as_root_with_mobile() { + Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, true); + + let root = getAllBookmarksQuery(); + root.containerOpen = true; + + assertExpectedChildren(root, expectedRootsWithMobile); + + root.containerOpen = false; + Services.prefs.clearUserPref(MOBILE_BOOKMARKS_PREF); +}); + +add_task(async function test_results_as_root_remove_mobile_dynamic() { + Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, true); + + let root = getAllBookmarksQuery(); + root.containerOpen = true; + + // Now un-set the pref, and poke the database to update the query. + Services.prefs.clearUserPref(MOBILE_BOOKMARKS_PREF); + + assertExpectedChildren(root, expectedRoots); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_results-as-tag-query.js b/toolkit/components/places/tests/queries/test_results-as-tag-query.js new file mode 100644 index 0000000000..0d4670b658 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_results-as-tag-query.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const testData = { + "http://foo.com/": ["tag1", "tag 2", "Space ☺️ Between"].sort(), + "http://bar.com/": ["tag1", "tag 2"].sort(), + "http://baz.com/": ["tag 2", "Space ☺️ Between"].sort(), + "http://qux.com/": ["Space ☺️ Between"], +}; + +const formattedTestData = []; +for (const [uri, tagArray] of Object.entries(testData)) { + formattedTestData.push({ + title: `Title of ${uri}`, + uri, + isBookmark: true, + isTag: true, + tagArray, + }); +} + +add_task(async function test_results_as_tags_root() { + await task_populateDB(formattedTestData); + + // Construct URL - tag mapping from tag query. + const actualData = {}; + for (const uri in testData) { + if (testData.hasOwnProperty(uri)) { + actualData[uri] = []; + } + } + + const options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_TAGS_ROOT; + const query = PlacesUtils.history.getNewQuery(); + const root = PlacesUtils.history.executeQuery(query, options).root; + + root.containerOpen = true; + Assert.equal(root.childCount, 3, "We should get as many results as tags."); + displayResultSet(root); + + for (let i = 0; i < root.childCount; ++i) { + const node = root.getChild(i); + const tagName = node.title; + Assert.equal( + node.type, + node.RESULT_TYPE_QUERY, + "Result type should be RESULT_TYPE_QUERY." + ); + const subRoot = node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + subRoot.containerOpen = true; + for (let j = 0; j < subRoot.childCount; ++j) { + actualData[subRoot.getChild(j).uri].push(tagName); + actualData[subRoot.getChild(j).uri].sort(); + } + } + + Assert.deepEqual( + actualData, + testData, + "URI-tag mapping should be same from query and initial data." + ); +}); diff --git a/toolkit/components/places/tests/queries/test_results-as-visit.js b/toolkit/components/places/tests/queries/test_results-as-visit.js new file mode 100644 index 0000000000..b10fb00bab --- /dev/null +++ b/toolkit/components/places/tests/queries/test_results-as-visit.js @@ -0,0 +1,158 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ +var testData = []; +var timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000); + +function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; +} + +function createTestData() { + function generateVisits(aPage) { + for (var i = 0; i < aPage.visitCount; i++) { + testData.push({ + isInQuery: aPage.inQuery, + isVisit: true, + title: aPage.title, + uri: aPage.uri, + lastVisit: newTimeInMicroseconds(), + isTag: aPage.tags && !!aPage.tags.length, + tagArray: aPage.tags, + }); + } + } + + var pages = [ + { + uri: "http://foo.com/", + title: "amo", + tags: ["moz"], + visitCount: 3, + inQuery: true, + }, + { + uri: "http://moilla.com/", + title: "bMoz", + tags: ["bugzilla"], + visitCount: 5, + inQuery: true, + }, + { + uri: "http://foo.mail.com/changeme1.html", + title: "c Moz", + visitCount: 7, + inQuery: true, + }, + { + uri: "http://foo.mail.com/changeme2.html", + tags: ["moz"], + title: "", + visitCount: 1, + inQuery: false, + }, + { + uri: "http://foo.mail.com/changeme3.html", + title: "zydeco", + visitCount: 5, + inQuery: false, + }, + ]; + pages.forEach(generateVisits); +} + +/** + * This test will test Queries that use relative search terms and URI options + */ +add_task(async function test_results_as_visit() { + createTestData(); + await task_populateDB(testData); + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "moz"; + query.minVisits = 2; + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_VISITCOUNT_ASCENDING; + options.resultType = options.RESULTS_AS_VISIT; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + info("Number of items in result set: " + root.childCount); + for (let i = 0; i < root.childCount; ++i) { + info( + "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title + ); + } + + // Check our inital result set + compareArrayToResult(testData, root); + + // If that passes, check liveupdate + // Add to the query set + info("Adding item to query"); + var tmp = []; + for (let i = 0; i < 2; i++) { + tmp.push({ + isVisit: true, + uri: "http://foo.com/added.html", + title: "ab moz", + }); + } + await task_populateDB(tmp); + for (let i = 0; i < 2; i++) { + Assert.equal(root.getChild(i).title, "ab moz"); + } + + // Update an existing URI + info("Updating Item"); + var change2 = [ + { isVisit: true, title: "moz", uri: "http://foo.mail.com/changeme2.html" }, + ]; + await task_populateDB(change2); + Assert.ok(isInResult(change2, root)); + + // Update some visits - add one and take one out of query set, and simply + // change one so that it still applies to the query. + info("Updating More Items"); + var change3 = [ + { + isVisit: true, + lastVisit: newTimeInMicroseconds(), + uri: "http://foo.mail.com/changeme1.html", + title: "foo", + }, + { + isVisit: true, + lastVisit: newTimeInMicroseconds(), + uri: "http://foo.mail.com/changeme3.html", + title: "moz", + isTag: true, + tagArray: ["foo", "moz"], + }, + ]; + await task_populateDB(change3); + Assert.ok(!isInResult({ uri: "http://foo.mail.com/changeme1.html" }, root)); + Assert.ok(isInResult({ uri: "http://foo.mail.com/changeme3.html" }, root)); + + // And now, delete one + info("Delete item outside of batch"); + var change4 = [ + { + isVisit: true, + lastVisit: newTimeInMicroseconds(), + uri: "http://moilla.com/", + title: "mo,z", + }, + ]; + await task_populateDB(change4); + Assert.ok(!isInResult(change4, root)); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js new file mode 100644 index 0000000000..224feb4f0c --- /dev/null +++ b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js @@ -0,0 +1,74 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Tests the interaction of includeHidden and searchTerms search options. + +var timeInMicroseconds = Date.now() * 1000; + +const VISITS = [ + { + isVisit: true, + transType: TRANSITION_TYPED, + uri: "http://redirect.example.com/", + title: "example", + isRedirect: true, + lastVisit: timeInMicroseconds--, + }, + { + isVisit: true, + transType: TRANSITION_TYPED, + uri: "http://target.example.com/", + title: "example", + lastVisit: timeInMicroseconds--, + }, +]; + +const HIDDEN_VISITS = [ + { + isVisit: true, + transType: TRANSITION_FRAMED_LINK, + uri: "http://hidden.example.com/", + title: "red", + lastVisit: timeInMicroseconds--, + }, +]; + +const TEST_DATA = [ + { searchTerms: "example", includeHidden: true, expectedResults: 2 }, + { searchTerms: "example", includeHidden: false, expectedResults: 1 }, + { searchTerms: "red", includeHidden: true, expectedResults: 1 }, +]; + +add_task(async function test_initalize() { + await task_populateDB(VISITS); +}); + +add_task(async function test_searchTerms_includeHidden() { + for (let data of TEST_DATA) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = data.searchTerms; + let options = PlacesUtils.history.getNewQueryOptions(); + options.includeHidden = data.includeHidden; + + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + let cc = root.childCount; + // Live update with hidden visits. + await task_populateDB(HIDDEN_VISITS); + let cc_update = root.childCount; + + root.containerOpen = false; + + Assert.equal(cc, data.expectedResults); + Assert.equal( + cc_update, + data.expectedResults + (data.includeHidden ? 1 : 0) + ); + + await PlacesUtils.history.remove("http://hidden.example.com/"); + } +}); diff --git a/toolkit/components/places/tests/queries/test_search_tags.js b/toolkit/components/places/tests/queries/test_search_tags.js new file mode 100644 index 0000000000..4f8cface07 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_search_tags.js @@ -0,0 +1,73 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_search_for_tagged_bookmarks() { + const testURI = "http://a1.com"; + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "bug 395101 test", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + title: "1 title", + url: testURI, + }); + + // tag the bookmarked URI + PlacesUtils.tagging.tagURI(uri(testURI), [ + "elephant", + "walrus", + "giraffe", + "turkey", + "hiPPo", + "BABOON", + "alf", + ]); + + // search for the bookmark, using a tag + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "elephant"; + var options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + query.setParents([folder.guid]); + + var result = PlacesUtils.history.executeQuery(query, options); + var rootNode = result.root; + rootNode.containerOpen = true; + + Assert.equal(rootNode.childCount, 1); + Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid); + rootNode.containerOpen = false; + + // partial matches are okay + query.searchTerms = "wal"; + result = PlacesUtils.history.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 1); + rootNode.containerOpen = false; + + // case insensitive search term + query.searchTerms = "WALRUS"; + result = PlacesUtils.history.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 1); + Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid); + rootNode.containerOpen = false; + + // case insensitive tag + query.searchTerms = "baboon"; + result = PlacesUtils.history.executeQuery(query, options); + rootNode = result.root; + rootNode.containerOpen = true; + Assert.equal(rootNode.childCount, 1); + Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid); + rootNode.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js new file mode 100644 index 0000000000..8eebf68cad --- /dev/null +++ b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Check that bookmarklets are returned by searches with searchTerms. + +var testData = [ + { + isInQuery: true, + isBookmark: true, + title: "bookmark 1", + uri: "http://mozilla.org/script/", + }, + + { + isInQuery: true, + isBookmark: true, + title: "bookmark 2", + uri: "javascript:alert('moz');", + }, +]; + +add_task(async function test_initalize() { + await task_populateDB(testData); +}); + +add_test(function test_search_by_title() { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "bookmark"; + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult(testData, root); + root.containerOpen = false; + + run_next_test(); +}); + +add_test(function test_search_by_schemeToken() { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "script"; + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult(testData, root); + root.containerOpen = false; + + run_next_test(); +}); + +add_test(function test_search_by_uriAndTitle() { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "moz"; + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult(testData, root); + root.containerOpen = false; + + run_next_test(); +}); diff --git a/toolkit/components/places/tests/queries/test_searchterms-domain.js b/toolkit/components/places/tests/queries/test_searchterms-domain.js new file mode 100644 index 0000000000..6e14a7ef93 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_searchterms-domain.js @@ -0,0 +1,197 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// The test data for our database, note that the ordering of the results that +// will be returned by the query (the isInQuery: true objects) is IMPORTANT. +// see compareArrayToResult in head_queries.js for more info. +var testData = [ + // Test ftp protocol - vary the title length, embed search term + { + isInQuery: true, + isVisit: true, + isDetails: true, + uri: "ftp://foo.com/ftp", + lastVisit: lastweek, + title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo", + }, + + // Test flat domain with annotation, search term in sentence + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://foo.com/", + annoName: "moz/test", + annoVal: "val", + lastVisit: lastweek, + title: "you know, moz is cool", + }, + + // Test subdomain included with isRedirect=true, different transtype + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "amozzie", + isRedirect: true, + uri: "http://mail.foo.com/redirect", + lastVisit: old, + referrer: "http://myreferrer.com", + transType: PlacesUtils.history.TRANSITION_LINK, + }, + + // Test subdomain inclued, search term at end + { + isInQuery: true, + isVisit: true, + isDetails: true, + uri: "http://mail.foo.com/yiihah", + title: "blahmoz", + lastVisit: daybefore, + }, + + // Test www. style URI is included, with a tag + { + isInQuery: true, + isVisit: true, + isDetails: true, + isTag: true, + uri: "http://www.foo.com/yiihah", + tagArray: ["moz"], + lastVisit: yesterday, + title: "foo", + }, + + // Test https protocol + { + isInQuery: true, + isVisit: true, + isDetails: true, + title: "moz", + uri: "https://foo.com/", + lastVisit: today, + }, + + // Begin the invalid queries: wrong search term + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m o z", + uri: "http://foo.com/tooearly.php", + lastVisit: today, + }, + + // Test bad URI + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "http://sffoo.com/justwrong.htm", + lastVisit: yesterday, + }, + + // Test what we do with escaping in titles + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m%0o%0z", + uri: "http://foo.com/changeme1.htm", + lastVisit: yesterday, + }, + + // Test another invalid title - for updating later + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m,oz", + uri: "http://foo.com/changeme2.htm", + lastVisit: yesterday, + }, +]; + +/** + * This test will test Queries that use relative search terms and domain options + */ +add_task(async function test_searchterms_domain() { + await task_populateDB(testData); + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "moz"; + query.domain = "foo.com"; + query.domainIsHost = false; + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_ASCENDING; + options.resultType = options.RESULTS_AS_URI; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + info("Number of items in result set: " + root.childCount); + for (var i = 0; i < root.childCount; ++i) { + info( + "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title + ); + } + + // Check our inital result set + compareArrayToResult(testData, root); + + // If that passes, check liveupdate + // Add to the query set + info("Adding item to query"); + var change1 = [ + { + isVisit: true, + isDetails: true, + uri: "http://foo.com/added.htm", + title: "moz", + transType: PlacesUtils.history.TRANSITION_LINK, + }, + ]; + await task_populateDB(change1); + Assert.ok(isInResult(change1, root)); + + // Update an existing URI + info("Updating Item"); + var change2 = [ + { isDetails: true, uri: "http://foo.com/changeme1.htm", title: "moz" }, + ]; + await task_populateDB(change2); + Assert.ok(isInResult(change2, root)); + + // Add one and take one out of query set, and simply change one so that it + // still applies to the query. + info("Updating More Items"); + var change3 = [ + { isDetails: true, uri: "http://foo.com/changeme2.htm", title: "moz" }, + { + isDetails: true, + uri: "http://mail.foo.com/yiihah", + title: "moz now updated", + }, + { isDetails: true, uri: "ftp://foo.com/ftp", title: "gone" }, + ]; + await task_populateDB(change3); + Assert.ok(isInResult({ uri: "http://foo.com/changeme2.htm" }, root)); + Assert.ok(isInResult({ uri: "http://mail.foo.com/yiihah" }, root)); + Assert.ok(!isInResult({ uri: "ftp://foo.com/ftp" }, root)); + + // And now, delete one + info("Deleting items"); + var change4 = [{ isDetails: true, uri: "https://foo.com/", title: "mo,z" }]; + await task_populateDB(change4); + Assert.ok(!isInResult(change4, root)); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_searchterms-uri.js b/toolkit/components/places/tests/queries/test_searchterms-uri.js new file mode 100644 index 0000000000..a10735ca04 --- /dev/null +++ b/toolkit/components/places/tests/queries/test_searchterms-uri.js @@ -0,0 +1,125 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// The test data for our database, note that the ordering of the results that +// will be returned by the query (the isInQuery: true objects) is IMPORTANT. +// see compareArrayToResult in head_queries.js for more info. +var testData = [ + // Test flat domain with annotation, search term in sentence + { + isInQuery: true, + isVisit: true, + isDetails: true, + isPageAnnotation: true, + uri: "http://foo.com/", + annoName: "moz/test", + annoVal: "val", + lastVisit: lastweek, + title: "you know, moz is cool", + }, + + // Test https protocol + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "moz", + uri: "https://foo.com/", + lastVisit: today, + }, + + // Begin the invalid queries: wrong search term + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m o z", + uri: "http://foo.com/wrongsearch.php", + lastVisit: today, + }, + + // Test subdomain inclued, search term at end + { + isInQuery: false, + isVisit: true, + isDetails: true, + uri: "http://mail.foo.com/yiihah", + title: "blahmoz", + lastVisit: daybefore, + }, + + // Test ftp protocol - vary the title length, embed search term + { + isInQuery: false, + isVisit: true, + isDetails: true, + uri: "ftp://foo.com/ftp", + lastVisit: lastweek, + title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo", + }, + + // Test what we do with escaping in titles + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m%0o%0z", + uri: "http://foo.com/changeme1.htm", + lastVisit: yesterday, + }, + + // Test another invalid title - for updating later + { + isInQuery: false, + isVisit: true, + isDetails: true, + title: "m,oz", + uri: "http://foo.com/changeme2.htm", + lastVisit: yesterday, + }, +]; + +/** + * This test will test Queries that use relative search terms and URI options + */ +add_task(async function test_searchterms_uri() { + await task_populateDB(testData); + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = "moz"; + query.uri = uri("http://foo.com"); + + // Options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_ASCENDING; + options.resultType = options.RESULTS_AS_URI; + + // Results + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + + info("Number of items in result set: " + root.childCount); + for (var i = 0; i < root.childCount; ++i) { + info( + "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title + ); + } + + // Check our inital result set + compareArrayToResult(testData, root); + + // live update. + info("change title"); + var change1 = [{ isDetails: true, uri: "http://foo.com/", title: "mo" }]; + await task_populateDB(change1); + + Assert.ok(!isInResult({ uri: "http://foo.com/" }, root)); + var change2 = [{ isDetails: true, uri: "http://foo.com/", title: "moz" }]; + await task_populateDB(change2); + Assert.ok(isInResult({ uri: "http://foo.com/" }, root)); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js new file mode 100644 index 0000000000..358ab45fdb --- /dev/null +++ b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js @@ -0,0 +1,223 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* ***** BEGIN LICENSE BLOCK ***** + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ + * ***** END LICENSE BLOCK ***** */ + +// This test ensures that the date and site type of |place:| query maintains +// its quantifications correctly. Namely, it ensures that the date part of the +// query is not lost when the domain queries are made. + +// We specifically craft these entries so that if a by Date and Site sorting is +// applied, we find one domain in the today range, and two domains in the older +// than six months range. +// The correspondence between item in |testData| and date range is stored in +// leveledTestData. +var testData = [ + { + isVisit: true, + uri: "file:///directory/1", + lastVisit: today, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "http://example.com/1", + lastVisit: today, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "http://example.com/2", + lastVisit: today, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "file:///directory/2", + lastVisit: olderthansixmonths, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "http://example.com/3", + lastVisit: olderthansixmonths, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "http://example.com/4", + lastVisit: olderthansixmonths, + title: "test visit", + isInQuery: true, + }, + { + isVisit: true, + uri: "http://example.net/1", + lastVisit: olderthansixmonths + 1000, + title: "test visit", + isInQuery: true, + }, +]; +var leveledTestData = [ + // Today + [ + [0], // Today, local files + [1, 2], + ], // Today, example.com + // Older than six months + [ + [3], // Older than six months, local files + [4, 5], // Older than six months, example.com + [6], // Older than six months, example.net + ], +]; + +// This test data is meant for live updating. The |levels| property indicates +// date range index and then domain index. +var testDataAddedLater = [ + { + isVisit: true, + uri: "http://example.com/5", + lastVisit: olderthansixmonths, + title: "test visit", + isInQuery: true, + levels: [1, 1], + }, + { + isVisit: true, + uri: "http://example.com/6", + lastVisit: olderthansixmonths, + title: "test visit", + isInQuery: true, + levels: [1, 1], + }, + { + isVisit: true, + uri: "http://example.com/7", + lastVisit: today, + title: "test visit", + isInQuery: true, + levels: [0, 1], + }, + { + isVisit: true, + uri: "file:///directory/3", + lastVisit: today, + title: "test visit", + isInQuery: true, + levels: [0, 0], + }, +]; + +add_task(async function test_sort_date_site_grouping() { + await task_populateDB(testData); + + // On Linux, the (local files) folder is shown after sites unlike Mac/Windows. + // Thus, we avoid running this test on Linux but this should be re-enabled + // after bug 624024 is resolved. + let isLinux = "@mozilla.org/gnome-gconf-service;1" in Cc; + if (isLinux) { + return; + } + + // In this test, there are three levels of results: + // 1st: Date queries. e.g., today, last week, or older than 6 months. + // 2nd: Domain queries restricted to a date. e.g. mozilla.com today. + // 3rd: Actual visits. e.g. mozilla.com/index.html today. + // + // We store all the third level result roots so that we can easily close all + // containers and test live updating into specific results. + let roots = []; + + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY; + + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + // This corresponds to the number of date ranges. + Assert.equal(root.childCount, leveledTestData.length); + + // We pass off to |checkFirstLevel| to check the first level of results. + for (let index = 0; index < leveledTestData.length; index++) { + let node = root.getChild(index); + checkFirstLevel(index, node, roots); + } + + // Test live updating. + for (let visit of testDataAddedLater) { + await task_populateDB([visit]); + let oldLength = testData.length; + let i = visit.levels[0]; + let j = visit.levels[1]; + testData.push(visit); + leveledTestData[i][j].push(oldLength); + compareArrayToResult( + leveledTestData[i][j].map(x => testData[x]), + roots[i][j] + ); + } + + for (let i = 0; i < roots.length; i++) { + for (let j = 0; j < roots[i].length; j++) { + roots[i][j].containerOpen = false; + } + } + + root.containerOpen = false; +}); + +function checkFirstLevel(index, node, roots) { + PlacesUtils.asContainer(node).containerOpen = true; + + Assert.ok(PlacesUtils.nodeIsDay(node)); + PlacesUtils.asQuery(node); + let query = node.query; + let options = node.queryOptions; + + Assert.ok(query.hasBeginTime && query.hasEndTime); + + // Here we check the second level of results. + let root = PlacesUtils.history.executeQuery(query, options).root; + roots.push([]); + root.containerOpen = true; + + Assert.equal(root.childCount, leveledTestData[index].length); + for (var secondIndex = 0; secondIndex < root.childCount; secondIndex++) { + let child = PlacesUtils.asQuery(root.getChild(secondIndex)); + checkSecondLevel(index, secondIndex, child, roots); + } + root.containerOpen = false; + node.containerOpen = false; +} + +function checkSecondLevel(index, secondIndex, child, roots) { + let query = child.query; + let options = child.queryOptions; + + Assert.ok(query.hasDomain); + Assert.ok(query.hasBeginTime && query.hasEndTime); + + let root = PlacesUtils.history.executeQuery(query, options).root; + // We should now have that roots[index][secondIndex] is set to the second + // level's results root. + roots[index].push(root); + + // We pass off to compareArrayToResult to check the third level of + // results. + root.containerOpen = true; + compareArrayToResult( + leveledTestData[index][secondIndex].map(x => testData[x]), + root + ); + // We close |root|'s container later so that we can test live + // updates into it. +} diff --git a/toolkit/components/places/tests/queries/test_sorting.js b/toolkit/components/places/tests/queries/test_sorting.js new file mode 100644 index 0000000000..fe4508de9b --- /dev/null +++ b/toolkit/components/places/tests/queries/test_sorting.js @@ -0,0 +1,968 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var tests = []; + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, + + async setup() { + info("Sorting test 1: SORT BY NONE"); + + this._unsortedData = [ + { + isBookmark: true, + uri: "http://example.com/b", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "y", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "z", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "x", + isInQuery: true, + }, + ]; + + this._sortedData = this._unsortedData; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + // no reverse sorting for SORT BY NONE + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING, + + async setup() { + info("Sorting test 2: SORT BY TITLE"); + + this._unsortedData = [ + { + isBookmark: true, + uri: "http://example.com/b1", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "y", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "z", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "x", + isInQuery: true, + }, + + // if titles are equal, should fall back to URI + { + isBookmark: true, + uri: "http://example.com/b2", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "y", + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[2], + this._unsortedData[0], + this._unsortedData[3], + this._unsortedData[1], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING, + + async setup() { + info("Sorting test 3: SORT BY DATE"); + + var timeInMicroseconds = Date.now() * 1000; + this._unsortedData = [ + { + isVisit: true, + isDetails: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + uri: "http://example.com/c1", + lastVisit: timeInMicroseconds - 2000, + title: "x1", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + uri: "http://example.com/a", + lastVisit: timeInMicroseconds - 1000, + title: "z", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 2, + uri: "http://example.com/b", + lastVisit: timeInMicroseconds - 3000, + title: "y", + isInQuery: true, + }, + + // if dates are equal, should fall back to title + { + isVisit: true, + isDetails: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 3, + uri: "http://example.com/c2", + lastVisit: timeInMicroseconds - 2000, + title: "x2", + isInQuery: true, + }, + + // if dates and title are equal, should fall back to bookmark index + { + isVisit: true, + isDetails: true, + isBookmark: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 4, + uri: "http://example.com/c2", + lastVisit: timeInMicroseconds - 2000, + title: "x2", + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[2], + this._unsortedData[0], + this._unsortedData[3], + this._unsortedData[4], + this._unsortedData[1], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING, + + async setup() { + info("Sorting test 4: SORT BY URI"); + + var timeInMicroseconds = Date.now() * 1000; + this._unsortedData = [ + { + isBookmark: true, + isDetails: true, + lastVisit: timeInMicroseconds, + uri: "http://example.com/b", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + title: "y", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + title: "x", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 2, + title: "z", + isInQuery: true, + }, + + // if URIs are equal, should fall back to date + { + isBookmark: true, + isDetails: true, + lastVisit: timeInMicroseconds + 1000, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 3, + title: "x", + isInQuery: true, + }, + + // if no URI (e.g., node is a folder), should fall back to title + { + isFolder: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 4, + title: "y", + isInQuery: true, + }, + + // if URIs and dates are equal, should fall back to bookmark index + { + isBookmark: true, + isDetails: true, + lastVisit: timeInMicroseconds + 1000, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 5, + title: "x", + isInQuery: true, + }, + + // if no URI and titles are equal, should fall back to bookmark index + { + isFolder: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 6, + title: "y", + isInQuery: true, + }, + + // if no URI and titles are equal, should fall back to title + { + isFolder: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 7, + title: "z", + isInQuery: true, + }, + + // Separator should go after folders. + { + isSeparator: true, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 8, + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[4], + this._unsortedData[6], + this._unsortedData[7], + this._unsortedData[8], + this._unsortedData[2], + this._unsortedData[0], + this._unsortedData[1], + this._unsortedData[3], + this._unsortedData[5], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING, + + async setup() { + info("Sorting test 5: SORT BY VISITCOUNT"); + + var timeInMicroseconds = Date.now() * 1000; + this._unsortedData = [ + { + isBookmark: true, + uri: "http://example.com/a", + lastVisit: timeInMicroseconds, + title: "z", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + lastVisit: timeInMicroseconds, + title: "x", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/b1", + lastVisit: timeInMicroseconds, + title: "y1", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 2, + isInQuery: true, + }, + + // if visitCounts are equal, should fall back to date + { + isBookmark: true, + uri: "http://example.com/b2", + lastVisit: timeInMicroseconds + 1000, + title: "y2a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 3, + isInQuery: true, + }, + + // if visitCounts and dates are equal, should fall back to bookmark index + { + isBookmark: true, + uri: "http://example.com/b2", + lastVisit: timeInMicroseconds + 1000, + title: "y2b", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 4, + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[0], + this._unsortedData[2], + this._unsortedData[3], + this._unsortedData[4], + this._unsortedData[1], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + // add visits to increase visit count + await PlacesTestUtils.addVisits([ + { + uri: uri("http://example.com/a"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + { + uri: uri("http://example.com/b1"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + { + uri: uri("http://example.com/b1"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + { + uri: uri("http://example.com/b2"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds + 1000, + }, + { + uri: uri("http://example.com/b2"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds + 1000, + }, + { + uri: uri("http://example.com/c"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + { + uri: uri("http://example.com/c"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + { + uri: uri("http://example.com/c"), + transition: TRANSITION_TYPED, + visitDate: timeInMicroseconds, + }, + ]); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING, + + async setup() { + info("Sorting test 7: SORT BY DATEADDED"); + + var timeInMicroseconds = Date.now() * 1000; + this._unsortedData = [ + { + isBookmark: true, + uri: "http://example.com/b1", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + title: "y1", + dateAdded: timeInMicroseconds - 1000, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + title: "z", + dateAdded: timeInMicroseconds - 2000, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 2, + title: "x", + dateAdded: timeInMicroseconds, + isInQuery: true, + }, + + // if dateAddeds are equal, should fall back to title + { + isBookmark: true, + uri: "http://example.com/b2", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 3, + title: "y2", + dateAdded: timeInMicroseconds - 1000, + isInQuery: true, + }, + + // if dateAddeds and titles are equal, should fall back to bookmark index + { + isBookmark: true, + uri: "http://example.com/b3", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 4, + title: "y3", + dateAdded: timeInMicroseconds - 1000, + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[1], + this._unsortedData[0], + this._unsortedData[3], + this._unsortedData[4], + this._unsortedData[2], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING, + + async setup() { + info("Sorting test 8: SORT BY LASTMODIFIED"); + + var timeInMicroseconds = Date.now() * 1000; + var timeAddedInMicroseconds = timeInMicroseconds - 10000; + + this._unsortedData = [ + { + isBookmark: true, + uri: "http://example.com/b1", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + title: "y1", + dateAdded: timeAddedInMicroseconds, + lastModified: timeInMicroseconds - 1000, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/a", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + title: "z", + dateAdded: timeAddedInMicroseconds, + lastModified: timeInMicroseconds - 2000, + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://example.com/c", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 2, + title: "x", + dateAdded: timeAddedInMicroseconds, + lastModified: timeInMicroseconds, + isInQuery: true, + }, + + // if lastModifieds are equal, should fall back to title + { + isBookmark: true, + uri: "http://example.com/b2", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 3, + title: "y2", + dateAdded: timeAddedInMicroseconds, + lastModified: timeInMicroseconds - 1000, + isInQuery: true, + }, + + // if lastModifieds and titles are equal, should fall back to bookmark + // index + { + isBookmark: true, + uri: "http://example.com/b3", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 4, + title: "y3", + dateAdded: timeAddedInMicroseconds, + lastModified: timeInMicroseconds - 1000, + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[1], + this._unsortedData[0], + this._unsortedData[3], + this._unsortedData[4], + this._unsortedData[2], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING, + + async setup() { + info("Sorting test 9: SORT BY TAGS"); + + this._unsortedData = [ + { + isBookmark: true, + uri: "http://url2.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title x", + isTag: true, + tagArray: ["x", "y", "z"], + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://url1a.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title y1", + isTag: true, + tagArray: ["a", "b"], + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://url3a.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title w1", + isInQuery: true, + }, + + { + isBookmark: true, + uri: "http://url0.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title z", + isTag: true, + tagArray: ["a", "y", "z"], + isInQuery: true, + }, + + // if tags are equal, should fall back to title + { + isBookmark: true, + uri: "http://url1b.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title y2", + isTag: true, + tagArray: ["b", "a"], + isInQuery: true, + }, + + // if tags are equal, should fall back to title + { + isBookmark: true, + uri: "http://url3b.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "title w2", + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[2], + this._unsortedData[5], + this._unsortedData[1], + this._unsortedData[4], + this._unsortedData[3], + this._unsortedData[0], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + // Query + var query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + query.onlyBookmarked = true; + + // query options + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + // Results - this gets the result set and opens it for reading and modification. + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +// SORT_BY_FRECENCY_* + +tests.push({ + _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_ASCENDING, + + async setup() { + info("Sorting test 13: SORT BY FRECENCY "); + + let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000); + + function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; + } + + this._unsortedData = [ + { + isVisit: true, + isDetails: true, + uri: "http://moz.com/", + lastVisit: newTimeInMicroseconds(), + title: "I", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + uri: "http://moz.com/", + lastVisit: newTimeInMicroseconds(), + title: "I", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + uri: "http://moz.com/", + lastVisit: newTimeInMicroseconds(), + title: "I", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + uri: "http://is.com/", + lastVisit: newTimeInMicroseconds(), + title: "love", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + uri: "http://best.com/", + lastVisit: newTimeInMicroseconds(), + title: "moz", + isInQuery: true, + }, + + { + isVisit: true, + isDetails: true, + uri: "http://best.com/", + lastVisit: newTimeInMicroseconds(), + title: "moz", + isInQuery: true, + }, + ]; + + this._sortedData = [ + this._unsortedData[3], + this._unsortedData[5], + this._unsortedData[2], + ]; + + // This function in head_queries.js creates our database with the above data + await task_populateDB(this._unsortedData); + }, + + check() { + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = this._sortingMode; + + var root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + compareArrayToResult(this._sortedData, root); + root.containerOpen = false; + }, + + check_reverse() { + this._sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING; + this._sortedData.reverse(); + this.check(); + }, +}); + +add_task(async function test_sorting() { + for (let test of tests) { + await test.setup(); + await PlacesTestUtils.promiseAsyncUpdates(); + test.check(); + // sorting reversed, usually SORT_BY have ASC and DESC + test.check_reverse(); + // Execute cleanup tasks + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + } +}); diff --git a/toolkit/components/places/tests/queries/test_tags.js b/toolkit/components/places/tests/queries/test_tags.js new file mode 100644 index 0000000000..17ad3478ce --- /dev/null +++ b/toolkit/components/places/tests/queries/test_tags.js @@ -0,0 +1,626 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests bookmark queries with tags. See bug 399799. + */ + +"use strict"; + +add_task(async function tags_getter_setter() { + info("Tags getter/setter should work correctly"); + info("Without setting tags, tags getter should return empty array"); + var [query] = makeQuery(); + Assert.equal(query.tags.length, 0); + + info("Setting tags to an empty array, tags getter should return empty array"); + [query] = makeQuery([]); + Assert.equal(query.tags.length, 0); + + info("Setting a few tags, tags getter should return correct array"); + var tags = ["bar", "baz", "foo"]; + [query] = makeQuery(tags); + setsAreEqual(query.tags, tags, true); + + info("Setting some dupe tags, tags getter return unique tags"); + [query] = makeQuery(["foo", "foo", "bar", "foo", "baz", "bar"]); + setsAreEqual(query.tags, ["bar", "baz", "foo"], true); +}); + +add_task(async function invalid_setter_calls() { + info("Invalid calls to tags setter should fail"); + try { + var query = PlacesUtils.history.getNewQuery(); + query.tags = null; + do_throw("Passing null to SetTags should fail"); + } catch (exc) {} + + try { + query = PlacesUtils.history.getNewQuery(); + query.tags = "this should not work"; + do_throw("Passing a string to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery([null]); + do_throw("Passing one-element array with null to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery([undefined]); + do_throw("Passing one-element array with undefined to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery(["foo", null, "bar"]); + do_throw("Passing mixture of tags and null to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery(["foo", undefined, "bar"]); + do_throw("Passing mixture of tags and undefined to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery([1, 2, 3]); + do_throw("Passing numbers to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery(["foo", 1, 2, 3]); + do_throw("Passing mixture of tags and numbers to SetTags should fail"); + } catch (exc) {} + + try { + var str = PlacesUtils.toISupportsString("foo"); + query = PlacesUtils.history.getNewQuery(); + query.tags = str; + do_throw("Passing nsISupportsString to SetTags should fail"); + } catch (exc) {} + + try { + makeQuery([str]); + do_throw("Passing array of nsISupportsStrings to SetTags should fail"); + } catch (exc) {} +}); + +add_task(async function not_setting_tags() { + info("Not setting tags at all should not affect query URI"); + checkQueryURI(); +}); + +add_task(async function empty_array_tags() { + info("Setting tags with an empty array should not affect query URI"); + checkQueryURI([]); +}); + +add_task(async function set_tags() { + info("Setting some tags should result in correct query URI"); + checkQueryURI([ + "foo", + "七難", + "", + "いっぱいおっぱい", + "Abracadabra", + "123", + "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!", + "アスキーでございません", + "あいうえお", + ]); +}); + +add_task(async function no_tags_tagsAreNot() { + info( + "Not setting tags at all but setting tagsAreNot should " + + "affect query URI" + ); + checkQueryURI(null, true); +}); + +add_task(async function empty_array_tags_tagsAreNot() { + info( + "Setting tags with an empty array and setting tagsAreNot " + + "should affect query URI" + ); + checkQueryURI([], true); +}); + +add_task(async function () { + info( + "Setting some tags and setting tagsAreNot should result in " + + "correct query URI" + ); + checkQueryURI( + [ + "foo", + "七難", + "", + "いっぱいおっぱい", + "Abracadabra", + "123", + "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!", + "アスキーでございません", + "あいうえお", + ], + true + ); +}); + +add_task(async function tag() { + info("Querying on tag associated with a URI should return that URI"); + await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) { + var [query, opts] = makeQuery(["foo"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["bar"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["baz"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + }); +}); + +add_task(async function many_tags() { + info("Querying on many tags associated with a URI should return that URI"); + await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) { + var [query, opts] = makeQuery(["foo", "bar"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["foo", "baz"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["bar", "baz"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["foo", "bar", "baz"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + }); +}); + +add_task(async function repeated_tag() { + info("Specifying the same tag multiple times should not matter"); + await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) { + var [query, opts] = makeQuery(["foo", "foo"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["foo", "foo", "foo", "bar", "bar", "baz"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + }); +}); + +add_task(async function many_tags_no_bookmark() { + info( + "Querying on many tags associated with a URI and tags not associated " + + "with that URI should not return that URI" + ); + await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) { + var [query, opts] = makeQuery(["foo", "bogus"]); + executeAndCheckQueryResults(query, opts, []); + [query, opts] = makeQuery(["foo", "bar", "bogus"]); + executeAndCheckQueryResults(query, opts, []); + [query, opts] = makeQuery(["foo", "bar", "baz", "bogus"]); + executeAndCheckQueryResults(query, opts, []); + }); +}); + +add_task(async function nonexistent_tags() { + info("Querying on nonexistent tag should return no results"); + await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) { + var [query, opts] = makeQuery(["bogus"]); + executeAndCheckQueryResults(query, opts, []); + [query, opts] = makeQuery(["bogus", "gnarly"]); + executeAndCheckQueryResults(query, opts, []); + }); +}); + +add_task(async function tagsAreNot() { + info("Querying bookmarks using tagsAreNot should work correctly"); + var urisAndTags = { + "http://example.com/1": ["foo", "bar"], + "http://example.com/2": ["baz", "qux"], + "http://example.com/3": null, + }; + + info("Add bookmarks and tag the URIs"); + for (let [pURI, tags] of Object.entries(urisAndTags)) { + let nsiuri = uri(pURI); + await addBookmark(nsiuri); + if (tags) { + PlacesUtils.tagging.tagURI(nsiuri, tags); + } + } + + info(' Querying for "foo" should match only /2 and /3'); + var [query, opts] = makeQuery(["foo"], true); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + "http://example.com/2", + "http://example.com/3", + ]); + + info(' Querying for "foo" and "bar" should match only /2 and /3'); + [query, opts] = makeQuery(["foo", "bar"], true); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + "http://example.com/2", + "http://example.com/3", + ]); + + info(' Querying for "foo" and "bogus" should match only /2 and /3'); + [query, opts] = makeQuery(["foo", "bogus"], true); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + "http://example.com/2", + "http://example.com/3", + ]); + + info(' Querying for "foo" and "baz" should match only /3'); + [query, opts] = makeQuery(["foo", "baz"], true); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + "http://example.com/3", + ]); + + info(' Querying for "bogus" should match all'); + [query, opts] = makeQuery(["bogus"], true); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + "http://example.com/1", + "http://example.com/2", + "http://example.com/3", + ]); + + // Clean up. + for (let [pURI, tags] of Object.entries(urisAndTags)) { + let nsiuri = uri(pURI); + if (tags) { + PlacesUtils.tagging.untagURI(nsiuri, tags); + } + } + await task_cleanDatabase(); +}); + +add_task(async function duplicate_tags() { + info( + "Duplicate existing tags (i.e., multiple tag folders with " + + "same name) should not throw off query results" + ); + var tagName = "foo"; + + info("Add bookmark and tag it normally"); + await addBookmark(TEST_URI); + PlacesUtils.tagging.tagURI(TEST_URI, [tagName]); + + info("Manually create tag folder with same name as tag and insert bookmark"); + let dupTag = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: tagName, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: dupTag.guid, + title: "title", + url: TEST_URI, + }); + + info("Querying for tag should match URI"); + var [query, opts] = makeQuery([tagName]); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + TEST_URI.spec, + ]); + + PlacesUtils.tagging.untagURI(TEST_URI, [tagName]); + await task_cleanDatabase(); +}); + +add_task(async function folder_named_as_tag() { + info( + "Regular folders with the same name as tag should not throw " + + "off query results" + ); + var tagName = "foo"; + + info("Add bookmark and tag it"); + await addBookmark(TEST_URI); + PlacesUtils.tagging.tagURI(TEST_URI, [tagName]); + + info("Create folder with same name as tag"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: tagName, + }); + + info("Querying for tag should match URI"); + var [query, opts] = makeQuery([tagName]); + queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [ + TEST_URI.spec, + ]); + + PlacesUtils.tagging.untagURI(TEST_URI, [tagName]); + await task_cleanDatabase(); +}); + +add_task(async function ORed_queries() { + info("Multiple queries ORed together should work"); + var urisAndTags = { + "http://example.com/1": [], + "http://example.com/2": [], + }; + + // Search with lots of tags to make sure tag parameter substitution in SQL + // can handle it with more than one query. + for (let i = 0; i < 11; i++) { + urisAndTags["http://example.com/1"].push("/1 tag " + i); + urisAndTags["http://example.com/2"].push("/2 tag " + i); + } + + info("Add bookmarks and tag the URIs"); + for (let [pURI, tags] of Object.entries(urisAndTags)) { + let nsiuri = uri(pURI); + await addBookmark(nsiuri); + if (tags) { + PlacesUtils.tagging.tagURI(nsiuri, tags); + } + } + + // Clean up. + for (let [pURI, tags] of Object.entries(urisAndTags)) { + let nsiuri = uri(pURI); + if (tags) { + PlacesUtils.tagging.untagURI(nsiuri, tags); + } + } + await task_cleanDatabase(); +}); + +add_task(async function tag_casing() { + info( + "Querying on associated tags should return " + + "correct results irrespective of casing of tags." + ); + await task_doWithBookmark(["fOo", "bAr"], function (aURI) { + let [query, opts] = makeQuery(["Foo"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["Foo", "Bar"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["Foo"], true); + executeAndCheckQueryResults(query, opts, []); + [query, opts] = makeQuery(["Bogus"], true); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + }); +}); + +add_task(async function tag_casing_l10n() { + info( + "Querying on associated tags should return " + + "correct results irrespective of casing of tags with international strings." + ); + // \u041F is a lowercase \u043F + await task_doWithBookmark( + ["\u041F\u0442\u0438\u0446\u044B"], + function (aURI) { + let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + } + ); + await task_doWithBookmark( + ["\u043F\u0442\u0438\u0446\u044B"], + function (aURI) { + let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + } + ); +}); + +add_task(async function tag_special_char() { + info( + "Querying on associated tags should return " + + "correct results even if tags contain special characters." + ); + await task_doWithBookmark(["Space ☺️ Between"], function (aURI) { + let [query, opts] = makeQuery(["Space ☺️ Between"]); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + [query, opts] = makeQuery(["Space ☺️ Between"], true); + executeAndCheckQueryResults(query, opts, []); + [query, opts] = makeQuery(["Bogus"], true); + executeAndCheckQueryResults(query, opts, [aURI.spec]); + }); +}); + +// The tag keys in query URIs, i.e., "place:tag=foo&!tags=1" +// --- ----- +const QUERY_KEY_TAG = "tag"; +const QUERY_KEY_NOT_TAGS = "!tags"; + +const TEST_URI = uri("http://example.com/"); + +/** + * Adds a bookmark. + * + * @param aURI + * URI of the page (an nsIURI) + */ +function addBookmark(aURI) { + return PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: aURI.spec, + url: aURI, + }); +} + +/** + * Asynchronous task that removes all pages from history and bookmarks. + */ +async function task_cleanDatabase(aCallback) { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +/** + * Sets up a query with the specified tags, converts it to a URI, and makes sure + * the URI is what we expect it to be. + * + * @param aTags + * The query's tags will be set to those in this array + * @param aTagsAreNot + * The query's tagsAreNot property will be set to this + */ +function checkQueryURI(aTags, aTagsAreNot) { + var pairs = (aTags || []).sort().map(t => QUERY_KEY_TAG + "=" + encodeTag(t)); + if (aTagsAreNot) { + pairs.push(QUERY_KEY_NOT_TAGS + "=1"); + } + var expURI = "place:" + pairs.join("&"); + var [query, opts] = makeQuery(aTags, aTagsAreNot); + var actualURI = queryURI(query, opts); + info("Query URI should be what we expect for the given tags"); + Assert.equal(actualURI, expURI); +} + +/** + * Asynchronous task that executes a callback task in a "scoped" database state. + * A bookmark is added and tagged before the callback is called, and afterward + * the database is cleared. + * + * @param aTags + * A bookmark will be added and tagged with this array of tags + * @param aCallback + * A task function that will be called after the bookmark has been tagged + */ +async function task_doWithBookmark(aTags, aCallback) { + await addBookmark(TEST_URI); + PlacesUtils.tagging.tagURI(TEST_URI, aTags); + await aCallback(TEST_URI); + PlacesUtils.tagging.untagURI(TEST_URI, aTags); + await task_cleanDatabase(); +} + +/** + * queryToQueryString() encodes every character in the query URI that doesn't + * match /[a-zA-Z]/. There's no simple JavaScript function that does the same, + * but encodeURIComponent() comes close, only missing some punctuation. This + * function takes care of all of that. + * + * @param aTag + * A tag name to encode + * @return A UTF-8 escaped string suitable for inclusion in a query URI + */ +function encodeTag(aTag) { + return encodeURIComponent(aTag).replace( + /[-_.!~*'()]/g, // ' + s => "%" + s.charCodeAt(0).toString(16) + ); +} + +/** + * Executes the given query and compares the results to the given URIs. + * See queryResultsAre(). + * + * @param aQuery + * An nsINavHistoryQuery + * @param aQueryOpts + * An nsINavHistoryQueryOptions + * @param aExpectedURIs + * Array of URIs (as strings) that aResultRoot should contain + */ +function executeAndCheckQueryResults(aQuery, aQueryOpts, aExpectedURIs) { + var root = PlacesUtils.history.executeQuery(aQuery, aQueryOpts).root; + root.containerOpen = true; + queryResultsAre(root, aExpectedURIs); + root.containerOpen = false; +} + +/** + * Returns new query and query options objects. The query's tags will be + * set to aTags. aTags may be null, in which case setTags() is not called at + * all on the query. + * + * @param aTags + * The query's tags will be set to those in this array + * @param aTagsAreNot + * The query's tagsAreNot property will be set to this + * @return [query, queryOptions] + */ +function makeQuery(aTags, aTagsAreNot) { + aTagsAreNot = !!aTagsAreNot; + info( + "Making a query " + + (aTags + ? "with tags " + aTags.toSource() + : "without calling setTags() at all") + + " and with tagsAreNot=" + + aTagsAreNot + ); + var query = PlacesUtils.history.getNewQuery(); + query.tagsAreNot = aTagsAreNot; + if (aTags) { + query.tags = aTags; + var uniqueTags = []; + aTags.forEach(function (t) { + if (typeof t === "string" && !uniqueTags.includes(t)) { + uniqueTags.push(t); + } + }); + uniqueTags.sort(); + } + + info("Made query should be correct for tags and tagsAreNot"); + if (uniqueTags) { + setsAreEqual(query.tags, uniqueTags, true); + } + var expCount = uniqueTags ? uniqueTags.length : 0; + Assert.equal(query.tags.length, expCount); + Assert.equal(query.tagsAreNot, aTagsAreNot); + + return [query, PlacesUtils.history.getNewQueryOptions()]; +} + +/** + * Ensures that the URIs of aResultRoot are the same as those in aExpectedURIs. + * + * @param aResultRoot + * The nsINavHistoryContainerResultNode root of an nsINavHistoryResult + * @param aExpectedURIs + * Array of URIs (as strings) that aResultRoot should contain + */ +function queryResultsAre(aResultRoot, aExpectedURIs) { + var rootWasOpen = aResultRoot.containerOpen; + if (!rootWasOpen) { + aResultRoot.containerOpen = true; + } + var actualURIs = []; + for (let i = 0; i < aResultRoot.childCount; i++) { + actualURIs.push(aResultRoot.getChild(i).uri); + } + setsAreEqual(actualURIs, aExpectedURIs); + if (!rootWasOpen) { + aResultRoot.containerOpen = false; + } +} + +/** + * Converts the given query into its query URI. + * + * @param aQuery + * An nsINavHistoryQuery + * @param aQueryOpts + * An nsINavHistoryQueryOptions + * @return The query's URI + */ +function queryURI(aQuery, aQueryOpts) { + return PlacesUtils.history.queryToQueryString(aQuery, aQueryOpts); +} + +/** + * Ensures that the arrays contain the same elements and, optionally, in the + * same order. + */ +function setsAreEqual(aArr1, aArr2, aIsOrdered) { + Assert.equal(aArr1.length, aArr2.length); + if (aIsOrdered) { + for (let i = 0; i < aArr1.length; i++) { + Assert.equal(aArr1[i], aArr2[i]); + } + } else { + aArr1.forEach(u => Assert.ok(aArr2.includes(u))); + aArr2.forEach(u => Assert.ok(aArr1.includes(u))); + } +} diff --git a/toolkit/components/places/tests/queries/test_transitions.js b/toolkit/components/places/tests/queries/test_transitions.js new file mode 100644 index 0000000000..3055f28e9f --- /dev/null +++ b/toolkit/components/places/tests/queries/test_transitions.js @@ -0,0 +1,175 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* ***** BEGIN LICENSE BLOCK ***** + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ + * ***** END LICENSE BLOCK ***** */ +var testData = [ + { + isVisit: true, + title: "page 0", + uri: "http://mozilla.com/", + transType: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + isVisit: true, + title: "page 1", + uri: "http://google.com/", + transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + { + isVisit: true, + title: "page 2", + uri: "http://microsoft.com/", + transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + { + isVisit: true, + title: "page 3", + uri: "http://en.wikipedia.org/", + transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + }, + { + isVisit: true, + title: "page 4", + uri: "http://fr.wikipedia.org/", + transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + { + isVisit: true, + title: "page 5", + uri: "http://apple.com/", + transType: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + isVisit: true, + title: "page 6", + uri: "http://campus-bike-store.com/", + transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + { + isVisit: true, + title: "page 7", + uri: "http://uwaterloo.ca/", + transType: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + isVisit: true, + title: "page 8", + uri: "http://pugcleaner.com/", + transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + }, + { + isVisit: true, + title: "page 9", + uri: "http://de.wikipedia.org/", + transType: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + isVisit: true, + title: "arewefastyet", + uri: "http://arewefastyet.com/", + transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + { + isVisit: true, + title: "arewefastyet", + uri: "http://arewefastyet.com/", + transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + }, +]; +// sets of indices of testData array by transition type +var testDataTyped = [0, 5, 7, 9]; +var testDataDownload = [1, 2, 4, 6, 10]; +var testDataBookmark = [3, 8, 11]; + +add_task(async function test_transitions() { + let timeNow = Date.now(); + for (let item of testData) { + await PlacesTestUtils.addVisits({ + uri: uri(item.uri), + transition: item.transType, + visitDate: timeNow++ * 1000, + title: item.title, + }); + } + + // dump_table("moz_places"); + // dump_table("moz_historyvisits"); + + var numSortFunc = function (a, b) { + return a - b; + }; + var arrs = testDataTyped + .concat(testDataDownload) + .concat(testDataBookmark) + .sort(numSortFunc); + + // Four tests which compare the result of a query to an expected set. + var data = arrs.filter(function (index) { + return ( + testData[index].uri.match(/arewefastyet\.com/) && + testData[index].transType == Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + ); + }); + + compareQueryToTestData( + "place:domain=arewefastyet.com&transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + data.slice() + ); + + compareQueryToTestData( + "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + testDataDownload.slice() + ); + + compareQueryToTestData( + "place:transition=" + Ci.nsINavHistoryService.TRANSITION_TYPED, + testDataTyped.slice() + ); + + compareQueryToTestData( + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&transition=" + + Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + data + ); + + // Tests the live update property of transitions. + var query = {}; + var options = {}; + PlacesUtils.history.queryStringToQuery( + "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + query, + options + ); + var result = PlacesUtils.history.executeQuery(query.value, options.value); + var root = result.root; + root.containerOpen = true; + Assert.equal(testDataDownload.length, root.childCount); + await PlacesTestUtils.addVisits({ + uri: uri("http://getfirefox.com"), + transition: TRANSITION_DOWNLOAD, + }); + Assert.equal(testDataDownload.length + 1, root.childCount); + root.containerOpen = false; +}); + +/* + * Takes a query and a set of indices. The indices correspond to elements + * of testData that are the result of the query. + */ +function compareQueryToTestData(queryStr, data) { + var query = {}; + var options = {}; + PlacesUtils.history.queryStringToQuery(queryStr, query, options); + var result = PlacesUtils.history.executeQuery(query.value, options.value); + var root = result.root; + for (var i = 0; i < data.length; i++) { + data[i] = testData[data[i]]; + data[i].isInQuery = true; + } + compareArrayToResult(data, root); +} diff --git a/toolkit/components/places/tests/queries/xpcshell.ini b/toolkit/components/places/tests/queries/xpcshell.ini new file mode 100644 index 0000000000..b968a46b5f --- /dev/null +++ b/toolkit/components/places/tests/queries/xpcshell.ini @@ -0,0 +1,34 @@ +[DEFAULT] +head = head_queries.js +skip-if = toolkit == 'android' + +[test_415716.js] +[test_abstime-annotation-domain.js] +[test_abstime-annotation-uri.js] +[test_async.js] +[test_bookmarks.js] +[test_containersQueries_sorting.js] +[test_downloadHistory_liveUpdate.js] +[test_excludeQueries.js] +[test_history_queries_tags_liveUpdate.js] +[test_history_queries_titles_liveUpdate.js] +[test_onlyBookmarked.js] +[test_options_inherit.js] +[test_query_uri_liveupdate.js] +[test_queryMultipleFolder.js] +[test_querySerialization.js] +[test_redirects.js] +[test_result_observeHistoryDetails.js] +[test_results-as-left-pane.js] +[test_results-as-roots.js] +[test_results-as-tag-query.js] +[test_results-as-visit.js] +[test_search_tags.js] +[test_searchterms-domain.js] +[test_searchterms-uri.js] +[test_searchterms-bookmarklets.js] +[test_sort-date-site-grouping.js] +[test_sorting.js] +[test_tags.js] +[test_transitions.js] +[test_searchTerms_includeHidden.js] diff --git a/toolkit/components/places/tests/sync/head_sync.js b/toolkit/components/places/tests/sync/head_sync.js new file mode 100644 index 0000000000..d6c8910ec3 --- /dev/null +++ b/toolkit/components/places/tests/sync/head_sync.js @@ -0,0 +1,449 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +var { CanonicalJSON } = ChromeUtils.import( + "resource://gre/modules/CanonicalJSON.jsm" +); +var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs"); + +var { PlacesSyncUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesSyncUtils.sys.mjs" +); +var { SyncedBookmarksMirror } = ChromeUtils.importESModule( + "resource://gre/modules/SyncedBookmarksMirror.sys.mjs" +); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +var { + HTTP_400, + HTTP_401, + HTTP_402, + HTTP_403, + HTTP_404, + HTTP_405, + HTTP_406, + HTTP_407, + HTTP_408, + HTTP_409, + HTTP_410, + HTTP_411, + HTTP_412, + HTTP_413, + HTTP_414, + HTTP_415, + HTTP_417, + HTTP_500, + HTTP_501, + HTTP_502, + HTTP_503, + HTTP_504, + HTTP_505, + HttpError, + HttpServer, +} = ChromeUtils.import("resource://testing-common/httpd.js"); + +// These titles are defined in Database::CreateBookmarkRoots +const BookmarksMenuTitle = "menu"; +const BookmarksToolbarTitle = "toolbar"; +const UnfiledBookmarksTitle = "unfiled"; +const MobileBookmarksTitle = "mobile"; + +function run_test() { + let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror"); + bufLog.level = Log.Level.All; + + let sqliteLog = Log.repository.getLogger("Sqlite"); + sqliteLog.level = Log.Level.Error; + + let formatter = new Log.BasicFormatter(); + let appender = new Log.DumpAppender(formatter); + appender.level = Log.Level.All; + + for (let log of [bufLog, sqliteLog]) { + log.addAppender(appender); + } + + do_get_profile(); + run_next_test(); +} + +// A test helper to insert local roots directly into Places, since the public +// bookmarks APIs no longer support custom roots. +async function insertLocalRoot({ guid, title }) { + await PlacesUtils.withConnectionWrapper( + "insertLocalRoot", + async function (db) { + let dateAdded = PlacesUtils.toPRTime(new Date()); + await db.execute( + ` + INSERT INTO moz_bookmarks(guid, type, parent, position, title, + dateAdded, lastModified) + VALUES(:guid, :type, (SELECT id FROM moz_bookmarks + WHERE guid = :parentGuid), + (SELECT COUNT(*) FROM moz_bookmarks + WHERE parent = (SELECT id FROM moz_bookmarks + WHERE guid = :parentGuid)), + :title, :dateAdded, :dateAdded)`, + { + guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.rootGuid, + title, + dateAdded, + } + ); + } + ); +} + +// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext. +// This exists to avoid importing `record.js` from Sync. +function makeRecord(cleartext) { + return new Proxy( + { cleartext }, + { + get(target, property, receiver) { + if (property == "cleartext") { + return target.cleartext; + } + if (property == "cleartextToString") { + return () => JSON.stringify(target.cleartext); + } + return target.cleartext[property]; + }, + set(target, property, value, receiver) { + if (property == "cleartext") { + target.cleartext = value; + } else if (property != "cleartextToString") { + target.cleartext[property] = value; + } + }, + has(target, property) { + return property == "cleartext" || property in target.cleartext; + }, + deleteProperty(target, property) {}, + ownKeys(target) { + return ["cleartext", ...Reflect.ownKeys(target)]; + }, + } + ); +} + +async function storeRecords(buf, records, options) { + await buf.store(records.map(makeRecord), options); +} + +async function storeChangesInMirror(buf, changesToUpload) { + let cleartexts = []; + for (let recordId in changesToUpload) { + changesToUpload[recordId].synced = true; + cleartexts.push(changesToUpload[recordId].cleartext); + } + await storeRecords(buf, cleartexts, { needsMerge: false }); + await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload); +} + +function inspectChangeRecords(changeRecords) { + let results = { updated: [], deleted: [] }; + for (let [id, record] of Object.entries(changeRecords)) { + (record.tombstone ? results.deleted : results.updated).push(id); + } + results.updated.sort(); + results.deleted.sort(); + return results; +} + +async function promiseManyDatesAdded(guids) { + let datesAdded = new Map(); + let db = await PlacesUtils.promiseDBConnection(); + for (let chunk of PlacesUtils.chunkArray(guids, 100)) { + let rows = await db.executeCached( + ` + SELECT guid, dateAdded FROM moz_bookmarks + WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`, + chunk + ); + if (rows.length != chunk.length) { + throw new TypeError("Can't fetch date added for nonexistent items"); + } + for (let row of rows) { + let dateAdded = row.getResultByName("dateAdded") / 1000; + datesAdded.set(row.getResultByName("guid"), dateAdded); + } + } + return datesAdded; +} + +async function fetchLocalTree(rootGuid) { + function bookmarkNodeToInfo(node) { + let { guid, index, title, typeCode: type } = node; + let itemInfo = { guid, index, title, type }; + if (node.annos) { + let syncableAnnos = node.annos.filter(anno => + [PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes( + anno.name + ) + ); + if (syncableAnnos.length) { + itemInfo.annos = syncableAnnos; + } + } + if (node.uri) { + itemInfo.url = node.uri; + } + if (node.keyword) { + itemInfo.keyword = node.keyword; + } + if (node.children) { + itemInfo.children = node.children.map(bookmarkNodeToInfo); + } + if (node.tags) { + itemInfo.tags = node.tags.split(",").sort(); + } + return itemInfo; + } + let root = await PlacesUtils.promiseBookmarksTree(rootGuid); + return bookmarkNodeToInfo(root); +} + +async function assertLocalTree(rootGuid, expected, message) { + let actual = await fetchLocalTree(rootGuid); + if (!ObjectUtils.deepEqual(actual, expected)) { + info( + `Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}` + ); + info( + `Actual structure for ${rootGuid}: ${CanonicalJSON.stringify(actual)}` + ); + throw new Assert.constructor.AssertionError({ actual, expected, message }); + } +} + +function makeLivemarkServer() { + let server = new HttpServer(); + server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml")); + server.start(-1); + return { + server, + get site() { + let { identity } = server; + let host = identity.primaryHost.includes(":") + ? `[${identity.primaryHost}]` + : identity.primaryHost; + return `${identity.primaryScheme}://${host}:${identity.primaryPort}`; + }, + stopServer() { + return new Promise(resolve => server.stop(resolve)); + }, + }; +} + +function shuffle(array) { + let results = []; + for (let i = 0; i < array.length; ++i) { + let randomIndex = Math.floor(Math.random() * (i + 1)); + results[i] = results[randomIndex]; + results[randomIndex] = array[i]; + } + return results; +} + +async function fetchAllKeywords(info) { + let entries = []; + await PlacesUtils.keywords.fetch(info, entry => entries.push(entry)); + return entries; +} + +async function openMirror(name, options = {}) { + let buf = await SyncedBookmarksMirror.open({ + path: `${name}_buf.sqlite`, + recordStepTelemetry(...args) { + if (options.recordStepTelemetry) { + options.recordStepTelemetry.call(this, ...args); + } + }, + recordValidationTelemetry(...args) { + if (options.recordValidationTelemetry) { + options.recordValidationTelemetry.call(this, ...args); + } + }, + }); + return buf; +} + +function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) { + this.notifications = []; + this.ignoreDates = ignoreDates; + this.skipTags = skipTags; + this.handlePlacesEvents = this.handlePlacesEvents.bind(this); +} + +BookmarkObserver.prototype = { + handlePlacesEvents(events) { + for (let event of events) { + switch (event.type) { + case "bookmark-added": { + if (this.skipTags && event.isTagging) { + continue; + } + let params = { + itemId: event.id, + parentId: event.parentId, + index: event.index, + type: event.itemType, + urlHref: event.url, + title: event.title, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + }; + if (!this.ignoreDates) { + params.dateAdded = event.dateAdded; + } + this.notifications.push({ name: "bookmark-added", params }); + break; + } + case "bookmark-removed": { + if (this.skipTags && event.isTagging) { + continue; + } + // Since we are now skipping tags on the listener side we don't + // prevent unTagging notifications from going out. These events cause empty + // tags folders to be removed which creates another bookmark-removed notification + if ( + this.skipTags && + event.parentGuid == PlacesUtils.bookmarks.tagsGuid + ) { + continue; + } + let params = { + itemId: event.id, + parentId: event.parentId, + index: event.index, + type: event.itemType, + urlHref: event.url || null, + title: event.title, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + }; + this.notifications.push({ name: "bookmark-removed", params }); + break; + } + case "bookmark-moved": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + source: event.source, + guid: event.guid, + newIndex: event.index, + newParentGuid: event.parentGuid, + oldIndex: event.oldIndex, + oldParentGuid: event.oldParentGuid, + isTagging: event.isTagging, + }; + this.notifications.push({ name: "bookmark-moved", params }); + break; + } + case "bookmark-guid-changed": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }; + this.notifications.push({ name: "bookmark-guid-changed", params }); + break; + } + case "bookmark-title-changed": { + const params = { + itemId: event.id, + guid: event.guid, + title: event.title, + parentGuid: event.parentGuid, + }; + this.notifications.push({ name: "bookmark-title-changed", params }); + break; + } + case "bookmark-url-changed": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }; + this.notifications.push({ name: "bookmark-url-changed", params }); + break; + } + } + } + }, + + check(expectedNotifications) { + PlacesUtils.observers.removeListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-guid-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + this.handlePlacesEvents + ); + if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) { + info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`); + info(`Actual notifications: ${JSON.stringify(this.notifications)}`); + throw new Assert.constructor.AssertionError({ + actual: this.notifications, + expected: expectedNotifications, + }); + } + }, +}; + +function expectBookmarkChangeNotifications(options) { + let observer = new BookmarkObserver(options); + PlacesUtils.observers.addListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-guid-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + observer.handlePlacesEvents + ); + return observer; +} + +// Copies a support file to a temporary fixture file, allowing the support +// file to be reused for multiple tests. +async function setupFixtureFile(fixturePath) { + let fixtureFile = do_get_file(fixturePath); + let tempFile = FileTestUtils.getTempFile(fixturePath); + await IOUtils.copy(fixtureFile.path, tempFile.path); + return tempFile; +} diff --git a/toolkit/components/places/tests/sync/mirror_corrupt.sqlite b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite new file mode 100644 index 0000000000..ed3613447c --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite @@ -0,0 +1 @@ +Not a database! diff --git a/toolkit/components/places/tests/sync/mirror_v1.sqlite b/toolkit/components/places/tests/sync/mirror_v1.sqlite Binary files differnew file mode 100644 index 0000000000..f0b8853616 --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_v1.sqlite diff --git a/toolkit/components/places/tests/sync/mirror_v5.sqlite b/toolkit/components/places/tests/sync/mirror_v5.sqlite Binary files differnew file mode 100644 index 0000000000..2a798ae908 --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_v5.sqlite diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.html b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html new file mode 100644 index 0000000000..53ad366b1f --- /dev/null +++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html @@ -0,0 +1,18 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1>Bookmarks Menu</H1> + +<DL><p> + <DT><A HREF="https://www.mozilla.org/" ADD_DATE="1471365662" LAST_MODIFIED="1471366005" LAST_CHARSET="UTF-8">Mozilla</A> + <DD>Mozilla home + <DT><H3 ADD_DATE="1449080379" LAST_MODIFIED="1471366005" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> + <DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="https://www.mozilla.org/en-US/firefox/" ADD_DATE="1471365681" LAST_MODIFIED="1471366005" SHORTCUTURL="fx" LAST_CHARSET="UTF-8" TAGS="browser">Firefox</A> + <DD>Firefox home + </DL><p> +</DL> diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.json b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json new file mode 100644 index 0000000000..961140843d --- /dev/null +++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json @@ -0,0 +1,94 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1449080379324000, + "lastModified": 1471365727344000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1449080379324000, + "lastModified": 1471365683893000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "NnvGl3CRA4hC", + "title": "Mozilla", + "index": 0, + "dateAdded": 1471365662585000, + "lastModified": 1471365667573000, + "id": 6, + "charset": "UTF-8", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Mozilla home" + } + ], + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1449080379324000, + "lastModified": 1471365683893000, + "id": 3, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "APzP8MupzA8l", + "title": "Firefox", + "index": 0, + "dateAdded": 1471365681801000, + "lastModified": 1471365687887000, + "id": 7, + "charset": "UTF-8", + "tags": "browser", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Firefox home" + } + ], + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/en-US/firefox/", + "keyword": "fx" + } + ] + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1449080379324000, + "lastModified": 1471365629626000, + "id": 5, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + } + ] +} diff --git a/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js new file mode 100644 index 0000000000..877feb99f4 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); + +add_task(async function test_transaction_in_progress() { + let buf = await openMirror("transaction_in_progress"); + + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + // This transaction should block merging until the transaction is committed. + info("Open transaction on Places connection"); + await buf.db.execute("BEGIN EXCLUSIVE"); + + await Assert.rejects( + buf.apply(), + ex => ex.name == "MergeConflictError", + "Should not merge when a transaction is in progress" + ); + + info("Commit open transaction"); + await buf.db.execute("COMMIT"); + + info("Merging should succeed after committing"); + await buf.apply(); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_abort_store() { + let buf = await openMirror("abort_store"); + + let controller = new AbortController(); + controller.abort(); + await Assert.rejects( + storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + ], + { signal: controller.signal } + ), + ex => ex.name == "InterruptedError", + "Should abort storing when signaled" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_abort_merging() { + let buf = await openMirror("abort_merging"); + + let controller = new AbortController(); + controller.abort(); + await Assert.rejects( + buf.apply({ signal: controller.signal }), + ex => ex.name == "InterruptedError", + "Should abort merge when signaled" + ); + + // Even though the merger is already finalized on the Rust side, the DB + // connection is still open on the JS side. Finalizing `buf` closes it. + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_blocker_state() { + let barrier = new AsyncShutdown.Barrier("Test"); + let buf = await SyncedBookmarksMirror.open({ + path: "blocker_state_buf.sqlite", + finalizeAt: barrier.client, + recordStepTelemetry(...args) {}, + recordValidationTelemetry(...args) {}, + }); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + await buf.tryApply(buf.finalizeController.signal); + await barrier.wait(); + + let state = buf.progress.fetchState(); + let names = []; + for (let s of state.steps) { + equal(typeof s.at, "number", `Should report timestamp for ${s.step}`); + switch (s.step) { + case "fetchLocalTree": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch local tree" + ); + deepEqual( + s.counts, + [ + { name: "items", count: 6 }, + { name: "deletions", count: 0 }, + ], + "Should report number of items in local tree" + ); + break; + + case "fetchRemoteTree": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch remote tree" + ); + deepEqual( + s.counts, + [ + { name: "items", count: 6 }, + { name: "deletions", count: 0 }, + ], + "Should report number of items in remote tree" + ); + break; + + case "merge": + greaterOrEqual(s.took, 0, "Should report time taken to merge"); + deepEqual( + s.counts, + [{ name: "items", count: 6 }], + "Should report merge stats" + ); + break; + + case "apply": + greaterOrEqual(s.took, 0, "Should report time taken to apply"); + ok(!("counts" in s), "Should not report counts for applying"); + break; + + case "notifyObservers": + greaterOrEqual( + s.took, + 0, + "Should report time taken to notify observers" + ); + ok(!("counts" in s), "Should not report counts for observers"); + break; + + case "fetchLocalChangeRecords": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch records for upload" + ); + deepEqual( + s.counts, + [{ name: "items", count: 4 }], + "Should report number of records to upload" + ); + break; + + case "finalize": + ok(!("took" in s), "Should not report time taken to finalize"); + ok(!("counts" in s), "Should not report counts for finalizing"); + } + names.push(s.step); + } + deepEqual( + names, + [ + "fetchLocalTree", + "fetchRemoteTree", + "merge", + "apply", + "notifyObservers", + "fetchLocalChangeRecords", + "finalize", + ], + "Should report merge progress after waiting on blocker" + ); + ok( + buf.finalizeController.signal.aborted, + "Should abort finalize signal on shutdown" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_chunking.js b/toolkit/components/places/tests/sync/test_bookmark_chunking.js new file mode 100644 index 0000000000..3652502a3d --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_chunking.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// These tests ensure we correctly chunk statements that exceed SQLite's +// binding parameter limit. + +// Inserts 1500 unfiled bookmarks. Using `PlacesUtils.bookmarks.insertTree` +// is an order of magnitude slower, so we write bookmarks directly into the +// database. +async function insertManyUnfiledBookmarks(db, url) { + await db.executeCached( + ` + INSERT OR IGNORE INTO moz_places(id, url, url_hash, rev_host, hidden, + frecency, guid) + VALUES((SELECT id FROM moz_places + WHERE url_hash = hash(:url) AND + url = :url), :url, hash(:url), :revHost, 0, -1, + generate_guid())`, + { url: url.href, revHost: PlacesUtils.getReversedHost(url) } + ); + + let guids = []; + + for (let position = 0; position < 1500; ++position) { + let title = position.toString(10); + let guid = title.padStart(12, "A"); + await db.executeCached( + ` + INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title, + syncStatus, syncChangeCounter) + VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND + url = :url), + :position, :type, :title, :syncStatus, 1)`, + { + guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + position, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + } + ); + guids.push(guid); + } + + return guids; +} + +add_task(async function test_merged_item_chunking() { + let buf = await openMirror("merged_item_chunking"); + + info("Set up local tree with 1500 bookmarks"); + let localGuids = await buf.db.executeTransaction(function () { + let url = new URL("http://example.com/a"); + return insertManyUnfiledBookmarks(buf.db, url); + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Set up remote tree with 1500 bookmarks"); + let toolbarRecord = makeRecord({ + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + }); + let records = [toolbarRecord]; + for (let i = 0; i < 1500; ++i) { + let title = i.toString(10); + let guid = title.padStart(12, "B"); + toolbarRecord.children.push(guid); + records.push( + makeRecord({ + id: guid, + parentid: "toolbar", + type: "bookmark", + title, + bmkUri: "http://example.com/b", + }) + ); + } + await buf.store(shuffle(records)); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.unfiledGuid], + "Should leave unfiled with new remote structure unmerged" + ); + + let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + "toolbar" + ); + deepEqual( + localChildRecordIds, + toolbarRecord.children, + "Should apply all remote toolbar children" + ); + + let guidsToUpload = Object.keys(changesToUpload); + deepEqual( + guidsToUpload.sort(), + ["unfiled", ...localGuids].sort(), + "Should upload unfiled and all new local children" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_deletion_chunking() { + let buf = await openMirror("deletion_chunking"); + + info("Set up local tree with 1500 bookmarks"); + let guids = await buf.db.executeTransaction(function () { + let url = new URL("http://example.com/a"); + return insertManyUnfiledBookmarks(buf.db, url); + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Delete them all on the server"); + let records = [ + makeRecord({ + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + }), + ]; + for (let guid of guids) { + records.push( + makeRecord({ + id: guid, + deleted: true, + }) + ); + } + await buf.store(shuffle(records)); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + deepEqual(changesToUpload, {}, "Should take all remote deletions"); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Shouldn't store tombstones for remote deletions"); + + let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + "unfiled" + ); + deepEqual( + localChildRecordIds, + [], + "Should delete all unfiled children locally" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_corruption.js b/toolkit/components/places/tests/sync/test_bookmark_corruption.js new file mode 100644 index 0000000000..5f0b0afeef --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js @@ -0,0 +1,3290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function reparentItem(db, guid, newParentGuid = null) { + await db.execute( + ` + UPDATE moz_bookmarks SET + parent = IFNULL((SELECT id FROM moz_bookmarks + WHERE guid = :newParentGuid), 0) + WHERE guid = :guid`, + { newParentGuid, guid } + ); +} + +async function getCountOfBookmarkRows(db) { + let queryRows = await db.execute("SELECT COUNT(*) FROM moz_bookmarks"); + Assert.equal(queryRows.length, 1); + return queryRows[0].getResultByIndex(0); +} + +add_task(async function test_multiple_parents() { + let buf = await openMirror("multiple_parents"); + let now = Date.now(); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "toolbar", + parentid: "places", + type: "folder", + modified: now / 1000 - 10, + children: ["bookmarkAAAA"], + }, + { + id: "menu", + parentid: "places", + type: "folder", + modified: now / 1000 - 5, + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + modified: now / 1000 - 3, + children: ["bookmarkBBBB"], + }, + { + id: "mobile", + parentid: "places", + type: "folder", + modified: now / 1000, + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "toolbar", + type: "bookmark", + title: "A", + modified: now / 1000 - 10, + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "mobile", + type: "bookmark", + title: "B", + modified: now / 1000 - 3, + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave items with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, + "bookmarkAAAA", + "bookmarkBBBB", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + title: MobileBookmarksTitle, + children: [], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + }); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + let newChangesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + newChangesToUpload, + {}, + "Should not upload any changes after updating mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_reupload_replace() { + let buf = await openMirror("reupload_replace"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: [], + }, + ], + { needsMerge: false } + ); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "folderBBBBBB", + "queryCCCCCCC", + "queryDDDDDDD", + ], + }, + { + // A has an invalid URL, but exists locally, so we should reupload a valid + // local copy. This discards _all_ remote changes to A. + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + bmkUri: "!@#$%", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B (remote)", + children: ["bookmarkEEEE"], + }, + { + // E is a bookmark with an invalid URL that doesn't exist locally, so we'll + // delete it. + id: "bookmarkEEEE", + parentid: "folderBBBBBB", + type: "bookmark", + title: "E (remote)", + bmkUri: "!@#$%", + }, + { + // C is a legacy tag query, so we'll rewrite its URL and reupload it. + id: "queryCCCCCCC", + parentid: "menu", + type: "query", + title: "C (remote)", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + // D is a query with an invalid URL, so we'll delete it. + id: "queryDDDDDDD", + parentid: "menu", + type: "query", + title: "D", + bmkUri: "^&*()", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkEEEE", + "folderBBBBBB", + PlacesUtils.bookmarks.menuGuid, + "queryCCCCCCC", + "queryDDDDDDD", + ], + "Should leave invalid A, E, D; reuploaded C; B, menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + "bookmarkAAAA", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", "folderBBBBBB", "queryCCCCCCC"], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + folderBBBBBB: { + // B is reuploaded because we deleted its child E. + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderBBBBBB", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + title: "B (remote)", + children: [], + }, + }, + queryCCCCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryCCCCCCC", + type: "query", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + bmkUri: "place:tag=taggy", + title: "C (remote)", + folderName: "taggy", + }, + }, + queryDDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "queryDDDDDDD", + deleted: true, + }, + }, + bookmarkEEEE: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkEEEE", + deleted: true, + }, + }, + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkEEEE", "queryDDDDDDD"], + "Should store local tombstones for (E D)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_local_roots() { + let buf = await openMirror("corrupt_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "toolbar", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + try { + info("Move local menu into unfiled"); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid + ); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented syncable root" + ); + + info("Move local Places root into toolbar"); + await buf.db.executeTransaction(async function () { + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.rootGuid, + PlacesUtils.bookmarks.toolbarGuid + ); + }); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented Places root" + ); + } finally { + info("Restore local roots"); + await buf.db.executeTransaction(async function () { + await reparentItem(buf.db, PlacesUtils.bookmarks.rootGuid); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + }); + } + + info("Apply remote with restored roots"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + deepEqual(changesToUpload, {}, "Should not reupload any local records"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly with restored roots" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_remote_roots() { + let buf = await openMirror("corrupt_remote_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: Menu > Unfiled"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["unfiled", "bookmarkAAAA"], + }, + { + id: "unfiled", + parentid: "menu", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "toolbar", + deleted: true, + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave deleted roots unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + }, + "Should reupload invalid roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not corrupt local roots" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_missing_children() { + let buf = await openMirror("missing_childen"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: A > ([B] C [D E])"); + { + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["menu"], + deleted: [], + }, + "Should reupload menu without missing children (B D E)" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + "Menu children should be (C)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add (B E) to remote"); + { + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkBBBB", "bookmarkEEEE"], + "Should leave B, E with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkEEEE", "menu"], + deleted: [], + }, + "Should reupload menu and restored children" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + ], + }, + "Menu children should be (C B E)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add D to remote"); + { + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ]); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkDDDD"], + "Should leave D with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkDDDD", "menu"], + deleted: [], + }, + "Should reupload complete menu" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D", + url: "http://example.com/d", + }, + ], + }, + "Menu children should be (C B E D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_without_local_parent() { + let buf = await openMirror("new_orphan_without_local_parent"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // A doesn't exist locally, so we move the bookmarks into "unfiled" without + // reuploading. When the partial uploader returns and uploads A, we'll + // move the bookmarks to the correct folder. + info("Make remote changes: [A] > (B C D)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + ]) + ); + + info("Apply remote with (B C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave orphans B, C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD", "unfiled"], + deleted: [], + }, + "Should reupload orphans (B C D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + "Should move (B C D) to unfiled" + ); + + // A is an orphan because we don't have E locally, but we should move + // (B C D) into A. + info("Add [E] > A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "folderEEEEEE", + type: "folder", + title: "A", + children: ["bookmarkDDDD", "bookmarkCCCC", "bookmarkBBBB"], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderAAAAAA"], + "Should leave A with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "folderAAAAAA", + "unfiled", + ], + deleted: [], + }, + "Should reupload A and its children" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + "Should move (D C B) into A" + ); + + info("Add E to remote"); + await storeRecords(buf, [ + { + id: "folderEEEEEE", + parentid: "menu", + type: "folder", + title: "E", + children: ["folderAAAAAA"], + }, + ]); + + info("Apply remote with E"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderEEEEEE"], + "Should leave E with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["folderAAAAAA", "folderEEEEEE", "menu", "unfiled"], + deleted: [], + }, + "Should move E out of unfiled into menu" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + "Should move Menu > E > A" + ); + + info("Add Menu > E to remote"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderEEEEEE"], + }, + ]); + + info("Apply remote with menu"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload after forming complete tree" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should form complete tree after applying E" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_into_orphaned() { + let buf = await openMirror("move_into_orphaned"); + + info("Set up mirror: Menu > (A B (C > (D (E > F))))"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "folderCCCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "folderEEEEEE"], + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "folderEEEEEE", + parentid: "folderCCCCCC", + type: "folder", + title: "E", + children: ["bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderEEEEEE", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete D, add E > I"); + await PlacesUtils.bookmarks.remove("bookmarkDDDD"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkIIII", + parentGuid: "folderEEEEEE", + title: "I (local)", + url: "http://example.com/i", + }); + + // G doesn't exist on the server. + info("Make remote changes: ([G] > A (C > (D H E))), (C > H)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "folderGGGGGG", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderCCCCCC", + parentid: "folderGGGGGG", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "bookmarkHHHH", "folderEEEEEE"], + }, + { + id: "bookmarkHHHH", + parentid: "folderCCCCCC", + type: "bookmark", + title: "H (remote)", + bmkUri: "http://example.com/h-remote", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA", "folderCCCCCC"], + "Should leave orphaned A, C with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkAAAA", + "bookmarkIIII", + "folderCCCCCC", + "folderEEEEEE", + "menu", + ], + deleted: ["bookmarkDDDD"], + }, + "Should upload records for (A I C E); tombstone for D" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + // A remains in its original place, since we don't use the `parentid`, + // and we don't have a record for G. + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + // C exists on the server, so we take its children and order. D was + // deleted locally, and doesn't exist remotely. C is also a child of + // G, but we don't have a record for it on the server. + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "C", + children: [ + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "H (remote)", + url: "http://example.com/h-remote", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "I (local)", + url: "http://example.com/i", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should treat local tree as canonical if server is missing new parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkDDDD"], + "Should store local tombstone for D" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_with_local_parent() { + let buf = await openMirror("new_orphan_with_local_parent"); + + info("Set up mirror: A > (B D)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkEEEE"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Simulate a partial write by another device that uploaded only B and C. A + // exists locally, so we can move B and C into the correct folder, but not + // the correct positions. + info("Set up remote with orphans: [A] > (C D)"); + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + ]); + + info("Apply remote with (C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkCCCC", "bookmarkDDDD"], + "Should leave orphaned C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "bookmarkDDDD", "folderAAAAAA"], + deleted: [], + }, + "Should reupload orphans (C D) and folder A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move (C D) to end of A" + ); + + // The partial uploader returns and uploads A. + info("Add A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: [ + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + "bookmarkBBBB", + ], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload orphan A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + "folderAAAAAA", + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "B", + url: "http://example.com/b", + }, + ], + }, + "Should update child positions once A exists in mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tombstone_as_child() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let buf = await openMirror("tombstone_as_child"); + // Setup the mirror such that an incoming folder references a tombstone + // as a child. + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkAAAA", "bookmarkTTTT", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkTTTT", + deleted: true, + }, + ]), + { needsMerge: true } + ); + + let changesToUpload = await buf.apply(); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual(idsToUpload.deleted, [], "no new tombstones were created."); + deepEqual(idsToUpload.updated, ["folderAAAAAA"], "parent is re-uploaded"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/a", + index: 0, + title: "Bookmark A", + }, + { + // Note that this was the 3rd child specified on the server record, + // but we we've correctly moved it back to being the second after + // ignoring the tombstone. + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/b", + index: 1, + title: "Bookmark B", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should have ignored tombstone record" + ); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_non_syncable_items() { + let buf = await openMirror("non_syncable_items"); + + info("Insert local orphaned left pane queries"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + guid: "folderLEFTPQ", + url: "place:folder=SOMETHING", + title: "Some query", + }, + { + guid: "folderLEFTPC", + url: "place:folder=SOMETHING_ELSE", + title: "A query under 'All Bookmarks'", + }, + ], + }); + + info( + "Insert syncable local items (A > B) that exist in non-syncable remote root H" + ); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A is non-syncable remotely, but B doesn't exist remotely, so we'll + // remove A from the merged structure, and move B to the menu. + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }); + + info("Insert non-syncable local root C and items (C > (D > E) F)"); + await insertLocalRoot({ + guid: "rootCCCCCCCC", + title: "C", + }); + await PlacesUtils.bookmarks.insertTree({ + guid: "rootCCCCCCCC", + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + // H is a non-syncable root that only exists remotely. + id: "rootHHHHHHHH", + type: "folder", + parentid: "places", + title: "H", + children: ["folderAAAAAA"], + }, + { + // A is a folder with children that's non-syncable remotely, and syncable + // locally. We should remove A and its descendants locally, since its parent + // H is known to be non-syncable remotely. + id: "folderAAAAAA", + parentid: "rootHHHHHHHH", + type: "folder", + title: "A", + children: ["bookmarkFFFF", "bookmarkIIII"], + }, + { + // F exists in two different non-syncable folders: C locally, and A + // remotely. + id: "bookmarkFFFF", + parentid: "folderAAAAAA", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + { + id: "bookmarkIIII", + parentid: "folderAAAAAA", + type: "query", + title: "I", + bmkUri: "http://example.com/i", + }, + { + // The complete left pane root. We should remove all left pane queries + // locally, even though they're syncable, since the left pane root is + // known to be non-syncable. + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + parentid: "folderLEFTPR", + type: "query", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + parentid: "folderLEFTPR", + type: "folder", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + parentid: "folderLEFTPF", + type: "query", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + { + // D, J, and G are syncable remotely, but D is non-syncable locally. Since + // J and G don't exist locally, and are syncable remotely, we'll remove D + // from the merged structure, and move J and G to unfiled. + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderDDDDDD", "bookmarkGGGG"], + }, + { + id: "folderDDDDDD", + parentid: "unfiled", + type: "folder", + title: "D", + children: ["bookmarkJJJJ"], + }, + { + id: "bookmarkJJJJ", + parentid: "folderDDDDDD", + type: "bookmark", + title: "J", + bmkUri: "http://example.com/j", + }, + { + id: "bookmarkGGGG", + parentid: "unfiled", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkFFFF", + "bookmarkIIII", + "bookmarkJJJJ", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + PlacesUtils.bookmarks.menuGuid, + "rootHHHHHHHH", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave non-syncable items and roots with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkBBBB", + "bookmarkJJJJ", + ]); + deepEqual( + changesToUpload, + { + folderAAAAAA: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderAAAAAA", + deleted: true, + }, + }, + folderDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + deleted: true, + }, + }, + folderLEFTPQ: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPQ", + deleted: true, + }, + }, + folderLEFTPC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPC", + deleted: true, + }, + }, + folderLEFTPR: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPR", + deleted: true, + }, + }, + folderLEFTPF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPF", + deleted: true, + }, + }, + rootHHHHHHHH: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "rootHHHHHHHH", + deleted: true, + }, + }, + bookmarkFFFF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkFFFF", + deleted: true, + }, + }, + bookmarkIIII: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkIIII", + deleted: true, + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkJJJJ: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkJJJJ", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: undefined, + bmkUri: "http://example.com/j", + title: "J", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkJJJJ", "bookmarkGGGG"], + }, + }, + }, + "Should upload new structure and tombstones for non-syncable items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkJJJJ", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "J", + url: "http://example.com/j", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should exclude non-syncable items from new local structure" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + [ + "bookmarkFFFF", + "bookmarkIIII", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + "rootHHHHHHHH", + ], + "Should store local tombstones for non-syncable items" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane root and a left-pane query are on the server +add_task(async function test_left_pane_root() { + let buf = await openMirror("lpr"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add a left pane root, a left-pane query and a left-pane folder to the + // mirror, all correctly parented. + // Because we can determine this is a complete tree that's outside our + // syncable trees, we expect none of them to be applied. + await storeRecords( + buf, + shuffle( + [ + { + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + type: "folder", + parentid: "folderLEFTPR", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + type: "query", + parentid: "folderLEFTPF", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + ], + { needsMerge: true } + ) + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and a check we didn't write *any* items to the places database, even + // outside of our user roots. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane query (without the left-pane root) is on +// the server +add_task(async function test_left_pane_query() { + let buf = await openMirror("lpq"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add the left pane root and left-pane folders to the mirror, correctly parented. + // We should not apply it because we made a policy decision to not apply + // orphaned queries (bug 1433182) + await storeRecords( + buf, + [ + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + ], + { needsMerge: true } + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and further check we didn't apply it as mis-rooted. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_partial_cycle() { + let buf = await openMirror("partial_cycle"); + + info("Set up mirror: Menu > A > B > C"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Try to create a cycle: move A into B, and B into the menu, but don't upload + // a record for the menu. + info("Make remote changes: A > C"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A (remote)", + children: ["bookmarkCCCC"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B (remote)", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderBBBBBB> can't contain itself/, + "Should abort merge if remote tree parents form `parentid` cycle" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_complete_cycle() { + let buf = await openMirror("complete_cycle"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // This test is order-dependent. We shouldn't recurse infinitely, but, + // depending on the order of the records, we might ignore the circular + // subtree because there's nothing linking it back to the rest of the + // tree. + info("Make remote changes: Menu > A > B > C > A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "folderBBBBBB", + type: "folder", + title: "C", + children: ["folderDDDDDD"], + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + title: "D", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderAAAAAA> can't contain itself/, + "Should abort merge if remote tree parents form cycle through `children`" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_guid() { + let now = new Date(); + + let buf = await openMirror("invalid_guid"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bad!guid~", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bad!guid~", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bad!guid~", PlacesUtils.bookmarks.menuGuid], + "Should leave bad GUID and menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + + let recordIdsToUpload = Object.keys(changesToUpload); + let newGuid = recordIdsToUpload.find( + recordId => !["bad!guid~", "menu"].includes(recordId) + ); + + equal( + recordIdsToUpload.length, + 3, + "Should reupload menu, C, and tombstone for bad GUID" + ); + + deepEqual( + changesToUpload["bad!guid~"], + { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bad!guid~", + deleted: true, + }, + }, + "Should upload tombstone for C's invalid GUID" + ); + + deepEqual( + changesToUpload[newGuid], + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: newGuid, + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/c", + title: "C", + }, + }, + "Should reupload C with new GUID" + ); + + deepEqual( + changesToUpload.menu, + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", newGuid, "bookmarkBBBB"], + }, + }, + "Should reupload menu with new child GUID for C" + ); + + await assertLocalTree(PlacesUtils.bookmarks.menuGuid, { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: newGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B", + url: "http://example.com/b", + }, + ], + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bad!guid~"], + "Should store local tombstone for C's invalid GUID" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_sync_status_mismatches() { + let dateAdded = new Date(); + + let buf = await openMirror("sync_status_mismatches"); + + info("Ensure mirror is up-to-date with Places"); + let initialChangesToUpload = await buf.apply(); + + deepEqual( + Object.keys(initialChangesToUpload).sort(), + ["menu", "mobile", "toolbar", "unfiled"], + "Should upload roots on first merge" + ); + + await storeChangesInMirror(buf, initialChangesToUpload); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + // A is NORMAL in Places, but doesn't exist in the mirror. + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded, + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + // B is NEW in Places and exists in the mirror. + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded, + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + [ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + bmkUri: "http://example.com/b", + title: "B", + }, + { + // C is flagged as merged in the mirror, but doesn't exist in Places. + id: "bookmarkCCCC", + parentid: "toolbar", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ], + { needsMerge: false } + ); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + ]); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + }, + "Should flag (A B) and their parents for upload" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent C correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_local_urls() { + let buf = await openMirror("invalid_local_urls"); + + info("Skip uploading local roots on first merge"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Set up local tree"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A has an invalid URL locally and doesn't exist remotely, so we + // should delete it without uploading a tombstone. + guid: "bookmarkAAAA", + title: "A (local)", + url: "http://example.com/a", + }, + { + // B has an invalid URL locally and has a valid URL remotely, so + // we should replace our local copy with the remote one. + guid: "bookmarkBBBB", + title: "B (local)", + url: "http://example.com/b", + }, + { + // C has an invalid URL on both sides, so we should delete it locally + // and upload a tombstone. + guid: "bookmarkCCCC", + title: "A (local)", + url: "http://example.com/c", + }, + ], + }); + + // The public API doesn't let us insert invalid URLs (for good reason!), so + // we update them directly in Places. + info("Invalidate local URLs"); + await buf.db.executeTransaction(async function () { + const invalidURLs = [ + { + guid: "bookmarkAAAA", + invalidURL: "!@#$%", + }, + { + guid: "bookmarkBBBB", + invalidURL: "^&*(", + }, + { + guid: "bookmarkCCCC", + invalidURL: ")-+!@", + }, + ]; + for (let params of invalidURLs) { + await buf.db.execute( + `UPDATE moz_places SET + url = :invalidURL, + url_hash = hash(:invalidURL) + WHERE id = (SELECT fk FROM moz_bookmarks WHERE guid = :guid)`, + params + ); + } + }); + + info("Set up remote tree"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b", + }, + { + // C should be marked as `VALIDITY_REPLACE` in the mirror database. + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C (remote)", + bmkUri: ")(*&^", + }, + { + // D has an invalid URL remotely and doesn't exist locally, so we + // should replace it with a tombstone. + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D (remote)", + bmkUri: "^%$#@", + }, + ]); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + bookmarkCCCC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + deleted: true, + }, + }, + bookmarkDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkDDDD", + deleted: true, + }, + }, + }, + "Should reupload menu and tombstones for (C D)" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b", + }, + ], + }, + "Should replace B with remote and delete (A C)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual( + await buf.fetchUnmergedGuids(), + [], + "Should flag all items as merged after upload" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_deduping.js b/toolkit/components/places/tests/sync/test_bookmark_deduping.js new file mode 100644 index 0000000000..0c6c79496a --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_deduping.js @@ -0,0 +1,1290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_duping_local_newer() { + let mergeTelemetryCounts; + let buf = await openMirror("duping_local_newer", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + let localModified = new Date(); + + info("Start with empty local and mirror with merged items"); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAA5"], + dateAdded: localModified.getTime(), + }, + { + id: "bookmarkAAA5", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + dateAdded: localModified.getTime(), + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add newer local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + { + guid: "bookmarkAAA2", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + { + guid: "bookmarkAAA3", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + ], + }); + + info("Add older remote dupes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"], + modified: localModified / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + keyword: "kw", + tags: ["remote", "tags"], + modified: localModified / 1000 - 5, + }, + { + id: "bookmarkAAA4", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + modified: localModified / 1000 - 5, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: localModified / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA4", "bookmarkAAAA", PlacesUtils.bookmarks.menuGuid], + "Should leave A4, A, menu with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 9 }, + { name: "dupes", count: 2 }, + ], + "Should record telemetry with dupe counts" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: menuInfo.title, + children: [ + "bookmarkAAAA", + "bookmarkAAA4", + "bookmarkAAA3", + "bookmarkAAA5", + ], + }, + }, + // Note that we always reupload the deduped local item, because content + // matching doesn't account for attributes like keywords, synced annos, or + // tags. + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + // Unchanged from local. + bookmarkAAA4: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA4", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + bookmarkAAA3: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA3", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + bookmarkAAA5: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA5", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + }, + "Should uploaded newer deduped local items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA4", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA3", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA5", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "Should dedupe local multiple bookmarks with similar contents" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_remote_newer() { + let buf = await openMirror("duping_remote_new"); + let localModified = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Shouldn't dupe to `folderA11111` because its sync status is "NORMAL". + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + // Shouldn't dupe to `bookmarkG111`. + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkGGGG"], + }, + { + id: "bookmarkGGGG", + parentid: "folderAAAAAA", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Insert local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Should dupe to `folderB11111`. + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: localModified, + lastModified: localModified, + children: [ + { + // Should dupe to `bookmarkC222`. + guid: "bookmarkC111", + url: "http://example.com/c", + title: "C", + dateAdded: localModified, + lastModified: localModified, + }, + { + // Should dupe to `separatorF11` because the positions are the same. + guid: "separatorFFF", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + dateAdded: localModified, + lastModified: localModified, + }, + ], + }, + { + // Shouldn't dupe to `separatorE11`, because the positions are different. + guid: "separatorEEE", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + dateAdded: localModified, + lastModified: localModified, + }, + { + // Shouldn't dupe to `bookmarkC222` because the parents are different. + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: localModified, + lastModified: localModified, + }, + { + // Should dupe to `queryD111111`. + guid: "queryDDDDDDD", + url: "place:maxResults=10&sort=8", + title: "Most Visited", + dateAdded: localModified, + lastModified: localModified, + }, + ], + }); + + // Make sure we still dedupe this even though it doesn't have SYNC_STATUS.NEW + PlacesTestUtils.setBookmarkSyncFields({ + guid: "folderBBBBBB", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + }); + + // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`. + await PlacesUtils.bookmarks.insert({ + parentGuid: "folderAAAAAA", + guid: "bookmarkHHHH", + url: "http://example.com/h", + title: "H", + dateAdded: localModified, + lastModified: localModified, + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "folderAAAAAA", + "folderB11111", + "folderA11111", + "separatorE11", + "queryD111111", + ], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "folderB11111", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkC222", "separatorF11"], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "bookmarkC222", + parentid: "folderB11111", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "separatorF11", + parentid: "folderB11111", + type: "separator", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "folderA11111", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkG111"], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "bookmarkG111", + parentid: "folderA11111", + type: "bookmark", + bmkUri: "http://example.com/g", + title: "G", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "separatorE11", + parentid: "menu", + type: "separator", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "queryD111111", + parentid: "menu", + type: "query", + bmkUri: "place:maxResults=10&sort=8", + title: "Most Visited", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: localModified / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkCCCC", + "bookmarkHHHH", + "folderAAAAAA", + "menu", + "separatorEEE", + ], + deleted: [], + }, + "Should not upload deduped local records" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "H", + url: "http://example.com/h", + }, + ], + }, + { + guid: "folderB11111", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "B", + children: [ + { + guid: "bookmarkC222", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "separatorF11", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 1, + title: "", + }, + ], + }, + { + guid: "folderA11111", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "A", + children: [ + { + guid: "bookmarkG111", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: "separatorE11", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 3, + title: "", + }, + { + guid: "queryD111111", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "Most Visited", + url: "place:maxResults=10&sort=8", + }, + { + guid: "separatorEEE", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 5, + title: "", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 6, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should dedupe matching NEW bookmarks" + ); + + ok( + ( + await PlacesTestUtils.fetchBookmarkSyncFields( + "menu________", + "folderB11111", + "bookmarkC222", + "separatorF11", + "folderA11111", + "bookmarkG111", + "separatorE11", + "queryD111111" + ) + ).every(info => info.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL) + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_both() { + let buf = await openMirror("duping_both"); + let now = Date.now(); + + info("Start with empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // `folderAAAAA1` is older than `folderAAAAAA`, but we should still flag + // it for upload because it has a new structure (`bookmarkCCCC`). + guid: "folderAAAAA1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + children: [ + { + // Shouldn't upload, since `bookmarkBBBB` is newer. + guid: "bookmarkBBB1", + title: "B", + url: "http://example.com/b", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + { + // Should upload, since `bookmarkCCCC` doesn't exist on the server and + // has no content matches. + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + // `folderDDDDD1` should keep complete local structure, but we'll still + // flag it for reupload because it's newer than `folderDDDDDD`. + guid: "folderDDDDD1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 10000), + lastModified: new Date(now + 5000), + children: [ + { + guid: "bookmarkEEE1", + title: "E", + url: "http://example.com/e", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + // `folderFFFFF1` should keep complete remote value and structure, so + // we shouldn't upload it or its children. + guid: "folderFFFFF1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGG1", + title: "G", + url: "http://example.com/g", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + ], + }); + + info("Add remote dupes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + dateAdded: now - 10000, + modified: now / 1000 + 5, + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + bmkUri: "http://example.com/b", + title: "B", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 10000, + modified: now / 1000 - 5, + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + bmkUri: "http://example.com/e", + title: "E", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F", + dateAdded: now - 10000, + modified: now / 1000 + 5, + children: ["bookmarkGGGG", "bookmarkHHHH"], + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + bmkUri: "http://example.com/g", + title: "G", + dateAdded: now - 10000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkHHHH", + parentid: "folderFFFFFF", + type: "bookmark", + bmkUri: "http://example.com/h", + title: "H", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: now / 1000, + }); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: menuInfo.title, + children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"], + }, + }, + folderAAAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderAAAAAA", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: now - 10000, + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + }, + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "folderAAAAAA", + hasDupe: true, + parentName: "A", + dateAdded: now - 10000, + title: "C", + bmkUri: "http://example.com/c", + }, + }, + folderDDDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: now - 10000, + title: "D", + children: ["bookmarkEEEE"], + }, + }, + }, + "Should upload new and newer locally deduped items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "F", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "H", + url: "http://example.com/h", + }, + ], + }, + ], + }, + "Should change local GUIDs for mixed older and newer items" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_applying_two_empty_folders_doesnt_smush() { + let buf = await openMirror("applying_two_empty_folders_doesnt_smush"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["emptyempty01", "emptyempty02"], + }, + { + id: "emptyempty01", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty02", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only value changes" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "mobile", + children: [ + { + guid: "emptyempty01", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Empty", + }, + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "Empty", + }, + ], + }, + "Should not smush 1 and 2" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_applying_two_empty_folders_matches_only_one() { + let buf = await openMirror("applying_two_empty_folders_doesnt_smush"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.mobileGuid, + children: [ + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Empty", + }, + { + guid: "emptyemptyL0", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Empty", + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["emptyempty01", "emptyempty02", "emptyempty03"], + }, + { + id: "emptyempty01", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty02", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty03", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.mobileGuid], + "Should leave mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["mobile"], + deleted: [], + }, + "Should not upload records after applying empty folders" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "mobile", + children: [ + { + guid: "emptyempty01", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Empty", + }, + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "Empty", + }, + { + guid: "emptyempty03", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "Empty", + }, + ], + }, + "Should apply 1 and dedupe L0 to 3" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Bug 747699. +add_task(async function test_duping_mobile_bookmarks() { + let buf = await openMirror("duping_mobile_bookmarks"); + + info("Set up empty mirror with localized mobile root title"); + let mobileInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.mobileGuid + ); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.mobileGuid, + title: "Favoritos do celular", + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAA1", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + title: "A", + url: "http://example.com/a", + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.mobileGuid], + "Should leave mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["mobile"], + deleted: [], + }, + "Should not upload records after applying deduped mobile bookmark" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "Favoritos do celular", + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "Should dedupe A1 to A with different parent title" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + // Restore the original mobile root title. + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.mobileGuid, + title: mobileInfo.title, + }); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_invalid() { + // To check if invalid items are prevented from deduping + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A", + url: "http://example.com/a", + }, + ], + }); + + let buf = await openMirror("duping_invalid"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAA2"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + // Invalidate bookmarkAAA2 so that it does not dedupe to bookmarkAAA1 + await buf.db.execute( + `UPDATE items SET + validity = :validity + WHERE guid = :guid`, + { + validity: Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE, + guid: "bookmarkAAA2", + } + ); + + let changesToUpload = await buf.apply(); + deepEqual( + changesToUpload.menu.cleartext.children, + ["bookmarkAAA1"], + "Should upload A1 in menu" + ); + ok( + !changesToUpload.bookmarkAAA1.tombstone, + "Should not upload tombstone for A1" + ); + ok(changesToUpload.bookmarkAAA2.tombstone, "Should upload tombstone for A2"); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAA1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "No deduping of invalid items" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_deletion.js b/toolkit/components/places/tests/sync/test_bookmark_deletion.js new file mode 100644 index 0000000000..fd29252e74 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_deletion.js @@ -0,0 +1,1602 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_complex_orphaning() { + let now = Date.now(); + + let mergeTelemetryCounts; + let buf = await openMirror("complex_orphaning", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + // On iOS, the mirror exists as a separate table. On Desktop, we have a + // shadow mirror of synced local bookmarks without new changes. + info("Set up mirror: ((Toolbar > A > B) (Menu > G > C > D))"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + }, + ]), + { needsMerge: false } + ); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderGGGGGG", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "G", + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderGGGGGG"], + }, + { + id: "folderGGGGGG", + parentid: "menu", + type: "folder", + title: "G", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "folderGGGGGG", + type: "folder", + title: "C", + children: ["folderDDDDDD"], + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + title: "D", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete D, add B > E"); + await PlacesUtils.bookmarks.remove("folderDDDDDD"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderBBBBBB", + title: "E", + url: "http://example.com/e", + }); + + info("Make remote changes: delete B, add D > F"); + await storeRecords( + buf, + shuffle([ + { + id: "folderBBBBBB", + deleted: true, + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + children: ["bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "folderAAAAAA", "folderDDDDDD"], + "Should leave deleted D; A and F with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 10 }, + { name: "localDeletes", count: 1 }, + { name: "remoteDeletes", count: 1 }, + ], + "Should record telemetry with structure change counts" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkEEEE", "bookmarkFFFF", "folderAAAAAA", "folderCCCCCC"], + deleted: ["folderDDDDDD"], + }, + "Should upload new records for (A > E), (C > F); tombstone for D" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderGGGGGG", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "G", + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "C", + children: [ + { + // D was deleted, so F moved to C, the closest surviving parent. + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + // B was deleted, so E moved to A. + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move orphans to closest surviving parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderDDDDDD"], + "Should store local tombstone for D" + ); + Assert.ok( + is_time_ordered(now, tombstones[0].dateRemoved.getTime()), + "Tombstone timestamp should be recent" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_locally_modified_remotely_deleted() { + let mergeTelemetryCounts; + let buf = await openMirror("locally_modified_remotely_deleted", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "folderDDDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: change A; B > ((D > F) G)"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkAAAA", + title: "A (local)", + url: "http://example.com/a-local", + }); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkFFFF", + parentGuid: "folderDDDDDD", + title: "F (local)", + url: "http://example.com/f-local", + }); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkGGGG", + parentGuid: "folderBBBBBB", + title: "G (local)", + url: "http://example.com/g-local", + }); + + info("Make remote changes: delete A, B"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "bookmarkAAAA", + deleted: true, + }, + { + id: "folderBBBBBB", + deleted: true, + }, + { + id: "bookmarkCCCC", + deleted: true, + }, + { + id: "folderDDDDDD", + deleted: true, + }, + { + id: "bookmarkEEEE", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA", PlacesUtils.bookmarks.menuGuid], + "Should leave revived A and menu with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 8 }, + { name: "localRevives", count: 1 }, + { name: "remoteDeletes", count: 2 }, + ], + "Should record telemetry for local item and remote folder deletions" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkAAAA", "bookmarkFFFF", "bookmarkGGGG", "menu"], + deleted: [], + }, + "Should upload A, relocated local orphans, and menu" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (local)", + url: "http://example.com/a-local", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F (local)", + url: "http://example.com/f-local", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G (local)", + url: "http://example.com/g-local", + }, + ], + }, + "Should restore A and relocate (F G) to menu" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_locally_deleted_remotely_modified() { + let now = Date.now(); + + let mergeTelemetryCounts; + let buf = await openMirror("locally_deleted_remotely_modified", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "folderDDDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete A, B"); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + await PlacesUtils.bookmarks.remove("folderBBBBBB"); + + info("Make remote changes: change A; B > ((D > F) G)"); + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + bmkUri: "http://example.com/a-remote", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B (remote)", + children: ["bookmarkCCCC", "folderDDDDDD", "bookmarkGGGG"], + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F (remote)", + bmkUri: "http://example.com/f-remote", + }, + { + id: "bookmarkGGGG", + parentid: "folderBBBBBB", + type: "bookmark", + title: "G (remote)", + bmkUri: "http://example.com/g-remote", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "bookmarkGGGG", "folderBBBBBB", "folderDDDDDD"], + "Should leave deleted B and D; relocated F and G unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 8 }, + { name: "remoteRevives", count: 1 }, + { name: "localDeletes", count: 2 }, + ], + "Should record telemetry for remote item and local folder deletions" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkFFFF", "bookmarkGGGG", "menu"], + deleted: ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"], + }, + "Should upload relocated remote orphans and menu" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (remote)", + url: "http://example.com/a-remote", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F (remote)", + url: "http://example.com/f-remote", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G (remote)", + url: "http://example.com/g-remote", + }, + ], + }, + "Should restore A and relocate (F G) to menu" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"], + "Should store local tombstones for deleted items; remove for undeleted" + ); + Assert.ok( + tombstones.every(({ dateRemoved }) => + is_time_ordered(now, dateRemoved.getTime()) + ), + "Local tombstone timestamps should be recent" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_to_new_then_delete() { + let buf = await openMirror("move_to_new_then_delete"); + + info("Set up mirror: A > B > (C D)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "bookmarkDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkDDDD", + parentid: "folderBBBBBB", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: E > A, delete E"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "E", + }); + await PlacesUtils.bookmarks.update({ + guid: "folderAAAAAA", + parentGuid: "folderEEEEEE", + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + // E isn't synced, so we shouldn't upload a tombstone. + await PlacesUtils.bookmarks.remove("folderEEEEEE"); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkCCCC", PlacesUtils.bookmarks.toolbarGuid], + "Should leave revived C and toolbar with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "menu", "toolbar"], + deleted: ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"], + }, + "Should upload records for Menu > C, Toolbar" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move C to closest surviving parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"], + "Should store local tombstones for (D A B)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_nonexistent_on_one_side() { + let buf = await openMirror("nonexistent_on_one_side"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // A doesn't exist in the mirror. + info("Create local tombstone for nonexistent remote item A"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "A", + url: "http://example.com/a", + // Pretend a bookmark restore added A, so that we'll write a tombstone when + // we remove it. + source: PlacesUtils.bookmarks.SOURCES.RESTORE, + }); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + + // B doesn't exist in Places, and we don't currently persist tombstones (bug + // 1343103), so we should ignore it. + info("Create remote tombstone for nonexistent local item B"); + await storeRecords(buf, [ + { + id: "bookmarkBBBB", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + + // We should still upload a record for the menu, since we changed its + // children when we added then removed A. + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: BookmarksMenuTitle, + children: [], + }, + }, + }); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_clear_folder_then_delete() { + let buf = await openMirror("clear_folder_then_delete"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: Menu > E, Mobile > F, delete D"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkEEEE", + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkFFFF", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + index: 0, + }); + await PlacesUtils.bookmarks.remove("folderDDDDDD"); + + info("Make remote changes: Menu > B, Unfiled > C, delete A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB", "folderDDDDDD"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkCCCC", + parentid: "unfiled", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.mobileGuid], + "Should leave menu and mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkEEEE", "bookmarkFFFF", "menu", "mobile"], + deleted: ["folderDDDDDD"], + }, + "Should upload locally moved and deleted items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + "Should not orphan moved children of a deleted folder" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderDDDDDD"], + "Should store local tombstone for D" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_move_to_deleted() { + let buf = await openMirror("test_newer_move_to_deleted"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD"], + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let now = Date.now(); + + // A will have a newer local timestamp. However, we should *not* revert + // remotely moving B to the toolbar. (Locally, B exists in A, but we + // deleted the now-empty A remotely). + info("Make local changes: A > E, Toolbar > D, delete C"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + title: "E", + url: "http://example.com/e", + dateAdded: new Date(now), + lastModified: new Date(now), + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkDDDD", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + lastModified: new Date(now), + }); + await PlacesUtils.bookmarks.remove("folderCCCCCC"); + + // C will have a newer remote timestamp. However, we should *not* revert + // locally moving D to the toolbar. (Locally, D exists in C, but we + // deleted the now-empty C locally). + info("Make remote changes: C > F, Toolbar > B, delete A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "bookmarkFFFF"], + modified: now / 1000 + 5, + }, + { + id: "bookmarkFFFF", + parentid: "folderCCCCCC", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + modified: now / 1000 - 5, + }, + { + id: "bookmarkBBBB", + parentid: "toolbar", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: now / 1000 - 5, + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkFFFF", + "folderCCCCCC", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + ], + "Should leave deleted C; revived F and roots with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkDDDD", + "bookmarkEEEE", + "bookmarkFFFF", + "menu", + "toolbar", + ], + deleted: ["folderCCCCCC"], + }, + "Should upload new and moved items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not decide to keep newly moved items in deleted parents" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderCCCCCC"], + "Should store local tombstone for C" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remotely_deleted_also_removes_keyword() { + let buf = await openMirror("remotely_deleted_removes_keyword"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "keyworda", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "keywordb", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "keyworda", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "keywordb", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Validate the keywords exists + let has_keyword_a = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + Assert.equal(has_keyword_a.keyword, "keyworda"); + + let has_keyword_b = await PlacesUtils.keywords.fetch({ + url: "http://example.com/b", + }); + Assert.equal(has_keyword_b.keyword, "keywordb"); + + info("Make remote changes: delete A & B"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "bookmarkAAAA", + deleted: true, + }, + { + id: "bookmarkBBBB", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "No local changes done" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + }, + "Should've remove A & B from menu" + ); + + // Validate the keyword no longer exists after removing the bookmark + let no_keyword_a = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + Assert.equal(no_keyword_a, null); + + // Both keywords should've been removed after the sync + let no_keyword_b = await PlacesUtils.keywords.fetch({ + url: "http://example.com/b", + }); + Assert.equal(no_keyword_b, null); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_haschanges.js b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js new file mode 100644 index 0000000000..32cfd050aa --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js @@ -0,0 +1,228 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_no_changes() { + let buf = await openMirror("nochanges"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(!wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_remote() { + let buf = await openMirror("remote_changes"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await storeRecords( + buf, + [ + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "New Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ], + { needsMerge: true } + ); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_local() { + let buf = await openMirror("local_changes"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.update({ + guid: "mozBmk______", + title: "New Mozilla!", + }); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_deleted_bookmark() { + let buf = await openMirror("delete_bookmark"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid) + ); + await PlacesUtils.bookmarks.remove("mozBmk______"); + + await wait; + // Wait for everything to be finished + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_kinds.js b/toolkit/components/places/tests/sync/test_bookmark_kinds.js new file mode 100644 index 0000000000..3372757532 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_kinds.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_queries() { + let buf = await openMirror("queries"); + + info("Set up places"); + + // create a tag and grab the local folder ID. + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "a-tag", + }); + + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // this entry has a tag= query param for a tag that exists. + guid: "queryAAAAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY query", + url: `place:tag=a-tag&&sort=14&maxResults=10`, + }, + { + // this entry has a tag= query param for a tag that doesn't exist. + guid: "queryBBBBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY query but invalid folder id", + url: `place:tag=b-tag&sort=14&maxResults=10`, + }, + { + // this entry has no tag= query param. + guid: "queryCCCCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY without a folder at all", + url: "place:sort=14&maxResults=10", + }, + { + // this entry has only a tag= query. + guid: "queryDDDDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY without a folder at all", + url: "place:tag=a-tag", + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [ + "queryEEEEEEE", + "queryFFFFFFF", + "queryGGGGGGG", + "queryHHHHHHH", + "queryIIIIIII", + ], + }, + { + // Legacy tag query. + id: "queryEEEEEEE", + parentid: "toolbar", + type: "query", + title: "E", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + // New tag query. + id: "queryFFFFFFF", + parentid: "toolbar", + type: "query", + title: "F", + bmkUri: "place:tag=a-tag", + folderName: "a-tag", + }, + { + // Legacy tag query referencing the same tag as the new query. + id: "queryGGGGGGG", + parentid: "toolbar", + type: "query", + title: "G", + bmkUri: "place:type=7&folder=111&something=else", + folderName: "a-tag", + }, + { + // Legacy folder lookup query. + id: "queryHHHHHHH", + parentid: "toolbar", + type: "query", + title: "H", + bmkUri: "place:folder=1", + }, + { + // Legacy tag query with invalid tag folder name. + id: "queryIIIIIII", + parentid: "toolbar", + type: "query", + title: "I", + bmkUri: "place:type=7&folder=222", + folderName: " ", + }, + ]) + ); + + info("Create records to upload"); + let changes = await buf.apply(); + deepEqual( + Object.keys(changes), + [ + "menu", + "toolbar", + "queryAAAAAAA", + "queryBBBBBBB", + "queryCCCCCCC", + "queryDDDDDDD", + "queryEEEEEEE", + "queryGGGGGGG", + "queryHHHHHHH", + "queryIIIIIII", + ], + "Should upload roots, new queries, and rewritten queries" + ); + Assert.strictEqual(changes.queryAAAAAAA.cleartext.folderName, tag.title); + Assert.strictEqual(changes.queryBBBBBBB.cleartext.folderName, "b-tag"); + Assert.strictEqual(changes.queryCCCCCCC.cleartext.folderName, undefined); + Assert.strictEqual(changes.queryDDDDDDD.cleartext.folderName, tag.title); + Assert.strictEqual(changes.queryIIIIIII.tombstone, true); + + await assertLocalTree( + PlacesUtils.bookmarks.toolbarGuid, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "queryEEEEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "place:tag=taggy", + }, + { + guid: "queryFFFFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "place:tag=a-tag", + }, + { + guid: "queryGGGGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G", + url: "place:tag=a-tag", + }, + { + guid: "queryHHHHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "H", + url: "place:folder=1&excludeItems=1", + }, + ], + }, + "Should rewrite legacy remote queries" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_different_but_compatible_bookmark_types() { + let buf = await openMirror("partial_queries"); + try { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "not yet a query", + url: "about:blank", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "a query", + url: "place:foo", + }, + ], + }); + + let changes = await buf.apply(); + // We should have an outgoing record for bookmarkA with type=bookmark + // and bookmarkB with type=query. + Assert.equal(changes.bookmarkAAAA.cleartext.type, "bookmark"); + Assert.equal(changes.bookmarkBBBB.cleartext.type, "query"); + + // Now pretend that same records are already on the server. + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "not yet a query", + bmkUri: "about:blank", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "query", + title: "a query", + bmkUri: "place:foo", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // change the url of bookmarkA to be a "real" query and of bookmarkB to + // no longer be a query. + await PlacesUtils.bookmarks.update({ + guid: "bookmarkAAAA", + url: "place:type=6&sort=14&maxResults=10", + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkBBBB", + url: "about:robots", + }); + + changes = await buf.apply(); + // We should have an outgoing record for bookmarkA with type=query and + // for bookmarkB with type=bookmark + Assert.equal(changes.bookmarkAAAA.cleartext.type, "query"); + Assert.equal(changes.bookmarkBBBB.cleartext.type, "bookmark"); + } finally { + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); + } +}); + +add_task(async function test_incompatible_types() { + try { + let buf = await openMirror("incompatible_types"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "AAAAAAAAAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "a bookmark", + url: "about:blank", + }, + ], + }); + + await buf.apply(); + + // Now pretend that same records are already on the server with incompatible + // types. + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["AAAAAAAAAAAA"], + }, + { + id: "AAAAAAAAAAAA", + parentid: "menu", + type: "folder", + title: "conflicting folder", + }, + ], + { needsMerge: true } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await Assert.rejects( + buf.apply(), + /Can't merge local Bookmark <guid: AAAAAAAAAAAA> and remote Folder <guid: AAAAAAAAAAAA>/ + ); + } finally { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); + } +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js new file mode 100644 index 0000000000..6c475daab6 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js @@ -0,0 +1,193 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_highWaterMark() { + let buf = await openMirror("highWaterMark"); + + strictEqual( + await buf.getCollectionHighWaterMark(), + 0, + "High water mark should be 0 without items" + ); + + await buf.setCollectionLastModified(123.45); + equal( + await buf.getCollectionHighWaterMark(), + 123.45, + "High water mark should be last modified time without items" + ); + + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + modified: 50, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + modified: 123.95, + }, + ]); + equal( + await buf.getCollectionHighWaterMark(), + 123.45, + "High water mark should be last modified time if items are older" + ); + + await storeRecords(buf, [ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + modified: 125.45, + }, + ]); + equal( + await buf.getCollectionHighWaterMark(), + 124.45, + "High water mark should be modified time - 1s of newest record if exists" + ); + + await buf.finalize(); +}); + +add_task(async function test_ensureCurrentSyncId() { + let buf = await openMirror("ensureCurrentSyncId"); + + await buf.ensureCurrentSyncId("syncIdAAAAAA"); + equal( + await buf.getCollectionHighWaterMark(), + 0, + "High water mark should be 0 after setting sync ID" + ); + + info("Insert items and set collection last modified"); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + modified: 125.45, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + children: [], + }, + ], + { needsMerge: false } + ); + await buf.setCollectionLastModified(123.45); + + info("Set matching sync ID"); + await buf.ensureCurrentSyncId("syncIdAAAAAA"); + { + equal( + await buf.getSyncId(), + "syncIdAAAAAA", + "Should return existing sync ID" + ); + strictEqual( + await buf.getCollectionHighWaterMark(), + 124.45, + "Different sync ID should reset high water mark" + ); + + let itemRows = await buf.db.execute(` + SELECT guid, needsMerge FROM items + ORDER BY guid`); + let itemInfos = itemRows.map(row => ({ + guid: row.getResultByName("guid"), + needsMerge: !!row.getResultByName("needsMerge"), + })); + deepEqual( + itemInfos, + [ + { + guid: "folderAAAAAA", + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.menuGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.rootGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + needsMerge: true, + }, + ], + "Matching sync ID should not reset items" + ); + } + + info("Set different sync ID"); + await buf.ensureCurrentSyncId("syncIdBBBBBB"); + { + equal( + await buf.getSyncId(), + "syncIdBBBBBB", + "Should replace existing sync ID" + ); + strictEqual( + await buf.getCollectionHighWaterMark(), + 0, + "Different sync ID should reset high water mark" + ); + + let itemRows = await buf.db.execute(` + SELECT guid, needsMerge FROM items + ORDER BY guid`); + let itemInfos = itemRows.map(row => ({ + guid: row.getResultByName("guid"), + needsMerge: !!row.getResultByName("needsMerge"), + })); + deepEqual( + itemInfos, + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.rootGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + needsMerge: true, + }, + ], + "Different sync ID should reset items" + ); + } +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js new file mode 100644 index 0000000000..9951983f78 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js @@ -0,0 +1,228 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Keep in sync with `SyncedBookmarksMirror.jsm`. +const CURRENT_MIRROR_SCHEMA_VERSION = 8; + +// The oldest schema version that we support. Any databases with schemas older +// than this will be dropped and recreated. +const OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION = 5; + +async function getIndexNames(db, table, schema = "mirror") { + let rows = await db.execute(`PRAGMA ${schema}.index_list(${table})`); + let names = []; + for (let row of rows) { + // Column 4 is `c` if the index was created via `CREATE INDEX`, `u` if + // via `UNIQUE`, and `pk` if via `PRIMARY KEY`. + let wasCreated = row.getResultByIndex(3) == "c"; + if (wasCreated) { + // Column 2 is the name of the index. + names.push(row.getResultByIndex(1)); + } + } + return names.sort(); +} + +add_task(async function test_migrate_after_downgrade() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let dbFile = await setupFixtureFile("mirror_v5.sqlite"); + let oldBuf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + info("Downgrade schema version to oldest supported"); + await oldBuf.db.setSchemaVersion( + OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION, + "mirror" + ); + await oldBuf.finalize(); + + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + // All migrations between `OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION` should + // be idempotent. When we downgrade, we roll back the schema version, but + // leave the schema changes in place, since we can't anticipate what a + // future version will change. + let schemaVersion = await buf.db.getSchemaVersion("mirror"); + equal( + schemaVersion, + CURRENT_MIRROR_SCHEMA_VERSION, + "Should upgrade downgraded mirror schema" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Migrations between 5 and 7 add three indexes. +add_task(async function test_migrate_from_5_to_current() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let dbFile = await setupFixtureFile("mirror_v5.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + let schemaVersion = await buf.db.getSchemaVersion("mirror"); + equal( + schemaVersion, + CURRENT_MIRROR_SCHEMA_VERSION, + "Should upgrade mirror schema to current version" + ); + + let itemsIndexNames = await getIndexNames(buf.db, "items"); + deepEqual( + itemsIndexNames, + ["itemKeywords", "itemURLs"], + "Should add two indexes on items" + ); + + let structureIndexNames = await getIndexNames(buf.db, "structure"); + deepEqual( + structureIndexNames, + ["structurePositions"], + "Should add an index on structure" + ); + + let changesToUpload = await buf.apply(); + deepEqual(changesToUpload, {}, "Shouldn't flag any items for reupload"); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + keyword: "hi", + }, + ], + }, + "Should apply mirror tree after migrating" + ); + + let keywordEntry = await PlacesUtils.keywords.fetch("hi"); + equal( + keywordEntry.url.href, + "http://example.com/b", + "Should apply keyword from migrated mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Migrations between 1 and 2 discard the entire database. +add_task(async function test_migrate_from_1_to_2() { + let dbFile = await setupFixtureFile("mirror_v1.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + }); + ok( + buf.wasCorrupt, + "Migrating from unsupported version should mark database as corrupt" + ); + await buf.finalize(); +}); + +add_task(async function test_database_corrupt() { + let corruptFile = await setupFixtureFile("mirror_corrupt.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: corruptFile.path, + }); + ok(buf.wasCorrupt, "Opening corrupt database should mark it as such"); + await buf.finalize(); +}); + +add_task(async function test_migrate_v8() { + let buf = await openMirror("test_migrate_v8"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + await buf.db.execute( + `UPDATE moz_bookmarks + SET syncChangeCounter = 0, + syncStatus = ${PlacesUtils.bookmarks.SYNC_STATUS.NEW}` + ); + + // setup the mirror. + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + ]); + + await buf.db.setSchemaVersion(7, "mirror"); + await buf.finalize(); + + // reopen it. + buf = await openMirror("test_migrate_v8"); + Assert.equal(await buf.db.getSchemaVersion("mirror"), 8, "did upgrade"); + + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + "bookmarkAAAA", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid + ); + let [fieldsA, fieldsB, fieldsMenu] = fields; + + // 'A' was in the mirror - should now be _NORMAL + Assert.equal(fieldsA.guid, "bookmarkAAAA"); + Assert.equal(fieldsA.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL); + // 'B' was not in the mirror so should be untouched. + Assert.equal(fieldsB.guid, "bookmarkBBBB"); + Assert.equal(fieldsB.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NEW); + // 'menu' was in the mirror - should now be _NORMAL + Assert.equal(fieldsMenu.guid, PlacesUtils.bookmarks.menuGuid); + Assert.equal(fieldsMenu.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL); + await buf.finalize(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js new file mode 100644 index 0000000000..a1dce86b2c --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js @@ -0,0 +1,637 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function promiseAllURLFrecencies() { + let frecencies = new Map(); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT url, frecency, recalc_frecency + FROM moz_places + WHERE url_hash BETWEEN hash('http', 'prefix_lo') AND + hash('http', 'prefix_hi')`); + for (let row of rows) { + frecencies.set(row.getResultByName("url"), { + frecency: row.getResultByName("frecency"), + recalc: row.getResultByName("recalc_frecency"), + }); + } + return frecencies; +} + +function mapFilterIterator(iter, fn) { + let results = []; + for (let value of iter) { + let newValue = fn(value); + if (newValue) { + results.push(newValue); + } + } + return results; +} + +add_task(async function test_update_frecencies() { + let buf = await openMirror("update_frecencies"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Not modified in mirror; shouldn't recalculate frecency. + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + // URL changed to B1 in mirror; should recalculate frecency for B + // and B1, using existing frecency to determine order. + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + { + // URL changed to new URL in mirror, should recalculate frecency + // for new URL first, before B1. + guid: "bookmarkBBB1", + title: "B1", + url: "http://example.com/b1", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b1", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Query; shouldn't recalculate frecency. + guid: "queryCCCCCCC", + title: "C", + url: "place:type=6&sort=14&maxResults=10", + }, + ], + }); + + info("Calculate frecencies for all local URLs"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBB2", + "bookmarkDDDD", + "bookmarkEEEE", + "queryFFFFFFF", + ], + }, + { + // Existing bookmark changed to existing URL. + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b1", + }, + { + // Existing bookmark with new URL; should recalculate frecency first. + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b11", + }, + { + id: "bookmarkBBB2", + parentid: "unfiled", + type: "bookmark", + title: "B2", + bmkUri: "http://example.com/b", + }, + { + // New bookmark with new URL; should recalculate frecency first. + id: "bookmarkDDDD", + parentid: "unfiled", + type: "bookmark", + title: null, + bmkUri: "http://example.com/d", + }, + { + // New bookmark with new URL. + id: "bookmarkEEEE", + parentid: "unfiled", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + // New query; shouldn't count against limit. + id: "queryFFFFFFF", + parentid: "unfiled", + type: "query", + title: "F", + bmkUri: `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + }, + ]); + + info("Apply new items and recalculate 3 frecencies"); + await buf.apply(); + await PlacesFrecencyRecalculator.recalculateSomeFrecencies({ chunkSize: 3 }); + + { + let frecencies = await promiseAllURLFrecencies(); + let urlsWithFrecency = mapFilterIterator( + frecencies.entries(), + ([href, { frecency, recalc }]) => (recalc == 0 ? href : null) + ); + + // A is unchanged, and we should recalculate frecency for three more + // random URLs. + equal( + urlsWithFrecency.length, + 4, + "Should keep unchanged frecency and recalculate 3" + ); + let unexpectedURLs = CommonUtils.difference( + urlsWithFrecency, + new Set([ + // A is unchanged. + "http://example.com/a", + + // B11, D, and E are new URLs. + "http://example.com/b11", + "http://example.com/d", + "http://example.com/e", + + // B and B1 are existing, changed URLs. + "http://example.com/b", + "http://example.com/b1", + ]) + ); + ok( + !unexpectedURLs.size, + "Should recalculate frecency for new and changed URLs only" + ); + } + + info("Change non-URL property of D"); + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "unfiled", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d", + }, + ]); + + info("Apply new item and recalculate remaining frecencies"); + await buf.apply(); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + { + let frecencies = await promiseAllURLFrecencies(); + let urlsWithoutFrecency = mapFilterIterator( + frecencies.entries(), + ([href, { frecency, recalc }]) => (recalc == 1 ? href : null) + ); + deepEqual( + urlsWithoutFrecency, + [], + "Should finish calculating remaining frecencies" + ); + } + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +async function setupLocalTree(localTimeSeconds) { + let dateAdded = new Date(localTimeSeconds * 1000); + let lastModified = new Date(localTimeSeconds * 1000); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded, + lastModified, + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + dateAdded, + lastModified, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded, + lastModified, + }, + ], + }, + { + guid: "bookmarkDDDD", + title: null, + url: "http://example.com/d", + dateAdded, + lastModified, + }, + ], + }); +} + +// This test ensures we clean up the temp tables between merges, and don't throw +// constraint errors recording observer notifications. +add_task(async function test_apply_then_revert() { + let buf = await openMirror("apply_then_revert"); + + let now = Date.now() / 1000; + let localTimeSeconds = now - 180; + + info("Set up initial local tree and mirror"); + await setupLocalTree(localTimeSeconds); + let recordsToUpload = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + }); + await storeChangesInMirror(buf, recordsToUpload); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEE1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "E", + url: "http://example.com/e", + dateAdded: new Date(localTimeSeconds * 1000), + lastModified: new Date(localTimeSeconds * 1000), + }); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "bookmarkFFFF"], + modified: now, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + modified: now, + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A (remote)", + children: ["bookmarkCCCC", "bookmarkBBBB"], + modified: now, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b-remote", + modified: now, + }, + { + id: "bookmarkDDDD", + deleted: true, + modified: now, + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + modified: now, + }, + { + id: "bookmarkFFFF", + parentid: "menu", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + modified: now, + }, + ]); + + info("Apply remote changes, first time"); + let firstTimeRecords = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged after first time" + ); + + info("Revert local tree"); + await PlacesSyncUtils.bookmarks.wipe(); + await setupLocalTree(localTimeSeconds); + await PlacesTestUtils.markBookmarksAsSynced(); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEE1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "E", + url: "http://example.com/e", + dateAdded: new Date(localTimeSeconds * 1000), + lastModified: new Date(localTimeSeconds * 1000), + }); + let localIdForD = await PlacesUtils.promiseItemId("bookmarkDDDD"); + + info("Apply remote changes, second time"); + await buf.db.execute( + ` + UPDATE items SET + needsMerge = 1 + WHERE guid <> :rootGuid`, + { rootGuid: PlacesUtils.bookmarks.rootGuid } + ); + let observer = expectBookmarkChangeNotifications(); + let secondTimeRecords = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged after second time" + ); + deepEqual( + secondTimeRecords, + firstTimeRecords, + "Should stage identical records to upload, first and second time" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkFFFF", + "bookmarkEEEE", + "folderAAAAAA", + "bookmarkCCCC", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid, + ]); + observer.check([ + { + name: "bookmark-removed", + params: { + itemId: localIdForD, + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/d", + title: "", // null titles get turned into empty strings. + guid: "bookmarkDDDD", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-guid-changed", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "", + guid: "bookmarkEEEE", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkFFFF"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/f", + title: "F", + guid: "bookmarkFFFF", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 2, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("folderAAAAAA"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "folderAAAAAA", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b-remote", + isTagging: false, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("folderAAAAAA"), + title: "A (remote)", + guid: "folderAAAAAA", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }, + }, + { + name: "bookmark-url-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/b-remote", + guid: "bookmarkBBBB", + parentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A (remote)", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should apply new structure, second time" + ); + + await storeChangesInMirror(buf, secondTimeRecords); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_reconcile.js b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js new file mode 100644 index 0000000000..218e84beb6 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js @@ -0,0 +1,191 @@ +// Get bookmarks which aren't marked as normally syncing and with no pending +// changes. +async function getBookmarksNotMarkedAsSynced() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + ` + SELECT guid, syncStatus, syncChangeCounter FROM moz_bookmarks + WHERE syncChangeCounter > 1 OR syncStatus != :syncStatus + ORDER BY guid + `, + { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } + ); + return rows.map(row => { + return { + guid: row.getResultByName("guid"), + syncStatus: row.getResultByName("syncStatus"), + syncChangeCounter: row.getResultByName("syncChangeCounter"), + }; + }); +} + +add_task(async function test_reconcile_metadata() { + let buf = await openMirror("test_reconcile_metadata"); + + let olderDate = new Date(Date.now() - 100000); + info("Set up local tree"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // this folder is going to reconcile exactly + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + { + // this folder's existing child isn't on the server (so will be + // outgoing) and also will take a new child from the server. + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + // This bookmark is going to take the remote title. + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "f", + dateAdded: olderDate, + lastModified: olderDate, + }, + ], + }); + // And a single, local-only bookmark in the toolbar. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkTTTT", + url: "http://example.com/t", + title: "in the toolbar", + dateAdded: olderDate, + lastModified: olderDate, + }, + ], + }); + // Reset to prepare for our reconciled sync. + await PlacesSyncUtils.bookmarks.reset(); + // setup the mirror. + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"], + modified: Date.now() / 1000 - 60, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: Date.now() / 1000 - 60, + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkFFFF", + parentid: "menu", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + dateAdded: olderDate, + modified: Date.now() / 1000 + 60, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + index: 1, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + index: 3, + }, + ]) + ); + info("Applying"); + let changesToUpload = await buf.apply(); + // We need to upload a bookmark and the parent as they didn't exist on the + // server. Since we always use the local state for roots (bug 1472241), we'll + // reupload them too. + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkEEEE", + "bookmarkTTTT", + "folderCCCCCC", + "menu", + "mobile", + "toolbar", + "unfiled", + ], + deleted: [], + }, + "Should upload the 2 local-only bookmarks and their parents" + ); + // Check it took the remote thing we were expecting. + Assert.equal((await PlacesUtils.bookmarks.fetch("bookmarkFFFF")).title, "F"); + // Most things should be synced and have no change counter. + let badGuids = await getBookmarksNotMarkedAsSynced(); + Assert.deepEqual(badGuids, [ + { + // The bookmark that was only on the server. Still have SYNC_STATUS_NEW + // as it's yet to be uploaded. + guid: "bookmarkEEEE", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + syncChangeCounter: 1, + }, + { + // This bookmark is local only so is yet to be uploaded. + guid: "bookmarkTTTT", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + syncChangeCounter: 1, + }, + ]); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js new file mode 100644 index 0000000000..fd4f3fdb6e --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js @@ -0,0 +1,2870 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_value_structure_conflict() { + let buf = await openMirror("value_structure_conflict"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD"], + modified: Date.now() / 1000 - 60, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + modified: Date.now() / 1000 - 60, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + modified: Date.now() / 1000 - 60, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local value change"); + await PlacesUtils.bookmarks.update({ + guid: "folderAAAAAA", + title: "A (local)", + }); + + info("Make local structure change"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkBBBB", + parentGuid: "folderDDDDDD", + index: 0, + }); + + info("Make remote value change"); + await storeRecords(buf, [ + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D (remote)", + children: ["bookmarkEEEE"], + modified: Date.now() / 1000 + 60, + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: Date.now() / 1000, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderDDDDDD"], + "Should leave D with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "folderAAAAAA", "folderDDDDDD"], + deleted: [], + }, + "Should upload records for merged and new local items" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "folderAAAAAA", + "bookmarkEEEE", + "bookmarkBBBB", + "folderDDDDDD", + ]); + observer.check([ + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: "folderDDDDDD", + newParentGuid: "folderDDDDDD", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderDDDDDD", + newParentGuid: "folderDDDDDD", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("folderDDDDDD"), + title: "D (remote)", + guid: "folderDDDDDD", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A (local)", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "D (remote)", + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }, + "Should reconcile structure and value changes" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move() { + let buf = await openMirror("move"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "devFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Dev", + children: [ + { + guid: "mdnBmk______", + title: "MDN", + url: "https://developer.mozilla.org", + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "mozFolder___", + title: "Mozilla", + children: [ + { + guid: "fxBmk_______", + title: "Get Firefox!", + url: "http://getfirefox.com/", + }, + { + guid: "nightlyBmk__", + title: "Nightly", + url: "https://nightly.mozilla.org", + }, + ], + }, + { + guid: "wmBmk_______", + title: "Webmaker", + url: "https://webmaker.org", + }, + ], + }, + { + guid: "bzBmk_______", + title: "Bugzilla", + url: "https://bugzilla.mozilla.org", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + await storeRecords( + buf, + shuffle([ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["mozFolder___"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["devFolder___"], + }, + { + // Moving to toolbar. + id: "devFolder___", + parentid: "toolbar", + type: "folder", + title: "Dev", + children: ["bzBmk_______", "wmBmk_______"], + }, + { + // Moving to "Mozilla". + id: "mdnBmk______", + parentid: "mozFolder___", + type: "bookmark", + title: "MDN", + bmkUri: "https://developer.mozilla.org", + }, + { + // Rearranging children and moving to unfiled. + id: "mozFolder___", + parentid: "unfiled", + type: "folder", + title: "Mozilla", + children: ["nightlyBmk__", "mdnBmk______", "fxBmk_______"], + }, + { + id: "fxBmk_______", + parentid: "mozFolder___", + type: "bookmark", + title: "Get Firefox!", + bmkUri: "http://getfirefox.com/", + }, + { + id: "nightlyBmk__", + parentid: "mozFolder___", + type: "bookmark", + title: "Nightly", + bmkUri: "https://nightly.mozilla.org", + }, + { + id: "wmBmk_______", + parentid: "devFolder___", + type: "bookmark", + title: "Webmaker", + bmkUri: "https://webmaker.org", + }, + { + id: "bzBmk_______", + parentid: "devFolder___", + type: "bookmark", + title: "Bugzilla", + bmkUri: "https://bugzilla.mozilla.org", + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remotely moved items" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "devFolder___", + "mozFolder___", + "bzBmk_______", + "wmBmk_______", + "nightlyBmk__", + "mdnBmk______", + "fxBmk_______", + ]); + observer.check([ + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("devFolder___"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "devFolder___", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("mozFolder___"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "mozFolder___", + oldParentGuid: "devFolder___", + newParentGuid: PlacesUtils.bookmarks.unfiledGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bzBmk_______"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bzBmk_______", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: "devFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://bugzilla.mozilla.org/", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("wmBmk_______"), + oldIndex: 2, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "wmBmk_______", + oldParentGuid: "devFolder___", + newParentGuid: "devFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://webmaker.org/", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("nightlyBmk__"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "nightlyBmk__", + oldParentGuid: "mozFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://nightly.mozilla.org/", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("mdnBmk______"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "mdnBmk______", + oldParentGuid: "devFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://developer.mozilla.org/", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("fxBmk_______"), + oldIndex: 0, + newIndex: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "fxBmk_______", + oldParentGuid: "mozFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://getfirefox.com/", + isTagging: false, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "devFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Dev", + children: [ + { + guid: "bzBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "Bugzilla", + url: "https://bugzilla.mozilla.org/", + }, + { + guid: "wmBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "Webmaker", + url: "https://webmaker.org/", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "mozFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Mozilla", + children: [ + { + guid: "nightlyBmk__", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "Nightly", + url: "https://nightly.mozilla.org/", + }, + { + guid: "mdnBmk______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "MDN", + url: "https://developer.mozilla.org/", + }, + { + guid: "fxBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "Get Firefox!", + url: "http://getfirefox.com/", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move and reorder bookmarks to match remote" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_into_parent_sibling() { + // This test moves a bookmark that exists locally into a new folder that only + // exists remotely, and is a later sibling of the local parent. This ensures + // we set up the local structure before applying structure changes. + let buf = await openMirror("move_into_parent_sibling"); + + info("Set up mirror: Menu > A > B"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: Menu > (A (B > C))"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderCCCCCC", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only structure changes" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "folderCCCCCC", + "bookmarkBBBB", + "folderAAAAAA", + PlacesUtils.bookmarks.menuGuid, + ]); + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("folderCCCCCC"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + urlHref: "", + title: "C", + guid: "folderCCCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderCCCCCC", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "C", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }, + "Should set up local structure correctly" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_complex_move_with_additions() { + let mergeTelemetryCounts; + let buf = await openMirror("complex_move_with_additions", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts; + } + }, + }); + + info("Set up mirror: Menu > A > (B C)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local change: Menu > A > (B C D)"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkDDDD", + parentGuid: "folderAAAAAA", + title: "D (local)", + url: "http://example.com/d-local", + }); + + info("Make remote change: ((Menu > C) (Toolbar > A > (B E)))"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkEEEE"], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderAAAAAA"], + "Should leave A with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [{ name: "items", count: 10 }], + "Should record telemetry with structure change counts" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkDDDD", "folderAAAAAA"], + deleted: [], + }, + "Should upload new records for (A D)" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkEEEE", + "folderAAAAAA", + "bookmarkCCCC", + ]); + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + parentId: localItemIds.get("folderAAAAAA"), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/e", + title: "E", + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: "folderAAAAAA", + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("folderAAAAAA"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "folderAAAAAA", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + // We can guarantee child order (B E D), since we always walk remote + // children first, and the remote folder A record is newer than the + // local folder. If the local folder were newer, the order would be + // (D B E). + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "D (local)", + url: "http://example.com/d-local", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should take remote order and preserve local children" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_reorder_and_insert() { + let buf = await openMirror("reorder_and_insert"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkDDDD", "bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkDDDD", + parentid: "toolbar", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "bookmarkFFFF", + parentid: "toolbar", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let now = Date.now(); + + info("Make local changes: Reorder Menu, Toolbar > (G H)"); + await PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, [ + "bookmarkCCCC", + "bookmarkAAAA", + "bookmarkBBBB", + ]); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now), + lastModified: new Date(now), + }, + { + guid: "bookmarkHHHH", + url: "http://example.com/h", + title: "H", + dateAdded: new Date(now), + lastModified: new Date(now), + }, + ], + }); + + info("Make remote changes: Reorder Toolbar, Menu > (I J)"); + await storeRecords( + buf, + shuffle([ + { + // The server has a newer toolbar, so we should use the remote order (F D E) + // as the base, then append (G H). + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkFFFF", "bookmarkDDDD", "bookmarkEEEE"], + modified: now / 1000 + 5, + }, + { + // The server has an older menu, so we should use the local order (C A B) + // as the base, then append (I J). + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkIIII", + "bookmarkJJJJ", + ], + modified: now / 1000 - 5, + }, + { + id: "bookmarkIIII", + parentid: "menu", + type: "bookmark", + title: "I", + bmkUri: "http://example.com/i", + }, + { + id: "bookmarkJJJJ", + parentid: "menu", + type: "bookmark", + title: "J", + bmkUri: "http://example.com/j", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: now / 1000, + localTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.toolbarGuid], + "Should leave roots with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkGGGG", "bookmarkHHHH", "menu", "toolbar"], + deleted: [], + }, + "Should upload records for merged and new local items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + url: "http://example.com/c", + title: "C", + }, + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + url: "http://example.com/i", + title: "I", + }, + { + guid: "bookmarkJJJJ", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + url: "http://example.com/j", + title: "J", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + url: "http://example.com/f", + title: "F", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + url: "http://example.com/g", + title: "G", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + url: "http://example.com/h", + title: "H", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should use timestamps to decide base folder order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_remote_moves() { + let now = Date.now(); + let buf = await openMirror("newer_remote_moves"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info( + "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G" + ); + let localMoves = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + guid: "folderBBBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + }, + { + guid: "bookmarkCCCC", + parentGuid: "folderDDDDDD", + }, + { + guid: "bookmarkGGGG", + parentGuid: "folderHHHHHH", + }, + ]; + for (let { guid, parentGuid } of localMoves) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid, + index: 0, + lastModified: new Date(now - 2500), + }); + } + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.toolbarGuid, + ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + { lastModified: new Date(now - 2500) } + ); + + info( + "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C" + ); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // This is similar to H > C, explained below, except we'll always reupload + // the mobile root, because we always prefer the local state for roots. + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderBBBBBB"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderBBBBBB", + parentid: "unfiled", + type: "folder", + title: "B", + children: [], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // Reparenting an item uploads records for the item and its parent. + // The merger would still work if we only marked H as unmerged; we'd + // then use the remote state for H, and local state for C. Since C was + // changed locally, we'll reupload it, even though it didn't actually + // change. + id: "bookmarkCCCC", + parentid: "folderHHHHHH", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000, + children: ["bookmarkGGGG"], + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: [], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // Same as C above. + id: "bookmarkGGGG", + parentid: "folderDDDDDD", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave roots with new remote structure unmerged" + ); + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + // We took the remote structure for the roots, but they're still flagged as + // changed locally. Since we always use the local state for roots + // (bug 1472241), and can't distinguish between value and structure changes + // in Places (see the comment for F below), we'll reupload them. + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + children: ["folderDDDDDD"], + title: BookmarksMenuTitle, + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + children: ["bookmarkAAAA"], + title: MobileBookmarksTitle, + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + children: ["folderBBBBBB"], + title: UnfiledBookmarksTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + title: BookmarksToolbarTitle, + }, + }, + }, + "Should only reupload local roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "D", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "F", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "H", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "B", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + ], + }, + "Should use newer remote parents and order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_local_moves() { + let now = Date.now(); + let buf = await openMirror("newer_local_moves"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info( + "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G" + ); + let localMoves = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + guid: "folderBBBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + }, + { + guid: "bookmarkCCCC", + parentGuid: "folderDDDDDD", + }, + { + guid: "bookmarkGGGG", + parentGuid: "folderHHHHHH", + }, + ]; + for (let { guid, parentGuid } of localMoves) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid, + index: 0, + lastModified: new Date(now), + }); + } + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.toolbarGuid, + ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + { lastModified: new Date(now) } + ); + + info( + "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C" + ); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderBBBBBB"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderBBBBBB", + parentid: "unfiled", + type: "folder", + title: "B", + children: [], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkCCCC", + parentid: "folderHHHHHH", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: [], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkGGGG", + parentid: "folderDDDDDD", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkGGGG", + "folderBBBBBB", + "folderDDDDDD", + "folderFFFFFF", + "folderHHHHHH", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave items with new remote structure unmerged" + ); + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + // Reupload roots with new children. + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + children: ["folderDDDDDD"], + title: BookmarksMenuTitle, + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + children: ["folderBBBBBB"], + title: MobileBookmarksTitle, + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + children: ["bookmarkAAAA"], + title: UnfiledBookmarksTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + children: ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + title: BookmarksToolbarTitle, + }, + }, + // G moved to H from F, so F and H have new children, and we need + // to upload G for the new `parentid`. + folderFFFFFF: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderFFFFFF", + type: "folder", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: now - 5000, + children: [], + title: "F", + }, + }, + folderHHHHHH: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderHHHHHH", + type: "folder", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: now - 5000, + children: ["bookmarkGGGG"], + title: "H", + }, + }, + bookmarkGGGG: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkGGGG", + type: "bookmark", + parentid: "folderHHHHHH", + hasDupe: true, + parentName: "H", + dateAdded: now - 5000, + bmkUri: "http://example.com/g", + title: "G", + }, + }, + // C moved to D, so we need to reupload D (for `children`) and C + // (for `parentid`). + folderDDDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now - 5000, + children: ["bookmarkCCCC"], + title: "D", + }, + }, + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "folderDDDDDD", + hasDupe: true, + parentName: "D", + dateAdded: now - 5000, + bmkUri: "http://example.com/c", + title: "C", + }, + }, + // Reupload A with the new `parentid`. B moved to mobile *and* has + // new children` so we should upload it, anyway. + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: now - 5000, + bmkUri: "http://example.com/a", + title: "A", + }, + }, + folderBBBBBB: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "folderBBBBBB", + type: "folder", + parentid: "mobile", + hasDupe: true, + parentName: MobileBookmarksTitle, + dateAdded: now - 5000, + children: [], + title: "B", + }, + }, + }, + "Should reupload new local structure" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "D", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "H", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "F", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "B", + }, + ], + }, + ], + }, + "Should use newer local parents and order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_unchanged_newer_changed_older() { + let buf = await openMirror("unchanged_newer_changed_older"); + let modified = new Date(Date.now() - 5000); + + info("Set up mirror"); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.menuGuid, + dateAdded: new Date(modified.getTime() - 5000), + }); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.toolbarGuid, + dateAdded: new Date(modified.getTime() - 5000), + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "bookmarkBBBB"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderCCCCCC", "bookmarkDDDD"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "folderCCCCCC", + parentid: "toolbar", + type: "folder", + title: "C", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "bookmarkDDDD", + parentid: "toolbar", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Even though the local menu is newer (local = 5s, remote = 9s; adding E + // updated the modified times of A and the menu), it's not *changed* locally, + // so we should merge remote children first. + info("Add A > E locally with newer time; delete A remotely with older time"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + url: "http://example.com/e", + title: "E", + index: 0, + dateAdded: new Date(modified.getTime() + 5000), + lastModified: new Date(modified.getTime() + 5000), + }); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 1, + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + // Even though the remote toolbar is newer (local = 15s, remote = 10s), it's + // not changed remotely, so we should merge local children first. + info("Add C > F remotely with newer time; delete C locally with older time"); + await storeRecords( + buf, + shuffle([ + { + id: "folderCCCCCC", + parentid: "toolbar", + type: "folder", + title: "C", + children: ["bookmarkFFFF"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 5, + }, + { + id: "bookmarkFFFF", + parentid: "folderCCCCCC", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 5, + }, + ]) + ); + await PlacesUtils.bookmarks.remove("folderCCCCCC"); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.toolbarGuid, + lastModified: new Date(modified.getTime() - 5000), + // Use `SOURCES.SYNC` to avoid bumping the change counter and flagging the + // local toolbar as modified. + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: modified.getTime() / 1000 + 10, + remoteTimeSeconds: modified.getTime() / 1000 + 10, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "folderCCCCCC", PlacesUtils.bookmarks.menuGuid], + "Should leave deleted C; F and menu with new remote structure unmerged" + ); + + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: modified.getTime() - 5000, + children: ["bookmarkBBBB", "bookmarkEEEE"], + title: BookmarksMenuTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: modified.getTime() - 5000, + children: ["bookmarkDDDD", "bookmarkFFFF"], + title: BookmarksToolbarTitle, + }, + }, + // Upload E and F with new `parentid`. + bookmarkEEEE: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkEEEE", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: modified.getTime() + 5000, + bmkUri: "http://example.com/e", + title: "E", + }, + }, + bookmarkFFFF: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkFFFF", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: modified.getTime() - 5000, + bmkUri: "http://example.com/f", + title: "F", + }, + }, + folderCCCCCC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderCCCCCC", + deleted: true, + }, + }, + }, + "Should reupload menu, toolbar, E, F with new structure; tombstone for C" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should merge children of changed side first, even if they're older" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderCCCCCC"], + "Should store local tombstone for C" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_value_changes.js b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js new file mode 100644 index 0000000000..6ce7f20562 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js @@ -0,0 +1,2580 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_value_combo() { + let buf = await openMirror("value_combo"); + let now = Date.now(); + + info("Set up mirror with existing bookmark to update"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Insert new local bookmark to upload"); + let [bzBmk] = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bzBmk_______", + url: "https://bugzilla.mozilla.org", + title: "Bugzilla", + tags: ["new", "tag"], + }, + ], + }); + + info("Insert remote bookmarks and folder to apply"); + await storeRecords( + buf, + shuffle([ + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla home page", + bmkUri: "https://mozilla.org", + tags: ["browsers"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["fxBmk_______", "tFolder_____"], + }, + { + id: "fxBmk_______", + parentid: "toolbar", + type: "bookmark", + title: "Get Firefox", + bmkUri: "http://getfirefox.com", + tags: ["taggy", "browsers"], + dateAdded: now, + }, + { + id: "tFolder_____", + parentid: "toolbar", + type: "folder", + title: "Mail", + children: ["tbBmk_______"], + dateAdded: now, + }, + { + id: "tbBmk_______", + parentid: "tFolder_____", + type: "bookmark", + title: "Get Thunderbird", + bmkUri: "http://getthunderbird.com", + keyword: "tb", + dateAdded: now, + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications({ + skipTags: true, + ignoreDates: false, + }); + let localTimeSeconds = Math.floor(now / 1000); + let changesToUpload = await buf.apply({ + localTimeSeconds, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.toolbarGuid], + "Should leave toolbar with new remote structure unmerged" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + bzBmk_______: { + tombstone: false, + counter: 3, + synced: false, + cleartext: { + id: "bzBmk_______", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: bzBmk.dateAdded.getTime(), + bmkUri: "https://bugzilla.mozilla.org/", + title: "Bugzilla", + tags: ["new", "tag"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: BookmarksToolbarTitle, + children: ["fxBmk_______", "tFolder_____", "bzBmk_______"], + }, + }, + }, + "Should upload new local bookmarks and parents" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "fxBmk_______", + "tFolder_____", + "tbBmk_______", + "bzBmk_______", + "mozBmk______", + PlacesUtils.bookmarks.toolbarGuid, + ]); + + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("fxBmk_______"), + parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://getfirefox.com/", + title: "Get Firefox", + guid: "fxBmk_______", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("tFolder_____"), + parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + urlHref: "", + title: "Mail", + guid: "tFolder_____", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("tbBmk_______"), + parentId: localItemIds.get("tFolder_____"), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://getthunderbird.com/", + title: "Get Thunderbird", + guid: "tbBmk_______", + parentGuid: "tFolder_____", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bzBmk_______"), + oldIndex: 0, + newIndex: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bzBmk_______", + oldParentGuid: PlacesUtils.bookmarks.toolbarGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://bugzilla.mozilla.org/", + isTagging: false, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("mozBmk______"), + title: "Mozilla home page", + guid: "mozBmk______", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + let fxBmk = await PlacesUtils.bookmarks.fetch("fxBmk_______"); + ok(fxBmk, "New Firefox bookmark should exist"); + equal( + fxBmk.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Should add Firefox bookmark to toolbar" + ); + let fxTags = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://getfirefox.com") + ); + deepEqual( + fxTags.sort(), + ["browsers", "taggy"], + "Should tag new Firefox bookmark" + ); + + let folder = await PlacesUtils.bookmarks.fetch("tFolder_____"); + ok(folder, "New folder should exist"); + equal( + folder.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Should add new folder to toolbar" + ); + + let tbBmk = await PlacesUtils.bookmarks.fetch("tbBmk_______"); + ok(tbBmk, "Should insert Thunderbird child bookmark"); + equal( + tbBmk.parentGuid, + folder.guid, + "Should add Thunderbird bookmark to new folder" + ); + let keywordInfo = await PlacesUtils.keywords.fetch("tb"); + equal( + keywordInfo.url.href, + "http://getthunderbird.com/", + "Should set keyword for Thunderbird bookmark" + ); + + let updatedBmk = await PlacesUtils.bookmarks.fetch("mozBmk______"); + equal( + updatedBmk.title, + "Mozilla home page", + "Should rename Mozilla bookmark" + ); + equal( + updatedBmk.parentGuid, + PlacesUtils.bookmarks.menuGuid, + "Should not move Mozilla bookmark" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_value_only_changes() { + let buf = await openMirror("value_only_changes"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + { + guid: "folderJJJJJJ", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "J", + children: [ + { + guid: "bookmarkKKKK", + url: "http://example.com/k", + title: "K", + }, + ], + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + children: [ + { + guid: "bookmarkIIII", + url: "http://example.com/i", + title: "I", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderFFFFFF"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "folderJJJJJJ", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderJJJJJJ", + parentid: "folderAAAAAA", + type: "folder", + title: "J", + children: ["bookmarkKKKK"], + }, + { + id: "bookmarkKKKK", + parentid: "folderJJJJJJ", + type: "bookmark", + title: "K", + bmkUri: "http://example.com/k", + }, + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F", + children: ["bookmarkGGGG", "folderHHHHHH"], + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + { + id: "folderHHHHHH", + parentid: "folderFFFFFF", + type: "folder", + title: "H", + children: ["bookmarkIIII"], + }, + { + id: "bookmarkIIII", + parentid: "folderHHHHHH", + type: "bookmark", + title: "I", + bmkUri: "http://example.com/i", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E (remote)", + bmkUri: "http://example.com/e-remote", + }, + { + id: "bookmarkIIII", + parentid: "folderHHHHHH", + type: "bookmark", + title: "I (remote)", + bmkUri: "http://example.com/i-remote", + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F (remote)", + children: ["bookmarkGGGG", "folderHHHHHH"], + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only value changes" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "folderJJJJJJ", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "J", + children: [ + { + guid: "bookmarkKKKK", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "K", + url: "http://example.com/k", + }, + ], + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "E (remote)", + url: "http://example.com/e-remote", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "F (remote)", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "H", + children: [ + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "I (remote)", + url: "http://example.com/i-remote", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not change structure for value-only changes" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_conflicting_keywords() { + let buf = await openMirror("conflicting_keywords"); + let dateAdded = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "one", + dateAdded, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: dateAdded.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + { + let entryByKeyword = await PlacesUtils.keywords.fetch("one"); + equal( + entryByKeyword.url.href, + "http://example.com/a", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "one", "Should return new entry by keyword"); + } + + info("Insert new bookmark with same URL and different keyword"); + { + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkAAA1"], + }, + { + id: "bookmarkAAA1", + parentid: "toolbar", + type: "bookmark", + title: "A1", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: dateAdded.getTime(), + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA1"], + "Should leave A1 with conflicting keyword unmerged" + ); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + keyword: "two", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A1", + keyword: "two", + }, + }, + }, + "Should reupload bookmarks with different keyword" + ); + await storeChangesInMirror(buf, changesToUpload); + + let entryByOldKeyword = await PlacesUtils.keywords.fetch("one"); + ok( + !entryByOldKeyword, + "Should remove old entry when inserting bookmark with different keyword" + ); + let entryByNewKeyword = await PlacesUtils.keywords.fetch("two"); + equal( + entryByNewKeyword.url.href, + "http://example.com/a", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "two", "Should return new entry by URL"); + } + + info("Update bookmark with different keyword"); + { + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "three", + dateAdded: dateAdded.getTime(), + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA"], + "Should leave A with conflicting keyword unmerged" + ); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + keyword: "three", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A1", + keyword: "three", + }, + }, + }, + "Should reupload A and A1 with updated keyword" + ); + await storeChangesInMirror(buf, changesToUpload); + + let entryByOldKeyword = await PlacesUtils.keywords.fetch("two"); + ok( + !entryByOldKeyword, + "Should remove old entry when updating bookmark keyword" + ); + let entryByNewKeyword = await PlacesUtils.keywords.fetch("three"); + equal( + entryByNewKeyword.url.href, + "http://example.com/a", + "Should return updated keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "three", "Should return updated entry by URL"); + } + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_keywords() { + let buf = await openMirror("keywords"); + let now = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "one", + dateAdded: now, + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "two", + dateAdded: now, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded: now, + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + keyword: "three", + dateAdded: now, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now.getTime(), + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + keyword: "three", + dateAdded: now.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Change keywords remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: now.getTime(), + }, + ]) + ); + + info("Change keywords locally"); + await PlacesUtils.keywords.insert({ + keyword: "four", + url: "http://example.com/c", + }); + await PlacesUtils.keywords.remove("three"); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + deepEqual( + changesToUpload, + { + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/c", + title: "C", + keyword: "four", + }, + }, + bookmarkDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkDDDD", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/d", + title: "D", + }, + }, + }, + "Should upload C with new keyword, D with keyword removed" + ); + + let entryForOne = await PlacesUtils.keywords.fetch("one"); + ok(!entryForOne, "Should remove existing keyword from A"); + + let entriesForTwo = await fetchAllKeywords("two"); + deepEqual( + entriesForTwo.map(entry => ({ + keyword: entry.keyword, + url: entry.url.href, + })), + [ + { + keyword: "two", + url: "http://example.com/a", + }, + ], + "Should move keyword for B to A" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_keywords_complex() { + let buf = await openMirror("keywords_complex"); + let now = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "four", + dateAdded: now, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + keyword: "five", + dateAdded: now, + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + dateAdded: now, + }, + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + keyword: "three", + dateAdded: now, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "four", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + keyword: "five", + dateAdded: now.getTime(), + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + dateAdded: now.getTime(), + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + keyword: "three", + dateAdded: now.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkAAA1", + "bookmarkBBB1", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: now.getTime(), + }, + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A (copy)", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + keyword: "six", + dateAdded: now.getTime(), + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA1", "bookmarkAAAA", "bookmarkBBB1"], + "Should leave A1, A, B with conflicting keywords unmerged" + ); + + let expectedChangesToUpload = { + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkBBB1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBB1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A (copy)", + }, + }, + }; + + // We'll take the keyword of either "bookmarkAAAA" or "bookmarkAAA1", + // depending on which we see first, and reupload the other. + let entriesForOne = await fetchAllKeywords("one"); + let entriesForTwo = await fetchAllKeywords("two"); + if (entriesForOne.length) { + ok(!entriesForTwo.length, "Should drop conflicting keyword from A1"); + deepEqual( + entriesForOne.map(keyword => keyword.url.href), + ["http://example.com/a"], + "Should use A keyword for A and A1" + ); + expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "one"; + expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "one"; + } else { + ok(!entriesForOne.length, "Should drop conflicting keyword from A"); + deepEqual( + entriesForTwo.map(keyword => keyword.url.href), + ["http://example.com/a"], + "Should use A1 keyword for A and A1" + ); + expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "two"; + expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "two"; + } + deepEqual( + changesToUpload, + expectedChangesToUpload, + "Should reupload all local records with corrected keywords" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkAAA1", + "bookmarkBBB1", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + PlacesUtils.bookmarks.menuGuid, + ]); + let expectedNotifications = [ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/a", + title: "A", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkAAA1"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/a", + title: "A (copy)", + guid: "bookmarkAAA1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkBBB1"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/b", + title: "B", + guid: "bookmarkBBB1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + // These `bookmark-moved` notifications aren't necessary: we only moved + // (B C D E) to accomodate (A A1 B1), and Places doesn't usually fire move + // notifications for repositioned siblings. However, detecting and filtering + // these out complicates `noteObserverChanges`, so, for simplicity, we + // record and fire the extra notifications. + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 3, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 4, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c-remote", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkDDDD"), + oldIndex: 2, + newIndex: 5, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkDDDD", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/d", + isTagging: false, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 3, + newIndex: 6, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + title: "C (remote)", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-url-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/c-remote", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + ]; + observer.check(expectedNotifications); + + let entriesForFour = await fetchAllKeywords("four"); + ok(!entriesForFour.length, "Should remove all keywords for B"); + + let entriesForOldC = await fetchAllKeywords({ + url: "http://example.com/c", + }); + ok(!entriesForOldC.length, "Should remove all keywords from old C URL"); + let entriesForNewC = await fetchAllKeywords({ + url: "http://example.com/c-remote", + }); + deepEqual( + entriesForNewC.map(entry => entry.keyword), + ["six"], + "Should add new keyword to new C URL" + ); + + let entriesForD = await fetchAllKeywords("http://example.com/d"); + ok(!entriesForD.length, "Should not add keywords to D"); + + let entriesForThree = await fetchAllKeywords("three"); + deepEqual( + entriesForThree.map(keyword => keyword.url.href), + ["http://example.com/e"], + "Should not change keywords for E" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tags_complex() { + let buf = await openMirror("tags_complex"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A1", + url: "http://example.com/a", + tags: ["one", "two"], + }, + { + guid: "bookmarkAAA2", + title: "A2", + url: "http://example.com/a", + tags: ["one", "two"], + }, + { + guid: "bookmarkBBB1", + title: "B1", + url: "http://example.com/b", + tags: ["one"], + }, + { + guid: "bookmarkBBB2", + title: "B2", + url: "http://example.com/b", + tags: ["one"], + }, + { + guid: "bookmarkCCC1", + title: "C1", + url: "http://example.com/c", + tags: ["two", "three"], + }, + { + guid: "bookmarkCCC2", + title: "C2", + url: "http://example.com/c", + tags: ["two", "three"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAA1", + "bookmarkAAA2", + "bookmarkBBB1", + "bookmarkBBB2", + "bookmarkCCC1", + "bookmarkCCC2", + ], + }, + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two"], + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b", + tags: ["one"], + }, + { + id: "bookmarkBBB2", + parentid: "menu", + type: "bookmark", + title: "B2", + bmkUri: "http://example.com/b", + tags: ["one"], + }, + { + id: "bookmarkCCC1", + parentid: "menu", + type: "bookmark", + title: "C1", + bmkUri: "http://example.com/c", + tags: ["two", "three"], + }, + { + id: "bookmarkCCC2", + parentid: "menu", + type: "bookmark", + title: "C2", + bmkUri: "http://example.com/c", + tags: ["two", "three"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add tags for B locally"); + PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/b"), [ + "four", + "five", + ]); + + info("Remove tag from C locally"); + PlacesUtils.tagging.untagURI(Services.io.newURI("http://example.com/c"), [ + "two", + ]); + + info("Update tags for A remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A1", + bmkUri: "http://example.com/a", + tags: ["one", "two", "four", "six"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A2", + bmkUri: "http://example.com/a", + tags: ["one", "two", "four", "six"], + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let datesAdded = await promiseManyDatesAdded([ + "bookmarkBBB1", + "bookmarkBBB2", + "bookmarkCCC1", + "bookmarkCCC2", + ]); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + deepEqual( + changesToUpload, + { + bookmarkBBB1: { + counter: 2, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkBBB1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBB1"), + bmkUri: "http://example.com/b", + title: "B1", + tags: ["five", "four", "one"], + }, + }, + bookmarkBBB2: { + counter: 2, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkBBB2", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBB2"), + bmkUri: "http://example.com/b", + title: "B2", + tags: ["five", "four", "one"], + }, + }, + bookmarkCCC1: { + counter: 1, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkCCC1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkCCC1"), + bmkUri: "http://example.com/c", + title: "C1", + tags: ["three"], + }, + }, + bookmarkCCC2: { + counter: 1, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkCCC2", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkCCC2"), + bmkUri: "http://example.com/c", + title: "C2", + tags: ["three"], + }, + }, + }, + "Should upload local records with new tags" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAA1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A1", + url: "http://example.com/a", + tags: ["four", "one", "six", "two"], + }, + { + guid: "bookmarkAAA2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "A2", + url: "http://example.com/a", + tags: ["four", "one", "six", "two"], + }, + { + guid: "bookmarkBBB1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B1", + url: "http://example.com/b", + tags: ["five", "four", "one"], + }, + { + guid: "bookmarkBBB2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "B2", + url: "http://example.com/b", + tags: ["five", "four", "one"], + }, + { + guid: "bookmarkCCC1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "C1", + url: "http://example.com/c", + tags: ["three"], + }, + { + guid: "bookmarkCCC2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 5, + title: "C2", + url: "http://example.com/c", + tags: ["three"], + }, + ], + }, + "Should update local items with new tags" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tags() { + let buf = await openMirror("tags"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + tags: ["one", "two", "three", "four"], + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + tags: ["five", "six"], + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + tags: ["seven", "eight", "nine"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two", "three", "four"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + tags: ["five", "six"], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + tags: ["seven", "eight", "nine"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Change tags remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two", "ten"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + tags: [], + }, + ]) + ); + + info("Change tags locally"); + PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/c"), [ + "eleven", + "twelve", + ]); + + let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid) + ); + + PlacesUtils.tagging.untagURI( + Services.io.newURI("http://example.com/d"), + null + ); + + await wait; + + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "bookmarkDDDD"], + deleted: [], + }, + "Should upload local records with new tags" + ); + + deepEqual( + changesToUpload.bookmarkCCCC.cleartext.tags.sort(), + ["eleven", "twelve"], + "Should upload record with new tags for C" + ); + ok( + !changesToUpload.bookmarkDDDD.cleartext.tags, + "Should upload record for D with tags removed" + ); + + let tagsForA = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/a") + ); + deepEqual(tagsForA.sort(), ["one", "ten", "two"], "Should change tags for A"); + + let tagsForB = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/b") + ); + deepEqual(tagsForB, [], "Should remove all tags from B"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_rewrite_tag_queries() { + let buf = await openMirror("rewrite_tag_queries"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + tags: ["kitty"], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkDDDD"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + tags: ["kitty"], + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add tag queries for new and existing tags"); + await storeRecords(buf, [ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["queryBBBBBBB", "queryCCCCCCC", "bookmarkEEEE"], + }, + { + id: "queryBBBBBBB", + parentid: "toolbar", + type: "query", + title: "Tagged stuff", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + id: "queryCCCCCCC", + parentid: "toolbar", + type: "query", + title: "Cats", + bmkUri: "place:type=7&folder=888", + folderName: "kitty", + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + tags: ["taggy"], + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["queryBBBBBBB", "queryCCCCCCC"], + "Should leave rewritten queries unmerged" + ); + + deepEqual( + changesToUpload, + { + queryBBBBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryBBBBBBB", + type: "query", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: undefined, + bmkUri: "place:tag=taggy", + title: "Tagged stuff", + folderName: "taggy", + }, + }, + queryCCCCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryCCCCCCC", + type: "query", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: undefined, + bmkUri: "place:tag=kitty", + title: "Cats", + folderName: "kitty", + }, + }, + }, + "Should reupload (E C) with rewritten URLs" + ); + + let bmWithTaggy = await PlacesUtils.bookmarks.fetch({ tags: ["taggy"] }); + equal( + bmWithTaggy.url.href, + "http://example.com/e", + "Should insert bookmark with new tag" + ); + + let bmWithKitty = await PlacesUtils.bookmarks.fetch({ tags: ["kitty"] }); + equal( + bmWithKitty.url.href, + "http://example.com/d", + "Should retain existing tag" + ); + + let { root: toolbarContainer } = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid, + false, + true + ); + equal( + toolbarContainer.childCount, + 3, + "Should add queries and bookmark to toolbar" + ); + + let containerForB = PlacesUtils.asContainer(toolbarContainer.getChild(0)); + containerForB.containerOpen = true; + for (let i = 0; i < containerForB.childCount; ++i) { + let child = containerForB.getChild(i); + equal( + child.uri, + "http://example.com/e", + `Rewritten tag query B should have tagged child node at ${i}` + ); + } + containerForB.containerOpen = false; + + let containerForC = PlacesUtils.asContainer(toolbarContainer.getChild(1)); + containerForC.containerOpen = true; + for (let i = 0; i < containerForC.childCount; ++i) { + let child = containerForC.getChild(i); + equal( + child.uri, + "http://example.com/d", + `Rewritten tag query C should have tagged child node at ${i}` + ); + } + containerForC.containerOpen = false; + + toolbarContainer.containerOpen = false; + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_date_added() { + let buf = await openMirror("date_added"); + + let aDateAdded = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); + let bDateAdded = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + dateAdded: aDateAdded, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + dateAdded: bDateAdded, + title: "B", + url: "http://example.com/b", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + dateAdded: aDateAdded.getTime(), + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + dateAdded: bDateAdded.getTime(), + bmkUri: "http://example.com/b", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + let bNewDateAdded = new Date(bDateAdded.getTime() - 1 * 60 * 60 * 1000); + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + dateAdded: Date.now(), + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B (remote)", + dateAdded: bNewDateAdded.getTime(), + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkAAAA"], + deleted: [], + }, + "Should flag A for weak reupload" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkBBBB", + ]); + observer.check([ + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + title: "A (remote)", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + title: "B (remote)", + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + let changeCounter = changesToUpload.bookmarkAAAA.counter; + strictEqual(changeCounter, 0, "Should not bump change counter for A"); + + let aInfo = await PlacesUtils.bookmarks.fetch("bookmarkAAAA"); + equal(aInfo.title, "A (remote)", "Should change local title for A"); + deepEqual( + aInfo.dateAdded, + aDateAdded, + "Should not change date added for A to newer remote date" + ); + + let bInfo = await PlacesUtils.bookmarks.fetch("bookmarkBBBB"); + equal(bInfo.title, "B (remote)", "Should change local title for B"); + deepEqual( + bInfo.dateAdded, + bNewDateAdded, + "Should take older date added for B" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Bug 1472435. +add_task(async function test_duplicate_url_rows() { + let buf = await openMirror("test_duplicate_url_rows"); + + let placesToInsert = [ + { + guid: "placeAAAAAAA", + href: "http://example.com", + }, + { + guid: "placeBBBBBBB", + href: "http://example.com", + }, + { + guid: "placeCCCCCCC", + href: "http://example.com/c", + }, + ]; + + let itemsToInsert = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + placeGuid: "placeAAAAAAA", + localTitle: "A", + remoteTitle: "A (remote)", + }, + { + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + placeGuid: "placeBBBBBBB", + localTitle: "B", + remoteTitle: "B (remote)", + }, + { + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + placeGuid: "placeCCCCCCC", + localTitle: "C", + remoteTitle: "C (remote)", + }, + ]; + + info("Manually insert local and remote items with duplicate URLs"); + await buf.db.executeTransaction(async function () { + for (let { guid, href } of placesToInsert) { + let url = new URL(href); + await buf.db.executeCached( + ` + INSERT INTO moz_places(url, url_hash, rev_host, hidden, frecency, guid) + VALUES(:url, hash(:url), :revHost, 0, -1, :guid)`, + { url: url.href, revHost: PlacesUtils.getReversedHost(url), guid } + ); + + await buf.db.executeCached( + ` + INSERT INTO urls(guid, url, hash, revHost) + VALUES(:guid, :url, hash(:url), :revHost)`, + { guid, url: url.href, revHost: PlacesUtils.getReversedHost(url) } + ); + } + + for (let { + guid, + parentGuid, + placeGuid, + localTitle, + remoteTitle, + } of itemsToInsert) { + await buf.db.executeCached( + ` + INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title, + syncStatus, syncChangeCounter) + VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT id FROM moz_places WHERE guid = :placeGuid), + (SELECT count(*) FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE p.guid = :parentGuid), :type, :localTitle, + :syncStatus, 1)`, + { + guid, + parentGuid, + placeGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + localTitle, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + } + ); + + await buf.db.executeCached( + ` + INSERT INTO items(guid, parentGuid, needsMerge, kind, title, urlId) + VALUES(:guid, :parentGuid, 1, :kind, :remoteTitle, + (SELECT id FROM urls WHERE guid = :placeGuid))`, + { + guid, + parentGuid, + placeGuid, + kind: Ci.mozISyncedBookmarksMerger.KIND_BOOKMARK, + remoteTitle, + } + ); + + await buf.db.executeCached( + ` + INSERT INTO structure(guid, parentGuid, position) + VALUES(:guid, :parentGuid, + IFNULL((SELECT count(*) FROM structure + WHERE parentGuid = :parentGuid), 0))`, + { guid, parentGuid } + ); + } + }); + + info("Apply mirror"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave roots unmerged" + ); + deepEqual( + Object.keys(changesToUpload).sort(), + ["menu", "mobile", "toolbar", "unfiled"], + "Should upload roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (remote)", + url: "http://example.com/", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should update titles for items with duplicate URLs" + ); + + let localItemIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + ]); + observer.check([ + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + title: "A (remote)", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + title: "B (remote)", + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + title: "C (remote)", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + }, + ]); + + info("Remove duplicate URLs from Places to avoid tripping debug asserts"); + await buf.db.executeTransaction(async function () { + for (let { guid } of placesToInsert) { + await buf.db.executeCached( + ` + DELETE FROM moz_places WHERE guid = :guid`, + { guid } + ); + } + }); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duplicate_local_tags() { + let buf = await openMirror("duplicate_local_tags"); + let now = new Date(); + + info("Insert A"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "A", + url: "http://example.com/a", + dateAdded: now, + }); + + // Each tag folder should have unique tag entries, but the tagging service + // doesn't enforce this. We should still sync the correct set of tags, + // though, even if there are duplicates for the same URL. + info("Manually insert local tags for A"); + for (let [tag, dupes] of [ + ["one", 2], + ["two", 1], + ["three", 2], + ]) { + let tagFolderInfo = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: tag, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + for (let i = 0; i < dupes; ++i) { + await PlacesUtils.bookmarks.insert({ + parentGuid: tagFolderInfo.guid, + url: "http://example.com/a", + }); + } + } + + let tagsForA = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/a") + ); + deepEqual( + tagsForA.sort(), + ["one", "one", "three", "three", "two"], + "Tagging service should return duplicate tags" + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + changesToUpload.bookmarkAAAA.cleartext, + { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A", + tags: ["one", "three", "two"], + }, + "Should upload A with tags" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_sync_utils.js b/toolkit/components/places/tests/sync/test_sync_utils.js new file mode 100644 index 0000000000..8b5a893e39 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_sync_utils.js @@ -0,0 +1,3074 @@ +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +var makeGuid = PlacesUtils.history.makeGuid; + +function shuffle(array) { + let results = []; + for (let i = 0; i < array.length; ++i) { + let randomIndex = Math.floor(Math.random() * (i + 1)); + results[i] = results[randomIndex]; + results[randomIndex] = array[i]; + } + return results; +} + +async function assertTagForURLs(tag, urls, message) { + let taggedURLs = new Set(); + await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => + taggedURLs.add(b.url.href) + ); + deepEqual( + Array.from(taggedURLs).sort(compareAscending), + urls.sort(compareAscending), + message + ); +} + +function assertURLHasTags(url, tags, message) { + let actualTags = PlacesUtils.tagging.getTagsForURI(uri(url)); + deepEqual(actualTags.sort(compareAscending), tags, message); +} + +var populateTree = async function populate(parentGuid, ...items) { + let guids = {}; + + for (let index = 0; index < items.length; index++) { + let item = items[index]; + let guid = makeGuid(); + + switch (item.kind) { + case "bookmark": + case "query": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: item.url, + title: item.title, + parentGuid, + guid, + index, + }); + break; + + case "separator": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid, + guid, + }); + break; + + case "folder": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.title, + parentGuid, + guid, + }); + if (item.children) { + Object.assign(guids, await populate(guid, ...item.children)); + } + break; + + default: + throw new Error(`Unsupported item type: ${item.type}`); + } + + guids[item.title] = guid; + } + + return guids; +}; + +var moveSyncedBookmarksToUnsyncedParent = async function () { + info("Insert synced bookmarks"); + let syncedGuids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "folder", + title: "folder", + children: [ + { + kind: "bookmark", + title: "childBmk", + url: "https://example.org", + }, + ], + }, + { + kind: "bookmark", + title: "topBmk", + url: "https://example.com", + } + ); + // Pretend we've synced each bookmark at least once. + await PlacesTestUtils.setBookmarkSyncFields( + ...Object.values(syncedGuids).map(guid => ({ + guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + })) + ); + + info("Make new folder"); + let unsyncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "unsyncedFolder", + }); + + info("Move synced bookmarks into unsynced new folder"); + for (let guid of Object.values(syncedGuids)) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid: unsyncedFolder.guid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + } + + return { syncedGuids, unsyncedFolder }; +}; + +var setChangesSynced = async function (changes) { + for (let recordId in changes) { + changes[recordId].synced = true; + } + await PlacesSyncUtils.bookmarks.pushChanges(changes); +}; + +var ignoreChangedRoots = async function () { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + let expectedRoots = ["menu", "mobile", "toolbar", "unfiled"]; + if (!ObjectUtils.deepEqual(Object.keys(changes).sort(), expectedRoots)) { + // Make sure the previous test cleaned up. + throw new Error( + `Unexpected changes at start of test: ${JSON.stringify(changes)}` + ); + } + await setChangesSynced(changes); +}; + +add_task(async function test_fetchURLFrecency() { + // Add visits to the following URLs and then check if frecency for those URLs is not -1. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com", + "http://getthunderbird.com", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + for (let url of arrayOfURLsToVisit) { + let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url); + equal(typeof frecency, "number", "The frecency should be of type: number"); + notEqual( + frecency, + -1, + "The frecency of this url should be different than -1" + ); + } + // Do not add visits to the following URLs, and then check if frecency for those URLs is -1. + let arrayOfURLsNotVisited = ["https://bugzilla.org", "https://example.org"]; + for (let url of arrayOfURLsNotVisited) { + let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url); + equal(frecency, -1, "The frecency of this url should be -1"); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_determineNonSyncableGuids() { + // Add visits to the following URLs with different transition types. + let arrayOfVisits = [ + { uri: "https://www.mozilla.org/en-US/", transition: TRANSITION_TYPED }, + { uri: "http://getfirefox.com/", transition: TRANSITION_LINK }, + { uri: "http://getthunderbird.com/", transition: TRANSITION_FRAMED_LINK }, + { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD }, + ]; + for (let visit of arrayOfVisits) { + await PlacesTestUtils.addVisits(visit); + } + + // Fetch the guid for each visit. + let guids = []; + let dictURLGuid = {}; + for (let visit of arrayOfVisits) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri); + guids.push(guid); + dictURLGuid[visit.uri] = guid; + } + + // Filter the visits. + let filteredGuids = await PlacesSyncUtils.history.determineNonSyncableGuids( + guids + ); + + let filtered = [TRANSITION_FRAMED_LINK, TRANSITION_DOWNLOAD]; + // Check if the filtered visits are of type TRANSITION_FRAMED_LINK. + for (let visit of arrayOfVisits) { + if (filtered.includes(visit.transition)) { + ok( + filteredGuids.includes(dictURLGuid[visit.uri]), + "This url should be one of the filtered guids." + ); + } else { + ok( + !filteredGuids.includes(dictURLGuid[visit.uri]), + "This url should not be one of the filtered guids." + ); + } + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_changeGuid() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + for (let url of arrayOfURLsToVisit) { + let originalGuid = await PlacesSyncUtils.history.fetchGuidForURL(url); + let newGuid = makeGuid(); + + // Change the original GUID for the new GUID. + await PlacesSyncUtils.history.changeGuid(url, newGuid); + + // Fetch the GUID for this URL. + let newGuidFetched = await PlacesSyncUtils.history.fetchGuidForURL(url); + + // Check that the URL has the new GUID as its GUID and not the original one. + equal( + newGuid, + newGuidFetched, + "These should be equal since we changed the guid for the visit." + ); + notEqual( + originalGuid, + newGuidFetched, + "These should be different since we changed the guid for the visit." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchVisitsForURL() { + // Get the date for this moment and a date for a minute ago. + let now = new Date(); + let aMinuteAgo = new Date(now.getTime() - 1 * 60000); + + // Add some visits of the following URLs, specifying the transition and the visit date. + let arrayOfVisits = [ + { + uri: "https://www.mozilla.org/en-US/", + transition: TRANSITION_TYPED, + visitDate: aMinuteAgo, + }, + { + uri: "http://getfirefox.com/", + transition: TRANSITION_LINK, + visitDate: aMinuteAgo, + }, + { + uri: "http://getthunderbird.com/", + transition: TRANSITION_LINK, + visitDate: aMinuteAgo, + }, + ]; + for (let elem of arrayOfVisits) { + await PlacesTestUtils.addVisits(elem); + } + + for (let elem of arrayOfVisits) { + // Fetch all the visits for this URL. + let visits = await PlacesSyncUtils.history.fetchVisitsForURL(elem.uri); + // Since the visit we added will be the last one in the collection of visits, we get the index of it. + let iLast = visits.length - 1; + + // The date is saved in _micro_seconds, here we change it to milliseconds. + let dateInMilliseconds = visits[iLast].date * 0.001; + + // Check that the info we provided for this URL is the same one retrieved. + equal( + dateInMilliseconds, + elem.visitDate.getTime(), + "The date we provided should be the same we retrieved." + ); + equal( + visits[iLast].type, + elem.transition, + "The transition type we provided should be the same we retrieved." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchGuidForURL() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // This tries to test fetchGuidForURL in two ways: + // 1- By fetching the GUID, and then using that GUID to retrieve the info of the visit. + // It then compares the URL with the URL that is on the visits info. + // 2- By creating a new GUID, changing the GUID for the visit, fetching the GUID and comparing them. + for (let url of arrayOfURLsToVisit) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(url); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + let newGuid = makeGuid(); + await PlacesSyncUtils.history.changeGuid(url, newGuid); + let newGuid2 = await PlacesSyncUtils.history.fetchGuidForURL(url); + + equal( + url, + info.url, + "The url provided and the url retrieved should be the same." + ); + equal( + newGuid, + newGuid2, + "The changed guid and the retrieved guid should be the same." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchURLInfoForGuid() { + // Add some visits of the following URLs. specifying the title. + let visits = [ + { uri: "https://www.mozilla.org/en-US/", title: "mozilla" }, + { uri: "http://getfirefox.com/", title: "firefox" }, + { uri: "http://getthunderbird.com/", title: "thunderbird" }, + { uri: "http://quantum.mozilla.com/", title: null }, + ]; + for (let visit of visits) { + await PlacesTestUtils.addVisits(visit); + } + + for (let visit of visits) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + // Compare the info returned by fetchURLInfoForGuid, + // URL and title should match while frecency must be different than -1. + equal( + info.url, + visit.uri, + "The url provided should be the same as the url retrieved." + ); + equal( + info.title, + visit.title || "", + "The title provided should be the same as the title retrieved." + ); + notEqual( + info.frecency, + -1, + "The frecency of the visit should be different than -1." + ); + } + + // Create a "fake" GUID and check that the result of fetchURLInfoForGuid is null. + let guid = makeGuid(); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + equal( + info, + null, + "The information object of a non-existent guid should be null." + ); + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_getAllURLs() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // Get all URLs. + let allURLs = await PlacesSyncUtils.history.getAllURLs({ + since: new Date(Date.now() - 2592000000), + limit: 5000, + }); + + // The amount of URLs must be the same in both collections. + equal( + allURLs.length, + arrayOfURLsToVisit.length, + "The amount of urls retrived should match the amount of urls provided." + ); + + // Check that the correct URLs were retrived. + for (let url of arrayOfURLsToVisit) { + ok( + allURLs.includes(url), + "The urls retrieved should match the ones used in this test." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_getAllURLs_skips_downloads() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD }, + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // Get all URLs. + let allURLs = await PlacesSyncUtils.history.getAllURLs({ + since: new Date(Date.now() - 2592000000), + limit: 5000, + }); + + // Should be only the non-download + equal(allURLs.length, 1, "Should only get one URL back."); + + // Check that the correct URLs were retrived. + equal(allURLs[0], arrayOfURLsToVisit[0], "Should get back our non-download."); + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_order() { + info("Insert some bookmarks"); + let guids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "bookmark", + title: "childBmk", + url: "http://getfirefox.com", + }, + { + kind: "bookmark", + title: "siblingBmk", + url: "http://getthunderbird.com", + }, + { + kind: "folder", + title: "siblingFolder", + }, + { + kind: "separator", + title: "siblingSep", + } + ); + + info("Reorder inserted bookmarks"); + { + let order = [ + guids.siblingFolder, + guids.siblingSep, + guids.childBmk, + guids.siblingBmk, + ]; + await PlacesSyncUtils.bookmarks.order( + PlacesUtils.bookmarks.menuGuid, + order + ); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + order, + "New bookmarks should be reordered according to array" + ); + } + + info("Same order with unspecified children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.siblingSep, + guids.siblingBmk, + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.siblingFolder, guids.siblingSep, guids.childBmk, guids.siblingBmk], + "Current order should be respected if possible" + ); + } + + info("New order with unspecified children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.siblingBmk, + guids.siblingSep, + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.siblingBmk, guids.siblingSep, guids.siblingFolder, guids.childBmk], + "Unordered children should be moved to end if current order can't be respected" + ); + } + + info("Reorder with nonexistent children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.childBmk, + makeGuid(), + guids.siblingBmk, + guids.siblingSep, + makeGuid(), + guids.siblingFolder, + makeGuid(), + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.childBmk, guids.siblingBmk, guids.siblingSep, guids.siblingFolder], + "Nonexistent children should be ignored" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_order_roots() { + let oldOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.rootGuid + ); + await PlacesSyncUtils.bookmarks.order( + PlacesUtils.bookmarks.rootGuid, + shuffle(oldOrder) + ); + let newOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.rootGuid + ); + deepEqual(oldOrder, newOrder, "Should ignore attempts to reorder roots"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_tags() { + await ignoreChangedRoots(); + + info("Insert untagged items with same URL"); + let firstItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let secondItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let untaggedItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://bugzilla.org", + }); + let taggedItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://mozilla.org", + }); + + info("Create tag"); + PlacesUtils.tagging.tagURI(uri("https://example.org"), ["taggy"]); + + let tagBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + index: 0, + }); + let tagFolderGuid = tagBm.guid; + let tagFolderId = await PlacesUtils.promiseItemId(tagFolderGuid); + + info("Tagged bookmarks should be in changeset"); + { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks in changeset" + ); + await setChangesSynced(changes); + } + + info("Change tag case"); + { + PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["TaGgY"]); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, taggedItem.recordId].sort(), + "Should include tagged bookmarks after changing case" + ); + await assertTagForURLs( + "TaGgY", + ["https://example.org/", "https://mozilla.org/"], + "Should add tag for new URL" + ); + await setChangesSynced(changes); + } + + // These tests change a tag item directly, without going through the tagging + // service. This behavior isn't supported, but the tagging service registers + // an observer to handle these cases, so we make sure we handle them + // correctly. + + info("Rename tag folder using Bookmarks.setItemTitle"); + { + PlacesUtils.bookmarks.setItemTitle(tagFolderId, "sneaky"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["sneaky"], + "Tagging service should update cache with new title" + ); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks after renaming tag folder" + ); + await setChangesSynced(changes); + } + + info("Rename tag folder using Bookmarks.update"); + { + await PlacesUtils.bookmarks.update({ + guid: tagFolderGuid, + title: "tricky", + }); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["tricky"], + "Tagging service should update cache after updating tag folder" + ); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks after updating tag folder" + ); + await setChangesSynced(changes); + } + + info("Change tag entry URL using Bookmarks.update"); + { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: tagFolderGuid, + index: 0, + }); + bm.url = "https://bugzilla.org/"; + await PlacesUtils.bookmarks.update(bm); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(), + "Should include tagged bookmarks after changing tag entry URI" + ); + await assertTagForURLs( + "tricky", + ["https://bugzilla.org/", "https://mozilla.org/"], + "Should remove tag entry for old URI" + ); + await setChangesSynced(changes); + + bm.url = "https://example.org/"; + await PlacesUtils.bookmarks.update(bm); + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(), + "Should include tagged bookmarks after changing tag entry URL" + ); + await assertTagForURLs( + "tricky", + ["https://example.org/", "https://mozilla.org/"], + "Should remove tag entry for old URL" + ); + await setChangesSynced(changes); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_conflicting_keywords() { + await ignoreChangedRoots(); + + info("Insert bookmark with new keyword"); + let tbBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "unfiled", + url: "http://getthunderbird.com", + keyword: "tbird", + }); + { + let entryByKeyword = await PlacesUtils.keywords.fetch("tbird"); + equal( + entryByKeyword.url.href, + "http://getthunderbird.com/", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://getthunderbird.com", + }); + equal(entryByURL.keyword, "tbird", "Should return new entry by keyword"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + changes, + {}, + "Should not bump change counter for new keyword entry" + ); + } + + info("Insert bookmark with same URL and different keyword"); + let dupeTbBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "http://getthunderbird.com", + keyword: "tb", + }); + { + let oldKeywordByURL = await PlacesUtils.keywords.fetch("tbird"); + ok( + !oldKeywordByURL, + "Should remove old entry when inserting bookmark with different keyword" + ); + let entryByKeyword = await PlacesUtils.keywords.fetch("tb"); + equal( + entryByKeyword.url.href, + "http://getthunderbird.com/", + "Should return different keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://getthunderbird.com", + }); + equal(entryByURL.keyword, "tb", "Should return different entry by keyword"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [tbBmk.recordId, dupeTbBmk.recordId].sort(), + "Should bump change counter for bookmarks with different keyword" + ); + await setChangesSynced(changes); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert() { + info("Insert bookmark"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Bookmark should have correct type" + ); + } + + info("Insert query"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "menu", + url: "place:terms=term&folder=TOOLBAR&queryType=1", + folder: "Saved search", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Queries should be stored as bookmarks" + ); + } + + info("Insert folder"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + recordId: makeGuid(), + parentRecordId: "menu", + title: "New folder", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Folder should have correct type" + ); + } + + info("Insert separator"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "separator", + recordId: makeGuid(), + parentRecordId: "menu", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + "Separator should have correct type" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tags() { + await Promise.all( + [ + { + kind: "bookmark", + url: "https://example.com", + recordId: makeGuid(), + parentRecordId: "menu", + tags: ["foo", "bar"], + }, + { + kind: "bookmark", + url: "https://example.org", + recordId: makeGuid(), + parentRecordId: "toolbar", + tags: ["foo", "baz"], + }, + { + kind: "query", + url: "place:queryType=1&sort=12&maxResults=10", + recordId: makeGuid(), + parentRecordId: "toolbar", + folder: "bar", + tags: ["baz", "qux"], + title: "bar", + }, + ].map(info => PlacesSyncUtils.test.bookmarks.insert(info)) + ); + + await assertTagForURLs( + "foo", + ["https://example.com/", "https://example.org/"], + "2 URLs with new tag" + ); + await assertTagForURLs( + "bar", + ["https://example.com/"], + "1 URL with existing tag" + ); + await assertTagForURLs( + "baz", + ["https://example.org/", "place:queryType=1&sort=12&maxResults=10"], + "Should support tagging URLs and tag queries" + ); + await assertTagForURLs( + "qux", + ["place:queryType=1&sort=12&maxResults=10"], + "Should support tagging tag queries" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tags_whitespace() { + info("Untrimmed and blank tags"); + let taggedBlanks = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + url: "https://example.org", + recordId: makeGuid(), + parentRecordId: "menu", + tags: [" untrimmed ", " ", "taggy"], + }); + deepEqual( + taggedBlanks.tags, + ["untrimmed", "taggy"], + "Should not return empty tags" + ); + assertURLHasTags( + "https://example.org/", + ["taggy", "untrimmed"], + "Should set trimmed tags and ignore dupes" + ); + + info("Dupe tags"); + let taggedDupes = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + url: "https://example.net", + recordId: makeGuid(), + parentRecordId: "toolbar", + tags: [" taggy", "taggy ", " taggy ", "taggy"], + }); + deepEqual( + taggedDupes.tags, + ["taggy", "taggy", "taggy", "taggy"], + "Should return trimmed and dupe tags" + ); + assertURLHasTags( + "https://example.net/", + ["taggy"], + "Should ignore dupes when setting tags" + ); + + await assertTagForURLs( + "taggy", + ["https://example.net/", "https://example.org/"], + "Should exclude falsy tags" + ); + + PlacesUtils.tagging.untagURI(uri("https://example.org"), [ + "untrimmed", + "taggy", + ]); + PlacesUtils.tagging.untagURI(uri("https://example.net"), ["taggy"]); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + [], + "Should clean up all tags" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_keyword() { + info("Insert item with new keyword"); + { + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + url: "https://example.com", + keyword: "moz", + recordId: makeGuid(), + }); + let entry = await PlacesUtils.keywords.fetch("moz"); + equal( + entry.url.href, + "https://example.com/", + "Should add keyword for item" + ); + } + + info("Insert item with existing keyword"); + { + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + url: "https://mozilla.org", + keyword: "moz", + recordId: makeGuid(), + }); + let entry = await PlacesUtils.keywords.fetch("moz"); + equal( + entry.url.href, + "https://mozilla.org/", + "Should reassign keyword to new item" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tag_query() { + info("Use the public tagging API to ensure we added the tag correctly"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "https://mozilla.org", + title: "Mozilla", + }); + PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["taggy"]); + assertURLHasTags( + "https://mozilla.org/", + ["taggy"], + "Should set tags using the tagging API" + ); + + info("Insert tag query for non existing tag"); + { + let query = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "place:type=7&folder=90", + folder: "nonexisting", + title: "Tagged stuff", + }); + let params = new URLSearchParams(query.url.pathname); + ok(!params.has("type"), "Should not preserve query type"); + ok(!params.has("folder"), "Should not preserve folder"); + equal(params.get("tag"), "nonexisting", "Should add tag"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["taggy"], + "The nonexisting tag should not be added" + ); + } + + info("Insert tag query for existing tag"); + { + let url = "place:type=7&folder=90&maxResults=15"; + let query = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "menu", + url, + folder: "taggy", + title: "Sorted and tagged", + }); + let params = new URLSearchParams(query.url.pathname); + ok(!params.get("type"), "Should not preserve query type"); + ok(!params.has("folder"), "Should not preserve folder"); + equal(params.get("maxResults"), "15", "Should preserve additional params"); + equal(params.get("tag"), "taggy", "Should add tag"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["taggy"], + "Should not duplicate existing tags" + ); + } + + info("Removing the tag should clean up the tag folder"); + PlacesUtils.tagging.untagURI(uri("https://mozilla.org"), null); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + [], + "Should remove tag folder once last item is untagged" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_fetch() { + let folder = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: "menu", + kind: "folder", + }); + let bmk = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: "menu", + kind: "bookmark", + url: "https://example.com", + tags: ["taggy"], + }); + let folderBmk = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: folder.recordId, + kind: "bookmark", + url: "https://example.org", + keyword: "kw", + }); + let folderSep = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: folder.recordId, + kind: "separator", + }); + let tagQuery = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "place:tag=taggy", + folder: "taggy", + title: "Tagged stuff", + }); + + info("Fetch empty folder"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folder.recordId); + deepEqual( + item, + { + recordId: folder.recordId, + kind: "folder", + parentRecordId: "menu", + childRecordIds: [folderBmk.recordId, folderSep.recordId], + parentTitle: "menu", + dateAdded: item.dateAdded, + title: "", + }, + "Should include children, title, and parent title in folder" + ); + } + + info("Fetch bookmark with tags"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(bmk.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "tags", + "parentTitle", + "title", + "dateAdded", + ].sort(), + "Should include bookmark-specific properties" + ); + equal(item.recordId, bmk.recordId, "Sync ID should match"); + equal(item.url.href, "https://example.com/", "Should return URL"); + equal(item.parentRecordId, "menu", "Should return parent sync ID"); + deepEqual(item.tags, ["taggy"], "Should return tags"); + equal(item.parentTitle, "menu", "Should return parent title"); + strictEqual(item.title, "", "Should return empty title"); + } + + info("Fetch bookmark with keyword; without parent title"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folderBmk.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "keyword", + "tags", + "parentTitle", + "title", + "dateAdded", + ].sort(), + "Should omit blank bookmark-specific properties" + ); + deepEqual(item.tags, [], "Tags should be empty"); + equal(item.keyword, "kw", "Should return keyword"); + strictEqual( + item.parentTitle, + "", + "Should include parent title even if empty" + ); + strictEqual(item.title, "", "Should include bookmark title even if empty"); + } + + info("Fetch separator"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folderSep.recordId); + strictEqual(item.index, 1, "Should return separator position"); + } + + info("Fetch tag query"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(tagQuery.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "title", + "folder", + "parentTitle", + "dateAdded", + ].sort(), + "Should include query-specific properties" + ); + equal( + item.url.href, + `place:tag=taggy`, + "Should not rewrite outgoing tag queries" + ); + equal(item.folder, "taggy", "Should return tag name for tag queries"); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_new_parent() { + await ignoreChangedRoots(); + + let { syncedGuids, unsyncedFolder } = + await moveSyncedBookmarksToUnsyncedParent(); + + info("Unsynced parent and synced items should be tracked"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + syncedGuids.folder, + syncedGuids.topBmk, + syncedGuids.childBmk, + unsyncedFolder.guid, + "menu", + ].sort(), + "Should return change records for moved items and new parent" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_deleted_folder() { + await ignoreChangedRoots(); + + let { syncedGuids, unsyncedFolder } = + await moveSyncedBookmarksToUnsyncedParent(); + + info("Remove unsynced new folder"); + await PlacesUtils.bookmarks.remove(unsyncedFolder.guid); + + info("Deleted synced items should be tracked; unsynced folder should not"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + syncedGuids.folder, + syncedGuids.topBmk, + syncedGuids.childBmk, + "menu", + ].sort(), + "Should return change records for all deleted items" + ); + for (let guid of Object.values(syncedGuids)) { + strictEqual( + changes[guid].tombstone, + true, + `Tombstone flag should be set for deleted item ${guid}` + ); + equal( + changes[guid].counter, + 1, + `Change counter should be 1 for deleted item ${guid}` + ); + equal( + changes[guid].status, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + `Sync status should be normal for deleted item ${guid}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_import_html() { + await ignoreChangedRoots(); + + info("Add unsynced bookmark"); + let unsyncedBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://example.com", + }); + + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Unsynced bookmark statuses should match" + ); + } + + info("Import new bookmarks from HTML"); + let { path } = do_get_file("./sync_utils_bookmarks.html"); + await BookmarkHTMLUtils.importFromFile(path); + + // Bookmarks.html doesn't store IDs, so we need to look these up. + let mozBmk = await PlacesUtils.bookmarks.fetch({ + url: "https://www.mozilla.org/", + }); + let fxBmk = await PlacesUtils.bookmarks.fetch({ + url: "https://www.mozilla.org/en-US/firefox/", + }); + // All Bookmarks.html bookmarks are stored under the menu. For toolbar + // bookmarks, this means they're imported into a "Bookmarks Toolbar" + // subfolder under the menu, instead of the real toolbar root. + let toolbarSubfolder = ( + await PlacesUtils.bookmarks.search({ + title: "Bookmarks Toolbar", + }) + ).find(item => item.guid != PlacesUtils.bookmarks.toolbarGuid); + let importedFields = await PlacesTestUtils.fetchBookmarkSyncFields( + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + importedFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Sync statuses should match for HTML imports" + ); + + info("Fetch new HTML imports"); + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(newChanges).sort(), + [ + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid, + "menu", + unsyncedBmk.guid, + ].sort(), + "Should return new IDs imported from HTML file" + ); + let newFields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + newFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Pulling new HTML imports should not mark them as syncing" + ); + + info("Mark new HTML imports as syncing"); + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Marking new HTML imports as syncing should update their statuses" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_import_json() { + await ignoreChangedRoots(); + + info("Add synced folder"); + let syncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "syncedFolder", + }); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: syncedFolder.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }); + + info("Import new bookmarks from JSON"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + deepEqual( + fields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + ], + "Sync statuses should match for JSON imports" + ); + } + + info("Fetch new JSON imports"); + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(newChanges).sort(), + [ + "NnvGl3CRA4hC", + "APzP8MupzA8l", + "menu", + "toolbar", + syncedFolder.guid, + ].sort(), + "Should return items imported from JSON backup" + ); + let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + deepEqual( + existingFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + ], + "Pulling new JSON imports should not mark them as syncing" + ); + + info("Mark new JSON imports as syncing"); + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Marking new JSON imports as syncing should update their statuses" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_restore_json_tracked() { + await ignoreChangedRoots(); + + let unsyncedBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://example.com", + }); + info(`Unsynced bookmark GUID: ${unsyncedBmk.guid}`); + let syncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "syncedFolder", + }); + info(`Synced folder GUID: ${syncedFolder.guid}`); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: syncedFolder.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + syncedFolder.guid + ); + deepEqual( + fields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + ], + "Sync statuses should match before restoring from JSON" + ); + } + + info("Restore from JSON, replacing existing items"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { replace: true }); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "All bookmarks should be NEW after restoring from JSON" + ); + } + + info("Fetch new items restored from JSON"); + { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + "menu", + "toolbar", + "unfiled", + "mobile", + "NnvGl3CRA4hC", + "APzP8MupzA8l", + ].sort(), + "Should restore items from JSON backup" + ); + + let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + existingFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Items restored from JSON backup should not be marked as syncing" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Tombstones should not exist after restoring from JSON backup" + ); + + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(changes); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Roots and NEW items restored from JSON backup should be marked as NORMAL" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_tombstones() { + await ignoreChangedRoots(); + + info("Insert new bookmarks"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + info("Manually insert conflicting tombstone for new bookmark"); + await PlacesUtils.withConnectionWrapper( + "test_pullChanges_tombstones", + async function (db) { + await db.executeCached( + ` + INSERT INTO moz_bookmarks_deleted(guid) + VALUES(:guid)`, + { guid: "bookmarkAAAA" } + ); + } + ); + + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + ["bookmarkAAAA", "bookmarkBBBB", "menu"], + "Should handle undeleted items when returning changes" + ); + strictEqual( + changes.bookmarkAAAA.tombstone, + false, + "Should replace tombstone for A with undeleted item" + ); + strictEqual( + changes.bookmarkBBBB.tombstone, + false, + "Should not report B as deleted" + ); + + await setChangesSynced(changes); + + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + newChanges, + {}, + "Should not return changes after marking undeleted items as synced" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pushChanges() { + await ignoreChangedRoots(); + + info("Populate test bookmarks"); + let guids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "bookmark", + title: "unknownBmk", + url: "https://example.org", + }, + { + kind: "bookmark", + title: "syncedBmk", + url: "https://example.com", + }, + { + kind: "bookmark", + title: "newBmk", + url: "https://example.info", + }, + { + kind: "bookmark", + title: "deletedBmk", + url: "https://example.edu", + }, + { + kind: "bookmark", + title: "unchangedBmk", + url: "https://example.systems", + } + ); + + info("Update sync statuses"); + await PlacesTestUtils.setBookmarkSyncFields( + { + guid: guids.syncedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }, + { + guid: guids.unknownBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + }, + { + guid: guids.deletedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }, + { + guid: guids.unchangedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + syncChangeCounter: 0, + } + ); + + info("Change synced bookmark; should bump change counter"); + await PlacesUtils.bookmarks.update({ + guid: guids.syncedBmk, + url: "https://example.ninja", + }); + + info("Remove synced bookmark"); + { + await PlacesUtils.bookmarks.remove(guids.deletedBmk); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + ok( + tombstones.some(({ guid }) => guid == guids.deletedBmk), + "Should write tombstone for deleted synced bookmark" + ); + } + + info("Pull changes"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + { + let actualChanges = Object.entries(changes).map(([recordId, change]) => ({ + recordId, + syncChangeCounter: change.counter, + })); + let expectedChanges = [ + { + recordId: guids.unknownBmk, + syncChangeCounter: 1, + }, + { + // Parent of changed bookmarks. + recordId: "menu", + syncChangeCounter: 6, + }, + { + recordId: guids.syncedBmk, + syncChangeCounter: 2, + }, + { + recordId: guids.newBmk, + syncChangeCounter: 1, + }, + { + recordId: guids.deletedBmk, + syncChangeCounter: 1, + }, + ]; + deepEqual( + sortBy(actualChanges, "recordId"), + sortBy(expectedChanges, "recordId"), + "Should return deleted, new, and unknown bookmarks" + ); + } + + info("Modify changed bookmark to bump its counter"); + await PlacesUtils.bookmarks.update({ + guid: guids.newBmk, + url: "https://example.club", + }); + + info("Mark some bookmarks as synced"); + for (let title of ["unknownBmk", "newBmk", "deletedBmk"]) { + let guid = guids[title]; + strictEqual( + changes[guid].synced, + false, + "All bookmarks should not be marked as synced yet" + ); + changes[guid].synced = true; + } + + await PlacesSyncUtils.bookmarks.pushChanges(changes); + equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 4); + + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + guids.newBmk, + guids.unknownBmk + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Should update sync statuses for synced bookmarks" + ); + } + + { + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + ok( + !tombstones.some(({ guid }) => guid == guids.deletedBmk), + "Should remove tombstone after syncing" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + guids.unknownBmk, + guids.syncedBmk, + guids.newBmk + ); + { + let info = syncFields.find(field => field.guid == guids.unknownBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing an UNKNOWN bookmark should set its sync status to NORMAL" + ); + strictEqual( + info.syncChangeCounter, + 0, + "Syncing an UNKNOWN bookmark should reduce its change counter" + ); + } + { + let info = syncFields.find(field => field.guid == guids.syncedBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing a NORMAL bookmark should not update its sync status" + ); + equal( + info.syncChangeCounter, + 2, + "Should not reduce counter for NORMAL bookmark not marked as synced" + ); + } + { + let info = syncFields.find(field => field.guid == guids.newBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing a NEW bookmark should update its sync status" + ); + strictEqual( + info.syncChangeCounter, + 1, + "Updating new bookmark after pulling changes should bump change counter" + ); + } + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_between_pull_and_push() { + await ignoreChangedRoots(); + + info("Populate test bookmarks"); + let guids = await populateTree(PlacesUtils.bookmarks.menuGuid, { + kind: "bookmark", + title: "bmk", + url: "https://example.info", + }); + + info("Update sync statuses"); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: guids.bmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + syncChangeCounter: 1, + }); + + info("Pull changes"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + Assert.equal(changes[guids.bmk].counter, 1); + Assert.equal(changes[guids.bmk].tombstone, false); + + // delete the bookmark. + await PlacesUtils.bookmarks.remove(guids.bmk); + + info("Push changes"); + await PlacesSyncUtils.bookmarks.pushChanges(changes); + equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + + // we should have a tombstone. + let ts = await PlacesTestUtils.fetchSyncTombstones(); + Assert.equal(ts.length, 1); + Assert.equal(ts[0].guid, guids.bmk); + + // there should be no record for the item we deleted. + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(guids.bmk), null); + + // and re-fetching changes should list it as a tombstone. + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + Assert.equal(changes[guids.bmk].counter, 1); + Assert.equal(changes[guids.bmk].tombstone, true); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_separator() { + await ignoreChangedRoots(); + + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://example.com", + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://foo.bar", + }); + let separatorRecordId = makeGuid(); + let separator = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "separator", + parentRecordId: "menu", + recordId: separatorRecordId, + }); + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://bar.foo", + }); + + let child2Guid = await PlacesSyncUtils.bookmarks.recordIdToGuid( + childBmk.recordId + ); + let parentGuid = await await PlacesSyncUtils.bookmarks.recordIdToGuid("menu"); + let separatorGuid = + PlacesSyncUtils.bookmarks.recordIdToGuid(separatorRecordId); + + info("Move a bookmark around the separator"); + await PlacesUtils.bookmarks.update({ + guid: child2Guid, + parentGuid, + index: 2, + }); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await setChangesSynced(changes); + + info("Move a separator around directly"); + await PlacesUtils.bookmarks.update({ + guid: separatorGuid, + parentGuid, + index: 0, + }); + + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await setChangesSynced(changes); + + info("Move a separator around directly using update"); + await PlacesUtils.bookmarks.update({ guid: separatorGuid, index: 2 }); + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove() { + await ignoreChangedRoots(); + + info("Insert subtree for removal"); + let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: "menu", + recordId: makeGuid(), + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.com", + }); + let childFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + }); + let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: childFolder.recordId, + recordId: makeGuid(), + url: "https://example.edu", + }); + + info("Remove entire subtree"); + await PlacesSyncUtils.bookmarks.remove([ + parentFolder.recordId, + childFolder.recordId, + childBmk.recordId, + grandChildBmk.recordId, + ]); + + /** + * Even though we've removed the entire subtree, we still track the menu + * because we 1) removed `parentFolder`, 2) reparented `childFolder` to + * `menu`, and 3) removed `childFolder`. + * + * This depends on the order of the folders passed to `remove`. If we + * removed `childFolder` *before* `parentFolder`, we wouldn't reparent + * anything to `menu`. + * + * `deleteSyncedFolder` could check if it's reparenting an item that will + * eventually be removed, and avoid bumping the new parent's change counter. + * Unfortunately, that introduces inconsistencies if `deleteSyncedFolder` is + * interrupted by shutdown. If the server changes before the next sync, + * we'll never upload records for the reparented item or the new parent. + * + * Another alternative: we can try to remove folders in level order, instead + * of the order passed to `remove`. But that means we need a recursive query + * to determine the order. This is already enough of an edge case that + * occasionally reuploading the closest living ancestor is the simplest + * solution. + */ + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes), + ["menu"], + "Should track closest living ancestor of removed subtree" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove_partial() { + await ignoreChangedRoots(); + + info("Insert subtree for partial removal"); + let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: PlacesUtils.bookmarks.menuGuid, + recordId: makeGuid(), + }); + let prevSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.net", + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.com", + }); + let nextSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.org", + }); + let childFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + }); + let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.edu", + }); + let grandChildSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://mozilla.org", + }); + let grandChildFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: childFolder.recordId, + recordId: makeGuid(), + }); + let greatGrandChildPrevSiblingBmk = + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: grandChildFolder.recordId, + recordId: makeGuid(), + url: "http://getfirefox.com", + }); + let greatGrandChildNextSiblingBmk = + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: grandChildFolder.recordId, + recordId: makeGuid(), + url: "http://getthunderbird.com", + }); + let menuBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://example.info", + }); + + info("Remove subset of folders and items in subtree"); + let changes = await PlacesSyncUtils.bookmarks.remove([ + parentFolder.recordId, + childBmk.recordId, + grandChildFolder.recordId, + grandChildBmk.recordId, + childFolder.recordId, + ]); + deepEqual( + Object.keys(changes).sort(), + [ + // Closest living ancestor. + "menu", + // Reparented bookmarks. + prevSiblingBmk.recordId, + nextSiblingBmk.recordId, + grandChildSiblingBmk.recordId, + greatGrandChildPrevSiblingBmk.recordId, + greatGrandChildNextSiblingBmk.recordId, + ].sort(), + "Should track reparented bookmarks and their closest living ancestor" + ); + + /** + * Reparented bookmarks should maintain their order relative to their + * siblings: `prevSiblingBmk` (0) should precede `nextSiblingBmk` (2) in the + * menu, and `greatGrandChildPrevSiblingBmk` (0) should precede + * `greatGrandChildNextSiblingBmk` (1). + */ + let menuChildren = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + menuChildren, + [ + // Existing bookmark. + menuBmk.recordId, + // 1) Moved out of `parentFolder` to `menu`. + prevSiblingBmk.recordId, + nextSiblingBmk.recordId, + // 3) Moved out of `childFolder` to `menu`. After this step, `childFolder` + // is deleted. + grandChildSiblingBmk.recordId, + // 2) Moved out of `grandChildFolder` to `childFolder`, because we remove + // `grandChildFolder` *before* `childFolder`. After this step, + // `grandChildFolder` is deleted and `childFolder`'s children are + // `[grandChildSiblingBmk, greatGrandChildPrevSiblingBmk, + // greatGrandChildNextSiblingBmk]`. + greatGrandChildPrevSiblingBmk.recordId, + greatGrandChildNextSiblingBmk.recordId, + ], + "Should move descendants to closest living ancestor" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_migrateOldTrackerEntries() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let unknownBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let newBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + let normalBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + + await PlacesTestUtils.setBookmarkSyncFields( + { + guid: unknownBmk.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + syncChangeCounter: 0, + }, + { + guid: normalBmk.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + } + ); + PlacesUtils.tagging.tagURI(uri("http://getfirefox.com"), ["taggy"]); + + let tombstoneRecordId = makeGuid(); + await PlacesSyncUtils.bookmarks.migrateOldTrackerEntries([ + { + recordId: normalBmk.guid, + modified: Date.now(), + }, + { + recordId: tombstoneRecordId, + modified: 1479162463976, + }, + ]); + + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [normalBmk.guid, tombstoneRecordId].sort(), + "Should return change records for migrated bookmark and tombstone" + ); + + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unknownBmk.guid, + newBmk.guid, + normalBmk.guid + ); + for (let field of fields) { + if (field.guid == normalBmk.guid) { + ok( + field.lastModified > normalBmk.lastModified, + `Should bump last modified date for migrated bookmark ${field.guid}` + ); + equal( + field.syncChangeCounter, + 1, + `Should bump change counter for migrated bookmark ${field.guid}` + ); + } else { + strictEqual( + field.syncChangeCounter, + 0, + `Should not bump change counter for ${field.guid}` + ); + } + equal( + field.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + `Should set sync status for ${field.guid} to NORMAL` + ); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [ + { + guid: tombstoneRecordId, + dateRemoved: new Date(1479162463976), + }, + ], + "Should write tombstone for nonexistent migrated item" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_ensureMobileQuery() { + info("Ensure we correctly set the showMobileBookmarks preference"); + const mobilePref = "browser.bookmarks.showMobileBookmarks"; + Services.prefs.clearUserPref(mobilePref); + + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://example.com/a", + title: "A", + }); + + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://example.com/b", + title: "B", + }); + + await PlacesSyncUtils.bookmarks.ensureMobileQuery(); + + Assert.ok( + Services.prefs.getBoolPref(mobilePref), + "Pref should be true where there are bookmarks in the folder." + ); + + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + + await PlacesSyncUtils.bookmarks.ensureMobileQuery(); + + Assert.ok( + !Services.prefs.getBoolPref(mobilePref), + "Pref should be false where there are no bookmarks in the folder." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove_stale_tombstones() { + info("Insert and delete synced bookmark"); + { + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://example.com/a", + title: "A", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkAAAA"], + "Should store tombstone for deleted synced bookmark" + ); + } + + info("Reinsert deleted bookmark"); + { + // Different parent, URL, and title, but same GUID. + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/a-restored", + title: "A (Restored)", + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should remove tombstone for reinserted bookmark" + ); + } + + info("Insert tree and erase everything"); + { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + await PlacesUtils.bookmarks.eraseEverything(); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid).sort(), + ["bookmarkBBBB", "bookmarkCCCC"], + "Should store tombstones after erasing everything" + ); + } + + info("Reinsert tree"); + { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.mobileGuid, + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid).sort(), + [], + "Should remove tombstones after reinserting tree" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_resetSyncId() { + let syncId = await PlacesSyncUtils.bookmarks.getSyncId(); + strictEqual(syncId, "", "Should start with empty bookmarks sync ID"); + + // Add a tree with a NORMAL bookmark (A), tombstone (B), NEW bookmark (C), + // and UNKNOWN bookmark (D). + info("Set up local tree before resetting bookmarks sync ID"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Assign new bookmarks sync ID for first time"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + syncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + newSyncId, + syncId, + "Should assign new bookmarks sync ID for first time" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should change all sync statuses to NEW after resetting bookmarks sync ID" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should remove all tombstones after resetting bookmarks sync ID" + ); + + info("Set bookmarks last sync time"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + equal( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should record bookmarks last sync time" + ); + + newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + notEqual( + newSyncId, + syncId, + "Should set new bookmarks sync ID if one already exists" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync time after resetting sync ID" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_wipe() { + info("Add Sync metadata before wipe"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + existingSyncId, + newSyncId, + "Ensure bookmarks sync ID was recorded before wipe" + ); + + info("Set up local tree before wipe"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Wipe bookmarks"); + await PlacesSyncUtils.bookmarks.wipe(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after wipe" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after wipe" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Wiping bookmarks locally should not wipe server" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should drop tombstones after wipe"); + + deepEqual( + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("menu"), + [], + "Should wipe menu children" + ); + deepEqual( + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("toolbar"), + [], + "Should wipe toolbar children" + ); + + let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid + ); + ok( + rootSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset all sync statuses to NEW after wipe" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_eraseEverything() { + info("Add Sync metadata before erase"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + + info("Set up local tree before reset"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Erase all bookmarks"); + await PlacesUtils.bookmarks.eraseEverything(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + newSyncId, + "Should not reset bookmarks sync ID after erase" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should not reset bookmarks last sync after erase" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Erasing everything should not wipe server" + ); + + deepEqual( + (await PlacesTestUtils.fetchSyncTombstones()).map(info => info.guid), + ["bookmarkAAAA", "bookmarkBBBB"], + "Should keep tombstones after erasing everything" + ); + + let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid + ); + ok( + rootSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Should not reset sync statuses after erasing everything" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_reset() { + info("Add Sync metadata before reset"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + info("Set up local tree before reset"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Reset Sync metadata for bookmarks"); + await PlacesSyncUtils.bookmarks.reset(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after reset" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after reset" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Resetting Sync metadata should not wipe server" + ); + + deepEqual( + await PlacesTestUtils.fetchSyncTombstones(), + [], + "Should drop tombstones after reset" + ); + + let itemSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + "bookmarkAAAA", + "bookmarkCCCC" + ); + ok( + itemSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset sync statuses for existing items to NEW after reset" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_restore() { + info("Add Sync metadata before manual restore"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server before manual restore" + ); + + info("Manually restore"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { replace: true }); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after manual restore" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after manual restore" + ); + ok( + await PlacesSyncUtils.bookmarks.shouldWipeRemote(), + "Should wipe server after manual restore" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + "NnvGl3CRA4hC", + PlacesUtils.bookmarks.toolbarGuid, + "APzP8MupzA8l" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset all sync stauses to NEW after manual restore" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_restore_on_startup() { + info("Add Sync metadata before simulated automatic restore"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server before automatic restore" + ); + + info("Simulate automatic restore on startup"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { + replace: true, + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after automatic restore" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after automatic restore" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server after manual restore" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + "NnvGl3CRA4hC", + PlacesUtils.bookmarks.toolbarGuid, + "APzP8MupzA8l" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN + ), + "Should reset all sync stauses to UNKNOWN after automatic restore" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_ensureCurrentSyncId() { + info("Set up local tree"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + strictEqual(existingSyncId, "", "Should start without bookmarks sync ID"); + + info("Assign new bookmarks sync ID"); + { + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA"); + + let newSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + newSyncId, + "syncIdAAAAAA", + "Should assign bookmarks sync ID if one doesn't exist" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkBBBB"], + "Should keep tombstones after assigning new bookmarks sync ID" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + deepEqual( + syncFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + ], + "Should not reset sync statuses after assigning new bookmarks sync ID" + ); + } + + info("Ensure existing bookmarks sync ID matches"); + { + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA"); + + equal( + await PlacesSyncUtils.bookmarks.getSyncId(), + "syncIdAAAAAA", + "Should keep existing bookmarks sync ID on match" + ); + equal( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should keep existing bookmarks last sync time on sync ID match" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkBBBB"], + "Should keep tombstones if bookmarks sync IDs match" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + deepEqual( + syncFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + ], + "Should not reset sync statuses if bookmarks sync IDs match" + ); + } + + info("Replace existing bookmarks sync ID with new ID"); + { + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdBBBBBB"); + + equal( + await PlacesSyncUtils.bookmarks.getSyncId(), + "syncIdBBBBBB", + "Should replace existing bookmarks sync ID on mismatch" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync time on sync ID mismatch" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should drop tombstones after bookmarks sync ID mismatch" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN + ), + "Should reset all sync statuses to UNKNOWN after bookmarks sync ID mismatch" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_history_resetSyncId() { + let syncId = await PlacesSyncUtils.history.getSyncId(); + strictEqual(syncId, "", "Should start with empty history sync ID"); + + info("Assign new history sync ID for first time"); + let newSyncId = await PlacesSyncUtils.history.resetSyncId(); + syncId = await PlacesSyncUtils.history.getSyncId(); + equal(newSyncId, syncId, "Should assign new history sync ID for first time"); + + info("Set history last sync time"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.history.setLastSync(lastSync); + equal( + await PlacesSyncUtils.history.getLastSync(), + lastSync, + "Should record history last sync time" + ); + + newSyncId = await PlacesSyncUtils.history.resetSyncId(); + notEqual( + newSyncId, + syncId, + "Should set new history sync ID if one already exists" + ); + strictEqual( + await PlacesSyncUtils.history.getLastSync(), + 0, + "Should reset history last sync time after resetting sync ID" + ); + + await PlacesSyncUtils.history.reset(); +}); + +add_task(async function test_history_ensureCurrentSyncId() { + info("Assign new history sync ID"); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA"); + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdAAAAAA", + "Should assign history sync ID if one doesn't exist" + ); + + info("Ensure existing history sync ID matches"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.history.setLastSync(lastSync); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA"); + + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdAAAAAA", + "Should keep existing history sync ID on match" + ); + equal( + await PlacesSyncUtils.history.getLastSync(), + lastSync, + "Should keep existing history last sync time on sync ID match" + ); + + info("Replace existing history sync ID with new ID"); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdBBBBBB"); + + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdBBBBBB", + "Should replace existing history sync ID on mismatch" + ); + strictEqual( + await PlacesSyncUtils.history.getLastSync(), + 0, + "Should reset history last sync time on sync ID mismatch" + ); + + await PlacesSyncUtils.history.reset(); +}); diff --git a/toolkit/components/places/tests/sync/xpcshell.ini b/toolkit/components/places/tests/sync/xpcshell.ini new file mode 100644 index 0000000000..0f0953a83c --- /dev/null +++ b/toolkit/components/places/tests/sync/xpcshell.ini @@ -0,0 +1,23 @@ +[DEFAULT] +head = head_sync.js +support-files = + sync_utils_bookmarks.html + sync_utils_bookmarks.json + mirror_corrupt.sqlite + mirror_v1.sqlite + mirror_v5.sqlite + +[test_bookmark_abort_merging.js] +[test_bookmark_chunking.js] +[test_bookmark_corruption.js] +[test_bookmark_deduping.js] +[test_bookmark_deletion.js] +[test_bookmark_haschanges.js] +[test_bookmark_kinds.js] +[test_bookmark_mirror_meta.js] +[test_bookmark_mirror_migration.js] +[test_bookmark_observer_recorder.js] +[test_bookmark_reconcile.js] +[test_bookmark_structure_changes.js] +[test_bookmark_value_changes.js] +[test_sync_utils.js] diff --git a/toolkit/components/places/tests/unit/bookmarks.corrupt.html b/toolkit/components/places/tests/unit/bookmarks.corrupt.html new file mode 100644 index 0000000000..3cf43367fb --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks.corrupt.html @@ -0,0 +1,36 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1 LAST_MODIFIED="1177541029">Bookmarks</H1> + +<DL><p> + <DT><H3 ID="rdf:#$ZvPhC3">Mozilla Firefox</H3> + <DL><p> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$22iCK1">Help and Tutorials</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/customize/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$32iCK1">Customize Firefox</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/community/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$42iCK1">Get Involved</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/about/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A> + <DT><A HREF="b0rked" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A> + </DL><p> + <DT><H3 ADD_DATE="1177541020" LAST_MODIFIED="1177541050" ID="rdf:#$74Gpx2">test</H3> +<DD>folder test comment + <DL><p> + <DT><A HREF="http://test/post" ADD_DATE="1177375336" LAST_MODIFIED="1177375423" SHORTCUTURL="test" WEB_PANEL="true" POST_DATA="hidden1%3Dbar&text1%3D%25s" LAST_CHARSET="ISO-8859-1" ID="rdf:#$pYFe7">test post keyword</A> +<DD>item description + </DL> + <DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3> + <DL><p> + <DT><A HREF="http://example.tld">Example.tld</A> + </DL><p> + <DT><H3 LAST_MODIFIED="1177541040" PERSONAL_TOOLBAR_FOLDER="true" ID="rdf:#$FvPhC3">Bookmarks Toolbar Folder</H3> +<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/central/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$GvPhC3">Getting Started</A> + <DT><A HREF="http://en-US.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" LAST_MODIFIED="1177541035" FEEDURL="http://en-US.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" ID="rdf:#$HvPhC3">Latest Headlines</A> + <DT><A HREF="http://bogus-icon.mozilla.com/" ICON="b0rked" ID="rdf:#$GvPhC3">Getting Started</A> +<DD>Livemark test comment + </DL><p> +</DL><p> diff --git a/toolkit/components/places/tests/unit/bookmarks.json b/toolkit/components/places/tests/unit/bookmarks.json new file mode 100644 index 0000000000..27ed9ce5ca --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks.json @@ -0,0 +1,307 @@ +{ + "guid": "root________", + "title": "", + "id": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551978957783, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "id": 2, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551979382837, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "OCyeUO5uu9FF", + "title": "Mozilla Firefox", + "id": 6, + "parent": 2, + "dateAdded": 1361551979350273, + "lastModified": 1361551979376699, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "OCyeUO5uu9FG", + "title": "Help and Tutorials", + "id": 7, + "parent": 6, + "dateAdded": 1361551979356436, + "lastModified": 1361551979362718, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/help/", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FH", + "index": 1, + "title": "Customize Firefox", + "id": 8, + "parent": 6, + "dateAdded": 1361551979365662, + "lastModified": 1361551979368077, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/customize/", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FJ", + "index": 3, + "title": "About Us", + "id": 10, + "parent": 6, + "dateAdded": 1361551979376699, + "lastModified": 1361551979379060, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/about/", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FI", + "index": 2, + "title": "Get Involved", + "id": 9, + "parent": 6, + "dateAdded": 1361551979371071, + "lastModified": 1361551979373745, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/community/", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "QFM-QnE2ZpMz", + "title": "Test null postData", + "index": 4, + "dateAdded": 1481639510868000, + "lastModified": 1489563704300000, + "id": 17, + "charset": "UTF-8", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "The best" + } + ], + "type": "text/x-moz-place", + "uri": "http://example.com/search?q=%s&suggid=", + "postData": null + } + ] + }, + { + "guid": "OCyeUO5uu9FK", + "index": 1, + "title": "", + "id": 11, + "parent": 2, + "dateAdded": 1361551979380988, + "lastModified": 1361551979380988, + "type": "text/x-moz-place-separator" + }, + { + "guid": "OCyeUO5uu9FL", + "index": 2, + "title": "test", + "id": 12, + "parent": 2, + "dateAdded": 1177541020000000, + "lastModified": 1177541050000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "folder test comment" + } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "OCyeUO5uu9GX", + "title": "test post keyword", + "id": 13, + "parent": 12, + "dateAdded": 1177375336000000, + "lastModified": 1177375423000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "item description" + }, + { + "name": "bookmarkProperties/loadInSidebar", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 1, + "value": 1 + } + ], + "type": "text/x-moz-place", + "uri": "http://test/post", + "keyword": "test", + "charset": "ISO-8859-1", + "postData": "hidden1%3Dbar&text1%3D%25s" + } + ] + } + ] + }, + { + "index": 1, + "title": "Bookmarks Toolbar", + "id": 3, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1177541050000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "OCyeUO5uu9FB", + "title": "Getting Started", + "id": 15, + "parent": 3, + "dateAdded": 1361551979409695, + "lastModified": 1361551979412080, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/central/", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FR", + "index": 1, + "title": "Latest Headlines", + "id": 16, + "parent": 3, + "dateAdded": 1361551979451584, + "lastModified": 1361551979457086, + "livemark": 1, + "annos": [ + { + "name": "livemark/feedURI", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" + }, + { + "name": "livemark/siteURI", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" + } + ], + "type": "text/x-moz-place-container", + "children": [] + } + ] + }, + { + "index": 2, + "title": "Tags", + "id": 4, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551978957783, + "type": "text/x-moz-place-container", + "root": "tagsFolder", + "children": [] + }, + { + "index": 3, + "title": "Unsorted Bookmarks", + "id": 5, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1177541050000000, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [ + { + "guid": "OCyeUO5uu9FW", + "title": "Example.tld", + "id": 14, + "parent": 5, + "dateAdded": 1361551979401846, + "lastModified": 1361551979402952, + "type": "text/x-moz-place", + "uri": "http://example.tld/" + }, + { + "guid": "Cfkety492Afk", + "title": "test tagged bookmark", + "id": 15, + "parent": 5, + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "type": "text/x-moz-place", + "uri": "http://example.tld/tagged", + "tags": "foo" + }, + { + "guid": "lOZGoFR1eXbl", + "title": "Bookmarks Toolbar Shortcut", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 16, + "type": "text/x-moz-place", + "uri": "place:folder=TOOLBAR" + }, + { + "guid": "7yJWnBVhjRtP", + "title": "Folder Shortcut", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 17, + "type": "text/x-moz-place", + "uri": "place:folder=6" + }, + { + "guid": "vm5QXWuWc12l", + "title": "Folder Shortcut 2", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 18, + "type": "text/x-moz-place", + "uri": "place:folder=6123443" + }, + { + "guid": "Icg1XlIozA1D", + "title": "Folder Shortcut 3", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 18, + "type": "text/x-moz-place", + "uri": "place:folder=6&folder=BOOKMARKS_MENU" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/bookmarks.preplaces.html b/toolkit/components/places/tests/unit/bookmarks.preplaces.html new file mode 100644 index 0000000000..0ddf7725b4 --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks.preplaces.html @@ -0,0 +1,36 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1 LAST_MODIFIED="1177541029">Bookmarks</H1> + +<DL><p> + <DT><H3 ID="rdf:#$ZvPhC3">Mozilla Firefox</H3> + <DL><p> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$22iCK1">Help and Tutorials</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/customize/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$32iCK1">Customize Firefox</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/community/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$42iCK1">Get Involved</A> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/about/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A> + </DL><p> + <HR> + <DT><H3 ADD_DATE="1177541020" LAST_MODIFIED="1177541050" ID="rdf:#$74Gpx2">test</H3> +<DD>folder test comment + <DL><p> + <DT><A HREF="http://test/post" ADD_DATE="1177375336" LAST_MODIFIED="1177375423" SHORTCUTURL="test" WEB_PANEL="true" POST_DATA="hidden1%3Dbar&text1%3D%25s" LAST_CHARSET="ISO-8859-1" ID="rdf:#$pYFe7">test post keyword</A> +<DD>item description + </DL> + <DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3> + <DL><p> + <DT><A HREF="http://example.tld">Example.tld</A> + </DL><p> + <DT><H3 LAST_MODIFIED="1177541040" PERSONAL_TOOLBAR_FOLDER="true" ID="rdf:#$FvPhC3">Bookmarks Toolbar Folder</H3> +<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/central/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$GvPhC3">Getting Started</A> + <DT><A HREF="http://en-US.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" LAST_MODIFIED="1177541035" FEEDURL="http://en-US.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" ID="rdf:#$HvPhC3">Latest Headlines</A> + <DT><A LAST_MODIFIED="1177541035" FEEDURL="http://en-US.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" ID="rdf:#$HvPhC3">Latest Headlines No Site</A> +<DD>Livemark test comment + </DL><p> +</DL><p> diff --git a/toolkit/components/places/tests/unit/bookmarks_corrupt.json b/toolkit/components/places/tests/unit/bookmarks_corrupt.json new file mode 100644 index 0000000000..93f21d3ece --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks_corrupt.json @@ -0,0 +1,72 @@ +{ + "guid": "root________", + "title": "", + "id": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551978957783, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "id": 2, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551979382837, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "OCyeUO5uu9FG", + "title": "Help and Tutorials", + "id": 7, + "dateAdded": 1361551979356436, + "lastModified": 1361551979362718, + "type": "x/invalid", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/help/" + }, + { + "guid": "OCyeUO5uu9FH", + "index": 1, + "title": "Customize Firefox", + "id": 8, + "dateAdded": 1361551979365662, + "lastModified": 1361551979368077, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/customize/" + }, + { + "guid": "OCyeUO5uu9FG", + "title": "Bad URL", + "id": 9, + "dateAdded": 1361551979356436, + "lastModified": 1361551979362718, + "type": "text/x-moz-place", + "uri": "http:///" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "id": 2, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551979382837, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "OCyeUO5uu9FG", + "title": "Bad URL", + "id": 9, + "dateAdded": 1361551979356436, + "lastModified": 1361551979362718, + "type": "text/x-moz-place", + "uri": "http:///" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/bookmarks_html_localized.html b/toolkit/components/places/tests/unit/bookmarks_html_localized.html new file mode 100644 index 0000000000..bc3bacc54d --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks_html_localized.html @@ -0,0 +1,21 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<HTML> +<HEAD> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<LINK REL="localization" HREF="bookmarks_html_localized.ftl"> +</HEAD> +<BODY> +<H1 LAST_MODIFIED="1177541029">Bookmarks</H1> + +<DL><p> + <DT><H3 ID="rdf:#$ZvPhC3" data-l10n-id="bookmarks-html-localized-folder">bookmarks-html-localized-folder</H3> + <DL><p> + <DT><A HREF="http://www.mozilla.com/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$22iCK1" data-l10n-id="bookmarks-html-localized-bookmark">bookmarks-html-localized-bookmark</A> + </DL><p> +</DL><p> +</BODY> +</HTML> diff --git a/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html b/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html new file mode 100644 index 0000000000..9fe662f320 --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html @@ -0,0 +1,10 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> + <HTML> + <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> + <Title>Bookmarks</Title> + <H1>Bookmarks</H1> + <DT><H3>Subtitle</H3> + <DL><p> + <DT><A HREF="http://www.mozilla.org/">Mozilla</A> + </DL><p> +</HTML> diff --git a/toolkit/components/places/tests/unit/bookmarks_iconuri.json b/toolkit/components/places/tests/unit/bookmarks_iconuri.json new file mode 100644 index 0000000000..4059c1d53f --- /dev/null +++ b/toolkit/components/places/tests/unit/bookmarks_iconuri.json @@ -0,0 +1,307 @@ +{ + "guid": "root________", + "title": "", + "id": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551978957783, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "id": 2, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551979382837, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "OCyeUO5uu9FF", + "title": "Mozilla Firefox", + "id": 6, + "parent": 2, + "dateAdded": 1361551979350273, + "lastModified": 1361551979376699, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "OCyeUO5uu9FG", + "title": "Help and Tutorials", + "id": 7, + "parent": 6, + "dateAdded": 1361551979356436, + "lastModified": 1361551979362718, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/help/", + "iconUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FH", + "index": 1, + "title": "Customize Firefox", + "id": 8, + "parent": 6, + "dateAdded": 1361551979365662, + "lastModified": 1361551979368077, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/customize/", + "iconUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FJ", + "index": 3, + "title": "About Us", + "id": 10, + "parent": 6, + "dateAdded": 1361551979376699, + "lastModified": 1361551979379060, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/about/", + "iconUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FI", + "index": 2, + "title": "Get Involved", + "id": 9, + "parent": 6, + "dateAdded": 1361551979371071, + "lastModified": 1361551979373745, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/community/", + "iconUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "QFM-QnE2ZpMz", + "title": "Test null postData", + "index": 4, + "dateAdded": 1481639510868000, + "lastModified": 1489563704300000, + "id": 17, + "charset": "UTF-8", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "The best" + } + ], + "type": "text/x-moz-place", + "uri": "http://example.com/search?q=%s&suggid=", + "postData": null + } + ] + }, + { + "guid": "OCyeUO5uu9FK", + "index": 1, + "title": "", + "id": 11, + "parent": 2, + "dateAdded": 1361551979380988, + "lastModified": 1361551979380988, + "type": "text/x-moz-place-separator" + }, + { + "guid": "OCyeUO5uu9FL", + "index": 2, + "title": "test", + "id": 12, + "parent": 2, + "dateAdded": 1177541020000000, + "lastModified": 1177541050000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "folder test comment" + } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "OCyeUO5uu9GX", + "title": "test post keyword", + "id": 13, + "parent": 12, + "dateAdded": 1177375336000000, + "lastModified": 1177375423000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "item description" + }, + { + "name": "bookmarkProperties/loadInSidebar", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 1, + "value": 1 + } + ], + "type": "text/x-moz-place", + "uri": "http://test/post", + "keyword": "test", + "charset": "ISO-8859-1", + "postData": "hidden1%3Dbar&text1%3D%25s" + } + ] + } + ] + }, + { + "index": 1, + "title": "Bookmarks Toolbar", + "id": 3, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1177541050000000, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "OCyeUO5uu9FB", + "title": "Getting Started", + "id": 15, + "parent": 3, + "dateAdded": 1361551979409695, + "lastModified": 1361551979412080, + "type": "text/x-moz-place", + "uri": "http://en-us.www.mozilla.com/en-US/firefox/central/", + "iconUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" + }, + { + "guid": "OCyeUO5uu9FR", + "index": 1, + "title": "Latest Headlines", + "id": 16, + "parent": 3, + "dateAdded": 1361551979451584, + "lastModified": 1361551979457086, + "livemark": 1, + "annos": [ + { + "name": "livemark/feedURI", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" + }, + { + "name": "livemark/siteURI", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" + } + ], + "type": "text/x-moz-place-container", + "children": [] + } + ] + }, + { + "index": 2, + "title": "Tags", + "id": 4, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1361551978957783, + "type": "text/x-moz-place-container", + "root": "tagsFolder", + "children": [] + }, + { + "index": 3, + "title": "Unsorted Bookmarks", + "id": 5, + "parent": 1, + "dateAdded": 1361551978957783, + "lastModified": 1177541050000000, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [ + { + "guid": "OCyeUO5uu9FW", + "title": "Example.tld", + "id": 14, + "parent": 5, + "dateAdded": 1361551979401846, + "lastModified": 1361551979402952, + "type": "text/x-moz-place", + "uri": "http://example.tld/" + }, + { + "guid": "Cfkety492Afk", + "title": "test tagged bookmark", + "id": 15, + "parent": 5, + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "type": "text/x-moz-place", + "uri": "http://example.tld/tagged", + "tags": "foo" + }, + { + "guid": "lOZGoFR1eXbl", + "title": "Bookmarks Toolbar Shortcut", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 16, + "type": "text/x-moz-place", + "uri": "place:folder=TOOLBAR" + }, + { + "guid": "7yJWnBVhjRtP", + "title": "Folder Shortcut", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 17, + "type": "text/x-moz-place", + "uri": "place:folder=6" + }, + { + "guid": "vm5QXWuWc12l", + "title": "Folder Shortcut 2", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 18, + "type": "text/x-moz-place", + "uri": "place:folder=6123443" + }, + { + "guid": "Icg1XlIozA1D", + "title": "Folder Shortcut 3", + "dateAdded": 1507025843703345, + "lastModified": 1507025844703124, + "id": 18, + "type": "text/x-moz-place", + "uri": "place:folder=6&folder=BOOKMARKS_MENU" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/head_bookmarks.js b/toolkit/components/places/tests/unit/head_bookmarks.js new file mode 100644 index 0000000000..5f6250aa5d --- /dev/null +++ b/toolkit/components/places/tests/unit/head_bookmarks.js @@ -0,0 +1,30 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.init(this, false); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); diff --git a/toolkit/components/places/tests/unit/missingBuiltIn.sqlite b/toolkit/components/places/tests/unit/missingBuiltIn.sqlite Binary files differnew file mode 100644 index 0000000000..4dbbb1ac75 --- /dev/null +++ b/toolkit/components/places/tests/unit/missingBuiltIn.sqlite diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json new file mode 100644 index 0000000000..930b7a8382 --- /dev/null +++ b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json @@ -0,0 +1,135 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731479000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731768000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "X6lUyOspVYwi", + "title": "Test Pilot", + "index": 0, + "dateAdded": 1475084731768000, + "lastModified": 1475084731768000, + "id": 3, + "type": "text/x-moz-place", + "uri": "https://testpilot.firefox.com/" + }, + { + "guid": "XF4yRP6bTuil", + "title": "Mobile bookmarks query", + "index": 1, + "dateAdded": 1475084731768000, + "lastModified": 1475084731768000, + "id": 11, + "type": "text/x-moz-place", + "uri": "place:folder=101" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 4, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "buy7711R3ZgE", + "title": "MDN", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 5, + "type": "text/x-moz-place", + "uri": "https://developer.mozilla.org" + } + ] + }, + { + "guid": "3qmd_imziEBE", + "title": "Mobile Bookmarks", + "index": 5, + "dateAdded": 1475084731479000, + "lastModified": 1475084731770000, + "id": 101, + "annos": [ + { + "name": "mobile/bookmarksRoot", + "flags": 0, + "expires": 4, + "value": 1 + }, + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "A description of the mobile folder that should be ignored on import" + } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "_o8e1_zxTJFg", + "title": "Get Firefox!", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 7, + "type": "text/x-moz-place", + "uri": "http://getfirefox.com/" + }, + { + "guid": "QCtSqkVYUbXB", + "title": "Get Thunderbird!", + "index": 1, + "dateAdded": 1475084731770000, + "lastModified": 1475084731770000, + "id": 8, + "type": "text/x-moz-place", + "uri": "http://getthunderbird.com/" + } + ] + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 9, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [ + { + "guid": "KIa9iKZab2Z5", + "title": "Add-ons", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 10, + "type": "text/x-moz-place", + "uri": "https://addons.mozilla.org" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json new file mode 100644 index 0000000000..8d376bf69c --- /dev/null +++ b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json @@ -0,0 +1,101 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731479000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731768000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "Utodo9b0oVws", + "title": "Firefox Accounts", + "index": 0, + "dateAdded": 1475084731955000, + "lastModified": 1475084731955000, + "id": 3, + "type": "text/x-moz-place", + "uri": "https://accounts.firefox.com/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 4, + "type": "text/x-moz-place-container", + "root": "toolbarFolder" + }, + { + "guid": "3qmd_imziEBE", + "title": "Mobile Bookmarks", + "index": 5, + "dateAdded": 1475084731479000, + "lastModified": 1475084731770000, + "id": 5, + "annos": [ + { + "name": "mobile/bookmarksRoot", + "flags": 0, + "expires": 4, + "value": 1 + }, + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "A description of the mobile folder that should be ignored on import" + } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "a17yW6-nTxEJ", + "title": "Mozilla", + "index": 0, + "dateAdded": 1475084731959000, + "lastModified": 1475084731959000, + "id": 6, + "type": "text/x-moz-place", + "uri": "https://mozilla.org/" + }, + { + "guid": "xV10h9Wi3FBM", + "title": "Bugzilla", + "index": 1, + "dateAdded": 1475084731961000, + "lastModified": 1475084731961000, + "id": 7, + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/" + } + ] + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 8, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + } + ] +} diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json b/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json new file mode 100644 index 0000000000..3c5cb63194 --- /dev/null +++ b/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json @@ -0,0 +1,159 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731479000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731768000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "buy7711R3ZgE", + "title": "MDN", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 3, + "type": "text/x-moz-place", + "uri": "https://developer.mozilla.org" + }, + { + "guid": "F_LBgd1fS_uQ", + "title": "Mobile bookmarks query for first folder", + "index": 1, + "dateAdded": 1475084731768000, + "lastModified": 1475084731768000, + "id": 11, + "type": "text/x-moz-place", + "uri": "place:folder=101" + }, + { + "guid": "oIpmQXMWsXvY", + "title": "Mobile bookmarks query for second folder", + "index": 2, + "dateAdded": 1475084731768000, + "lastModified": 1475084731768000, + "id": 12, + "type": "text/x-moz-place", + "uri": "place:folder=102" + } + ] + }, + { + "guid": "3qmd_imziEBE", + "title": "Mobile Bookmarks", + "index": 5, + "dateAdded": 1475084731479000, + "lastModified": 1475084731770000, + "id": 101, + "annos": [ + { + "name": "mobile/bookmarksRoot", + "flags": 0, + "expires": 4, + "value": 1 + }, + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "A description of the mobile folder that should be ignored on import" + } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "a17yW6-nTxEJ", + "title": "Mozilla", + "index": 0, + "dateAdded": 1475084731959000, + "lastModified": 1475084731959000, + "id": 5, + "type": "text/x-moz-place", + "uri": "https://mozilla.org/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 6, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "Utodo9b0oVws", + "title": "Firefox Accounts", + "index": 0, + "dateAdded": 1475084731955000, + "lastModified": 1475084731955000, + "id": 7, + "type": "text/x-moz-place", + "uri": "https://accounts.firefox.com/" + } + ] + }, + { + "guid": "o4YjJpgsufU-", + "title": "Mobile Bookmarks", + "index": 7, + "dateAdded": 1475084731479000, + "lastModified": 1475084731770000, + "id": 102, + "annos": [ + { "name": "mobile/bookmarksRoot", "flags": 0, "expires": 4, "value": 1 } + ], + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "sSZ86WT9WbN3", + "title": "DXR", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 9, + "type": "text/x-moz-place", + "uri": "https://dxr.mozilla.org" + } + ] + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 10, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [ + { + "guid": "xV10h9Wi3FBM", + "title": "Bugzilla", + "index": 1, + "dateAdded": 1475084731961000, + "lastModified": 1475084731961000, + "id": 11, + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json b/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json new file mode 100644 index 0000000000..33908e1fea --- /dev/null +++ b/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json @@ -0,0 +1,89 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731479000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731768000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "X6lUyOspVYwi", + "title": "Test Pilot", + "index": 0, + "dateAdded": 1475084731768000, + "lastModified": 1475084731768000, + "id": 3, + "type": "text/x-moz-place", + "uri": "https://testpilot.firefox.com/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 4, + "type": "text/x-moz-place-container", + "root": "toolbarFolder" + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1475084731479000, + "lastModified": 1475084731742000, + "id": 5, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + }, + { + "guid": "mobile______", + "title": "Mobile Bookmarks", + "index": 4, + "dateAdded": 1475084731479000, + "lastModified": 1475084731770000, + "id": 6, + "annos": [ + { "name": "mobile/bookmarksRoot", "flags": 0, "expires": 4, "value": 1 } + ], + "type": "text/x-moz-place-container", + "root": "mobileFolder", + "children": [ + { + "guid": "_o8e1_zxTJFg", + "title": "Get Firefox!", + "index": 0, + "dateAdded": 1475084731769000, + "lastModified": 1475084731769000, + "id": 7, + "type": "text/x-moz-place", + "uri": "http://getfirefox.com/" + }, + { + "guid": "QCtSqkVYUbXB", + "title": "Get Thunderbird!", + "index": 1, + "dateAdded": 1475084731770000, + "lastModified": 1475084731770000, + "id": 8, + "type": "text/x-moz-place", + "uri": "http://getthunderbird.com/" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json b/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json new file mode 100644 index 0000000000..97af52c44a --- /dev/null +++ b/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json @@ -0,0 +1,89 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731479000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1475084731479000, + "lastModified": 1475084731955000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "Utodo9b0oVws", + "title": "Firefox Accounts", + "index": 0, + "dateAdded": 1475084731955000, + "lastModified": 1475084731955000, + "id": 3, + "type": "text/x-moz-place", + "uri": "https://accounts.firefox.com/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1475084731479000, + "lastModified": 1475084731938000, + "id": 4, + "type": "text/x-moz-place-container", + "root": "toolbarFolder" + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1475084731479000, + "lastModified": 1475084731938000, + "id": 5, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + }, + { + "guid": "mobile______", + "title": "Mobile Bookmarks", + "index": 4, + "dateAdded": 1475084731479000, + "lastModified": 1475084731961000, + "id": 6, + "annos": [ + { "name": "mobile/bookmarksRoot", "flags": 0, "expires": 4, "value": 1 } + ], + "type": "text/x-moz-place-container", + "root": "mobileFolder", + "children": [ + { + "guid": "a17yW6-nTxEJ", + "title": "Mozilla", + "index": 0, + "dateAdded": 1475084731959000, + "lastModified": 1475084731959000, + "id": 7, + "type": "text/x-moz-place", + "uri": "https://mozilla.org/" + }, + { + "guid": "xV10h9Wi3FBM", + "title": "Bugzilla", + "index": 1, + "dateAdded": 1475084731961000, + "lastModified": 1475084731961000, + "id": 8, + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/" + } + ] + } + ] +} diff --git a/toolkit/components/places/tests/unit/noRoot.sqlite b/toolkit/components/places/tests/unit/noRoot.sqlite Binary files differnew file mode 100644 index 0000000000..02bd907891 --- /dev/null +++ b/toolkit/components/places/tests/unit/noRoot.sqlite diff --git a/toolkit/components/places/tests/unit/places.sparse.sqlite b/toolkit/components/places/tests/unit/places.sparse.sqlite Binary files differnew file mode 100644 index 0000000000..915089021c --- /dev/null +++ b/toolkit/components/places/tests/unit/places.sparse.sqlite diff --git a/toolkit/components/places/tests/unit/test_1085291.js b/toolkit/components/places/tests/unit/test_1085291.js new file mode 100644 index 0000000000..b7ec4181f0 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_1085291.js @@ -0,0 +1,48 @@ +add_task(async function () { + // test that nodes inserted by incremental update for bookmarks of all types + // have the extra bookmark properties (bookmarkGuid, dateAdded, lastModified). + + // getFolderContents opens the root node. + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + + async function insertAndTest(bmInfo) { + bmInfo = await PlacesUtils.bookmarks.insert(bmInfo); + let node = root.getChild(root.childCount - 1); + Assert.equal(node.bookmarkGuid, bmInfo.guid); + Assert.equal(node.dateAdded, bmInfo.dateAdded * 1000); + Assert.equal(node.lastModified, bmInfo.lastModified * 1000); + } + + // Normal bookmark. + await insertAndTest({ + parentGuid: root.bookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "Test Bookmark", + url: "http://test.url.tld", + }); + + // place: query + await insertAndTest({ + parentGuid: root.bookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "Test Query", + url: `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + }); + + // folder + await insertAndTest({ + parentGuid: root.bookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Test Folder", + }); + + // separator + await insertAndTest({ + parentGuid: root.bookmarkGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_1105208.js b/toolkit/components/places/tests/unit/test_1105208.js new file mode 100644 index 0000000000..6b3f31f96a --- /dev/null +++ b/toolkit/components/places/tests/unit/test_1105208.js @@ -0,0 +1,25 @@ +// Test that result node for folder shortcuts get the target folder title if +// the shortcut itself has no title set. +add_task(async function () { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "fake", + }); + + let shortcutInfo = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `place:parent=${folder.guid}`, + }); + + let unfiledRoot = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1); + Assert.equal(shortcutNode.bookmarkGuid, shortcutInfo.guid); + + Assert.equal(shortcutNode.title, folder.title); + + unfiledRoot.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_1105866.js b/toolkit/components/places/tests/unit/test_1105866.js new file mode 100644 index 0000000000..76ecfb6b7d --- /dev/null +++ b/toolkit/components/places/tests/unit/test_1105866.js @@ -0,0 +1,77 @@ +add_task(async function test_folder_shortcuts() { + let shortcutInfo = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`, + }); + + let unfiledRoot = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1); + Assert.strictEqual( + shortcutNode.itemId, + await PlacesUtils.promiseItemId(shortcutInfo.guid) + ); + Assert.strictEqual( + PlacesUtils.asQuery(shortcutNode).folderItemId, + await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.toolbarGuid) + ); + Assert.strictEqual(shortcutNode.bookmarkGuid, shortcutInfo.guid); + Assert.strictEqual( + PlacesUtils.asQuery(shortcutNode).targetFolderGuid, + PlacesUtils.bookmarks.toolbarGuid + ); + + // test that a node added incrementally also behaves just as well. + shortcutInfo = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + }); + shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1); + Assert.strictEqual( + shortcutNode.itemId, + await PlacesUtils.promiseItemId(shortcutInfo.guid) + ); + Assert.strictEqual( + PlacesUtils.asQuery(shortcutNode).folderItemId, + await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.menuGuid) + ); + Assert.strictEqual(shortcutNode.bookmarkGuid, shortcutInfo.guid); + Assert.strictEqual( + PlacesUtils.asQuery(shortcutNode).targetFolderGuid, + PlacesUtils.bookmarks.menuGuid + ); + + unfiledRoot.containerOpen = false; +}); + +add_task(async function test_plain_folder() { + let folderInfo = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + let unfiledRoot = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + let lastChild = unfiledRoot.getChild(unfiledRoot.childCount - 1); + Assert.strictEqual(lastChild.bookmarkGuid, folderInfo.guid); + Assert.strictEqual( + PlacesUtils.asQuery(lastChild).targetFolderGuid, + folderInfo.guid + ); +}); + +add_task(async function test_non_item_query() { + let options = PlacesUtils.history.getNewQueryOptions(); + let root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + Assert.strictEqual(root.itemId, -1); + Assert.strictEqual(PlacesUtils.asQuery(root).folderItemId, -1); + Assert.strictEqual(root.bookmarkGuid, ""); + Assert.strictEqual(PlacesUtils.asQuery(root).targetFolderGuid, ""); +}); diff --git a/toolkit/components/places/tests/unit/test_1606731.js b/toolkit/components/places/tests/unit/test_1606731.js new file mode 100644 index 0000000000..89e8ec0498 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_1606731.js @@ -0,0 +1,21 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +let hs = PlacesUtils.history; + +/* Test for diacritic-insensitive history search */ + +add_task(async function test_execute() { + const TEST_URL = "http://example.net/El_%C3%81rea_51"; + const SEARCH_TERM = "area"; + await PlacesTestUtils.addVisits(uri(TEST_URL)); + let query = hs.getNewQuery(); + query.searchTerms = SEARCH_TERM; + let options = hs.getNewQueryOptions(); + let result = hs.executeQuery(query, options); + result.root.containerOpen = true; + Assert.ok(result.root.childCount == 1); +}); diff --git a/toolkit/components/places/tests/unit/test_331487.js b/toolkit/components/places/tests/unit/test_331487.js new file mode 100644 index 0000000000..2d4f5f8279 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_331487.js @@ -0,0 +1,113 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get history service +try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); +} catch (ex) { + do_throw("Could not get history service\n"); +} + +add_task(async function test_hierarchical_query() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "1 title", + url: "http://a1.com/", + }, + { + title: "subfolder 1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "2 title", + url: "http://a2.com/", + }, + { + title: "subfolder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "3 title", + url: "http://a3.com/", + }, + ], + }, + ], + }, + ], + }, + ], + }); + + let [folderGuid, b1, sf1, b2, sf2, b3] = bookmarks.map( + bookmark => bookmark.guid + ); + + // bookmark query that should result in the "hierarchical" result + // because there is one query, one folder, + // no begin time, no end time, no domain, no uri, no search term + // and no max results. See GetSimpleBookmarksQueryFolder() + // for more details. + var options = histsvc.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + var query = histsvc.getNewQuery(); + query.setParents([folderGuid]); + var result = histsvc.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 2); + Assert.equal(root.getChild(0).bookmarkGuid, b1); + Assert.equal(root.getChild(1).bookmarkGuid, sf1); + + // check the contents of the subfolder + var sf1Node = root.getChild(1); + sf1Node = sf1Node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + sf1Node.containerOpen = true; + Assert.equal(sf1Node.childCount, 2); + Assert.equal(sf1Node.getChild(0).bookmarkGuid, b2); + Assert.equal(sf1Node.getChild(1).bookmarkGuid, sf2); + + // check the contents of the subfolder's subfolder + var sf2Node = sf1Node.getChild(1); + sf2Node = sf2Node.QueryInterface(Ci.nsINavHistoryContainerResultNode); + sf2Node.containerOpen = true; + Assert.equal(sf2Node.childCount, 1); + Assert.equal(sf2Node.getChild(0).bookmarkGuid, b3); + + sf2Node.containerOpen = false; + sf1Node.containerOpen = false; + root.containerOpen = false; + + // bookmark query that should result in a flat list + // because we specified max results + options = histsvc.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + options.maxResults = 10; + query = histsvc.getNewQuery(); + query.setParents([folderGuid]); + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 3); + Assert.equal(root.getChild(0).bookmarkGuid, b1); + Assert.equal(root.getChild(1).bookmarkGuid, b2); + Assert.equal(root.getChild(2).bookmarkGuid, b3); + root.containerOpen = false; + + // XXX TODO + // test that if we have: more than one query, + // multiple folders, a begin time, an end time, a domain, a uri + // or a search term, that we get the (correct) flat list results + // (like we do when specified maxResults) +}); diff --git a/toolkit/components/places/tests/unit/test_384370.js b/toolkit/components/places/tests/unit/test_384370.js new file mode 100644 index 0000000000..88c0fd5976 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_384370.js @@ -0,0 +1,188 @@ +var tagData = [ + { uri: uri("http://slint.us"), tags: ["indie", "kentucky", "music"] }, + { + uri: uri("http://en.wikipedia.org/wiki/Diplodocus"), + tags: ["dinosaur", "dj", "rad word"], + }, +]; + +var bookmarkData = [ + { uri: uri("http://slint.us"), title: "indie, kentucky, music" }, + { + uri: uri("http://en.wikipedia.org/wiki/Diplodocus"), + title: "dinosaur, dj, rad word", + }, +]; + +/* + HTML+FEATURES SUMMARY: + - import legacy bookmarks + - export as json, import, test (tests integrity of html > json) + - export as html, import, test (tests integrity of json > html) + + BACKUP/RESTORE SUMMARY: + - create a bookmark in each root + - tag multiple URIs with multiple tags + - export as json, import, test +*/ +add_task(async function () { + // Remove eventual bookmarks.exported.json. + let jsonFile = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.json" + ); + await IOUtils.remove(jsonFile, { ignoreAbsent: true }); + + // Test importing a pre-Places canonical bookmarks file. + // Note: we do not empty the db before this import to catch bugs like 380999 + let htmlFile = PathUtils.join(do_get_cwd().path, "bookmarks.preplaces.html"); + await BookmarkHTMLUtils.importFromFile(htmlFile, { replace: true }); + + // Populate the database. + for (let { uri, tags } of tagData) { + PlacesUtils.tagging.tagURI(uri, tags); + } + for (let { uri, title } of bookmarkData) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title, + }); + } + for (let { uri, title } of bookmarkData) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: uri, + title, + }); + } + + await validate("initial database"); + + // Test exporting a Places canonical json file. + // 1. export to bookmarks.exported.json + await BookmarkJSONUtils.exportToFile(jsonFile); + info("exported json"); + + // 2. empty bookmarks db + // 3. import bookmarks.exported.json + await BookmarkJSONUtils.importFromFile(jsonFile, { replace: true }); + info("imported json"); + + // 4. run the test-suite + await validate("re-imported json"); + info("validated import"); +}); + +async function validate(infoMsg) { + info(`Validating ${infoMsg}: testMenuBookmarks`); + await testMenuBookmarks(); + info(`Validating ${infoMsg}: testToolbarBookmarks`); + await testToolbarBookmarks(); + info(`Validating ${infoMsg}: testUnfiledBookmarks`); + testUnfiledBookmarks(); + info(`Validating ${infoMsg}: testTags`); + testTags(); + await PlacesTestUtils.promiseAsyncUpdates(); +} + +// Tests a bookmarks datastore that has a set of bookmarks, etc +// that flex each supported field and feature. +async function testMenuBookmarks() { + let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid).root; + Assert.equal(root.childCount, 3); + + let separatorNode = root.getChild(1); + Assert.equal(separatorNode.type, separatorNode.RESULT_TYPE_SEPARATOR); + + let folderNode = root.getChild(2); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER); + Assert.equal(folderNode.title, "test"); + let folder = await PlacesUtils.bookmarks.fetch(folderNode.bookmarkGuid); + Assert.equal(folder.dateAdded.getTime(), 1177541020000); + + Assert.equal(PlacesUtils.asQuery(folderNode).hasChildren, true); + + // open test folder, and test the children + folderNode.containerOpen = true; + Assert.equal(folderNode.childCount, 1); + + let bookmarkNode = folderNode.getChild(0); + Assert.equal("http://test/post", bookmarkNode.uri); + Assert.equal("test post keyword", bookmarkNode.title); + Assert.equal(bookmarkNode.dateAdded, 1177375336000000); + + let entry = await PlacesUtils.keywords.fetch({ url: bookmarkNode.uri }); + Assert.equal("test", entry.keyword); + Assert.equal("hidden1%3Dbar&text1%3D%25s", entry.postData); + + let pageInfo = await PlacesUtils.history.fetch(bookmarkNode.uri, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + "ISO-8859-1", + "Should have the correct charset" + ); + + folderNode.containerOpen = false; + root.containerOpen = false; +} + +async function testToolbarBookmarks() { + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + + // child count (add 2 for pre-existing items, one of the feeds is skipped + // because it doesn't have href) + Assert.equal(root.childCount, bookmarkData.length + 2); + + // Livemarks are no more supported but may still exist in old html files. + let legacyLivemarkNode = root.getChild(1); + Assert.equal("Latest Headlines", legacyLivemarkNode.title); + Assert.equal( + "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/", + legacyLivemarkNode.uri + ); + Assert.equal( + legacyLivemarkNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_URI + ); + + // test added bookmark data + let bookmarkNode = root.getChild(2); + Assert.equal(bookmarkNode.uri, bookmarkData[0].uri.spec); + Assert.equal(bookmarkNode.title, bookmarkData[0].title); + bookmarkNode = root.getChild(3); + Assert.equal(bookmarkNode.uri, bookmarkData[1].uri.spec); + Assert.equal(bookmarkNode.title, bookmarkData[1].title); + + root.containerOpen = false; +} + +function testUnfiledBookmarks() { + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ).root; + // child count (add 1 for pre-existing item) + Assert.equal(root.childCount, bookmarkData.length + 1); + for (let i = 1; i < root.childCount; ++i) { + let child = root.getChild(i); + Assert.equal(child.uri, bookmarkData[i - 1].uri.spec); + Assert.equal(child.title, bookmarkData[i - 1].title); + if (child.tags) { + Assert.equal(child.tags, bookmarkData[i - 1].title); + } + } + root.containerOpen = false; +} + +function testTags() { + for (let { uri, tags } of tagData) { + info("Test tags for " + uri.spec + ": " + tags + "\n"); + let foundTags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(foundTags.length, tags.length); + Assert.ok(tags.every(tag => foundTags.includes(tag))); + } +} diff --git a/toolkit/components/places/tests/unit/test_385397.js b/toolkit/components/places/tests/unit/test_385397.js new file mode 100644 index 0000000000..7746b89657 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_385397.js @@ -0,0 +1,152 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const TOTAL_SITES = 20; + +add_task(async function test_execute() { + let now = (Date.now() - 10000) * 1000; + + for (let i = 0; i < TOTAL_SITES; i++) { + let site = "http://www.test-" + i + ".com/"; + let testURI = uri(site); + let testImageURI = uri(site + "blank.gif"); + let when = now + i * TOTAL_SITES * 1000; + await PlacesTestUtils.addVisits([ + { uri: testURI, visitDate: when, transition: TRANSITION_TYPED }, + { + uri: testImageURI, + visitDate: when + 1000, + transition: TRANSITION_EMBED, + }, + { + uri: testImageURI, + visitDate: when + 2000, + transition: TRANSITION_FRAMED_LINK, + }, + { uri: testURI, visitDate: when + 3000, transition: TRANSITION_LINK }, + ]); + } + + // verify our visits AS_VISIT, ordered by date descending + // including hidden + // we should get 80 visits: + // http://www.test-19.com/ + // http://www.test-19.com/blank.gif + // http://www.test-19.com/ + // http://www.test-19.com/ + // ... + // http://www.test-0.com/ + // http://www.test-0.com/blank.gif + // http://www.test-0.com/ + // http://www.test-0.com/ + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_VISIT; + options.includeHidden = true; + let root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + let cc = root.childCount; + // Embed visits are not added to the database, thus they won't appear. + Assert.equal(cc, 3 * TOTAL_SITES); + for (let i = 0; i < TOTAL_SITES; i++) { + let index = i * 3; + let node = root.getChild(index); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + node = root.getChild(++index); + Assert.equal(node.uri, site + "blank.gif"); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + node = root.getChild(++index); + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; + + // verify our visits AS_VISIT, ordered by date descending + // we should get 40 visits: + // http://www.test-19.com/ + // http://www.test-19.com/ + // ... + // http://www.test-0.com/ + // http://www.test-0.com/ + options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_VISIT; + root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + cc = root.childCount; + // 2 * TOTAL_SITES because we count the TYPED and LINK, but not EMBED or FRAMED + Assert.equal(cc, 2 * TOTAL_SITES); + for (let i = 0; i < TOTAL_SITES; i++) { + let index = i * 2; + let node = root.getChild(index); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + node = root.getChild(++index); + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; + + // test our optimized query for the places menu + // place:type=0&sort=4&maxResults=10 + // verify our visits AS_URI, ordered by date descending + // we should get 10 visits: + // http://www.test-19.com/ + // ... + // http://www.test-10.com/ + options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.maxResults = 10; + options.resultType = options.RESULTS_AS_URI; + root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + cc = root.childCount; + Assert.equal(cc, options.maxResults); + for (let i = 0; i < cc; i++) { + let node = root.getChild(i); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; + + // test without a maxResults, which executes a different query + // but the first 10 results should be the same. + // verify our visits AS_URI, ordered by date descending + // we should get 20 visits, but the first 10 should be + // http://www.test-19.com/ + // ... + // http://www.test-10.com/ + options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_URI; + root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + cc = root.childCount; + Assert.equal(cc, TOTAL_SITES); + for (let i = 0; i < 10; i++) { + let node = root.getChild(i); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_399266.js b/toolkit/components/places/tests/unit/test_399266.js new file mode 100644 index 0000000000..6f99c710f8 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_399266.js @@ -0,0 +1,82 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const TOTAL_SITES = 20; + +add_task(async function test_execute() { + let places = []; + for (let i = 0; i < TOTAL_SITES; i++) { + for (let j = 0; j <= i; j++) { + places.push({ + uri: uri("http://www.test-" + i + ".com/"), + transition: TRANSITION_TYPED, + }); + // because these are embedded visits, they should not show up on our + // query results. If they do, we have a problem. + places.push({ + uri: uri("http://www.hidden.com/hidden.gif"), + transition: TRANSITION_EMBED, + }); + places.push({ + uri: uri("http://www.alsohidden.com/hidden.gif"), + transition: TRANSITION_FRAMED_LINK, + }); + } + } + await PlacesTestUtils.addVisits(places); + + // test our optimized query for the "Most Visited" item + // in the "Smart Bookmarks" folder + // place:queryType=0&sort=8&maxResults=10 + // verify our visits AS_URI, ordered by visit count descending + // we should get 10 visits: + // http://www.test-19.com/ + // ... + // http://www.test-10.com/ + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING; + options.maxResults = 10; + options.resultType = options.RESULTS_AS_URI; + let root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + let cc = root.childCount; + Assert.equal(cc, options.maxResults); + for (let i = 0; i < cc; i++) { + let node = root.getChild(i); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; + + // test without a maxResults, which executes a different query + // but the first 10 results should be the same. + // verify our visits AS_URI, ordered by visit count descending + // we should get 20 visits, but the first 10 should be + // http://www.test-19.com/ + // ... + // http://www.test-10.com/ + options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING; + options.resultType = options.RESULTS_AS_URI; + root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + cc = root.childCount; + Assert.equal(cc, TOTAL_SITES); + for (let i = 0; i < 10; i++) { + let node = root.getChild(i); + let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/"; + Assert.equal(node.uri, site); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_402799.js b/toolkit/components/places/tests/unit/test_402799.js new file mode 100644 index 0000000000..f621911cce --- /dev/null +++ b/toolkit/components/places/tests/unit/test_402799.js @@ -0,0 +1,60 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get history services +try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); +} catch (ex) { + do_throw("Could not get history services\n"); +} + +// Get tagging service +try { + var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].getService( + Ci.nsITaggingService + ); +} catch (ex) { + do_throw("Could not get tagging service\n"); +} + +add_task(async function test_query_only_returns_bookmarks_not_tags() { + const url = "http://foo.bar/"; + + // create 2 bookmarks on the same uri + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "title 1", + url, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "title 2", + url, + }); + // add some tags + tagssvc.tagURI(uri(url), ["foo", "bar", "foobar", "foo bar"]); + + // check that a generic bookmark query returns only real bookmarks + let options = histsvc.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let query = histsvc.getNewQuery(); + let result = histsvc.executeQuery(query, options); + let root = result.root; + + root.containerOpen = true; + let cc = root.childCount; + Assert.equal(cc, 2); + let node1 = root.getChild(0); + node1 = await PlacesUtils.bookmarks.fetch(node1.bookmarkGuid); + Assert.equal(node1.parentGuid, PlacesUtils.bookmarks.menuGuid); + let node2 = root.getChild(1); + node2 = await PlacesUtils.bookmarks.fetch(node2.bookmarkGuid); + Assert.equal(node2.parentGuid, PlacesUtils.bookmarks.toolbarGuid); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_412132.js b/toolkit/components/places/tests/unit/test_412132.js new file mode 100644 index 0000000000..65653b5c0a --- /dev/null +++ b/toolkit/components/places/tests/unit/test_412132.js @@ -0,0 +1,181 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + * TEST DESCRIPTION: + * + * Tests patch to Bug 412132: + * https://bugzilla.mozilla.org/show_bug.cgi?id=412132 + */ + +const TEST_URL0 = "http://example.com/"; +const TEST_URL1 = "http://example.com/1"; +const TEST_URL2 = "http://example.com/2"; + +add_task(async function changeuri_unvisited_bookmark() { + info( + "After changing URI of bookmark, frecency of bookmark's " + + "original URI should be zero if original URI is unvisited and " + + "no longer bookmarked." + ); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URL1, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL1, + }), + 0, + "Bookmarked => frecency of URI should be != 0" + ); + + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + url: TEST_URL2, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL1, + }), + 0, + "Unvisited URI no longer bookmarked => frecency should = 0" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function changeuri_visited_bookmark() { + info( + "After changing URI of bookmark, frecency of bookmark's " + + "original URI should not be zero if original URI is visited." + ); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URL1, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL1, + }), + 0, + "Bookmarked => frecency of URI should be != 0" + ); + + await PlacesTestUtils.addVisits(TEST_URL1); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + url: TEST_URL2, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL1, + }), + 0, + "*Visited* URI no longer bookmarked => frecency should != 0" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function changeuri_bookmark_still_bookmarked() { + info( + "After changing URI of bookmark, frecency of bookmark's " + + "original URI should not be zero if original URI is still " + + "bookmarked." + ); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark 1 title", + url: TEST_URL1, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark 2 title", + url: TEST_URL1, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL1, + }), + 0, + "Bookmarked => frecency of URI should be != 0" + ); + + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + url: TEST_URL2, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("URI still bookmarked => frecency should != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL2, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function changeuri_nonexistent_bookmark() { + // Try a bogus guid. + await Assert.rejects( + PlacesUtils.bookmarks.update({ + guid: "ABCDEDFGHIJK", + url: TEST_URL2, + }), + /No bookmarks found for the provided GUID/, + "Changing the URI of a non-existent bookmark should fail." + ); + + // Now add a bookmark, delete it, and check. + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URL0, + }); + + await PlacesUtils.bookmarks.remove(bookmark.guid); + + await Assert.rejects( + PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + url: TEST_URL2, + }), + /No bookmarks found for the provided GUID/, + "Changing the URI of a non-existent bookmark should fail." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/unit/test_415460.js b/toolkit/components/places/tests/unit/test_415460.js new file mode 100644 index 0000000000..3f0f7a1edb --- /dev/null +++ b/toolkit/components/places/tests/unit/test_415460.js @@ -0,0 +1,37 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService +); + +/** + * Checks to see that a search has exactly one result in the database. + * + * @param aTerms + * The terms to search for. + * @returns true if the search returns one result, false otherwise. + */ +function search_has_result(aTerms) { + var options = hs.getNewQueryOptions(); + options.maxResults = 1; + options.resultType = options.RESULTS_AS_URI; + var query = hs.getNewQuery(); + query.searchTerms = aTerms; + var result = hs.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var cc = root.childCount; + root.containerOpen = false; + return cc == 1; +} + +add_task(async function test_execute() { + const SEARCH_TERM = "ユニコード"; + const TEST_URL = "http://example.com/" + SEARCH_TERM + "/"; + await PlacesTestUtils.addVisits(uri(TEST_URL)); + Assert.ok(search_has_result(SEARCH_TERM)); +}); diff --git a/toolkit/components/places/tests/unit/test_415757.js b/toolkit/components/places/tests/unit/test_415757.js new file mode 100644 index 0000000000..a069bc6aa3 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_415757.js @@ -0,0 +1,92 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Checks to see that a URI is in the database. + * + * @param aURI + * The URI to check. + * @returns true if the URI is in the DB, false otherwise. + */ +function uri_in_db(aURI) { + var options = PlacesUtils.history.getNewQueryOptions(); + options.maxResults = 1; + options.resultType = options.RESULTS_AS_URI; + var query = PlacesUtils.history.getNewQuery(); + query.uri = aURI; + var result = PlacesUtils.history.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var cc = root.childCount; + root.containerOpen = false; + return cc == 1; +} + +const TOTAL_SITES = 20; + +// main +add_task(async function test_execute() { + // add pages to global history + for (let i = 0; i < TOTAL_SITES; i++) { + let uri = "http://www.test-" + i + ".com/"; + let when = Date.now() * 1000 + i * TOTAL_SITES; + await PlacesTestUtils.addVisits({ uri, visitDate: when }); + } + for (let i = 0; i < TOTAL_SITES; i++) { + let uri = "http://www.test.com/" + i + "/"; + let when = Date.now() * 1000 + i * TOTAL_SITES; + await PlacesTestUtils.addVisits({ uri, visitDate: when }); + } + + // set a page annotation on one of the urls that will be removed + var testAnnoDeletedURI = "http://www.test.com/1/"; + var testAnnoDeletedName = "foo"; + var testAnnoDeletedValue = "bar"; + await PlacesUtils.history.update({ + url: testAnnoDeletedURI, + annotations: new Map([[testAnnoDeletedName, testAnnoDeletedValue]]), + }); + + // set a page annotation on one of the urls that will NOT be removed + var testAnnoRetainedURI = "http://www.test-1.com/"; + var testAnnoRetainedName = "foo"; + var testAnnoRetainedValue = "bar"; + await PlacesUtils.history.update({ + url: testAnnoRetainedURI, + annotations: new Map([[testAnnoRetainedName, testAnnoRetainedValue]]), + }); + + // remove pages from www.test.com + await PlacesUtils.history.removeByFilter({ host: "www.test.com" }); + + // check that all pages in www.test.com have been removed + for (let i = 0; i < TOTAL_SITES; i++) { + let site = "http://www.test.com/" + i + "/"; + let testURI = uri(site); + Assert.ok(!uri_in_db(testURI)); + } + + // check that all pages in www.test-X.com have NOT been removed + for (let i = 0; i < TOTAL_SITES; i++) { + let site = "http://www.test-" + i + ".com/"; + let testURI = uri(site); + Assert.ok(uri_in_db(testURI)); + } + + // check that annotation on the removed item does not exists + await assertNoOrphanPageAnnotations(); + + // check that annotation on the NOT removed item still exists + let pageInfo = await PlacesUtils.history.fetch(testAnnoRetainedURI, { + includeAnnotations: true, + }); + + Assert.equal( + pageInfo.annotations.get(testAnnoRetainedName), + testAnnoRetainedValue, + "Should have kept the annotation for the non-removed items" + ); +}); diff --git a/toolkit/components/places/tests/unit/test_419792_node_tags_property.js b/toolkit/components/places/tests/unit/test_419792_node_tags_property.js new file mode 100644 index 0000000000..5efadb39fe --- /dev/null +++ b/toolkit/components/places/tests/unit/test_419792_node_tags_property.js @@ -0,0 +1,52 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// get services +var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService +); +var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].getService( + Ci.nsITaggingService +); + +add_task(async function test_query_node_tags_property() { + // get toolbar node + var options = histsvc.getNewQueryOptions(); + var query = histsvc.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var result = histsvc.executeQuery(query, options); + var toolbarNode = result.root; + toolbarNode.containerOpen = true; + + // add a bookmark + var bookmarkURI = uri("http://foo.com"); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "", + url: bookmarkURI, + }); + + // get the node for the new bookmark + var node = toolbarNode.getChild(toolbarNode.childCount - 1); + Assert.equal(node.bookmarkGuid, bookmark.guid); + + // confirm there's no tags via the .tags property + Assert.equal(node.tags, null); + + // add a tag + tagssvc.tagURI(bookmarkURI, ["foo"]); + Assert.equal(node.tags, "foo"); + + // add another tag, to test delimiter and sorting + tagssvc.tagURI(bookmarkURI, ["bar"]); + Assert.equal(node.tags, "bar, foo"); + + // remove the tags, confirming the property is cleared + tagssvc.untagURI(bookmarkURI, null); + Assert.equal(node.tags, null); + + toolbarNode.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_425563.js b/toolkit/components/places/tests/unit/test_425563.js new file mode 100644 index 0000000000..8ecf8bbcc8 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_425563.js @@ -0,0 +1,76 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_execute() { + let count_visited_URIs = [ + "http://www.test-link.com/", + "http://www.test-typed.com/", + "http://www.test-bookmark.com/", + "http://www.test-redirect-permanent.com/", + "http://www.test-redirect-temporary.com/", + ]; + + let notcount_visited_URIs = [ + "http://www.test-download.com/", + "http://www.test-framed.com/", + "http://www.test-reload.com/", + ]; + + // add visits, one for each transition type + await PlacesTestUtils.addVisits([ + { uri: uri("http://www.test-link.com/"), transition: TRANSITION_LINK }, + { uri: uri("http://www.test-typed.com/"), transition: TRANSITION_TYPED }, + { + uri: uri("http://www.test-bookmark.com/"), + transition: TRANSITION_BOOKMARK, + }, + { + uri: uri("http://www.test-framed.com/"), + transition: TRANSITION_FRAMED_LINK, + }, + { + uri: uri("http://www.test-redirect-permanent.com/"), + transition: TRANSITION_REDIRECT_PERMANENT, + }, + { + uri: uri("http://www.test-redirect-temporary.com/"), + transition: TRANSITION_REDIRECT_TEMPORARY, + }, + { + uri: uri("http://www.test-download.com/"), + transition: TRANSITION_DOWNLOAD, + }, + { uri: uri("http://www.test-reload.com/"), transition: TRANSITION_RELOAD }, + ]); + + // check that all links are marked as visited + for (let visited_uri of count_visited_URIs) { + Assert.ok(await PlacesUtils.history.hasVisits(uri(visited_uri))); + } + for (let visited_uri of notcount_visited_URIs) { + Assert.ok(await PlacesUtils.history.hasVisits(uri(visited_uri))); + } + + // check that visit_count does not take in count embed and downloads + // maxVisits query are directly binded to visit_count + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING; + options.resultType = options.RESULTS_AS_VISIT; + options.includeHidden = true; + let query = PlacesUtils.history.getNewQuery(); + query.minVisits = 1; + let root = PlacesUtils.history.executeQuery(query, options).root; + + root.containerOpen = true; + let cc = root.childCount; + Assert.equal(cc, count_visited_URIs.length); + + for (let i = 0; i < cc; i++) { + let node = root.getChild(i); + Assert.notEqual(count_visited_URIs.indexOf(node.uri), -1); + } + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js b/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js new file mode 100644 index 0000000000..d5926d0c17 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js @@ -0,0 +1,45 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + +- add a folder +- add a folder-shortcut to the new folder +- query for the shortcut +- remove the folder-shortcut +- confirm the shortcut is removed from the query results + +*/ + +add_task(async function test_query_with_remove_shortcut() { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + let query = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "", + url: `place:parent=${folder.guid}`, + }); + + var root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid, + false, + true + ).root; + + var oldCount = root.childCount; + + await PlacesUtils.bookmarks.remove(query.guid); + + Assert.equal(root.childCount, oldCount - 1); + + root.containerOpen = false; + + await PlacesTestUtils.promiseAsyncUpdates(); +}); diff --git a/toolkit/components/places/tests/unit/test_433317_query_title_update.js b/toolkit/components/places/tests/unit/test_433317_query_title_update.js new file mode 100644 index 0000000000..d8f69064d9 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_433317_query_title_update.js @@ -0,0 +1,43 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_query_title_update() { + try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); + } catch (ex) { + do_throw("Unable to initialize Places services"); + } + + // create a query bookmark + let bmQuery = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "test query", + url: "place:", + }); + + // query for that query + var options = histsvc.getNewQueryOptions(); + let query = histsvc.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var result = histsvc.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var queryNode = root.getChild(0); + Assert.equal(queryNode.title, "test query"); + + // change the title + await PlacesUtils.bookmarks.update({ + guid: bmQuery.guid, + title: "foo", + }); + + // confirm the node was updated + Assert.equal(queryNode.title, "foo"); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js b/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js new file mode 100644 index 0000000000..231722e72b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js @@ -0,0 +1,52 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_execute() { + try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); + } catch (ex) { + do_throw("Unable to initialize Places services"); + } + + // add a visit + var testURI = uri("http://test"); + await PlacesTestUtils.addVisits(testURI); + + // query for the visit + var options = histsvc.getNewQueryOptions(); + options.maxResults = 1; + options.resultType = options.RESULTS_AS_URI; + var query = histsvc.getNewQuery(); + query.uri = testURI; + var result = histsvc.executeQuery(query, options); + var root = result.root; + + // check hasChildren while the container is closed + Assert.equal(root.hasChildren, true); + + // now check via the saved search path + var queryURI = histsvc.queryToQueryString(query, options); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "test query", + url: queryURI, + }); + + // query for that query + options = histsvc.getNewQueryOptions(); + query = histsvc.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + var queryNode = root.getChild(0); + Assert.equal(queryNode.title, "test query"); + queryNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + Assert.equal(queryNode.hasChildren, true); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_454977.js b/toolkit/components/places/tests/unit/test_454977.js new file mode 100644 index 0000000000..aa6437988f --- /dev/null +++ b/toolkit/components/places/tests/unit/test_454977.js @@ -0,0 +1,121 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Cache actual visit_count value, filled by add_visit, used by check_results +var visit_count = 0; + +// Returns the Place ID corresponding to an added visit. +async function task_add_visit(aURI, aVisitType) { + // Wait for a visits notification and get the visitId. + let visitId; + let visitsPromise = PlacesTestUtils.waitForNotification( + "page-visited", + visits => { + visitId = visits[0].visitId; + let { url } = visits[0]; + return url == aURI.spec; + } + ); + + // Add visits. + await PlacesTestUtils.addVisits([ + { + uri: aURI, + transition: aVisitType, + }, + ]); + + if (aVisitType != TRANSITION_EMBED) { + await visitsPromise; + } + + // Increase visit_count if applicable + if ( + aVisitType != 0 && + aVisitType != TRANSITION_EMBED && + aVisitType != TRANSITION_FRAMED_LINK && + aVisitType != TRANSITION_DOWNLOAD && + aVisitType != TRANSITION_RELOAD + ) { + visit_count++; + } + + // Get the place id + if (visitId > 0) { + let sql = "SELECT place_id FROM moz_historyvisits WHERE id = ?1"; + let stmt = DBConn().createStatement(sql); + stmt.bindByIndex(0, visitId); + Assert.ok(stmt.executeStep()); + let placeId = stmt.getInt64(0); + stmt.finalize(); + Assert.ok(placeId > 0); + return placeId; + } + return 0; +} + +/** + * Checks for results consistency, using visit_count as constraint + * @param aExpectedCount + * Number of history results we are expecting (excluded hidden ones) + * @param aExpectedCountWithHidden + * Number of history results we are expecting (included hidden ones) + */ +function check_results(aExpectedCount, aExpectedCountWithHidden) { + let query = PlacesUtils.history.getNewQuery(); + // used to check visit_count + query.minVisits = visit_count; + query.maxVisits = visit_count; + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + // Children without hidden ones + Assert.equal(root.childCount, aExpectedCount); + root.containerOpen = false; + + // Execute again with includeHidden = true + // This will ensure visit_count is correct + options.includeHidden = true; + root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + // Children with hidden ones + Assert.equal(root.childCount, aExpectedCountWithHidden); + root.containerOpen = false; +} + +// main + +add_task(async function test_execute() { + const TEST_URI = uri("http://test.mozilla.org/"); + + // Add a visit that force hidden + await task_add_visit(TEST_URI, TRANSITION_EMBED); + check_results(0, 0); + + let placeId = await task_add_visit(TEST_URI, TRANSITION_FRAMED_LINK); + check_results(0, 1); + + // Add a visit that force unhide and check the place id. + // - We expect that the place gets hidden = 0 while retaining the same + // place id and a correct visit_count. + Assert.equal(await task_add_visit(TEST_URI, TRANSITION_TYPED), placeId); + check_results(1, 1); + + // Add a visit that should not increase visit_count + Assert.equal(await task_add_visit(TEST_URI, TRANSITION_RELOAD), placeId); + check_results(1, 1); + + // Add a visit that should not increase visit_count + Assert.equal(await task_add_visit(TEST_URI, TRANSITION_DOWNLOAD), placeId); + check_results(1, 1); + + // Add a visit, check that hidden is not overwritten + // - We expect that the place has still hidden = 0, while retaining + // correct visit_count. + await task_add_visit(TEST_URI, TRANSITION_EMBED); + check_results(1, 1); +}); diff --git a/toolkit/components/places/tests/unit/test_463863.js b/toolkit/components/places/tests/unit/test_463863.js new file mode 100644 index 0000000000..d524c00cd3 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_463863.js @@ -0,0 +1,56 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + * TEST DESCRIPTION: + * + * This test checks that in a basic history query all transition types visits + * appear but TRANSITION_EMBED and TRANSITION_FRAMED_LINK ones. + */ + +var transitions = [ + TRANSITION_LINK, + TRANSITION_TYPED, + TRANSITION_BOOKMARK, + TRANSITION_EMBED, + TRANSITION_FRAMED_LINK, + TRANSITION_REDIRECT_PERMANENT, + TRANSITION_REDIRECT_TEMPORARY, + TRANSITION_DOWNLOAD, +]; + +function runQuery(aResultType) { + let options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = aResultType; + let root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), + options + ).root; + root.containerOpen = true; + let cc = root.childCount; + Assert.equal(cc, transitions.length - 2); + + for (let i = 0; i < cc; i++) { + let node = root.getChild(i); + // Check that all transition types but EMBED and FRAMED appear in results + Assert.notEqual(node.uri.substr(6, 1), TRANSITION_EMBED); + Assert.notEqual(node.uri.substr(6, 1), TRANSITION_FRAMED_LINK); + } + root.containerOpen = false; +} + +add_task(async function test_execute() { + // add visits, one for each transition type + for (let transition of transitions) { + await PlacesTestUtils.addVisits({ + uri: uri("http://" + transition + ".mozilla.org/"), + transition, + }); + } + + runQuery(Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT); + runQuery(Ci.nsINavHistoryQueryOptions.RESULTS_AS_URI); +}); diff --git a/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js b/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js new file mode 100644 index 0000000000..73bb5e9af4 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js @@ -0,0 +1,19 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function run_test() { + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = options.RESULT_TYPE_QUERY; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal( + PlacesUtils.asQuery(root).query.uri, + null, + "Should be null and not crash the browser" + ); + root.containerOpen = false; +} diff --git a/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js b/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js new file mode 100644 index 0000000000..221377e184 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js @@ -0,0 +1,130 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + * TEST DESCRIPTION: + * + * This test checks that setting a sort on a RESULTS_AS_DATE_QUERY query, + * children of inside containers are sorted accordingly. + */ + +var hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService +); + +// Will be inserted in this order, so last one will be the newest visit. +var pages = [ + "http://a.mozilla.org/1/", + "http://a.mozilla.org/2/", + "http://a.mozilla.org/3/", + "http://a.mozilla.org/4/", + "http://b.mozilla.org/5/", + "http://b.mozilla.org/6/", + "http://b.mozilla.org/7/", + "http://b.mozilla.org/8/", +]; + +add_task(async function test_initialize() { + // Add visits. + let now = new Date(); + for (let pageIndex = 0; pageIndex < pages.length; ++pageIndex) { + let page = pages[pageIndex]; + await PlacesTestUtils.addVisits({ + uri: uri(page), + visitDate: new Date(now - (pages.length - pageIndex)), + }); + } +}); + +/** + * Tests that sorting date query by none will sort by title asc. + */ +add_task(function () { + var options = hs.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_DATE_QUERY; + // This should sort by title asc. + options.sortingMode = options.SORT_BY_NONE; + var query = hs.getNewQuery(); + var result = hs.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var dayContainer = root + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + dayContainer.containerOpen = true; + + var cc = dayContainer.childCount; + Assert.equal(cc, pages.length); + for (var i = 0; i < cc; i++) { + var node = dayContainer.getChild(i); + Assert.equal(pages[i], node.uri); + } + + dayContainer.containerOpen = false; + root.containerOpen = false; +}); + +/** + * Tests that sorting date query by date will sort accordingly. + */ +add_task(function () { + var options = hs.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_DATE_QUERY; + // This should sort by title asc. + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + var query = hs.getNewQuery(); + var result = hs.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var dayContainer = root + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + dayContainer.containerOpen = true; + + var cc = dayContainer.childCount; + Assert.equal(cc, pages.length); + for (var i = 0; i < cc; i++) { + var node = dayContainer.getChild(i); + Assert.equal(pages[pages.length - i - 1], node.uri); + } + + dayContainer.containerOpen = false; + root.containerOpen = false; +}); + +/** + * Tests that sorting date site query by date will still sort by title asc. + */ +add_task(function () { + var options = hs.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_DATE_SITE_QUERY; + // This should sort by title asc. + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + var query = hs.getNewQuery(); + var result = hs.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var dayContainer = root + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + dayContainer.containerOpen = true; + var siteContainer = dayContainer + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + Assert.equal(siteContainer.title, "a.mozilla.org"); + siteContainer.containerOpen = true; + + var cc = siteContainer.childCount; + Assert.equal(cc, pages.length / 2); + for (var i = 0; i < cc / 2; i++) { + var node = siteContainer.getChild(i); + Assert.equal(pages[i], node.uri); + } + + siteContainer.containerOpen = false; + dayContainer.containerOpen = false; + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_536081.js b/toolkit/components/places/tests/unit/test_536081.js new file mode 100644 index 0000000000..4105b09d1b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_536081.js @@ -0,0 +1,35 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const TEST_URL = { + u: "http://www.google.com/search?q=testing%3Bthis&ie=utf-8&oe=utf-8&aq=t&rls=org.mozilla:en-US:unofficial&client=firefox-a", + s: "goog", +}; + +add_task(async function () { + print("Testing url: " + TEST_URL.u); + await PlacesTestUtils.addVisits(uri(TEST_URL.u)); + + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = TEST_URL.s; + let options = PlacesUtils.history.getNewQueryOptions(); + + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + let cc = root.childCount; + Assert.equal(cc, 1); + + print("Checking url is in the query."); + let node = root.getChild(0); + print("Found " + node.uri); + + Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL.u)); + + root.containerOpen = false; + await PlacesUtils.history.remove(node.uri); + + Assert.equal(false, await PlacesTestUtils.isPageInDB(TEST_URL.u)); +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesDBUtils_removeOldCorruptDBs.js b/toolkit/components/places/tests/unit/test_PlacesDBUtils_removeOldCorruptDBs.js new file mode 100644 index 0000000000..26ec85ffc4 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesDBUtils_removeOldCorruptDBs.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEMP_FILES_TO_CREATE = 5; +const LAST_MODIFICATION_DAY = [5, 10, 15, 20, 25]; +const TEST_CURRENT_TIME = Date.now(); +const MS_PER_DAY = 86400000; +const RETAIN_DAYS = 14; + +async function createfiles() { + for (let i = 0; i < TEMP_FILES_TO_CREATE; i++) { + let setTime = TEST_CURRENT_TIME; + setTime -= LAST_MODIFICATION_DAY[i] * MS_PER_DAY; + let fileName = "places.sqlite" + (i > 0 ? "-" + i : "") + ".corrupt"; + let filePath = PathUtils.join(PathUtils.profileDir, fileName); + await IOUtils.writeUTF8(filePath, "test-file-delete-me", { + tmpPath: filePath + ".tmp", + }); + Assert.ok(await IOUtils.exists(filePath), "file created: " + filePath); + await IOUtils.setModificationTime(filePath, setTime); + } +} + +add_task(async function removefiles() { + await createfiles(); + await PlacesDBUtils.runTasks([PlacesDBUtils.removeOldCorruptDBs]); + for (let i = 0; i < TEMP_FILES_TO_CREATE; i++) { + let fileName = "places.sqlite" + (i > 0 ? "-" + i : "") + ".corrupt"; + let filePath = PathUtils.join(PathUtils.profileDir, fileName); + if (LAST_MODIFICATION_DAY[i] >= RETAIN_DAYS) { + Assert.ok( + !(await IOUtils.exists(filePath)), + "Old corrupt file has been removed" + filePath + ); + } else { + Assert.ok( + await IOUtils.exists(filePath), + "Files that are not old are not removed" + filePath + ); + } + } +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesQuery_history.js b/toolkit/components/places/tests/unit/test_PlacesQuery_history.js new file mode 100644 index 0000000000..02648ead05 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesQuery_history.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PlacesQuery } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesQuery.sys.mjs" +); + +add_task(async function test_visits_cache_is_updated() { + const placesQuery = new PlacesQuery(); + const now = new Date(); + info("Insert the first visit."); + await PlacesUtils.history.insert({ + url: "https://www.example.com/", + title: "Example Domain", + visits: [{ date: now }], + }); + let history = await placesQuery.getHistory(); + Assert.equal(history.length, 1); + Assert.equal(history[0].url, "https://www.example.com/"); + Assert.equal(history[0].date.getTime(), now.getTime()); + Assert.equal(history[0].title, "Example Domain"); + + info("Insert the next visit."); + let historyUpdated = PromiseUtils.defer(); + placesQuery.observeHistory(newHistory => { + history = newHistory; + historyUpdated.resolve(); + }); + await PlacesUtils.history.insert({ + url: "https://example.net/", + visits: [{ date: now }], + }); + await historyUpdated.promise; + Assert.equal(history.length, 2); + Assert.equal( + history[0].url, + "https://example.net/", + "The most recent visit should come first." + ); + Assert.equal(history[0].date.getTime(), now.getTime()); + + info("Remove the first visit."); + historyUpdated = PromiseUtils.defer(); + await PlacesUtils.history.remove("https://www.example.com/"); + await historyUpdated.promise; + Assert.equal(history.length, 1); + Assert.equal(history[0].url, "https://example.net/"); + + info("Remove all visits."); + historyUpdated = PromiseUtils.defer(); + await PlacesUtils.history.clear(); + await historyUpdated.promise; + Assert.equal(history.length, 0); + placesQuery.close(); +}); + +add_task(async function test_filter_visits_by_age() { + const placesQuery = new PlacesQuery(); + await PlacesUtils.history.insertMany([ + { + url: "https://www.example.com/", + visits: [{ date: new Date("2000-01-01T12:00:00") }], + }, + { + url: "https://example.net/", + visits: [{ date: new Date() }], + }, + ]); + const history = await placesQuery.getHistory({ daysOld: 1 }); + Assert.equal(history.length, 1, "The older visit should be excluded."); + Assert.equal(history[0].url, "https://example.net/"); + await PlacesUtils.history.clear(); + placesQuery.close(); +}); + +add_task(async function test_filter_redirecting_visits() { + const placesQuery = new PlacesQuery(); + await PlacesUtils.history.insertMany([ + { + url: "http://google.com/", + visits: [{ transition: PlacesUtils.history.TRANSITIONS.TYPED }], + }, + { + url: "https://www.google.com/", + visits: [ + { + transition: PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT, + referrer: "http://google.com/", + }, + ], + }, + ]); + const history = await placesQuery.getHistory(); + Assert.equal(history.length, 1, "Redirecting visits should be excluded."); + Assert.equal(history[0].url, "https://www.google.com/"); + await PlacesUtils.history.clear(); + placesQuery.close(); +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js new file mode 100644 index 0000000000..8ec7eccfee --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js @@ -0,0 +1,32 @@ +add_task(async function () { + info("Add a bookmark."); + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let id = await PlacesUtils.promiseItemId(bm.guid); + Assert.equal(await PlacesUtils.promiseItemGuid(id), bm.guid); + + // Ensure invalidating a non-existent itemId doesn't throw. + PlacesUtils.invalidateCachedGuidFor(null); + PlacesUtils.invalidateCachedGuidFor(9999); + + info("Change the GUID."); + await PlacesUtils.withConnectionWrapper("test", async function (db) { + await db.execute("UPDATE moz_bookmarks SET guid = :guid WHERE id = :id", { + guid: "123456789012", + id, + }); + }); + // The cache should still point to the wrong id. + Assert.equal(await PlacesUtils.promiseItemGuid(id), bm.guid); + + info("Invalidate the cache."); + PlacesUtils.invalidateCachedGuidFor(id); + Assert.equal(await PlacesUtils.promiseItemGuid(id), "123456789012"); + Assert.equal(await PlacesUtils.promiseItemId("123456789012"), id); + await Assert.rejects( + PlacesUtils.promiseItemId(bm.guid), + /no item found for the given GUID/ + ); +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuids.js b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuids.js new file mode 100644 index 0000000000..72304e8524 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuids.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function changeGuid(db, id, newGuid) { + await db.execute( + `UPDATE moz_bookmarks SET + guid = :newGuid + WHERE id = :id`, + { id, newGuid } + ); +} + +add_task(async function test_invalidateCachedGuids() { + info("Add bookmarks"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + let ids = await PlacesUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkBBBB", + ]); + Assert.equal( + await PlacesUtils.promiseItemGuid(ids.get("bookmarkAAAA")), + "bookmarkAAAA" + ); + Assert.equal( + await PlacesUtils.promiseItemGuid(ids.get("bookmarkBBBB")), + "bookmarkBBBB" + ); + + info("Change GUIDs"); + await PlacesUtils.withConnectionWrapper( + "test_invalidateCachedGuids", + async function (db) { + await db.executeTransaction(async function () { + await changeGuid(db, ids.get("bookmarkAAAA"), "bookmarkCCCC"); + await changeGuid(db, ids.get("bookmarkBBBB"), "bookmarkDDDD"); + }); + } + ); + Assert.equal( + await PlacesUtils.promiseItemId("bookmarkAAAA"), + ids.get("bookmarkAAAA") + ); + Assert.equal( + await PlacesUtils.promiseItemId("bookmarkBBBB"), + ids.get("bookmarkBBBB") + ); + + info("Invalidate the cache"); + PlacesUtils.invalidateCachedGuids(); + + let newIds = await PlacesUtils.promiseManyItemIds([ + "bookmarkCCCC", + "bookmarkDDDD", + ]); + Assert.equal( + await PlacesUtils.promiseItemGuid(newIds.get("bookmarkCCCC")), + "bookmarkCCCC" + ); + Assert.equal( + await PlacesUtils.promiseItemGuid(newIds.get("bookmarkDDDD")), + "bookmarkDDDD" + ); + await Assert.rejects( + PlacesUtils.promiseItemId("bookmarkAAAA"), + /no item found for the given GUID/ + ); + await Assert.rejects( + PlacesUtils.promiseItemId("bookmarkBBBB"), + /no item found for the given GUID/ + ); +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_isRootItem.js b/toolkit/components/places/tests/unit/test_PlacesUtils_isRootItem.js new file mode 100644 index 0000000000..f626d437e0 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesUtils_isRootItem.js @@ -0,0 +1,21 @@ +"use strict"; + +const GUIDS = [ + PlacesUtils.bookmarks.rootGuid, + ...PlacesUtils.bookmarks.userContentRoots, + PlacesUtils.bookmarks.tagsGuid, +]; + +add_task(async function test_isRootItem() { + for (let guid of GUIDS) { + Assert.ok( + PlacesUtils.isRootItem(guid), + `Should correctly identify root item ${guid}` + ); + } + + Assert.ok( + !PlacesUtils.isRootItem("fakeguid1234"), + "Should not identify other items as root." + ); +}); diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_unwrapNodes_place.js b/toolkit/components/places/tests/unit/test_PlacesUtils_unwrapNodes_place.js new file mode 100644 index 0000000000..8944dc22f4 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_PlacesUtils_unwrapNodes_place.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that unwrapNodes properly filters out place: uris from text flavors. + +add_task(function () { + let tests = [ + // Single url. + ["place:type=0&sort=1:", PlacesUtils.TYPE_X_MOZ_URL], + // Multiple urls. + [ + "place:type=0&sort=1:\nfirst\nplace:type=0&sort=1\nsecond", + PlacesUtils.TYPE_X_MOZ_URL, + ], + // Url == title. + ["place:type=0&sort=1:\nplace:type=0&sort=1", PlacesUtils.TYPE_X_MOZ_URL], + // Malformed. + [ + "place:type=0&sort=1:\nplace:type=0&sort=1\nmalformed", + PlacesUtils.TYPE_X_MOZ_URL, + ], + // Single url. + ["place:type=0&sort=1:", PlacesUtils.TYPE_PLAINTEXT], + // Multiple urls. + ["place:type=0&sort=1:\nplace:type=0&sort=1", PlacesUtils.TYPE_PLAINTEXT], + ]; + for (let [blob, type] of tests) { + Assert.deepEqual( + PlacesUtils.unwrapNodes(blob, type), + [], + "No valid entries should be found" + ); + } +}); diff --git a/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js b/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js new file mode 100644 index 0000000000..084415fb37 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// This is a test for asyncExecuteLegacyQuery API. + +add_task(async function test_history_query() { + let uri = "http://test.visit.mozilla.com/"; + let title = "Test visit"; + await PlacesTestUtils.addVisits({ uri, title }); + + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + let query = PlacesUtils.history.getNewQuery(); + + return new Promise(resolve => { + PlacesUtils.history.asyncExecuteLegacyQuery(query, options, { + handleResult(aResultSet) { + for (let row; (row = aResultSet.getNextRow()); ) { + try { + Assert.equal(row.getResultByIndex(1), uri); + Assert.equal(row.getResultByIndex(2), title); + } catch (e) { + do_throw("Error while fetching page data."); + } + } + }, + handleError(aError) { + do_throw( + "Async execution error (" + aError.result + "): " + aError.message + ); + }, + handleCompletion(aReason) { + cleanupTest().then(resolve); + }, + }); + }); +}); + +add_task(async function test_bookmarks_query() { + let url = "http://test.bookmark.mozilla.com/"; + let title = "Test bookmark"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title, + url, + }); + + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING; + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let query = PlacesUtils.history.getNewQuery(); + + return new Promise(resolve => { + PlacesUtils.history.asyncExecuteLegacyQuery(query, options, { + handleResult(aResultSet) { + for (let row; (row = aResultSet.getNextRow()); ) { + try { + Assert.equal(row.getResultByIndex(1), url); + Assert.equal(row.getResultByIndex(2), title); + } catch (e) { + do_throw("Error while fetching page data."); + } + } + }, + handleError(aError) { + do_throw( + "Async execution error (" + aError.result + "): " + aError.message + ); + }, + handleCompletion(aReason) { + cleanupTest().then(resolve); + }, + }); + }); +}); + +function cleanupTest() { + return Promise.all([ + PlacesUtils.history.clear(), + PlacesUtils.bookmarks.eraseEverything(), + ]); +} diff --git a/toolkit/components/places/tests/unit/test_async_transactions.js b/toolkit/components/places/tests/unit/test_async_transactions.js new file mode 100644 index 0000000000..71701ec540 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_async_transactions.js @@ -0,0 +1,2214 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const bmsvc = PlacesUtils.bookmarks; +const obsvc = PlacesUtils.observers; +const tagssvc = PlacesUtils.tagging; +const PT = PlacesTransactions; +const menuGuid = PlacesUtils.bookmarks.menuGuid; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +// Create and add bookmarks observer. +var observer = { + tagRelatedGuids: new Set(), + + reset() { + this.itemsAdded = new Map(); + this.itemsRemoved = new Map(); + this.itemsMoved = new Map(); + this.itemsKeywordChanged = new Map(); + this.itemsTitleChanged = new Map(); + this.itemsUrlChanged = new Map(); + }, + + handlePlacesEvents(events) { + for (let event of events) { + switch (event.type) { + case "bookmark-added": + // Ignore tag items. + if (event.isTagging) { + this.tagRelatedGuids.add(event.guid); + return; + } + + this.itemsAdded.set(event.guid, { + itemId: event.id, + parentGuid: event.parentGuid, + index: event.index, + itemType: event.itemType, + title: event.title, + url: event.url, + }); + break; + case "bookmark-removed": + if (this.tagRelatedGuids.has(event.guid)) { + return; + } + + this.itemsRemoved.set(event.guid, { + parentGuid: event.parentGuid, + index: event.index, + itemType: event.itemType, + }); + break; + case "bookmark-moved": + this.itemsMoved.set(event.guid, { + oldParentGuid: event.oldParentGuid, + oldIndex: event.oldIndex, + newParentGuid: event.parentGuid, + newIndex: event.index, + itemType: event.itemType, + }); + break; + case "bookmark-keyword-changed": + if (this.tagRelatedGuids.has(event.guid)) { + return; + } + + this.itemsKeywordChanged.set(event.guid, { + keyword: event.keyword, + }); + break; + case "bookmark-title-changed": + if (this.tagRelatedGuids.has(event.guid)) { + return; + } + + this.itemsTitleChanged.set(event.guid, { + title: event.title, + parentGuid: event.parentGuid, + }); + break; + case "bookmark-url-changed": + if (this.tagRelatedGuids.has(event.guid)) { + return; + } + + this.itemsUrlChanged.set(event.guid, { + url: event.url, + }); + break; + } + } + }, +}; +observer.reset(); + +// index at which items should begin +var bmStartIndex = 0; + +function run_test() { + observer.handlePlacesEvents = observer.handlePlacesEvents.bind(observer); + obsvc.addListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-keyword-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + observer.handlePlacesEvents + ); + registerCleanupFunction(function () { + obsvc.removeListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-keyword-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + observer.handlePlacesEvents + ); + }); + + run_next_test(); +} + +function sanityCheckTransactionHistory() { + Assert.ok(PT.undoPosition <= PT.length); + + let check_entry_throws = f => { + try { + f(); + do_throw("PT.entry should throw for invalid input"); + } catch (ex) {} + }; + check_entry_throws(() => PT.entry(-1)); + check_entry_throws(() => PT.entry({})); + check_entry_throws(() => PT.entry(PT.length)); + + if (PT.undoPosition < PT.length) { + Assert.equal(PT.topUndoEntry, PT.entry(PT.undoPosition)); + } else { + Assert.equal(null, PT.topUndoEntry); + } + if (PT.undoPosition > 0) { + Assert.equal(PT.topRedoEntry, PT.entry(PT.undoPosition - 1)); + } else { + Assert.equal(null, PT.topRedoEntry); + } +} + +function getTransactionsHistoryState() { + let history = []; + for (let i = 0; i < PT.length; i++) { + history.push(PT.entry(i)); + } + return [history, PT.undoPosition]; +} + +function ensureUndoState(aExpectedEntries = [], aExpectedUndoPosition = 0) { + // ensureUndoState is called in various places during this test, so it's + // a good places to sanity-check the transaction-history APIs in all + // cases. + sanityCheckTransactionHistory(); + + let [actualEntries, actualUndoPosition] = getTransactionsHistoryState(); + Assert.equal(actualEntries.length, aExpectedEntries.length); + Assert.equal(actualUndoPosition, aExpectedUndoPosition); + + function checkEqualEntries(aExpectedEntry, aActualEntry) { + Assert.equal(aExpectedEntry.length, aActualEntry.length); + aExpectedEntry.forEach((t, i) => Assert.equal(t, aActualEntry[i])); + } + aExpectedEntries.forEach((e, i) => checkEqualEntries(e, actualEntries[i])); +} + +function ensureItemsAdded(...items) { + let expectedResultsCount = items.length; + + for (let item of items) { + if ("children" in item) { + expectedResultsCount += item.children.length; + } + Assert.ok( + observer.itemsAdded.has(item.guid), + `Should have the expected guid ${item.guid}` + ); + let info = observer.itemsAdded.get(item.guid); + Assert.equal( + info.parentGuid, + item.parentGuid, + "Should have notified the correct parentGuid" + ); + for (let propName of ["title", "index", "itemType"]) { + if (propName in item) { + Assert.equal(info[propName], item[propName]); + } + } + if ("url" in item) { + Assert.ok( + Services.io.newURI(info.url).equals(Services.io.newURI(item.url)), + "Should have the correct url" + ); + } + } + + Assert.equal( + observer.itemsAdded.size, + expectedResultsCount, + "Should have added the correct number of items" + ); +} + +function ensureItemsRemoved(...items) { + let expectedResultsCount = items.length; + + for (let item of items) { + // We accept both guids and full info object here. + if (typeof item == "string") { + Assert.ok( + observer.itemsRemoved.has(item), + `Should have removed the expected guid ${item}` + ); + } else { + if ("children" in item) { + expectedResultsCount += item.children.length; + } + + Assert.ok( + observer.itemsRemoved.has(item.guid), + `Should have removed expected guid ${item.guid}` + ); + let info = observer.itemsRemoved.get(item.guid); + Assert.equal( + info.parentGuid, + item.parentGuid, + "Should have notified the correct parentGuid" + ); + if ("index" in item) { + Assert.equal(info.index, item.index); + } + } + } + + Assert.equal( + observer.itemsRemoved.size, + expectedResultsCount, + "Should have removed the correct number of items" + ); +} + +function ensureItemsMoved(...items) { + Assert.equal( + observer.itemsMoved.size, + items.length, + "Should have received the correct number of moved notifications" + ); + for (let item of items) { + Assert.ok( + observer.itemsMoved.has(item.guid), + `Observer should have a move for ${item.guid}` + ); + let info = observer.itemsMoved.get(item.guid); + Assert.equal( + info.oldParentGuid, + item.oldParentGuid, + "Should have the correct old parent guid" + ); + Assert.equal( + info.oldIndex, + item.oldIndex, + "Should have the correct old index" + ); + Assert.equal( + info.newParentGuid, + item.newParentGuid, + "Should have the correct new parent guid" + ); + Assert.equal( + info.newIndex, + item.newIndex, + "Should have the correct new index" + ); + } +} + +function ensureItemsKeywordChanged(...items) { + for (const item of items) { + Assert.ok( + observer.itemsKeywordChanged.has(item.guid), + `Observer should have a keyword changed for ${item.guid}` + ); + const info = observer.itemsKeywordChanged.get(item.guid); + Assert.equal(info.keyword, item.keyword, "Should have the correct keyword"); + } +} + +function ensureItemsTitleChanged(...items) { + Assert.equal( + observer.itemsTitleChanged.size, + items.length, + "Should have received the correct number of bookmark-title-changed notifications" + ); + for (const item of items) { + Assert.ok( + observer.itemsTitleChanged.has(item.guid), + `Observer should have a title changed for ${item.guid}` + ); + const info = observer.itemsTitleChanged.get(item.guid); + Assert.equal(info.title, item.title, "Should have the correct title"); + Assert.equal( + info.parentGuid, + item.parentGuid, + "Should have the correct parent guid" + ); + } +} + +function ensureItemsUrlChanged(...items) { + Assert.equal( + observer.itemsUrlChanged.size, + items.length, + "Should have received the correct number of bookmark-url-changed notifications" + ); + for (const item of items) { + Assert.ok( + observer.itemsUrlChanged.has(item.guid), + `Observer should have a title changed for ${item.guid}` + ); + const info = observer.itemsUrlChanged.get(item.guid); + Assert.equal(info.url, item.url, "Should have the correct url"); + } +} + +function ensureTagsForURI(aURI, aTags) { + let tagsSet = tagssvc.getTagsForURI(Services.io.newURI(aURI)); + Assert.equal(tagsSet.length, aTags.length); + Assert.ok(aTags.every(t => tagsSet.includes(t))); +} + +function createTestFolderInfo( + title = "Test Folder", + parentGuid = menuGuid, + children = undefined +) { + let info = { parentGuid, title }; + if (children) { + info.children = children; + } + return info; +} + +function removeAllDatesInTree(tree) { + if ("lastModified" in tree) { + delete tree.lastModified; + } + if ("dateAdded" in tree) { + delete tree.dateAdded; + } + + if (!tree.children) { + return; + } + + for (let child of tree.children) { + removeAllDatesInTree(child); + } +} + +// Checks if two bookmark trees (as returned by promiseBookmarksTree) are the +// same. +// false value for aCheckParentAndPosition is ignored if aIsRestoredItem is set. +async function ensureEqualBookmarksTrees( + aOriginal, + aNew, + aIsRestoredItem = true, + aCheckParentAndPosition = false, + aIgnoreAllDates = false +) { + // Note "id" is not-enumerable, and is therefore skipped by Object.keys (both + // ours and the one at deepEqual). This is fine for us because ids are not + // restored by Redo. + if (aIsRestoredItem) { + if (aIgnoreAllDates) { + removeAllDatesInTree(aOriginal); + removeAllDatesInTree(aNew); + } else if (!aOriginal.lastModified) { + // Ignore lastModified for newly created items, for performance reasons. + aNew.lastModified = aOriginal.lastModified; + } + Assert.deepEqual(aOriginal, aNew); + return; + } + + for (let property of Object.keys(aOriginal)) { + if (property == "children") { + Assert.equal(aOriginal.children.length, aNew.children.length); + for (let i = 0; i < aOriginal.children.length; i++) { + await ensureEqualBookmarksTrees( + aOriginal.children[i], + aNew.children[i], + false, + true, + aIgnoreAllDates + ); + } + } else if (property == "guid") { + // guid shouldn't be copied if the item was not restored. + Assert.notEqual(aOriginal.guid, aNew.guid); + } else if (property == "dateAdded") { + // dateAdded shouldn't be copied if the item was not restored. + Assert.ok(is_time_ordered(aOriginal.dateAdded, aNew.dateAdded)); + } else if (property == "lastModified") { + // same same, except for the never-changed case + if (!aOriginal.lastModified) { + Assert.ok(!aNew.lastModified); + } else { + Assert.ok(is_time_ordered(aOriginal.lastModified, aNew.lastModified)); + } + } else if ( + aCheckParentAndPosition || + (property != "parentGuid" && property != "index") + ) { + Assert.deepEqual(aOriginal[property], aNew[property]); + } + } +} + +async function ensureBookmarksTreeRestoredCorrectly( + ...aOriginalBookmarksTrees +) { + for (let originalTree of aOriginalBookmarksTrees) { + let restoredTree = await PlacesUtils.promiseBookmarksTree( + originalTree.guid + ); + await ensureEqualBookmarksTrees(originalTree, restoredTree); + } +} + +async function ensureBookmarksTreeRestoredCorrectlyExceptDates( + ...aOriginalBookmarksTrees +) { + for (let originalTree of aOriginalBookmarksTrees) { + let restoredTree = await PlacesUtils.promiseBookmarksTree( + originalTree.guid + ); + await ensureEqualBookmarksTrees( + originalTree, + restoredTree, + true, + false, + true + ); + } +} + +async function ensureNonExistent(...aGuids) { + for (let guid of aGuids) { + Assert.strictEqual(await PlacesUtils.promiseBookmarksTree(guid), null); + } +} + +add_task(async function test_recycled_transactions() { + async function ensureTransactThrowsFor(aTransaction) { + let [txns, undoPosition] = getTransactionsHistoryState(); + try { + await aTransaction.transact(); + do_throw("Shouldn't be able to use the same transaction twice"); + } catch (ex) {} + ensureUndoState(txns, undoPosition); + } + + let txn_a = PT.NewFolder(createTestFolderInfo()); + await txn_a.transact(); + ensureUndoState([[txn_a]], 0); + await ensureTransactThrowsFor(txn_a); + + await PT.undo(); + ensureUndoState([[txn_a]], 1); + ensureTransactThrowsFor(txn_a); + + await PT.clearTransactionsHistory(); + ensureUndoState(); + ensureTransactThrowsFor(txn_a); + + let txn_b = PT.NewFolder(createTestFolderInfo()); + await PT.batch(async function () { + try { + await txn_a.transact(); + do_throw("Shouldn't be able to use the same transaction twice"); + } catch (ex) {} + ensureUndoState(); + await txn_b.transact(); + }); + ensureUndoState([[txn_b]], 0); + + await PT.undo(); + ensureUndoState([[txn_b]], 1); + ensureTransactThrowsFor(txn_a); + ensureTransactThrowsFor(txn_b); + + await PT.clearTransactionsHistory(); + ensureUndoState(); + observer.reset(); +}); + +add_task(async function test_new_folder_with_children() { + let folder_info = createTestFolderInfo( + "Test folder", + PlacesUtils.bookmarks.menuGuid, + [ + { + url: "http://test_create_item.com", + title: "Test creating an item", + }, + ] + ); + ensureUndoState(); + let txn = PT.NewFolder(folder_info); + folder_info.guid = await txn.transact(); + let originalInfo = await PlacesUtils.promiseBookmarksTree(folder_info.guid); + let ensureDo = async function (aRedo = false) { + ensureUndoState([[txn]], 0); + ensureItemsAdded(folder_info); + if (aRedo) { + // Ignore lastModified in the comparison, for performance reasons. + originalInfo.lastModified = null; + await ensureBookmarksTreeRestoredCorrectlyExceptDates(originalInfo); + } + observer.reset(); + }; + + let ensureUndo = () => { + ensureUndoState([[txn]], 1); + ensureItemsRemoved({ + guid: folder_info.guid, + parentGuid: folder_info.parentGuid, + index: bmStartIndex, + children: [ + { + title: "Test creating an item", + url: "http://test_create_item.com", + }, + ], + }); + observer.reset(); + }; + + await ensureDo(); + await PT.undo(); + await ensureUndo(); + await PT.redo(); + await ensureDo(true); + await PT.undo(); + ensureUndo(); + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_new_bookmark() { + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test_create_item.com", + index: bmStartIndex, + title: "Test creating an item", + }; + + ensureUndoState(); + let txn = PT.NewBookmark(bm_info); + bm_info.guid = await txn.transact(); + + let originalInfo = await PlacesUtils.promiseBookmarksTree(bm_info.guid); + let ensureDo = async function (aRedo = false) { + ensureUndoState([[txn]], 0); + await ensureItemsAdded(bm_info); + if (aRedo) { + await ensureBookmarksTreeRestoredCorrectly(originalInfo); + } + observer.reset(); + }; + let ensureUndo = () => { + ensureUndoState([[txn]], 1); + ensureItemsRemoved({ + guid: bm_info.guid, + parentGuid: bm_info.parentGuid, + index: bmStartIndex, + }); + observer.reset(); + }; + + await ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(true); + await ensureDo(); + await PT.undo(); + ensureUndo(); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_merge_create_folder_and_item() { + let folder_info = createTestFolderInfo(); + let bm_info = { + url: "http://test_create_item_to_folder.com", + title: "Test Bookmark", + index: bmStartIndex, + }; + + let [folderTxnResult, bkmTxnResult] = await PT.batch(async function () { + let folderTxn = PT.NewFolder(folder_info); + folder_info.guid = bm_info.parentGuid = await folderTxn.transact(); + let bkmTxn = PT.NewBookmark(bm_info); + bm_info.guid = await bkmTxn.transact(); + return [folderTxn, bkmTxn]; + }); + + let ensureDo = async function () { + ensureUndoState([[bkmTxnResult, folderTxnResult]], 0); + await ensureItemsAdded(folder_info, bm_info); + observer.reset(); + }; + + let ensureUndo = () => { + ensureUndoState([[bkmTxnResult, folderTxnResult]], 1); + ensureItemsRemoved(folder_info, bm_info); + observer.reset(); + }; + + await ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(); + await ensureDo(); + await PT.undo(); + ensureUndo(); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_move_items_to_folder() { + let folder_a_info = createTestFolderInfo("Folder A"); + let bkm_a_info = { url: "http://test_move_items.com", title: "Bookmark A" }; + let bkm_b_info = { url: "http://test_move_items.com", title: "Bookmark B" }; + + // Test moving items within the same folder. + let [folder_a_txn_result, bkm_a_txn_result, bkm_b_txn_result] = + await PT.batch(async function () { + let folder_a_txn = PT.NewFolder(folder_a_info); + + folder_a_info.guid = + bkm_a_info.parentGuid = + bkm_b_info.parentGuid = + await folder_a_txn.transact(); + let bkm_a_txn = PT.NewBookmark(bkm_a_info); + bkm_a_info.guid = await bkm_a_txn.transact(); + let bkm_b_txn = PT.NewBookmark(bkm_b_info); + bkm_b_info.guid = await bkm_b_txn.transact(); + return [folder_a_txn, bkm_a_txn, bkm_b_txn]; + }); + + ensureUndoState( + [[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], + 0 + ); + + let moveTxn = PT.Move({ + guid: bkm_a_info.guid, + newParentGuid: folder_a_info.guid, + }); + await moveTxn.transact(); + + let ensureDo = () => { + ensureUndoState( + [[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], + 0 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 0, + newIndex: 1, + }); + observer.reset(); + }; + let ensureUndo = () => { + ensureUndoState( + [[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], + 1 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 1, + newIndex: 0, + }); + observer.reset(); + }; + + ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(); + ensureDo(); + await PT.undo(); + ensureUndo(); + + await PT.clearTransactionsHistory(false, true); + ensureUndoState( + [[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], + 0 + ); + + // Test moving items between folders. + let folder_b_info = createTestFolderInfo("Folder B"); + let folder_b_txn = PT.NewFolder(folder_b_info); + folder_b_info.guid = await folder_b_txn.transact(); + ensureUndoState( + [[folder_b_txn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], + 0 + ); + + moveTxn = PT.Move({ + guid: bkm_a_info.guid, + newParentGuid: folder_b_info.guid, + newIndex: bmsvc.DEFAULT_INDEX, + }); + await moveTxn.transact(); + + ensureDo = () => { + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result], + ], + 0 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_b_info.guid, + oldIndex: 0, + newIndex: 0, + }); + observer.reset(); + }; + ensureUndo = () => { + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result], + ], + 1 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_b_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 0, + newIndex: 0, + }); + observer.reset(); + }; + + ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(); + ensureDo(); + await PT.undo(); + ensureUndo(); + + // Clean up + await PT.undo(); // folder_b_txn + await PT.undo(); // folder_a_txn + the bookmarks; + Assert.equal(observer.itemsRemoved.size, 4); + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result], + ], + 3 + ); + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_move_multiple_items_to_folder() { + let folder_a_info = createTestFolderInfo("Folder A"); + let bkm_a_info = { url: "http://test_move_items.com", title: "Bookmark A" }; + let bkm_b_info = { url: "http://test_move_items.com", title: "Bookmark B" }; + let bkm_c_info = { url: "http://test_move_items.com", title: "Bookmark C" }; + + // Test moving items within the same folder. + let [ + folder_a_txn_result, + bkm_a_txn_result, + bkm_b_txn_result, + bkm_c_txn_result, + ] = await PT.batch(async function () { + let folder_a_txn = PT.NewFolder(folder_a_info); + + folder_a_info.guid = + bkm_a_info.parentGuid = + bkm_b_info.parentGuid = + bkm_c_info.parentGuid = + await folder_a_txn.transact(); + let bkm_a_txn = PT.NewBookmark(bkm_a_info); + bkm_a_info.guid = await bkm_a_txn.transact(); + let bkm_b_txn = PT.NewBookmark(bkm_b_info); + bkm_b_info.guid = await bkm_b_txn.transact(); + let bkm_c_txn = PT.NewBookmark(bkm_c_info); + bkm_c_info.guid = await bkm_c_txn.transact(); + return [folder_a_txn, bkm_a_txn, bkm_b_txn, bkm_c_txn]; + }); + + ensureUndoState( + [ + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 0 + ); + + let moveTxn = PT.Move({ + guids: [bkm_a_info.guid, bkm_b_info.guid], + newParentGuid: folder_a_info.guid, + }); + await moveTxn.transact(); + + let ensureDo = () => { + ensureUndoState( + [ + [moveTxn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 0 + ); + ensureItemsMoved( + { + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 0, + newIndex: 2, + }, + { + guid: bkm_b_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 1, + newIndex: 2, + } + ); + observer.reset(); + }; + let ensureUndo = () => { + ensureUndoState( + [ + [moveTxn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 1 + ); + ensureItemsMoved( + { + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 1, + newIndex: 0, + }, + { + guid: bkm_b_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 2, + newIndex: 1, + } + ); + observer.reset(); + }; + + ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(); + ensureDo(); + await PT.undo(); + ensureUndo(); + + await PT.clearTransactionsHistory(false, true); + ensureUndoState( + [ + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 0 + ); + + // Test moving items between folders. + let folder_b_info = createTestFolderInfo("Folder B"); + let folder_b_txn = PT.NewFolder(folder_b_info); + folder_b_info.guid = await folder_b_txn.transact(); + ensureUndoState( + [ + [folder_b_txn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 0 + ); + + moveTxn = PT.Move({ + guid: bkm_a_info.guid, + newParentGuid: folder_b_info.guid, + newIndex: bmsvc.DEFAULT_INDEX, + }); + await moveTxn.transact(); + + ensureDo = () => { + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 0 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_a_info.guid, + newParentGuid: folder_b_info.guid, + oldIndex: 0, + newIndex: 0, + }); + observer.reset(); + }; + ensureUndo = () => { + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 1 + ); + ensureItemsMoved({ + guid: bkm_a_info.guid, + oldParentGuid: folder_b_info.guid, + newParentGuid: folder_a_info.guid, + oldIndex: 0, + newIndex: 0, + }); + observer.reset(); + }; + + ensureDo(); + await PT.undo(); + ensureUndo(); + await PT.redo(); + ensureDo(); + await PT.undo(); + ensureUndo(); + + // Clean up + await PT.undo(); // folder_b_txn + await PT.undo(); // folder_a_txn + the bookmarks; + Assert.equal(observer.itemsRemoved.size, 5); + ensureUndoState( + [ + [moveTxn], + [folder_b_txn], + [ + bkm_c_txn_result, + bkm_b_txn_result, + bkm_a_txn_result, + folder_a_txn_result, + ], + ], + 3 + ); + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_remove_folder() { + let folder_level_1_info = createTestFolderInfo("Folder Level 1"); + let folder_level_2_info = { title: "Folder Level 2" }; + let [folder_level_1_txn_result, folder_level_2_txn_result] = await PT.batch( + async function () { + let folder_level_1_txn = PT.NewFolder(folder_level_1_info); + folder_level_1_info.guid = await folder_level_1_txn.transact(); + folder_level_2_info.parentGuid = folder_level_1_info.guid; + let folder_level_2_txn = PT.NewFolder(folder_level_2_info); + folder_level_2_info.guid = await folder_level_2_txn.transact(); + return [folder_level_1_txn, folder_level_2_txn]; + } + ); + + let original_folder_level_1_tree = await PlacesUtils.promiseBookmarksTree( + folder_level_1_info.guid + ); + let original_folder_level_2_tree = Object.assign( + { parentGuid: original_folder_level_1_tree.guid }, + original_folder_level_1_tree.children[0] + ); + + ensureUndoState([[folder_level_2_txn_result, folder_level_1_txn_result]]); + await ensureItemsAdded(folder_level_1_info, folder_level_2_info); + observer.reset(); + + let remove_folder_2_txn = PT.Remove(folder_level_2_info); + await remove_folder_2_txn.transact(); + + ensureUndoState([ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ]); + await ensureItemsRemoved(folder_level_2_info); + + // Undo Remove "Folder Level 2" + await PT.undo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 1 + ); + await ensureItemsAdded(folder_level_2_info); + await ensureBookmarksTreeRestoredCorrectly(original_folder_level_2_tree); + observer.reset(); + + // Redo Remove "Folder Level 2" + await PT.redo(); + ensureUndoState([ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ]); + await ensureItemsRemoved(folder_level_2_info); + observer.reset(); + + // Undo it again + await PT.undo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 1 + ); + await ensureItemsAdded(folder_level_2_info); + await ensureBookmarksTreeRestoredCorrectly(original_folder_level_2_tree); + observer.reset(); + + // Undo the creation of both folders + await PT.undo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 2 + ); + await ensureItemsRemoved(folder_level_2_info, folder_level_1_info); + observer.reset(); + + // Redo the creation of both folders + await PT.redo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 1 + ); + await ensureItemsAdded(folder_level_1_info, folder_level_2_info); + await ensureBookmarksTreeRestoredCorrectlyExceptDates( + original_folder_level_1_tree + ); + observer.reset(); + + // Redo Remove "Folder Level 2" + await PT.redo(); + ensureUndoState([ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ]); + await ensureItemsRemoved(folder_level_2_info); + observer.reset(); + + // Undo everything one last time + await PT.undo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 1 + ); + await ensureItemsAdded(folder_level_2_info); + observer.reset(); + + await PT.undo(); + ensureUndoState( + [ + [remove_folder_2_txn], + [folder_level_2_txn_result, folder_level_1_txn_result], + ], + 2 + ); + await ensureItemsRemoved(folder_level_2_info, folder_level_2_info); + observer.reset(); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_add_and_remove_bookmarks_with_additional_info() { + const testURI = "http://add.remove.tag"; + const TAG_1 = "TestTag1"; + const TAG_2 = "TestTag2"; + + let folder_info = createTestFolderInfo(); + folder_info.guid = await PT.NewFolder(folder_info).transact(); + let ensureTags = ensureTagsForURI.bind(null, testURI); + + // Check that the NewBookmark transaction preserves tags. + observer.reset(); + let b1_info = { parentGuid: folder_info.guid, url: testURI, tags: [TAG_1] }; + b1_info.guid = await PT.NewBookmark(b1_info).transact(); + let b1_originalInfo = await PlacesUtils.promiseBookmarksTree(b1_info.guid); + ensureTags([TAG_1]); + await PT.undo(); + ensureTags([]); + + observer.reset(); + await PT.redo(); + await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo); + ensureTags([TAG_1]); + + // Check if the Remove transaction removes and restores tags of children + // correctly. + await PT.Remove(folder_info.guid).transact(); + ensureTags([]); + + observer.reset(); + await PT.undo(); + await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo); + ensureTags([TAG_1]); + + await PT.redo(); + ensureTags([]); + + observer.reset(); + await PT.undo(); + await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo); + ensureTags([TAG_1]); + + // * Check that no-op tagging (the uri is already tagged with TAG_1) is + // also a no-op on undo. + observer.reset(); + let b2_info = { + parentGuid: folder_info.guid, + url: testURI, + tags: [TAG_1, TAG_2], + }; + b2_info.guid = await PT.NewBookmark(b2_info).transact(); + ensureTags([TAG_1, TAG_2]); + + observer.reset(); + await PT.undo(); + await ensureItemsRemoved(b2_info); + ensureTags([TAG_1]); + + // Check if Remove correctly restores tags. + observer.reset(); + await PT.redo(); + ensureTags([TAG_1, TAG_2]); + + // Test Remove for multiple items. + observer.reset(); + await PT.Remove(b1_info.guid).transact(); + await PT.Remove(b2_info.guid).transact(); + await PT.Remove(folder_info.guid).transact(); + await ensureItemsRemoved(b1_info, b2_info, folder_info); + ensureTags([]); + + observer.reset(); + await PT.undo(); + await ensureItemsAdded(folder_info); + ensureTags([]); + + observer.reset(); + await PT.undo(); + ensureTags([TAG_1, TAG_2]); + + observer.reset(); + await PT.undo(); + await ensureItemsAdded(b1_info); + ensureTags([TAG_1, TAG_2]); + + // The redo calls below cleanup everything we did. + observer.reset(); + await PT.redo(); + await ensureItemsRemoved(b1_info); + ensureTags([TAG_1, TAG_2]); + + observer.reset(); + await PT.redo(); + // The tag containers are removed in async and take some time + let oldCountTag1 = 0; + let oldCountTag2 = 0; + let allTags = await bmsvc.fetchTags(); + for (let i of allTags) { + if (i.name == TAG_1) { + oldCountTag1 = i.count; + } + if (i.name == TAG_2) { + oldCountTag2 = i.count; + } + } + await TestUtils.waitForCondition(async () => { + allTags = await bmsvc.fetchTags(); + let newCountTag1 = 0; + let newCountTag2 = 0; + for (let i of allTags) { + if (i.name == TAG_1) { + newCountTag1 = i.count; + } + if (i.name == TAG_2) { + newCountTag2 = i.count; + } + } + return newCountTag1 == oldCountTag1 - 1 && newCountTag2 == oldCountTag2 - 1; + }); + await ensureItemsRemoved(b2_info); + + ensureTags([]); + + observer.reset(); + await PT.redo(); + await ensureItemsRemoved(folder_info); + ensureTags([]); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_creating_and_removing_a_separator() { + let folder_info = createTestFolderInfo(); + let separator_info = {}; + let undoEntries = []; + + observer.reset(); + let create_txns = await PT.batch(async function () { + let folder_txn = PT.NewFolder(folder_info); + folder_info.guid = separator_info.parentGuid = await folder_txn.transact(); + let separator_txn = PT.NewSeparator(separator_info); + separator_info.guid = await separator_txn.transact(); + return [separator_txn, folder_txn]; + }); + undoEntries.unshift(create_txns); + ensureUndoState(undoEntries, 0); + ensureItemsAdded(folder_info, separator_info); + + observer.reset(); + await PT.undo(); + ensureUndoState(undoEntries, 1); + ensureItemsRemoved(folder_info, separator_info); + + observer.reset(); + await PT.redo(); + ensureUndoState(undoEntries, 0); + ensureItemsAdded(folder_info, separator_info); + + observer.reset(); + let remove_sep_txn = PT.Remove(separator_info); + await remove_sep_txn.transact(); + undoEntries.unshift([remove_sep_txn]); + ensureUndoState(undoEntries, 0); + ensureItemsRemoved(separator_info); + + observer.reset(); + await PT.undo(); + ensureUndoState(undoEntries, 1); + ensureItemsAdded(separator_info); + + observer.reset(); + await PT.undo(); + ensureUndoState(undoEntries, 2); + ensureItemsRemoved(folder_info, separator_info); + + observer.reset(); + await PT.redo(); + ensureUndoState(undoEntries, 1); + ensureItemsAdded(folder_info, separator_info); + + // Clear redo entries and check that |redo| does nothing + observer.reset(); + await PT.clearTransactionsHistory(false, true); + undoEntries.shift(); + ensureUndoState(undoEntries, 0); + await PT.redo(); + ensureItemsAdded(); + ensureItemsRemoved(); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureUndoState(undoEntries, 1); + ensureItemsRemoved(folder_info, separator_info); + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_edit_title() { + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test_create_item.com", + title: "Original Title", + }; + + function ensureTitleChange(aCurrentTitle) { + ensureItemsTitleChanged({ + guid: bm_info.guid, + title: aCurrentTitle, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + + observer.reset(); + await PT.EditTitle({ guid: bm_info.guid, title: "New Title" }).transact(); + ensureTitleChange("New Title"); + + observer.reset(); + await PT.undo(); + ensureTitleChange("Original Title"); + + observer.reset(); + await PT.redo(); + ensureTitleChange("New Title"); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureTitleChange("Original Title"); + await PT.undo(); + ensureItemsRemoved(bm_info); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_edit_url() { + let oldURI = "http://old.test_editing_item_uri.com/"; + let newURI = "http://new.test_editing_item_uri.com/"; + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: oldURI, + tags: ["TestTag"], + }; + function ensureURIAndTags( + aPreChangeURI, + aPostChangeURI, + aOLdURITagsPreserved + ) { + ensureItemsUrlChanged({ + guid: bm_info.guid, + url: aPostChangeURI, + }); + ensureTagsForURI(aPostChangeURI, bm_info.tags); + ensureTagsForURI(aPreChangeURI, aOLdURITagsPreserved ? bm_info.tags : []); + } + + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + ensureTagsForURI(oldURI, bm_info.tags); + + // When there's a single bookmark for the same url, tags should be moved. + observer.reset(); + await PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact(); + ensureURIAndTags(oldURI, newURI, false); + + observer.reset(); + await PT.undo(); + ensureURIAndTags(newURI, oldURI, false); + + observer.reset(); + await PT.redo(); + ensureURIAndTags(oldURI, newURI, false); + + observer.reset(); + await PT.undo(); + ensureURIAndTags(newURI, oldURI, false); + + // When there're multiple bookmarks for the same url, tags should be copied. + let bm2_info = Object.create(bm_info); + bm2_info.guid = await PT.NewBookmark(bm2_info).transact(); + let bm3_info = Object.create(bm_info); + bm3_info.url = newURI; + bm3_info.guid = await PT.NewBookmark(bm3_info).transact(); + + observer.reset(); + await PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact(); + ensureURIAndTags(oldURI, newURI, true); + + observer.reset(); + await PT.undo(); + ensureURIAndTags(newURI, oldURI, true); + + observer.reset(); + await PT.redo(); + ensureURIAndTags(oldURI, newURI, true); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureURIAndTags(newURI, oldURI, true); + await PT.undo(); + await PT.undo(); + await PT.undo(); + ensureItemsRemoved(bm3_info, bm2_info, bm_info); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_edit_keyword() { + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test.edit.keyword/", + }; + const KEYWORD = "test_keyword"; + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + function ensureKeywordChange(aCurrentKeyword = "") { + ensureItemsKeywordChanged({ + guid: bm_info.guid, + keyword: aCurrentKeyword, + }); + } + + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + + observer.reset(); + await PT.EditKeyword({ + guid: bm_info.guid, + keyword: KEYWORD, + postData: "postData", + }).transact(); + ensureKeywordChange(KEYWORD); + let entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData"); + + observer.reset(); + await PT.undo(); + ensureKeywordChange(); + entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry, null); + + observer.reset(); + await PT.redo(); + ensureKeywordChange(KEYWORD); + entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData"); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureKeywordChange(); + await PT.undo(); + ensureItemsRemoved(bm_info); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_edit_keyword_null_postData() { + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test.edit.keyword/", + }; + const KEYWORD = "test_keyword"; + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + function ensureKeywordChange(aCurrentKeyword = "") { + ensureItemsKeywordChanged({ + guid: bm_info.guid, + keyword: aCurrentKeyword, + }); + } + + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + + observer.reset(); + await PT.EditKeyword({ + guid: bm_info.guid, + keyword: KEYWORD, + postData: null, + }).transact(); + ensureKeywordChange(KEYWORD); + let entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, null); + + observer.reset(); + await PT.undo(); + ensureKeywordChange(); + entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry, null); + + observer.reset(); + await PT.redo(); + ensureKeywordChange(KEYWORD); + entry = await PlacesUtils.keywords.fetch(KEYWORD); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, null); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureKeywordChange(); + await PT.undo(); + ensureItemsRemoved(bm_info); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_edit_specific_keyword() { + let bm_info = { + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test.edit.keyword/", + }; + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + function ensureKeywordChange(aCurrentKeyword = "", aPreviousKeyword = "") { + ensureItemsKeywordChanged({ + guid: bm_info.guid, + keyword: aCurrentKeyword, + }); + } + + await PlacesUtils.keywords.insert({ + keyword: "kw1", + url: bm_info.url, + postData: "postData1", + }); + await PlacesUtils.keywords.insert({ + keyword: "kw2", + url: bm_info.url, + postData: "postData2", + }); + bm_info.guid = await PT.NewBookmark(bm_info).transact(); + + observer.reset(); + await PT.EditKeyword({ + guid: bm_info.guid, + keyword: "keyword", + oldKeyword: "kw2", + }).transact(); + ensureKeywordChange("keyword", "kw2"); + let entry = await PlacesUtils.keywords.fetch("kw1"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData1"); + entry = await PlacesUtils.keywords.fetch("keyword"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData2"); + entry = await PlacesUtils.keywords.fetch("kw2"); + Assert.equal(entry, null); + + observer.reset(); + await PT.undo(); + ensureKeywordChange("kw2", "keyword"); + entry = await PlacesUtils.keywords.fetch("kw1"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData1"); + entry = await PlacesUtils.keywords.fetch("kw2"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData2"); + entry = await PlacesUtils.keywords.fetch("keyword"); + Assert.equal(entry, null); + + observer.reset(); + await PT.redo(); + ensureKeywordChange("keyword", "kw2"); + entry = await PlacesUtils.keywords.fetch("kw1"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData1"); + entry = await PlacesUtils.keywords.fetch("keyword"); + Assert.equal(entry.url.href, bm_info.url); + Assert.equal(entry.postData, "postData2"); + entry = await PlacesUtils.keywords.fetch("kw2"); + Assert.equal(entry, null); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureKeywordChange("kw2"); + await PT.undo(); + ensureItemsRemoved(bm_info); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_tag_uri() { + // This also tests passing uri specs. + let bm_info_a = { + url: "http://bookmarked.uri", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + let bm_info_b = { + url: "http://bookmarked2.uri", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + let unbookmarked_uri = "http://un.bookmarked.uri"; + + await PT.batch(async function () { + bm_info_a.guid = await PT.NewBookmark(bm_info_a).transact(); + bm_info_b.guid = await PT.NewBookmark(bm_info_b).transact(); + }); + + async function doTest(aInfo) { + let urls = "url" in aInfo ? [aInfo.url] : aInfo.urls; + let tags = "tag" in aInfo ? [aInfo.tag] : aInfo.tags; + + let tagWillAlsoBookmark = new Set(); + for (let url of urls) { + if (!(await bmsvc.fetch({ url }))) { + tagWillAlsoBookmark.add(url); + } + } + + async function ensureTagsSet() { + for (let url of urls) { + ensureTagsForURI(url, tags); + Assert.ok(await bmsvc.fetch({ url })); + } + } + async function ensureTagsUnset() { + for (let url of urls) { + ensureTagsForURI(url, []); + if (tagWillAlsoBookmark.has(url)) { + Assert.ok(!(await bmsvc.fetch({ url }))); + } else { + Assert.ok(await bmsvc.fetch({ url })); + } + } + } + + await PT.Tag(aInfo).transact(); + await ensureTagsSet(); + await PT.undo(); + await ensureTagsUnset(); + await PT.redo(); + await ensureTagsSet(); + await PT.undo(); + await ensureTagsUnset(); + } + + await doTest({ url: bm_info_a.url, tags: ["MyTag"] }); + await doTest({ urls: [bm_info_a.url], tag: "MyTag" }); + await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A, B"] }); + await doTest({ urls: [bm_info_a.url, unbookmarked_uri], tag: "C" }); + // Duplicate URLs listed. + await doTest({ + urls: [bm_info_a.url, bm_info_b.url, bm_info_a.url], + tag: "D", + }); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureItemsRemoved(bm_info_a, bm_info_b); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_untag_uri() { + let bm_info_a = { + url: "http://bookmarked.uri", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + tags: ["A", "B"], + }; + let bm_info_b = { + url: "http://bookmarked2.uri", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + tag: "B", + }; + + await PT.batch(async function () { + bm_info_a.guid = await PT.NewBookmark(bm_info_a).transact(); + ensureTagsForURI(bm_info_a.url, bm_info_a.tags); + bm_info_b.guid = await PT.NewBookmark(bm_info_b).transact(); + ensureTagsForURI(bm_info_b.url, [bm_info_b.tag]); + }); + + async function doTest(aInfo) { + let urls, tagsRemoved; + if (typeof aInfo == "string") { + urls = [aInfo]; + tagsRemoved = []; + } else if (Array.isArray(aInfo)) { + urls = aInfo; + tagsRemoved = []; + } else { + urls = "url" in aInfo ? [aInfo.url] : aInfo.urls; + tagsRemoved = "tag" in aInfo ? [aInfo.tag] : aInfo.tags; + } + + let preRemovalTags = new Map(); + for (let url of urls) { + preRemovalTags.set(url, tagssvc.getTagsForURI(Services.io.newURI(url))); + } + + function ensureTagsSet() { + for (let url of urls) { + ensureTagsForURI(url, preRemovalTags.get(url)); + } + } + function ensureTagsUnset() { + for (let url of urls) { + let expectedTags = !tagsRemoved.length + ? [] + : preRemovalTags.get(url).filter(tag => !tagsRemoved.includes(tag)); + ensureTagsForURI(url, expectedTags); + } + } + + await PT.Untag(aInfo).transact(); + await ensureTagsUnset(); + await PT.undo(); + await ensureTagsSet(); + await PT.redo(); + await ensureTagsUnset(); + await PT.undo(); + await ensureTagsSet(); + } + + await doTest(bm_info_a); + await doTest(bm_info_b); + await doTest(bm_info_a.url); + await doTest(bm_info_b.url); + await doTest([bm_info_a.url, bm_info_b.url]); + await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A", "B"] }); + await doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "B" }); + await doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "C" }); + await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["C"] }); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureItemsRemoved(bm_info_a, bm_info_b); + + await PT.clearTransactionsHistory(); + ensureUndoState(); +}); + +add_task(async function test_sort_folder_by_name() { + let folder_info = createTestFolderInfo(); + + let url = "http://sort.by.name/"; + let preSep = ["3", "2", "1"].map(i => ({ title: i, url })); + let sep = {}; + let postSep = ["c", "b", "a"].map(l => ({ title: l, url })); + let originalOrder = [...preSep, sep, ...postSep]; + let sortedOrder = [ + ...preSep.slice(0).reverse(), + sep, + ...postSep.slice(0).reverse(), + ]; + await PT.batch(async function () { + folder_info.guid = await PT.NewFolder(folder_info).transact(); + for (let info of originalOrder) { + info.parentGuid = folder_info.guid; + info.guid = await (info == sep + ? PT.NewSeparator(info).transact() + : PT.NewBookmark(info).transact()); + } + }); + + let folderContainer = PlacesUtils.getFolderContents(folder_info.guid).root; + function ensureOrder(aOrder) { + for (let i = 0; i < folderContainer.childCount; i++) { + Assert.equal(folderContainer.getChild(i).bookmarkGuid, aOrder[i].guid); + } + } + + ensureOrder(originalOrder); + await PT.SortByName(folder_info.guid).transact(); + ensureOrder(sortedOrder); + await PT.undo(); + ensureOrder(originalOrder); + await PT.redo(); + ensureOrder(sortedOrder); + + // Cleanup + observer.reset(); + await PT.undo(); + ensureOrder(originalOrder); + await PT.undo(); + ensureItemsRemoved(...originalOrder, folder_info); +}); + +add_task(async function test_copy() { + async function duplicate_and_test(aOriginalGuid) { + let txn = PT.Copy({ + guid: aOriginalGuid, + newParentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let duplicateGuid = await txn.transact(); + let originalInfo = await PlacesUtils.promiseBookmarksTree(aOriginalGuid); + let duplicateInfo = await PlacesUtils.promiseBookmarksTree(duplicateGuid); + await ensureEqualBookmarksTrees(originalInfo, duplicateInfo, false); + + async function redo() { + await PT.redo(); + await ensureBookmarksTreeRestoredCorrectlyExceptDates(originalInfo); + await PT.redo(); + await ensureBookmarksTreeRestoredCorrectlyExceptDates(duplicateInfo); + } + async function undo() { + await PT.undo(); + // also undo the original item addition. + await PT.undo(); + await ensureNonExistent(aOriginalGuid, duplicateGuid); + } + + await undo(); + await redo(); + await undo(); + await redo(); + + // Cleanup. This also remove the original item. + await PT.undo(); + observer.reset(); + await PT.clearTransactionsHistory(); + } + + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + // Test duplicating leafs (bookmark, separator, empty folder) + PT.NewBookmark({ + url: "http://test.item.duplicate", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + annos: [{ name: "Anno", value: "AnnoValue" }], + }); + let sepTxn = PT.NewSeparator({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 1, + }); + + let emptyFolderTxn = PT.NewFolder(createTestFolderInfo()); + for (let txn of [sepTxn, emptyFolderTxn]) { + let guid = await txn.transact(); + await duplicate_and_test(guid); + } + + // Test duplicating a folder having some contents. + let filledFolderGuid = await PT.batch(async function () { + let folderGuid = await PT.NewFolder(createTestFolderInfo()).transact(); + let nestedFolderGuid = await PT.NewFolder({ + parentGuid: folderGuid, + title: "Nested Folder", + }).transact(); + // Insert a bookmark under the nested folder. + await PT.NewBookmark({ + url: "http://nested.nested.bookmark", + parentGuid: nestedFolderGuid, + }).transact(); + // Insert a separator below the nested folder + await PT.NewSeparator({ parentGuid: folderGuid }).transact(); + // And another bookmark. + await PT.NewBookmark({ + url: "http://nested.bookmark", + parentGuid: folderGuid, + }).transact(); + return folderGuid; + }); + + await duplicate_and_test(filledFolderGuid); + + // Cleanup + await PT.clearTransactionsHistory(); +}); + +add_task(async function test_array_input_for_batch() { + let folderTxn = PT.NewFolder(createTestFolderInfo()); + let folderGuid = await folderTxn.transact(); + + let sep1_txn = PT.NewSeparator({ parentGuid: folderGuid }); + let sep2_txn = PT.NewSeparator({ parentGuid: folderGuid }); + await PT.batch([sep1_txn, sep2_txn]); + ensureUndoState([[sep2_txn, sep1_txn], [folderTxn]], 0); + + let ensureChildCount = async function (count) { + let tree = await PlacesUtils.promiseBookmarksTree(folderGuid); + if (count == 0) { + Assert.ok(!("children" in tree)); + } else { + Assert.equal(tree.children.length, count); + } + }; + + await ensureChildCount(2); + await PT.undo(); + await ensureChildCount(0); + await PT.redo(); + await ensureChildCount(2); + await PT.undo(); + await ensureChildCount(0); + + await PT.undo(); + Assert.equal(await PlacesUtils.promiseBookmarksTree(folderGuid), null); + + // Cleanup + await PT.clearTransactionsHistory(); +}); + +add_task(async function test_invalid_uri_spec_throws() { + Assert.throws( + () => + PT.NewBookmark({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "invalid uri spec", + title: "test bookmark", + }), + /invalid uri spec is not a valid URL/ + ); + Assert.throws( + () => PT.Tag({ tag: "TheTag", urls: ["invalid uri spec"] }), + /TypeError: URL constructor: invalid uri spec is not a valid URL/ + ); + Assert.throws( + () => PT.Tag({ tag: "TheTag", urls: ["about:blank", "invalid uri spec"] }), + /TypeError: URL constructor: invalid uri spec is not a valid URL/ + ); +}); + +add_task(async function test_remove_multiple() { + let guids = []; + await PT.batch(async function () { + let folderGuid = await PT.NewFolder({ + title: "Test Folder", + parentGuid: menuGuid, + }).transact(); + let nestedFolderGuid = await PT.NewFolder({ + title: "Nested Test Folder", + parentGuid: folderGuid, + }).transact(); + await PT.NewSeparator(nestedFolderGuid).transact(); + + guids.push(folderGuid); + + let bmGuid = await PT.NewBookmark({ + url: "http://test.bookmark.removed", + parentGuid: menuGuid, + }).transact(); + guids.push(bmGuid); + }); + + let originalInfos = []; + for (let guid of guids) { + originalInfos.push(await PlacesUtils.promiseBookmarksTree(guid)); + } + + await PT.Remove(guids).transact(); + await ensureNonExistent(...guids); + await PT.undo(); + await ensureBookmarksTreeRestoredCorrectly(...originalInfos); + await PT.redo(); + await ensureNonExistent(...guids); + await PT.undo(); + await ensureBookmarksTreeRestoredCorrectly(...originalInfos); + + // Undo the New* transactions batch. + await PT.undo(); + await ensureNonExistent(...guids); + + // Redo it. + await PT.redo(); + await ensureBookmarksTreeRestoredCorrectlyExceptDates(...originalInfos); + + // Redo remove. + await PT.redo(); + await ensureNonExistent(...guids); + + // Cleanup + await PT.clearTransactionsHistory(); + observer.reset(); +}); + +add_task(async function test_renameTag() { + let url = "http://test.edit.keyword/"; + await PT.Tag({ url, tags: ["t1", "t2"] }).transact(); + ensureTagsForURI(url, ["t1", "t2"]); + + // Create bookmark queries that point to the modified tag. + let bm1 = await PlacesUtils.bookmarks.insert({ + url: "place:tag=t2", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bm2 = await PlacesUtils.bookmarks.insert({ + url: "place:tag=t2&sort=1", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + // This points to 2 tags, and as such won't be touched. + let bm3 = await PlacesUtils.bookmarks.insert({ + url: "place:tag=t2&tag=t1", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + await PT.RenameTag({ oldTag: "t2", tag: "t3" }).transact(); + ensureTagsForURI(url, ["t1", "t3"]); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, + "place:tag=t3", + "The fitst bookmark has been updated" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, + "place:tag=t3&sort=1", + "The second bookmark has been updated" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, + "place:tag=t3&tag=t1", + "The third bookmark has been updated" + ); + + await PT.undo(); + ensureTagsForURI(url, ["t1", "t2"]); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, + "place:tag=t2", + "The fitst bookmark has been restored" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, + "place:tag=t2&sort=1", + "The second bookmark has been restored" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, + "place:tag=t2&tag=t1", + "The third bookmark has been restored" + ); + + await PT.redo(); + ensureTagsForURI(url, ["t1", "t3"]); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, + "place:tag=t3", + "The fitst bookmark has been updated" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, + "place:tag=t3&sort=1", + "The second bookmark has been updated" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, + "place:tag=t3&tag=t1", + "The third bookmark has been updated" + ); + + await PT.undo(); + ensureTagsForURI(url, ["t1", "t2"]); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, + "place:tag=t2", + "The fitst bookmark has been restored" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, + "place:tag=t2&sort=1", + "The second bookmark has been restored" + ); + Assert.equal( + (await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, + "place:tag=t2&tag=t1", + "The third bookmark has been restored" + ); + + await PT.undo(); + ensureTagsForURI(url, []); + + await PT.clearTransactionsHistory(); + ensureUndoState(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_remove_invalid_url() { + let folderGuid = await PT.NewFolder({ + title: "Test Folder", + parentGuid: menuGuid, + }).transact(); + + let guid = "invalid_____"; + let folderedGuid = "invalid____2"; + let url = "invalid-uri"; + await PlacesUtils.withConnectionWrapper("test_bookmarks_remove", async db => { + await db.execute( + ` + INSERT INTO moz_places(url, url_hash, title, rev_host, guid) + VALUES (:url, hash(:url), 'Invalid URI', '.', GENERATE_GUID()) + `, + { url } + ); + await db.execute( + `INSERT INTO moz_bookmarks (type, fk, parent, position, guid) + VALUES (:type, + (SELECT id FROM moz_places WHERE url = :url), + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)), + :guid) + `, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid, + } + ); + await db.execute( + `INSERT INTO moz_bookmarks (type, fk, parent, position, guid) + VALUES (:type, + (SELECT id FROM moz_places WHERE url = :url), + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)), + :guid) + `, + { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url, + parentGuid: folderGuid, + guid: folderedGuid, + } + ); + }); + + let guids = [folderGuid, guid]; + await PT.Remove(guids).transact(); + await ensureNonExistent(...guids, folderedGuid); + // Shouldn't throw, should restore the folder but not the bookmarks. + await PT.undo(); + await ensureNonExistent(guid, folderedGuid); + Assert.ok( + await PlacesUtils.bookmarks.fetch(folderGuid), + "The folder should have been re-created" + ); + await PT.redo(); + await ensureNonExistent(guids, folderedGuid); + // Cleanup + await PT.clearTransactionsHistory(); + observer.reset(); +}); diff --git a/toolkit/components/places/tests/unit/test_autocomplete_match_fallbackTitle.js b/toolkit/components/places/tests/unit/test_autocomplete_match_fallbackTitle.js new file mode 100644 index 0000000000..109ca1edce --- /dev/null +++ b/toolkit/components/places/tests/unit/test_autocomplete_match_fallbackTitle.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// This is a test for the fallbackTitle argument of autocomplete_match. + +add_task(async function test_match() { + async function search(text) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + ` + SELECT AUTOCOMPLETE_MATCH(:text, 'http://mozilla.org/', 'Main title', + NULL, NULL, 1, 1, NULL, + :matchBehavior, :searchBehavior, + 'Fallback title') + `, + { + text, + matchBehavior: Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE, + searchBehavior: 643, + } + ); + return !!rows[0].getResultByIndex(0); + } + Assert.ok(await search("mai"), "Match on main title"); + Assert.ok(await search("fall"), "Match on fallback title"); +}); diff --git a/toolkit/components/places/tests/unit/test_bookmark-tags-changed_frequency.js b/toolkit/components/places/tests/unit/test_bookmark-tags-changed_frequency.js new file mode 100644 index 0000000000..568ad43841 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmark-tags-changed_frequency.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks that changing a tag for a bookmark with multiple tags +// notifies bookmark-tags-changed event only once, and not once per tag. + +add_task(async function run_test() { + let tags = ["a", "b", "c"]; + let uri = Services.io.newURI("http://1.moz.org/"); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: "Bookmark 1", + }); + PlacesUtils.tagging.tagURI(uri, tags); + + let promise = PromiseUtils.defer(); + + let bookmarksObserver = { + _changedCount: 0, + handlePlacesEvents(events) { + for (let event of events) { + switch (event.type) { + case "bookmark-removed": + if (event.guid == bookmark.guid) { + PlacesUtils.observers.removeListener( + ["bookmark-removed"], + this.handlePlacesEvents + ); + Assert.equal(this._changedCount, 2); + promise.resolve(); + } + break; + case "bookmark-tags-changed": + Assert.equal(event.guid, bookmark.guid); + this._changedCount++; + break; + } + } + }, + }; + bookmarksObserver.handlePlacesEvents = + bookmarksObserver.handlePlacesEvents.bind(bookmarksObserver); + PlacesUtils.observers.addListener( + ["bookmark-removed", "bookmark-tags-changed"], + bookmarksObserver.handlePlacesEvents + ); + + PlacesUtils.tagging.tagURI(uri, ["d"]); + PlacesUtils.tagging.tagURI(uri, ["e"]); + + await promise; + + await PlacesUtils.bookmarks.remove(bookmark.guid); +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html.js b/toolkit/components/places/tests/unit/test_bookmarks_html.js new file mode 100644 index 0000000000..4b3f04b444 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html.js @@ -0,0 +1,417 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// An object representing the contents of bookmarks.preplaces.html. +var test_bookmarks = { + menu: [ + { + title: "Mozilla Firefox", + children: [ + { + title: "Help and Tutorials", + url: "http://en-us.www.mozilla.com/en-US/firefox/help/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + title: "Customize Firefox", + url: "http://en-us.www.mozilla.com/en-US/firefox/customize/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + title: "Get Involved", + url: "http://en-us.www.mozilla.com/en-US/firefox/community/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + title: "About Us", + url: "http://en-us.www.mozilla.com/en-US/about/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + ], + }, + { + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR, + }, + { + title: "test", + dateAdded: 1177541020000000, + lastModified: 1177541050000000, + children: [ + { + title: "test post keyword", + dateAdded: 1177375336000000, + lastModified: 1177375423000000, + keyword: "test", + postData: "hidden1%3Dbar&text1%3D%25s", + charset: "ISO-8859-1", + url: "http://test/post", + }, + ], + }, + ], + toolbar: [ + { + title: "Getting Started", + url: "http://en-us.www.mozilla.com/en-US/firefox/central/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + title: "Latest Headlines", + url: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/", + feedUrl: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml", + }, + // This will be ignored, because it has no url. + { + title: "Latest Headlines No Site", + feedUrl: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml", + ignore: true, + }, + ], + unfiled: [{ title: "Example.tld", url: "http://example.tld/" }], +}; + +// Pre-Places bookmarks.html file pointer. +var gBookmarksFileOld; +// Places bookmarks.html file pointer. +var gBookmarksFileNew; + +add_task(async function setup() { + // File pointer to legacy bookmarks file. + gBookmarksFileOld = PathUtils.join( + do_get_cwd().path, + "bookmarks.preplaces.html" + ); + + // File pointer to a new Places-exported bookmarks file. + gBookmarksFileNew = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.html" + ); + await IOUtils.remove(gBookmarksFileNew, { ignoreAbsent: true }); + + // This test must be the first one, since it setups the new bookmarks.html. + // Test importing a pre-Places canonical bookmarks file. + // 1. import bookmarks.preplaces.html + // 2. run the test-suite + // Note: we do not empty the db before this import to catch bugs like 380999 + await BookmarkHTMLUtils.importFromFile(gBookmarksFileOld, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); + + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_count() { + // Ensure the bookmarks count is correct when importing in various cases + let count = await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { + replace: true, + }); + Assert.equal( + count, + 8, + "There should be 8 imported bookmarks when importing from an empty database" + ); + await PlacesTestUtils.promiseAsyncUpdates(); + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + + count = -1; + count = await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { + replace: true, + }); + Assert.equal( + count, + 8, + "There should be 8 imported bookmarks when replacing existing bookmarks" + ); + await PlacesTestUtils.promiseAsyncUpdates(); + + count = -1; + count = await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew); + Assert.equal( + count, + 8, + "There should be 8 imported bookmarks even when we are not replacing existing bookmarks" + ); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_new() { + // Test importing a Places bookmarks.html file. + // 1. import bookmarks.exported.html + // 2. run the test-suite + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + + await testImportedBookmarks(); + await PlacesTestUtils.promiseAsyncUpdates(); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_emptytitle_export() { + // Test exporting and importing with an empty-titled bookmark. + // 1. import bookmarks + // 2. create an empty-titled bookmark. + // 3. export to bookmarks.exported.html + // 4. empty bookmarks db + // 5. import bookmarks.exported.html + // 6. run the test-suite + // 7. remove the empty-titled bookmark + // 8. export to bookmarks.exported.html + // 9. empty bookmarks db and continue + + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + + const NOTITLE_URL = "http://notitle.mozilla.org/"; + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: NOTITLE_URL, + }); + test_bookmarks.unfiled.push({ title: "", url: NOTITLE_URL }); + + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); + + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); + + // Cleanup. + test_bookmarks.unfiled.pop(); + // HTML imports don't restore GUIDs yet. + let reimportedBookmark = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + Assert.equal(reimportedBookmark.url.href, bookmark.url.href); + await PlacesUtils.bookmarks.remove(reimportedBookmark); + + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_chromefavicon() { + // Test exporting and importing with a bookmark pointing to a chrome favicon. + // 1. import bookmarks + // 2. create a bookmark pointing to a chrome favicon. + // 3. export to bookmarks.exported.html + // 4. empty bookmarks db + // 5. import bookmarks.exported.html + // 6. run the test-suite + // 7. remove the bookmark pointing to a chrome favicon. + // 8. export to bookmarks.exported.html + // 9. empty bookmarks db and continue + + const PAGE_URI = NetUtil.newURI("http://example.com/chromefavicon_page"); + const CHROME_FAVICON_URI = NetUtil.newURI( + "chrome://global/skin/icons/delete.svg" + ); + const CHROME_FAVICON_URI_2 = NetUtil.newURI( + "chrome://global/skin/icons/error.svg" + ); + + info("Importing from html"); + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + + info("Insert bookmark"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: PAGE_URI, + title: "Test", + }); + + info("Set favicon"); + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI, + CHROME_FAVICON_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + let data = await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + PAGE_URI, + (uri, dataLen, faviconData, mimeType) => resolve(faviconData) + ); + }); + + let base64Icon = + "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, data)); + + test_bookmarks.unfiled.push({ + title: "Test", + url: PAGE_URI.spec, + icon: base64Icon, + }); + + info("Export to html"); + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + + info("Set favicon"); + // Change the favicon to check it's really imported again later. + await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI, + CHROME_FAVICON_URI_2, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + + info("import from html"); + await PlacesUtils.bookmarks.eraseEverything(); + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + + info("Test imported bookmarks"); + await testImportedBookmarks(); + + // Cleanup. + test_bookmarks.unfiled.pop(); + // HTML imports don't restore GUIDs yet. + let reimportedBookmark = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + await PlacesUtils.bookmarks.remove(reimportedBookmark); + + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_ontop() { + // Test importing the exported bookmarks.html file *on top of* the existing + // bookmarks. + // 1. empty bookmarks db + // 2. import the exported bookmarks file + // 3. export to file + // 3. import the exported bookmarks file + // 4. run the test-suite + + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await BookmarkHTMLUtils.exportToFile(gBookmarksFileNew); + await PlacesTestUtils.promiseAsyncUpdates(); + + await BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); + await PlacesTestUtils.promiseAsyncUpdates(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function testImportedBookmarks() { + for (let group in test_bookmarks) { + info("[testImportedBookmarks()] Checking group '" + group + "'"); + + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks[`${group}Guid`] + ).root; + + let items = test_bookmarks[group].filter(b => !b.ignore); + Assert.equal(root.childCount, items.length); + + for (let key in items) { + await checkItem(items[key], root.getChild(key)); + } + + root.containerOpen = false; + } +} + +function checkItem(aExpected, aNode) { + return (async function () { + let bookmark = await PlacesUtils.bookmarks.fetch(aNode.bookmarkGuid); + + for (let prop in aExpected) { + switch (prop) { + case "type": + Assert.equal(aNode.type, aExpected.type); + break; + case "title": + Assert.equal(aNode.title, aExpected.title); + break; + case "dateAdded": + Assert.equal( + PlacesUtils.toPRTime(bookmark.dateAdded), + aExpected.dateAdded + ); + break; + case "lastModified": + Assert.equal( + PlacesUtils.toPRTime(bookmark.lastModified), + aExpected.lastModified + ); + break; + case "url": + Assert.equal(aNode.uri, aExpected.url); + break; + case "icon": + let { data } = await getFaviconDataForPage(aExpected.url); + let base64Icon = + "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, data)); + Assert.ok(base64Icon == aExpected.icon); + break; + case "keyword": { + let entry = await PlacesUtils.keywords.fetch({ url: aNode.uri }); + Assert.equal(entry.keyword, aExpected.keyword); + break; + } + case "postData": { + let entry = await PlacesUtils.keywords.fetch({ url: aNode.uri }); + Assert.equal(entry.postData, aExpected.postData); + break; + } + case "charset": + let pageInfo = await PlacesUtils.history.fetch(aNode.uri, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + aExpected.charset + ); + break; + case "feedUrl": + // No more supported. + break; + case "children": + let folder = aNode.QueryInterface( + Ci.nsINavHistoryContainerResultNode + ); + Assert.equal(folder.hasChildren, !!aExpected.children.length); + folder.containerOpen = true; + Assert.equal(folder.childCount, aExpected.children.length); + + for (let index = 0; index < aExpected.children.length; index++) { + await checkItem(aExpected.children[index], folder.getChild(index)); + } + + folder.containerOpen = false; + break; + default: + throw new Error("Unknown property"); + } + } + })(); +} diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js b/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js new file mode 100644 index 0000000000..061c8c0c5f --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js @@ -0,0 +1,126 @@ +/* + * This test ensures that importing/exporting to HTML does not stop + * if a malformed uri is found. + */ + +const TEST_FAVICON_PAGE_URL = + "http://en-US.www.mozilla.com/en-US/firefox/central/"; +const TEST_FAVICON_DATA_SIZE = 580; + +add_task(async function test_corrupt_file() { + // Import bookmarks from the corrupt file. + let corruptHtml = PathUtils.join(do_get_cwd().path, "bookmarks.corrupt.html"); + await BookmarkHTMLUtils.importFromFile(corruptHtml, { replace: true }); + + // Check that bookmarks that are not corrupt have been imported. + await PlacesTestUtils.promiseAsyncUpdates(); + await database_check(); +}); + +add_task(async function test_corrupt_database() { + // Create corruption in the database, then export. + let corruptBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://test.mozilla.org", + title: "We love belugas", + }); + await PlacesUtils.withConnectionWrapper("test", async function (db) { + await db.execute("UPDATE moz_bookmarks SET fk = NULL WHERE guid = :guid", { + guid: corruptBookmark.guid, + }); + }); + + let bookmarksFile = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.html" + ); + await IOUtils.remove(bookmarksFile, { ignoreAbsent: true }); + await BookmarkHTMLUtils.exportToFile(bookmarksFile); + + // Import again and check for correctness. + await PlacesUtils.bookmarks.eraseEverything(); + await BookmarkHTMLUtils.importFromFile(bookmarksFile, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await database_check(); +}); + +/* + * Check for imported bookmarks correctness + * + * @return {Promise} + * @resolves When the checks are finished. + * @rejects Never. + */ +var database_check = async function () { + // BOOKMARKS MENU + let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid).root; + Assert.equal(root.childCount, 2); + + let folderNode = root.getChild(1); + Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER); + Assert.equal(folderNode.title, "test"); + + let bookmark = await PlacesUtils.bookmarks.fetch({ + guid: folderNode.bookmarkGuid, + }); + Assert.equal(PlacesUtils.toPRTime(bookmark.dateAdded), 1177541020000000); + Assert.equal(PlacesUtils.toPRTime(bookmark.lastModified), 1177541050000000); + + // open test folder, and test the children + PlacesUtils.asQuery(folderNode); + Assert.equal(folderNode.hasChildren, true); + folderNode.containerOpen = true; + Assert.equal(folderNode.childCount, 1); + + let bookmarkNode = folderNode.getChild(0); + Assert.equal("http://test/post", bookmarkNode.uri); + Assert.equal("test post keyword", bookmarkNode.title); + + let entry = await PlacesUtils.keywords.fetch({ url: bookmarkNode.uri }); + Assert.equal("test", entry.keyword); + Assert.equal("hidden1%3Dbar&text1%3D%25s", entry.postData); + + Assert.equal(bookmarkNode.dateAdded, 1177375336000000); + Assert.equal(bookmarkNode.lastModified, 1177375423000000); + + let pageInfo = await PlacesUtils.history.fetch(bookmarkNode.uri, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + "ISO-8859-1", + "Should have the correct charset" + ); + + // clean up + folderNode.containerOpen = false; + root.containerOpen = false; + + // BOOKMARKS TOOLBAR + root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.toolbarGuid).root; + Assert.equal(root.childCount, 3); + + // cleanup + root.containerOpen = false; + + // UNFILED BOOKMARKS + root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.unfiledGuid).root; + Assert.equal(root.childCount, 1); + root.containerOpen = false; + + // favicons + await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + uri(TEST_FAVICON_PAGE_URL), + (aURI, aDataLen, aData, aMimeType) => { + // aURI should never be null when aDataLen > 0. + Assert.notEqual(aURI, null); + // Favicon data is stored in the bookmarks file as a "data:" URI. For + // simplicity, instead of converting the data we receive to a "data:" URI + // and comparing it, we just check the data size. + Assert.equal(TEST_FAVICON_DATA_SIZE, aDataLen); + resolve(); + } + ); + }); +}; diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_escape_entities.js b/toolkit/components/places/tests/unit/test_bookmarks_html_escape_entities.js new file mode 100644 index 0000000000..5349d3948c --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html_escape_entities.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Checks that html entities are escaped in bookmarks.html files. + +add_task(async function () { + // Removes bookmarks.html if the file already exists. + let HTMLFile = PathUtils.join(PathUtils.profileDir, "bookmarks.html"); + await IOUtils.remove(HTMLFile, { ignoreAbsent: true }); + + let unescaped = '<unescaped="test">'; + // Adds bookmarks and tags to the database. + const url = 'http://www.google.it/"/'; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: unescaped, + }); + await PlacesUtils.keywords.insert({ + url, + keyword: unescaped, + postData: unescaped, + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), [unescaped]); + await PlacesUtils.history.update({ + url, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, unescaped]]), + }); + + // Exports the bookmarks as a HTML file. + await BookmarkHTMLUtils.exportToFile(HTMLFile); + await PlacesUtils.bookmarks.remove(bm); + + // Check there are no unescaped entities in the html file. + let xml = await new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.onload = () => { + try { + resolve(xhr.responseXML); + } catch (e) { + reject(e); + } + }; + xhr.onabort = + xhr.onerror = + xhr.ontimeout = + () => { + reject(new Error("xmlhttprequest failed")); + }; + xhr.open("GET", PathUtils.toFileURI(HTMLFile)); + xhr.responseType = "document"; + xhr.overrideMimeType("text/html"); + xhr.send(); + }); + + let checksCount = 5; + for ( + let current = xml; + current; + current = + current.firstChild || + current.nextSibling || + current.parentNode.nextSibling + ) { + switch (current.nodeType) { + case current.ELEMENT_NODE: + for (let { name, value } of current.attributes) { + info("Found attribute: " + name); + // Check tags, keyword, postData and charSet. + if ( + ["tags", "last_charset", "shortcuturl", "post_data"].includes(name) + ) { + Assert.equal( + value, + unescaped, + `Attribute ${name} should be complete` + ); + checksCount--; + } + } + break; + case current.TEXT_NODE: + // Check Title. + if (!current.data.startsWith("\n") && current.data.includes("test")) { + Assert.equal( + current.data.trim(), + unescaped, + "Text node should be complete" + ); + checksCount--; + } + break; + } + } + Assert.equal(checksCount, 0, "All the checks ran"); +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js b/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js new file mode 100644 index 0000000000..f01048e1a5 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js @@ -0,0 +1,64 @@ +var bookmarkData = [ + { + uri: uri("http://www.toastytech.com"), + title: "Nathan's Toasty Technology Page", + tags: ["technology", "personal", "retro"], + }, + { + uri: uri("http://www.reddit.com"), + title: "reddit: the front page of the internet", + tags: ["social media", "news", "humour"], + }, + { + uri: uri("http://www.4chan.org"), + title: "4chan", + tags: ["discussion", "imageboard", "anime"], + }, +]; + +/* + TEST SUMMARY + - Add bookmarks with tags + - Export tagged bookmarks as HTML file + - Delete bookmarks + - Import bookmarks from HTML file + - Check that all bookmarks are successfully imported with tags +*/ + +add_task(async function test_import_tags() { + // Removes bookmarks.html if the file already exists. + let HTMLFile = PathUtils.join(PathUtils.profileDir, "bookmarks.html"); + await IOUtils.remove(HTMLFile, { ignoreAbsent: true }); + + // Adds bookmarks and tags to the database. + let bookmarkList = new Set(); + for (let { uri, title, tags } of bookmarkData) { + bookmarkList.add( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title, + }) + ); + PlacesUtils.tagging.tagURI(uri, tags); + } + + // Exports the bookmarks as a HTML file. + await BookmarkHTMLUtils.exportToFile(HTMLFile); + + // Deletes bookmarks and tags from the database. + for (let bookmark of bookmarkList) { + await PlacesUtils.bookmarks.remove(bookmark.guid); + } + + // Re-imports the bookmarks from the HTML file. + await BookmarkHTMLUtils.importFromFile(HTMLFile, { replace: true }); + + // Tests to ensure that the tags are still present for each bookmark URI. + for (let { uri, tags } of bookmarkData) { + info("Test tags for " + uri.spec + ": " + tags + "\n"); + let foundTags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(foundTags.length, tags.length); + Assert.ok(tags.every(tag => foundTags.includes(tag))); + } +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_localized.js b/toolkit/components/places/tests/unit/test_bookmarks_html_localized.js new file mode 100644 index 0000000000..4ef2393efb --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html_localized.js @@ -0,0 +1,51 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" +); + +add_task(async function setup_l10n() { + // A single localized string. + const mockSource = L10nFileSource.createMock( + "test", + "app", + ["en-US"], + "/localization/{locale}/", + [ + { + path: "/localization/en-US/bookmarks_html_localized.ftl", + source: ` +bookmarks-html-localized-folder = Localized Folder +bookmarks-html-localized-bookmark = Localized Bookmark +`, + }, + ] + ); + + L10nRegistry.getInstance().registerSources([mockSource]); +}); + +add_task(async function test_bookmarks_html_localized() { + let bookmarksFile = PathUtils.join( + do_get_cwd().path, + "bookmarks_html_localized.html" + ); + await BookmarkHTMLUtils.importFromFile(bookmarksFile, { replace: true }); + + let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid).root; + Assert.equal(root.childCount, 1); + let folder = root.getChild(0); + PlacesUtils.asContainer(folder).containerOpen = true; + // Folder title is localized. + Assert.equal(folder.title, "Localized Folder"); + Assert.equal(folder.childCount, 1); + let bookmark = folder.getChild(0); + Assert.equal(bookmark.uri, "http://www.mozilla.com/firefox/help/"); + // Bookmark title is localized. + Assert.equal(bookmark.title, "Localized Bookmark"); + folder.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js b/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js new file mode 100644 index 0000000000..07131feafe --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js @@ -0,0 +1,31 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Test for bug #801450 + +// Get Services +const { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" +); + +add_task(async function test_bookmarks_html_singleframe() { + let bookmarksFile = PathUtils.join( + do_get_cwd().path, + "bookmarks_html_singleframe.html" + ); + await BookmarkHTMLUtils.importFromFile(bookmarksFile, { replace: true }); + + let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid).root; + Assert.equal(root.childCount, 1); + let folder = root.getChild(0); + PlacesUtils.asContainer(folder).containerOpen = true; + Assert.equal(folder.title, "Subtitle"); + Assert.equal(folder.childCount, 1); + let bookmark = folder.getChild(0); + Assert.equal(bookmark.uri, "http://www.mozilla.org/"); + Assert.equal(bookmark.title, "Mozilla"); + folder.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_json.js b/toolkit/components/places/tests/unit/test_bookmarks_json.js new file mode 100644 index 0000000000..19aaac7f95 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_json.js @@ -0,0 +1,368 @@ +/* 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/. */ + +const { BookmarkJSONUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkJSONUtils.sys.mjs" +); + +// An object representing the contents of bookmarks.json. +var test_bookmarks = { + menu: [ + { + guid: "OCyeUO5uu9FF", + title: "Mozilla Firefox", + children: [ + { + guid: "OCyeUO5uu9FG", + title: "Help and Tutorials", + url: "http://en-us.www.mozilla.com/en-US/firefox/help/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + guid: "OCyeUO5uu9FH", + title: "Customize Firefox", + url: "http://en-us.www.mozilla.com/en-US/firefox/customize/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + guid: "OCyeUO5uu9FI", + title: "Get Involved", + url: "http://en-us.www.mozilla.com/en-US/firefox/community/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + guid: "OCyeUO5uu9FJ", + title: "About Us", + url: "http://en-us.www.mozilla.com/en-US/about/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + guid: "QFM-QnE2ZpMz", + title: "Test null postData", + url: "http://example.com/search?q=%s&suggid=", + }, + ], + }, + { + guid: "OCyeUO5uu9FK", + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR, + }, + { + guid: "OCyeUO5uu9FL", + title: "test", + dateAdded: 1177541020000000, + lastModified: 1177541050000000, + children: [ + { + guid: "OCyeUO5uu9GX", + title: "test post keyword", + dateAdded: 1177375336000000, + lastModified: 1177375423000000, + keyword: "test", + postData: "hidden1%3Dbar&text1%3D%25s", + charset: "ISO-8859-1", + }, + ], + }, + ], + toolbar: [ + { + guid: "OCyeUO5uu9FB", + title: "Getting Started", + url: "http://en-us.www.mozilla.com/en-US/firefox/central/", + icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==", + }, + { + guid: "OCyeUO5uu9FR", + title: "Latest Headlines", + // This used to be a livemark, but we don't import them anymore, instead + // it will be imported as an empty folder, because the json format stores + // it like that: an empty folder with a couple annotations. Since + // annotations will go away, there won't be a clean way to import it as a + // bookmark instead. + // Note: date gets truncated to milliseconds, whereas the value in bookmarks.json + // has full microseconds. + dateAdded: 1361551979451000, + lastModified: 1361551979457000, + }, + ], + unfiled: [ + { guid: "OCyeUO5uu9FW", title: "Example.tld", url: "http://example.tld/" }, + { + guid: "Cfkety492Afk", + title: "test tagged bookmark", + dateAdded: 1507025843703000, + lastModified: 1507025844703000, + url: "http://example.tld/tagged", + tags: ["foo"], + }, + { + guid: "lOZGoFR1eXbl", + title: "Bookmarks Toolbar Shortcut", + dateAdded: 1507025843703000, + lastModified: 1507025844703000, + url: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`, + }, + { + guid: "7yJWnBVhjRtP", + title: "Folder Shortcut", + dateAdded: 1507025843703000, + lastModified: 1507025844703000, + url: `place:parent=OCyeUO5uu9FF`, + }, + { + guid: "vm5QXWuWc12l", + title: "Folder Shortcut 2", + dateAdded: 1507025843703000, + lastModified: 1507025844703000, + url: "place:invalidOldParentId=6123443&excludeItems=1", + }, + { + guid: "Icg1XlIozA1D", + title: "Folder Shortcut 3", + dateAdded: 1507025843703000, + lastModified: 1507025844703000, + url: `place:parent=OCyeUO5uu9FF&parent=${PlacesUtils.bookmarks.menuGuid}`, + }, + ], +}; + +// Exported bookmarks file pointer. +var bookmarksExportedFile; + +add_task(async function test_import_bookmarks_disallowed_url() { + await Assert.rejects( + BookmarkJSONUtils.importFromURL("http://example.com/bookmarks.json"), + /importFromURL can only be used with/, + "Should reject importing from an http based url" + ); + await Assert.rejects( + BookmarkJSONUtils.importFromURL("https://example.com/bookmarks.json"), + /importFromURL can only be used with/, + "Should reject importing from an https based url" + ); +}); + +add_task(async function test_import_bookmarks_count() { + // Ensure the bookmarks count is correct when importing in various cases + await PlacesUtils.bookmarks.eraseEverything(); + let bookmarksFile = PathUtils.join(do_get_cwd().path, "bookmarks.json"); + bookmarksExportedFile = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.json" + ); + + let count = await BookmarkJSONUtils.importFromFile(bookmarksFile, { + replace: true, + }); + Assert.equal( + count, + 13, + "There should be 13 imported bookmarks when importing from an empty database" + ); + + await BookmarkJSONUtils.exportToFile(bookmarksExportedFile); + count = -1; + count = await BookmarkJSONUtils.importFromFile(bookmarksExportedFile, { + replace: true, + }); + Assert.equal( + count, + 13, + "There should be 13 imported bookmarks when replacing existing bookmarks" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + count = -1; + let bookmarksUrl = PathUtils.toFileURI(bookmarksFile); + count = await BookmarkJSONUtils.importFromURL(bookmarksUrl); + Assert.equal( + count, + 13, + "There should be 13 imported bookmarks when importing from a URL" + ); + + // Clean up task + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_bookmarks() { + let bookmarksFile = PathUtils.join(do_get_cwd().path, "bookmarks.json"); + + await BookmarkJSONUtils.importFromFile(bookmarksFile, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); +}); + +add_task(async function test_export_bookmarks() { + bookmarksExportedFile = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.json" + ); + await BookmarkJSONUtils.exportToFile(bookmarksExportedFile); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_import_exported_bookmarks() { + await PlacesUtils.bookmarks.eraseEverything(); + await BookmarkJSONUtils.importFromFile(bookmarksExportedFile, { + replace: true, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); +}); + +add_task(async function test_import_ontop() { + await PlacesUtils.bookmarks.eraseEverything(); + await BookmarkJSONUtils.importFromFile(bookmarksExportedFile, { + replace: true, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + await BookmarkJSONUtils.exportToFile(bookmarksExportedFile); + await PlacesTestUtils.promiseAsyncUpdates(); + await BookmarkJSONUtils.importFromFile(bookmarksExportedFile, { + replace: true, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); +}); + +add_task(async function test_import_iconuri() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + let bookmarksFile = PathUtils.join( + do_get_cwd().path, + "bookmarks_iconuri.json" + ); + + await BookmarkJSONUtils.importFromFile(bookmarksFile, { + replace: true, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); +}); + +add_task(async function test_export_bookmarks_with_iconuri() { + bookmarksExportedFile = PathUtils.join( + PathUtils.profileDir, + "bookmarks.exported.json" + ); + await BookmarkJSONUtils.exportToFile(bookmarksExportedFile); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function test_import_exported_bookmarks_with_iconuri() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await BookmarkJSONUtils.importFromFile(bookmarksExportedFile, { + replace: true, + }); + await PlacesTestUtils.promiseAsyncUpdates(); + await testImportedBookmarks(); +}); + +add_task(async function test_clean() { + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function testImportedBookmarks() { + for (let group in test_bookmarks) { + info("[testImportedBookmarks()] Checking group '" + group + "'"); + + let root = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks[`${group}Guid`] + ).root; + + let items = test_bookmarks[group]; + Assert.equal(root.childCount, items.length); + + for (let key in items) { + await checkItem(items[key], root.getChild(key)); + } + + root.containerOpen = false; + } +} + +async function checkItem(aExpected, aNode) { + let bookmark = await PlacesUtils.bookmarks.fetch(aNode.bookmarkGuid); + + for (let prop in aExpected) { + switch (prop) { + case "type": + Assert.equal(aNode.type, aExpected.type); + break; + case "title": + Assert.equal(aNode.title, aExpected.title); + break; + case "dateAdded": + Assert.equal( + PlacesUtils.toPRTime(bookmark.dateAdded), + aExpected.dateAdded + ); + break; + case "lastModified": + Assert.equal( + PlacesUtils.toPRTime(bookmark.lastModified), + aExpected.lastModified + ); + break; + case "url": + Assert.equal(aNode.uri, aExpected.url); + break; + case "icon": + let { data } = await getFaviconDataForPage(aExpected.url); + let base64Icon = + "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, data)); + Assert.equal(base64Icon, aExpected.icon); + break; + case "keyword": { + let entry = await PlacesUtils.keywords.fetch({ url: aNode.uri }); + Assert.equal(entry.keyword, aExpected.keyword); + break; + } + case "guid": + Assert.equal(bookmark.guid, aExpected.guid); + break; + case "postData": { + let entry = await PlacesUtils.keywords.fetch({ url: aNode.uri }); + Assert.equal(entry.postData, aExpected.postData); + break; + } + case "charset": + let pageInfo = await PlacesUtils.history.fetch(aNode.uri, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + aExpected.charset + ); + break; + case "children": + let folder = aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + Assert.equal(folder.hasChildren, !!aExpected.children.length); + folder.containerOpen = true; + Assert.equal(folder.childCount, aExpected.children.length); + + for (let index = 0; index < aExpected.children.length; index++) { + await checkItem(aExpected.children[index], folder.getChild(index)); + } + + folder.containerOpen = false; + break; + case "tags": + let uri = Services.io.newURI(aNode.uri); + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(uri), + aExpected.tags, + "should have the expected tags" + ); + break; + default: + throw new Error("Unknown property"); + } + } +} diff --git a/toolkit/components/places/tests/unit/test_bookmarks_json_corrupt.js b/toolkit/components/places/tests/unit/test_bookmarks_json_corrupt.js new file mode 100644 index 0000000000..b31f1da5bb --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_json_corrupt.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/** + * Tests for importing a corrupt json file. + * + * The corrupt json file attempts to import into: + * - the menu folder: + * - A bookmark with an invalid type. + * - A valid bookmark. + * - A bookmark with an invalid url. + * - the toolbar folder: + * - A bookmark with an invalid url. + * + * The menu case ensure that we strip out invalid bookmarks, but retain valid + * ones. + * The toolbar case ensures that if no valid bookmarks remain, then we do not + * throw an error. + */ + +const { BookmarkJSONUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkJSONUtils.sys.mjs" +); + +add_task(async function test_import_bookmarks() { + let bookmarksFile = PathUtils.join( + do_get_cwd().path, + "bookmarks_corrupt.json" + ); + + await BookmarkJSONUtils.importFromFile(bookmarksFile, { replace: true }); + await PlacesTestUtils.promiseAsyncUpdates(); + + let bookmarks = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + bookmarks.children.length, + 1, + "should only be one bookmark in the menu" + ); + let bookmark = bookmarks.children[0]; + Assert.equal(bookmark.guid, "OCyeUO5uu9FH", "should have correct guid"); + Assert.equal( + bookmark.title, + "Customize Firefox", + "should have correct title" + ); + Assert.equal( + bookmark.uri, + "http://en-us.www.mozilla.com/en-US/firefox/customize/", + "should have correct uri" + ); + + bookmarks = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.toolbarGuid + ); + + Assert.ok( + !bookmarks.children, + "should not have any bookmarks in the toolbar" + ); +}); diff --git a/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js b/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js new file mode 100644 index 0000000000..892b2d1d04 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js @@ -0,0 +1,319 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" +); + +/** + * Tests the bookmarks-restore-* nsIObserver notifications after restoring + * bookmarks from JSON and HTML. See bug 470314. + */ + +// The topics and data passed to nsIObserver.observe() on bookmarks restore +const NSIOBSERVER_TOPIC_BEGIN = "bookmarks-restore-begin"; +const NSIOBSERVER_TOPIC_SUCCESS = "bookmarks-restore-success"; +const NSIOBSERVER_TOPIC_FAILED = "bookmarks-restore-failed"; +const NSIOBSERVER_DATA_JSON = "json"; +const NSIOBSERVER_DATA_HTML = "html"; +const NSIOBSERVER_DATA_HTML_INIT = "html-initial"; + +// Bookmarks are added for these URIs +var uris = [ + "http://example.com/1", + "http://example.com/2", + "http://example.com/3", + "http://example.com/4", + "http://example.com/5", +]; + +/** + * Adds some bookmarks for the URIs in |uris|. + */ +async function addBookmarks() { + for (let url of uris) { + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + Assert.ok(await PlacesUtils.bookmarks.fetch({ url }), "Url is bookmarked"); + } +} + +/** + * Creates an file in the profile directory. + * + * @param aBasename + * e.g., "foo.txt" in the path /some/long/path/foo.txt + * @return {Promise} + * @resolves to an OS.File path + */ +async function promiseFile(aBasename) { + let path = PathUtils.join(PathUtils.profileDir, aBasename); + info("opening " + path); + + await IOUtils.writeUTF8(path, ""); + return path; +} + +/** + * Register observers via promiseTopicObserved helper. + * + * @param {boolean} expectSuccess pass true when expect a success notification + * @return {Promise[]} + */ +function registerObservers(expectSuccess) { + let promiseBegin = promiseTopicObserved(NSIOBSERVER_TOPIC_BEGIN); + let promiseResult; + if (expectSuccess) { + promiseResult = promiseTopicObserved(NSIOBSERVER_TOPIC_SUCCESS); + } else { + promiseResult = promiseTopicObserved(NSIOBSERVER_TOPIC_FAILED); + } + + return [promiseBegin, promiseResult]; +} + +/** + * Check notification results. + * + * @param {Promise[]} expectPromises array contain promiseBegin and promiseResult + * @param {object} expectedData contain data and folderId + */ +async function checkObservers(expectPromises, expectedData) { + let [promiseBegin, promiseResult] = expectPromises; + + let beginData = (await promiseBegin)[1]; + Assert.equal( + beginData, + expectedData.data, + "Data for current test should be what is expected" + ); + + let [resultSubject, resultData] = await promiseResult; + Assert.equal( + resultData, + expectedData.data, + "Data for current test should be what is expected" + ); + + // Make sure folder ID is what is expected. For importing HTML into a + // folder, this will be an integer, otherwise null. + if (resultSubject) { + Assert.equal( + resultSubject.QueryInterface(Ci.nsISupportsPRInt64).data, + expectedData.folderId + ); + } else { + Assert.equal(expectedData.folderId, null); + } +} + +/** + * Run after every test cases. + */ +async function teardown(file, begin, success, fail) { + // On restore failed, file may not exist, so wrap in try-catch. + await IOUtils.remove(file, { ignoreAbsent: true }); + + // clean up bookmarks + await PlacesUtils.bookmarks.eraseEverything(); +} + +add_task(async function test_json_restore_normal() { + // data: the data passed to nsIObserver.observe() corresponding to the test + // folderId: for HTML restore into a folder, the folder ID to restore into; + // otherwise, set it to null + let expectedData = { + data: NSIOBSERVER_DATA_JSON, + folderId: null, + }; + let expectPromises = registerObservers(true); + + info("JSON restore: normal restore should succeed"); + let file = await promiseFile("bookmarks-test_restoreNotification.json"); + await addBookmarks(); + + await BookmarkJSONUtils.exportToFile(file); + await PlacesUtils.bookmarks.eraseEverything(); + try { + await BookmarkJSONUtils.importFromFile(file, { replace: true }); + } catch (e) { + do_throw(" Restore should not have failed " + e); + } + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_json_restore_empty() { + let expectedData = { + data: NSIOBSERVER_DATA_JSON, + folderId: null, + }; + let expectPromises = registerObservers(false); + + info("JSON restore: empty file should fail"); + let file = await promiseFile("bookmarks-test_restoreNotification.json"); + await Assert.rejects( + BookmarkJSONUtils.importFromFile(file, { replace: true }), + /SyntaxError/, + "Restore should reject for an empty file." + ); + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_json_restore_nonexist() { + let expectedData = { + data: NSIOBSERVER_DATA_JSON, + folderId: null, + }; + let expectPromises = registerObservers(false); + + info("JSON restore: nonexistent file should fail"); + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("this file doesn't exist because nobody created it 1"); + await Assert.rejects( + BookmarkJSONUtils.importFromFile(file.path, { replace: true }), + /Cannot restore from nonexisting json file/, + "Restore should reject for a non-existent file." + ); + + await checkObservers(expectPromises, expectedData); + await teardown(file.path); +}); + +add_task(async function test_html_restore_normal() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML, + folderId: null, + }; + let expectPromises = registerObservers(true); + + info("HTML restore: normal restore should succeed"); + let file = await promiseFile("bookmarks-test_restoreNotification.html"); + await addBookmarks(); + await BookmarkHTMLUtils.exportToFile(file); + await PlacesUtils.bookmarks.eraseEverything(); + try { + BookmarkHTMLUtils.importFromFile(file).catch( + do_report_unexpected_exception + ); + } catch (e) { + do_throw(" Restore should not have failed"); + } + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_html_restore_empty() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML, + folderId: null, + }; + let expectPromises = registerObservers(true); + + info("HTML restore: empty file should succeed"); + let file = await promiseFile("bookmarks-test_restoreNotification.init.html"); + try { + BookmarkHTMLUtils.importFromFile(file).catch( + do_report_unexpected_exception + ); + } catch (e) { + do_throw(" Restore should not have failed"); + } + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_html_restore_nonexist() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML, + folderId: null, + }; + let expectPromises = registerObservers(false); + + info("HTML restore: nonexistent file should fail"); + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("this file doesn't exist because nobody created it 2"); + await Assert.rejects( + BookmarkHTMLUtils.importFromFile(file.path), + /Cannot import from nonexisting html file/, + "Restore should reject for a non-existent file." + ); + + await checkObservers(expectPromises, expectedData); + await teardown(file.path); +}); + +add_task(async function test_html_init_restore_normal() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML_INIT, + folderId: null, + }; + let expectPromises = registerObservers(true); + + info("HTML initial restore: normal restore should succeed"); + let file = await promiseFile("bookmarks-test_restoreNotification.init.html"); + await addBookmarks(); + await BookmarkHTMLUtils.exportToFile(file); + await PlacesUtils.bookmarks.eraseEverything(); + try { + BookmarkHTMLUtils.importFromFile(file, { replace: true }).catch( + do_report_unexpected_exception + ); + } catch (e) { + do_throw(" Restore should not have failed"); + } + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_html_init_restore_empty() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML_INIT, + folderId: null, + }; + let expectPromises = registerObservers(true); + + info("HTML initial restore: empty file should succeed"); + let file = await promiseFile("bookmarks-test_restoreNotification.init.html"); + try { + BookmarkHTMLUtils.importFromFile(file, { replace: true }).catch( + do_report_unexpected_exception + ); + } catch (e) { + do_throw(" Restore should not have failed"); + } + + await checkObservers(expectPromises, expectedData); + await teardown(file); +}); + +add_task(async function test_html_init_restore_nonexist() { + let expectedData = { + data: NSIOBSERVER_DATA_HTML_INIT, + folderId: null, + }; + let expectPromises = registerObservers(false); + + info("HTML initial restore: nonexistent file should fail"); + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("this file doesn't exist because nobody created it 3"); + await Assert.rejects( + BookmarkHTMLUtils.importFromFile(file.path, { replace: true }), + /Cannot import from nonexisting html file/, + "Restore should reject for a non-existent file." + ); + + await checkObservers(expectPromises, expectedData); + await teardown(file.path); +}); diff --git a/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js b/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js new file mode 100644 index 0000000000..1e1f7bc9c7 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_brokenFolderShortcut() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + url: "http://1.moz.org/", + title: "Bookmark 1", + }, + { + url: "place:parent=1234", + title: "Shortcut 1", + }, + { + url: "place:parent=-1", + title: "Shortcut 2", + }, + { + url: "http://2.moz.org/", + title: "Bookmark 2", + }, + ], + }); + + // Add also a simple visit. + await PlacesTestUtils.addVisits(uri("http://3.moz.org/")); + + // Query containing a broken folder shortcuts among results. + let query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.unfiledGuid]); + let options = PlacesUtils.history.getNewQueryOptions(); + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + + Assert.equal(root.childCount, 4); + + let shortcut = root.getChild(1); + Assert.equal(shortcut.uri, "place:parent=1234"); + PlacesUtils.asContainer(shortcut); + shortcut.containerOpen = true; + Assert.equal(shortcut.childCount, 0); + shortcut.containerOpen = false; + // Remove the broken shortcut while the containing result is open. + await PlacesUtils.bookmarks.remove(bookmarks[1]); + Assert.equal(root.childCount, 3); + + shortcut = root.getChild(1); + Assert.equal(shortcut.uri, "place:parent=-1"); + PlacesUtils.asContainer(shortcut); + shortcut.containerOpen = true; + Assert.equal(shortcut.childCount, 0); + shortcut.containerOpen = false; + // Remove the broken shortcut while the containing result is open. + await PlacesUtils.bookmarks.remove(bookmarks[2]); + Assert.equal(root.childCount, 2); + + root.containerOpen = false; + + // Broken folder shortcut as root node. + query = PlacesUtils.history.getNewQuery(); + query.setParents([1234]); + options = PlacesUtils.history.getNewQueryOptions(); + root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal(root.childCount, 0); + root.containerOpen = false; + + // Broken folder shortcut as root node with folder=-1. + query = PlacesUtils.history.getNewQuery(); + query.setParents([-1]); + options = PlacesUtils.history.getNewQueryOptions(); + root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal(root.childCount, 0); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_browserhistory.js b/toolkit/components/places/tests/unit/test_browserhistory.js new file mode 100644 index 0000000000..f737262ae7 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_browserhistory.js @@ -0,0 +1,122 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const TEST_URI = "http://mozilla.com/"; +const TEST_SUBDOMAIN_URI = "http://foobar.mozilla.com/"; + +async function checkEmptyHistory() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached("SELECT count(*) FROM moz_historyvisits"); + return !rows[0].getResultByIndex(0); +} + +add_task(async function test_addPage() { + await PlacesTestUtils.addVisits(TEST_URI); + Assert.ok(!(await checkEmptyHistory()), "History has entries"); +}); + +add_task(async function test_removePage() { + await PlacesUtils.history.remove(TEST_URI); + Assert.ok(await checkEmptyHistory(), "History is empty"); +}); + +add_task(async function test_removePages() { + let pages = []; + for (let i = 0; i < 8; i++) { + pages.push(TEST_URI + i); + } + + await PlacesTestUtils.addVisits(pages.map(uri => ({ uri }))); + // Bookmarked item should not be removed from moz_places. + const ANNO_INDEX = 1; + const ANNO_NAME = "testAnno"; + const ANNO_VALUE = "foo"; + const BOOKMARK_INDEX = 2; + await PlacesUtils.history.update({ + url: pages[ANNO_INDEX], + annotations: new Map([[ANNO_NAME, ANNO_VALUE]]), + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: pages[BOOKMARK_INDEX], + title: "test bookmark", + }); + await PlacesUtils.history.update({ + url: pages[BOOKMARK_INDEX], + annotations: new Map([[ANNO_NAME, ANNO_VALUE]]), + }); + + await PlacesUtils.history.remove(pages); + Assert.ok(await checkEmptyHistory(), "History is empty"); + + // Check that the bookmark and its annotation still exist. + let folder = await PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.unfiledGuid + ); + Assert.equal(folder.root.childCount, 1); + let pageInfo = await PlacesUtils.history.fetch(pages[BOOKMARK_INDEX], { + includeAnnotations: true, + }); + Assert.equal(pageInfo.annotations.get(ANNO_NAME), ANNO_VALUE); + + // Check the annotation on the non-bookmarked page does not exist anymore. + await assertNoOrphanPageAnnotations(); + + // Cleanup. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_removePagesByTimeframe() { + let visits = []; + let startDate = (Date.now() - 10000) * 1000; + for (let i = 0; i < 10; i++) { + visits.push({ + uri: TEST_URI + i, + visitDate: startDate + i * 1000, + }); + } + + await PlacesTestUtils.addVisits(visits); + + // Delete all pages except the first and the last. + await PlacesUtils.history.removeByFilter({ + beginDate: PlacesUtils.toDate(startDate + 1000), + endDate: PlacesUtils.toDate(startDate + 8000), + }); + + // Check that we have removed the correct pages. + for (let i = 0; i < 10; i++) { + Assert.equal(page_in_database(TEST_URI + i) == 0, i > 0 && i < 9); + } + + // Clear remaining items and check that all pages have been removed. + await PlacesUtils.history.removeByFilter({ + beginDate: PlacesUtils.toDate(startDate), + endDate: PlacesUtils.toDate(startDate + 9000), + }); + Assert.ok(await checkEmptyHistory(), "History is empty"); +}); + +add_task(async function test_removePagesFromHost() { + await PlacesTestUtils.addVisits(TEST_URI); + await PlacesUtils.history.removeByFilter({ host: ".mozilla.com" }); + Assert.ok(await checkEmptyHistory(), "History is empty"); +}); + +add_task(async function test_removePagesFromHost_keepSubdomains() { + await PlacesTestUtils.addVisits([ + { uri: TEST_URI }, + { uri: TEST_SUBDOMAIN_URI }, + ]); + await PlacesUtils.history.removeByFilter({ host: "mozilla.com" }); + Assert.ok(!(await checkEmptyHistory()), "History has entries"); +}); + +add_task(async function test_history_clear() { + await PlacesUtils.history.clear(); + Assert.ok(await checkEmptyHistory(), "History is empty"); +}); diff --git a/toolkit/components/places/tests/unit/test_childlessTags.js b/toolkit/components/places/tests/unit/test_childlessTags.js new file mode 100644 index 0000000000..9f489a266b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_childlessTags.js @@ -0,0 +1,140 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Ensures that removal of a bookmark untags the bookmark if it's no longer + * contained in any regular, non-tag folders. See bug 444849. + */ + +var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService +); + +var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].getService( + Ci.nsITaggingService +); + +const BOOKMARK_URI = uri("http://example.com/"); + +add_task(async function test_removing_tagged_bookmark_removes_tag() { + print(" Make a bookmark."); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: BOOKMARK_URI, + title: "test bookmark", + }); + + print(" Tag it up."); + let tags = ["foo", "bar"]; + tagssvc.tagURI(BOOKMARK_URI, tags); + ensureTagsExist(tags); + let root = getTagRoot(); + root.containerOpen = true; + let oldCount = root.childCount; + root.containerOpen = false; + + print(" Remove the bookmark. The tags should no longer exist."); + let wait = TestUtils.waitForCondition(() => { + root = getTagRoot(); + root.containerOpen = true; + let val = root.childCount == oldCount - 2; + root.containerOpen = false; + return val; + }); + await PlacesUtils.bookmarks.remove(bookmark.guid); + await wait; + ensureTagsExist([]); +}); + +add_task( + async function test_removing_folder_containing_tagged_bookmark_removes_tag() { + print(" Make a folder."); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + print(" Stick a bookmark in the folder."); + var bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: BOOKMARK_URI, + title: "test bookmark", + }); + + print(" Tag the bookmark."); + var tags = ["foo", "bar"]; + tagssvc.tagURI(BOOKMARK_URI, tags); + ensureTagsExist(tags); + + // The tag containers are removed in async and take some time + let oldCountFoo = await tagCount("foo"); + let oldCountBar = await tagCount("bar"); + + print(" Remove the folder. The tags should no longer exist."); + + let wait = TestUtils.waitForCondition(async () => { + let newCountFoo = await tagCount("foo"); + let newCountBar = await tagCount("bar"); + return newCountFoo == oldCountFoo - 1 && newCountBar == oldCountBar - 1; + }); + await PlacesUtils.bookmarks.remove(bookmark.guid); + await wait; + ensureTagsExist([]); + } +); + +async function tagCount(aTag) { + let allTags = await PlacesUtils.bookmarks.fetchTags(); + for (let i of allTags) { + if (i.name == aTag) { + return i.count; + } + } + return 0; +} + +function getTagRoot() { + var query = histsvc.getNewQuery(); + var opts = histsvc.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_TAGS_ROOT; + var resultRoot = histsvc.executeQuery(query, opts).root; + return resultRoot; +} +/** + * Runs a tag query and ensures that the tags returned are those and only those + * in aTags. aTags may be empty, in which case this function ensures that no + * tags exist. + * + * @param aTags + * An array of tags (strings) + */ +function ensureTagsExist(aTags) { + var query = histsvc.getNewQuery(); + var opts = histsvc.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_TAGS_ROOT; + var resultRoot = histsvc.executeQuery(query, opts).root; + + // Dupe aTags. + var tags = aTags.slice(0); + + resultRoot.containerOpen = true; + + // Ensure that the number of tags returned from the query is the same as the + // number in |tags|. + Assert.equal(resultRoot.childCount, tags.length); + + // For each tag result from the query, ensure that it's contained in |tags|. + // Remove the tag from |tags| so that we ensure the sets are equal. + for (let i = 0; i < resultRoot.childCount; i++) { + var tag = resultRoot.getChild(i).title; + var indexOfTag = tags.indexOf(tag); + Assert.ok(indexOfTag >= 0); + tags.splice(indexOfTag, 1); + } + + resultRoot.containerOpen = false; +} diff --git a/toolkit/components/places/tests/unit/test_frecency_decay.js b/toolkit/components/places/tests/unit/test_frecency_decay.js new file mode 100644 index 0000000000..8fbb08aecc --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_decay.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_FREC_DECAY_RATE_DEF = 0.975; + +/** + * Promises that the pages-rank-changed event has been seen. + * + * @returns {Promise} A promise which is resolved when the notification is seen. + */ +function promiseRankingChanged() { + return PlacesTestUtils.waitForNotification("pages-rank-changed"); +} + +add_task(async function setup() { + Services.prefs.setCharPref( + "places.frecency.decayRate", + PREF_FREC_DECAY_RATE_DEF + ); +}); + +add_task(async function test_isFrecencyDecaying() { + let db = await PlacesUtils.promiseDBConnection(); + async function queryFrecencyDecaying() { + return ( + await db.executeCached(`SELECT is_frecency_decaying()`) + )[0].getResultByIndex(0); + } + PlacesUtils.history.isFrecencyDecaying = true; + Assert.equal(PlacesUtils.history.isFrecencyDecaying, true); + Assert.equal(await queryFrecencyDecaying(), true); + PlacesUtils.history.isFrecencyDecaying = false; + Assert.equal(PlacesUtils.history.isFrecencyDecaying, false); + Assert.equal(await queryFrecencyDecaying(), false); +}); + +add_task(async function test_frecency_decay() { + let unvisitedBookmarkFrecency = Services.prefs.getIntPref( + "places.frecency.unvisitedBookmarkBonus" + ); + + // Add a bookmark and check its frecency. + let url = "http://example.com/b"; + let promiseOne = promiseRankingChanged(); + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promiseOne; + + let histogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_IDLE_FRECENCY_DECAY_TIME_MS" + ); + info("Trigger frecency decay."); + Assert.equal(PlacesUtils.history.isFrecencyDecaying, false); + let promiseRanking = promiseRankingChanged(); + + PlacesFrecencyRecalculator.observe(null, "idle-daily", ""); + Assert.equal(PlacesUtils.history.isFrecencyDecaying, true); + info("Wait for completion."); + await PlacesFrecencyRecalculator.pendingFrecencyDecayPromise; + + await promiseRanking; + Assert.equal(PlacesUtils.history.isFrecencyDecaying, false); + + // Now check the new frecency is correct. + let newFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url } + ); + + Assert.equal( + newFrecency, + Math.round(unvisitedBookmarkFrecency * PREF_FREC_DECAY_RATE_DEF), + "Frecencies should match" + ); + + let snapshot = histogram.snapshot(); + Assert.greater(snapshot.sum, 0); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_observers.js b/toolkit/components/places/tests/unit/test_frecency_observers.js new file mode 100644 index 0000000000..44747b06f9 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_observers.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Each of these tests a path that triggers a frecency update. Together they +// hit all sites that update a frecency. + +// InsertVisitedURIs::UpdateFrecency and History::InsertPlace +add_task( + async function test_InsertVisitedURIs_UpdateFrecency_and_History_InsertPlace() { + // InsertPlace is at the end of a path that UpdateFrecency is also on, so kill + // two birds with one stone and expect two notifications. Trigger the path by + // adding a download. + let url = Services.io.newURI("http://example.com/a"); + let promise = onRankingChanged(); + await PlacesUtils.history.insert({ + url, + visits: [ + { + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + }, + ], + }); + await promise; + } +); + +// nsNavHistory::UpdateFrecency +add_task(async function test_nsNavHistory_UpdateFrecency() { + let url = Services.io.newURI("http://example.com/b"); + let promise = onRankingChanged(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "test", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; +}); + +// History.jsm invalidateFrecencies() +add_task(async function test_invalidateFrecencies() { + let url = Services.io.newURI("http://test-invalidateFrecencies.com/"); + // Bookmarking the URI is enough to add it to moz_places, and importantly, it + // means that removeByFilter doesn't remove it from moz_places, so its + // frecency is able to be changed. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "test", + }); + let promise = onRankingChanged(); + await PlacesUtils.history.removeByFilter({ host: url.host }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await promise; +}); + +// History.jsm clear() should not cause a frecency recalculation since pages +// are removed. +add_task(async function test_clear() { + let received = []; + let listener = events => + (received = received.concat(events.map(e => e.type))); + PlacesObservers.addListener( + ["history-cleared", "pages-rank-changed"], + listener + ); + await PlacesUtils.history.clear(); + PlacesObservers.removeListener( + ["history-cleared", "pages-rank-changed"], + listener + ); + Assert.deepEqual(received, ["history-cleared"]); +}); + +add_task(async function test_nsNavHistory_idleDaily() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://test-site1.org", + title: "test", + }); + PlacesFrecencyRecalculator.observe(null, "idle-daily", ""); + await Promise.all([onRankingChanged()]); +}); + +add_task(async function test_nsNavHistory_recalculate() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://test-site1.org", + title: "test", + }); + await Promise.all([ + onRankingChanged(), + PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(), + ]); +}); + +function onRankingChanged() { + return PlacesTestUtils.waitForNotification("pages-rank-changed"); +} diff --git a/toolkit/components/places/tests/unit/test_frecency_origins_alternative.js b/toolkit/components/places/tests/unit/test_frecency_origins_alternative.js new file mode 100644 index 0000000000..5519149cac --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_origins_alternative.js @@ -0,0 +1,301 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests alternative origins frecency. +// Note: the order of the tests here matters, since we are emulating subsquent +// starts of the recalculator component with different initial conditions. + +const FEATURE_PREF = "places.frecency.origins.alternative.featureGate"; + +async function restartRecalculator() { + let subject = {}; + PlacesFrecencyRecalculator.observe( + subject, + "test-alternative-frecency-init", + "" + ); + await subject.promise; +} + +async function getAllOrigins() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(`SELECT * FROM moz_origins`); + Assert.greater(rows.length, 0); + return rows.map(r => ({ + host: r.getResultByName("host"), + frecency: r.getResultByName("frecency"), + recalc_frecency: r.getResultByName("recalc_frecency"), + alt_frecency: r.getResultByName("alt_frecency"), + recalc_alt_frecency: r.getResultByName("recalc_alt_frecency"), + })); +} + +add_setup(async function () { + await PlacesTestUtils.addVisits([ + "https://testdomain1.moz.org", + "https://testdomain2.moz.org", + "https://testdomain3.moz.org", + ]); + registerCleanupFunction(PlacesUtils.history.clear); +}); + +add_task(async function test_normal_init() { + // Ensure moz_meta doesn't report anything. + Assert.ok( + !PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.enabled, + "Check the pref is disabled by default" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.ok( + ObjectUtils.isEmpty( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ) + ), + "Check there's no variables stored" + ); +}); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_enable_init() { + // Set alt_frecency to NULL and recalc_alt_frecency = 0 for the entries in + // moz_origins to verify they are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_origins", { + alt_frecency: null, + recalc_alt_frecency: 0, + }); + + await restartRecalculator(); + + // Ensure moz_meta doesn't report anything. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.equal( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins + .metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.variables + .version, + "Check the algorithm version has been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + let origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + origins.every(o => o.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_different_version() { + let origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + + // Set alt_frecency to NULL for the entries in moz_origins to verify they + // are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_origins", { + alt_frecency: null, + }); + + // It doesn't matter that the version is, it just have to be different. + let variables = await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ); + variables.version = 999; + await PlacesUtils.metadata.set( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + variables + ); + + await restartRecalculator(); + + // Check alternative frecency has been marked for recalculation. + // Note just after init we reculate a chunk, and this test code is expected + // to run before that... though we can't be sure, so if this starts failing + // intermittently we'll have to add more synchronization test code. + origins = await getAllOrigins(); + // Ensure moz_meta has been updated. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.deepEqual( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins + .metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.variables + .version, + "Check the algorithm version has been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + origins.every(o => o.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_different_variables() { + let origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + + // Set alt_frecency to NULL for the entries in moz_origins to verify they + // are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_origins", { + alt_frecency: null, + }); + + // Change variables. + let variables = await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ); + Assert.greater(Object.keys(variables).length, 1); + Assert.ok("version" in variables, "At least the version is always present"); + await PlacesUtils.metadata.set( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + { + version: + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.variables + .version, + someVar: 1, + } + ); + + await restartRecalculator(); + + // Ensure moz_meta has been updated. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.deepEqual( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ), + variables, + "Check the algorithm variables have been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + origins.every(o => o.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task(async function test_disable() { + let origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.equal( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.variables + .version, + "Check the algorithm version has been stored" + ); + + await restartRecalculator(); + + // Check alternative frecency has not been marked for recalculation. + origins = await getAllOrigins(); + Assert.ok( + origins.every(o => o.recalc_alt_frecency == 0), + "The entries not have been marked for recalc" + ); + Assert.ok( + origins.every(o => o.alt_frecency === null), + "All the alt_frecency values should have been nullified" + ); + + // Ensure moz_meta has been updated. + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.ok( + ObjectUtils.isEmpty( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.origins.metadataKey, + Object.create(null) + ) + ), + "Check the algorithm variables has been removed" + ); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_origins_recalc.js b/toolkit/components/places/tests/unit/test_frecency_origins_recalc.js new file mode 100644 index 0000000000..b96f9ecb58 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_origins_recalc.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that recalc_frecency in the moz_origins table works is consistent. + +// This test does not completely cover origins frecency recalculation because +// the current system uses temp tables and triggers to make the recalculation, +// but it's likely that will change in the future and then we can add to this. + +add_task(async function test() { + // test recalc_frecency is set to 1 when frecency of a page changes. + // Add a couple visits, then remove one of them. + const now = new Date(); + const url = "https://mozilla.org/test/"; + await PlacesTestUtils.addVisits([ + { + url, + visitDate: now, + }, + { + url, + visitDate: new Date(new Date().setDate(now.getDate() - 30)), + }, + ]); + // TODO: use PlacesTestUtils.getDatabaseValue once available. + let db = await PlacesUtils.promiseDBConnection(); + Assert.equal( + ( + await db.execute(`SELECT recalc_frecency FROM moz_origins`) + )[0].getResultByIndex(0), + 0, + "Should have been calculated already" + ); + Assert.equal( + ( + await db.execute(`SELECT recalc_alt_frecency FROM moz_origins`) + )[0].getResultByIndex(0), + 1, + "Should not have been calculated" + ); + + // Remove only one visit (otherwise the page would be orphaned). + await PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(now.valueOf() - 10000), + endDate: new Date(now.valueOf() + 10000), + }); + Assert.equal( + ( + await db.execute(`SELECT recalc_frecency FROM moz_origins`) + )[0].getResultByIndex(0), + 0, + "Should have been calculated already" + ); + Assert.equal( + ( + await db.execute(`SELECT recalc_alt_frecency FROM moz_origins`) + )[0].getResultByIndex(0), + 1, + "Should not have been calculated yet" + ); + // test recalc_frecency is set back to 0 when frecency of the origin is set +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_pages_alternative.js b/toolkit/components/places/tests/unit/test_frecency_pages_alternative.js new file mode 100644 index 0000000000..7fc21d9eb3 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_pages_alternative.js @@ -0,0 +1,352 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests alternative pages frecency. +// Note: the order of the tests here matters, since we are emulating subsquent +// starts of the recalculator component with different initial conditions. + +const FEATURE_PREF = "places.frecency.pages.alternative.featureGate"; + +async function restartRecalculator() { + let subject = {}; + PlacesFrecencyRecalculator.observe( + subject, + "test-alternative-frecency-init", + "" + ); + await subject.promise; +} + +async function getAllPages() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(`SELECT * FROM moz_places`); + Assert.greater(rows.length, 0); + return rows.map(r => ({ + url: r.getResultByName("url"), + frecency: r.getResultByName("frecency"), + recalc_frecency: r.getResultByName("recalc_frecency"), + alt_frecency: r.getResultByName("alt_frecency"), + recalc_alt_frecency: r.getResultByName("recalc_alt_frecency"), + })); +} + +add_setup(async function () { + await PlacesTestUtils.addVisits([ + "https://testdomain1.moz.org", + "https://testdomain2.moz.org", + "https://testdomain3.moz.org", + ]); + registerCleanupFunction(PlacesUtils.history.clear); +}); + +add_task( + { + pref_set: [[FEATURE_PREF, false]], + }, + async function test_normal_init() { + // The test starts with the pref enabled, otherwise we'd not have the SQL + // function defined. So here we disable it, then enable again later. + await restartRecalculator(); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.ok( + ObjectUtils.isEmpty( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ) + ), + "Check there's no variables stored" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_enable_init() { + // Set alt_frecency to NULL and recalc_alt_frecency = 0 for the entries in + // moz_places to verify they are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_places", { + alt_frecency: null, + recalc_alt_frecency: 0, + }); + + await restartRecalculator(); + + // Ensure moz_meta doesn't report anything. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.equal( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.variables + .version, + "Check the algorithm version has been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + let pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + pages.every(p => p.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_different_version() { + let pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + + // Set alt_frecency to NULL to verify all the entries are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_places", { + alt_frecency: null, + }); + + // It doesn't matter that the version is, it just have to be different. + let variables = await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ); + variables.version = 999; + await PlacesUtils.metadata.set( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + variables + ); + + await restartRecalculator(); + + // Check alternative frecency has been marked for recalculation. + // Note just after init we reculate a chunk, and this test code is expected + // to run before that... though we can't be sure, so if this starts failing + // intermittently we'll have to add more synchronization test code. + pages = await getAllPages(); + // Ensure moz_meta has been updated. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.deepEqual( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.variables + .version, + "Check the algorithm version has been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + pages.every(p => p.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_different_variables() { + let pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + + // Set alt_frecency to NULL to verify all the entries are recalculated. + await PlacesTestUtils.updateDatabaseValues("moz_places", { + alt_frecency: null, + }); + + // Change variables. + let variables = await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ); + Assert.greater(Object.keys(variables).length, 1); + Assert.ok("version" in variables, "At least the version is always present"); + await PlacesUtils.metadata.set( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + { + version: + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.variables + .version, + someVar: 1, + } + ); + + await restartRecalculator(); + + // Ensure moz_meta has been updated. + Assert.ok( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.enabled, + "Check the pref is enabled" + ); + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.deepEqual( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ), + variables, + "Check the algorithm variables have been stored" + ); + + // Check all alternative frecencies have been calculated, since we just have + // a few. + pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries have been recalculated" + ); + Assert.ok( + pages.every(p => p.alt_frecency > 0), + "All the entries have been recalculated" + ); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, false]], + }, + async function test_disable() { + let pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "All the entries should not need recalculation" + ); + + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.equal( + ( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ) + ).version, + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.variables + .version, + "Check the algorithm version has been stored" + ); + + await restartRecalculator(); + + // Check alternative frecency has not been marked for recalculation. + pages = await getAllPages(); + Assert.ok( + pages.every(p => p.recalc_alt_frecency == 0), + "The entries not have been marked for recalc" + ); + Assert.ok( + pages.every(p => p.alt_frecency === null), + "All the alt_frecency values should have been nullified" + ); + + // Ensure moz_meta has been updated. + // Avoid hitting the cache, we want to check the actual database value. + PlacesUtils.metadata.cache.clear(); + Assert.ok( + ObjectUtils.isEmpty( + await PlacesUtils.metadata.get( + PlacesFrecencyRecalculator.alternativeFrecencyInfo.pages.metadataKey, + Object.create(null) + ) + ), + "Check the algorithm variables has been removed" + ); + } +); + +add_task( + { + pref_set: [[FEATURE_PREF, true]], + }, + async function test_score() { + await restartRecalculator(); + + // This is not intended to cover the algorithm as a whole, but just as a + // sanity check for scores. + + await PlacesTestUtils.addVisits([ + { + url: "https://low.moz.org", + transition: PlacesUtils.history.TRANSITIONS.FRAMED_LINK, + }, + { + url: "https://old.moz.org", + visitDate: (Date.now() - 2 * 86400000) * 1000, + }, + { url: "https://base.moz.org" }, + { url: "https://manyvisits.moz.org" }, + { url: "https://manyvisits.moz.org" }, + { url: "https://manyvisits.moz.org" }, + { url: "https://manyvisits.moz.org" }, + ]); + await PlacesUtils.bookmarks.insert({ + url: "https://unvisitedbookmark.moz.org", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let getFrecency = url => + PlacesTestUtils.getDatabaseValue("moz_places", "alt_frecency", { + url, + }); + let low = await getFrecency("https://low.moz.org/"); + let old = await getFrecency("https://old.moz.org/"); + Assert.greater(old, low); + let base = await getFrecency("https://base.moz.org/"); + Assert.greater(base, old); + let unvisitedBm = await getFrecency("https://unvisitedbookmark.moz.org/"); + Assert.greater(unvisitedBm, base); + let manyVisits = await getFrecency("https://manyvisits.moz.org/"); + Assert.greater(manyVisits, unvisitedBm); + } +); diff --git a/toolkit/components/places/tests/unit/test_frecency_pages_recalc_alt.js b/toolkit/components/places/tests/unit/test_frecency_pages_recalc_alt.js new file mode 100644 index 0000000000..e492099845 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_pages_recalc_alt.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that recalc_alt_frecency in the moz_places table is updated correctly. + +add_task(async function test() { + info("test recalc_alt_frecency is set to 1 when a visit is added"); + const now = new Date(); + const URL = "https://mozilla.org/test/"; + let getRecalc = url => + PlacesTestUtils.getDatabaseValue("moz_places", "recalc_alt_frecency", { + url, + }); + let setRecalc = (url, val) => + PlacesTestUtils.updateDatabaseValues( + "moz_places", + { recalc_alt_frecency: val }, + { url } + ); + let getFrecency = url => + PlacesTestUtils.getDatabaseValue("moz_places", "alt_frecency", { + url, + }); + await PlacesTestUtils.addVisits([ + { + url: URL, + visitDate: now, + }, + { + url: URL, + visitDate: new Date(new Date().setDate(now.getDate() - 30)), + }, + ]); + Assert.equal(await getRecalc(URL), 1); + Assert.greater(await getFrecency(URL), 0); + + info("Remove just one visit (otherwise the page would be orphaned)."); + await PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(now.valueOf() - 10000), + endDate: new Date(now.valueOf() + 10000), + }); + Assert.equal(await getRecalc(URL), 1); + await setRecalc(URL, 0); + + info("Add a bookmark to the page"); + let bm = await PlacesUtils.bookmarks.insert({ + url: URL, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + Assert.equal(await getRecalc(URL), 1); + await setRecalc(URL, 0); + + info("Clear history"); + await PlacesUtils.history.clear(); + Assert.equal(await getRecalc(URL), 1); + await setRecalc(URL, 0); + + // Add back a visit so the page is not an orphan once we remove the bookmark. + await PlacesTestUtils.addVisits(URL); + Assert.equal(await getRecalc(URL), 0); + Assert.greater(await getFrecency(URL), 0); + + info("change the bookmark URL"); + const URL2 = "https://editedbookmark.org/"; + bm.url = URL2; + await PlacesUtils.bookmarks.update(bm); + Assert.equal(await getRecalc(URL), 1); + Assert.equal(await getRecalc(URL2), 1); + await setRecalc(URL, 0); + await setRecalc(URL2, 0); + + info("Remove the bookmark from the page"); + await PlacesUtils.bookmarks.remove(bm); + Assert.equal(await getRecalc(URL2), 1); + await setRecalc(URL2, 0); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_recalc_triggers.js b/toolkit/components/places/tests/unit/test_frecency_recalc_triggers.js new file mode 100644 index 0000000000..2e75a6d459 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_recalc_triggers.js @@ -0,0 +1,281 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that all the operations affecting frecency are either recalculating + * immediately or triggering a recalculation. + * Operations that should recalculate immediately: + * - adding visits + * Operations that should just trigger a recalculation: + * - removing visits + * - adding a bookmark + * - removing a bookmark + * - changing url of a bookmark + * + * Also check setting a frecency resets recalc_frecency to 0. + **/ + +const TEST_URL = "https://example.com/"; +const TEST_URL_2 = "https://example2.com/"; + +// NOTE: Until we fix Bug 1806666 this test has to run queries manually because +// the official APIs recalculate frecency immediately. After the fix, these +// helpers can be removed and the test can be much simpler. +function insertVisit(url) { + return PlacesUtils.withConnectionWrapper("insertVisit", async db => { + await db.execute( + `INSERT INTO moz_historyvisits(place_id, visit_date, visit_type) + VALUES ((SELECT id FROM moz_places WHERE url = :url), 1648226608386000, 1)`, + { url } + ); + }); +} +function removeVisit(url) { + return PlacesUtils.withConnectionWrapper("insertVisit", async db => { + await db.execute( + `DELETE FROM moz_historyvisits WHERE place_id + = (SELECT id FROM moz_places WHERE url = :url)`, + { url } + ); + }); +} +function resetFrecency(url) { + return PlacesUtils.withConnectionWrapper("insertVisit", async db => { + await db.execute(`UPDATE moz_places SET frecency = -1 WHERE url = :url`, { + url, + }); + }); +} + +add_task(async function test_visit() { + // First add a bookmark so the page is not orphaned. + let bm = await PlacesUtils.bookmarks.insert({ + url: TEST_URL, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + info("Add a visit check frecency is calculated immediately"); + await PlacesTestUtils.addVisits(TEST_URL); + let originalFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: TEST_URL, + } + ); + Assert.greater(originalFrecency, 0, "frecency was recalculated immediately"); + let recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 0, "frecency doesn't need a recalc"); + + info("Add a visit (raw query) check frecency is not calculated immediately"); + await insertVisit(TEST_URL); + let frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: TEST_URL, + } + ); + Assert.equal(frecency, originalFrecency, "frecency is unchanged"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + + info("Check setting frecency resets recalc_frecency"); + await resetFrecency(TEST_URL); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 0, "frecency doesn't need a recalc"); + + info("Removing a visit sets recalc_frecency"); + await removeVisit(TEST_URL); + frecency = await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL, + }); + Assert.equal(frecency, -1, "frecency is unchanged"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.remove(bm); +}); + +add_task(async function test_bookmark() { + // First add a visit so the page is not orphaned. + await PlacesTestUtils.addVisits([TEST_URL, TEST_URL_2]); + + let originalFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: TEST_URL, + } + ); + Assert.greater(originalFrecency, 0); + + info("Check adding a bookmark sets recalc_frecency"); + let bm = await PlacesUtils.bookmarks.insert({ + url: TEST_URL, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + let frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: TEST_URL, + } + ); + Assert.equal(frecency, originalFrecency, "frecency is unchanged"); + let recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + + info("Check changing a bookmark url sets recalc_frecency on both urls"); + await PlacesUtils.bookmarks.update({ + guid: bm.guid, + url: TEST_URL_2, + }); + frecency = await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL, + }); + Assert.equal(frecency, originalFrecency, "frecency is unchanged"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + frecency = await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL_2, + }); + Assert.ok(frecency > 0, "frecency is valid"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL_2, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + + info("Check setting frecency resets recalc_frecency"); + await resetFrecency(TEST_URL); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 0, "frecency doesn't need a recalc"); + await resetFrecency(TEST_URL_2); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL_2, + } + ); + Assert.equal(recalc, 0, "frecency doesn't need a recalc"); + + info("Removing a bookmark sets recalc_frecency"); + await PlacesUtils.bookmarks.remove(bm.guid); + frecency = await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL, + }); + Assert.equal(frecency, -1, "frecency is unchanged"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL, + } + ); + Assert.equal(recalc, 0, "frecency doesn't need a recalc"); + frecency = await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URL_2, + }); + Assert.equal(frecency, -1, "frecency is unchanged"); + recalc = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "recalc_frecency", + { + url: TEST_URL_2, + } + ); + Assert.equal(recalc, 1, "frecency needs a recalc"); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_bookmark_frecency_zero() { + info("A url with frecency 0 should be recalculated if bookmarked"); + let url = "https://zerofrecency.org/"; + await PlacesTestUtils.addVisits({ url, transition: TRANSITION_FRAMED_LINK }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { url }), + 0 + ); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "recalc_frecency", { + url, + }), + 0 + ); + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "recalc_frecency", { + url, + }), + 1 + ); + info("place: uris should not be recalculated"); + url = "place:test"; + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { url }), + 0 + ); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "recalc_frecency", { + url, + }), + 0 + ); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_recalculator.js b/toolkit/components/places/tests/unit/test_frecency_recalculator.js new file mode 100644 index 0000000000..91ed743f79 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_recalculator.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test PlacesFrecencyRecalculator scheduling. + +// Enable the collection (during test) for all products so even products +// that don't collect the data will be able to run the test without failure. +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +async function getOriginFrecency(origin) { + let db = await PlacesUtils.promiseDBConnection(); + return ( + await db.execute( + `SELECT frecency + FROM moz_origins + WHERE host = :origin`, + { origin } + ) + )[0].getResultByIndex(0); +} + +async function resetOriginFrecency(origin) { + await PlacesUtils.withConnectionWrapper( + "test_frecency_recalculator reset origin", + async db => { + await db.executeCached( + `UPDATE moz_origins + SET frecency = -1 + WHERE host = :origin`, + { origin } + ); + } + ); +} + +async function addVisitsAndSetRecalc(urls) { + await PlacesTestUtils.addVisits(urls); + await PlacesUtils.withConnectionWrapper( + "test_frecency_recalculator set recalc", + async db => { + await db.executeCached( + `UPDATE moz_places + SET frecency = -1 + WHERE url in ( + ${PlacesUtils.sqlBindPlaceholders(urls)} + )`, + urls + ); + await db.executeCached(`DELETE FROM moz_updateoriginsupdate_temp`); + await db.executeCached( + `UPDATE moz_places + SET recalc_frecency = (CASE WHEN url in ( + ${PlacesUtils.sqlBindPlaceholders(urls)} + ) THEN 1 ELSE 0 END)`, + urls + ); + } + ); +} + +add_task(async function test() { + info("On startup a recalculation is always pending."); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + // If everything gets recalculated, then it should not be pending anymore. + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.ok( + !PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should not be pending" + ); + + // If after a recalculation there's outdated entries left, a new recalculation + // should be pending. + info("Insert outdated frecencies"); + const url1 = new URL("https://test1.moz.org/"); + const url2 = new URL("https://test2.moz.org/"); + await addVisitsAndSetRecalc([url1.href, url2.href]); + await resetOriginFrecency(url1.host); + await resetOriginFrecency(url2.host); + + Assert.ok( + PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have set shouldStartFrecencyRecalculation" + ); + await PlacesFrecencyRecalculator.recalculateSomeFrecencies({ chunkSize: 1 }); + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + await PlacesFrecencyRecalculator.recalculateSomeFrecencies({ chunkSize: 2 }); + Assert.ok( + !PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should not be pending" + ); + Assert.ok( + !PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have unset shouldStartFrecencyRecalculation" + ); + + Assert.greater(await getOriginFrecency(url1.host), 0); + Assert.greater(await getOriginFrecency(url2.host), 0); + + info("Changing recalc_frecency of an entry adds a pending recalculation."); + PlacesUtils.history.shouldStartFrecencyRecalculation = false; + let promiseNotify = TestUtils.topicObserved("frecency-recalculation-needed"); + await PlacesUtils.withConnectionWrapper( + "test_frecency_recalculator", + async db => { + await db.executeCached( + `UPDATE moz_places SET recalc_frecency = 1 WHERE url = :url`, + { url: url1.href } + ); + } + ); + Assert.ok( + PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have set shouldStartFrecencyRecalculation" + ); + + await promiseNotify; + Assert.ok( + PlacesFrecencyRecalculator.isRecalculationPending, + "Recalculation should be pending" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.ok( + !PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have unset shouldStartFrecencyRecalculation" + ); +}); + +add_task(async function test_chunk_time_telemetry() { + await PlacesUtils.bookmarks.insert({ + url: "https://test-bookmark.com", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + Assert.ok( + PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have set shouldStartFrecencyRecalculation" + ); + let histogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_FRECENCY_RECALC_CHUNK_TIME_MS" + ); + let subject = {}; + PlacesFrecencyRecalculator.observe(subject, "test-execute-taskFn", ""); + await subject.promise; + let snapshot = histogram.snapshot(); + Assert.equal( + Object.values(snapshot.values).reduce((a, b) => a + b, 0), + 1 + ); + Assert.greater(snapshot.sum, 0); + Assert.ok( + !PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should have unset shouldStartFrecencyRecalculation" + ); + + // It should now not report any new time, since there's nothing to recalculate. + histogram.clear(); + PlacesFrecencyRecalculator.observe(subject, "test-execute-taskFn", ""); + await subject.promise; + snapshot = histogram.snapshot(); + Assert.equal( + Object.values(snapshot.values).reduce((a, b) => a + b, 0), + 0 + ); + Assert.ok( + !PlacesUtils.history.shouldStartFrecencyRecalculation, + "Should still not have set shouldStartFrecencyRecalculation" + ); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_unvisited_bookmark.js b/toolkit/components/places/tests/unit/test_frecency_unvisited_bookmark.js new file mode 100644 index 0000000000..ae7bd3d813 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_unvisited_bookmark.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests frecency of unvisited bookmarks. + +add_task(async function () { + // Randomly sorted by date. + const now = new Date(); + const bookmarks = [ + { + url: "https://example.com/1", + date: new Date(new Date().setDate(now.getDate() - 30)), + }, + { + url: "https://example.com/2", + date: new Date(new Date().setDate(now.getDate() - 1)), + }, + { + url: "https://example.com/3", + date: new Date(new Date().setDate(now.getDate() - 100)), + }, + { + url: "https://example.com/1", // Same url but much older. + date: new Date(new Date().setDate(now.getDate() - 120)), + }, + ]; + + for (let bookmark of bookmarks) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmark.url, + dateAdded: bookmark.date, + }); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // The newest bookmark should have an higher frecency. + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bookmarks[1].url, + }), + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bookmarks[0].url, + }) + ); + Assert.greater( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bookmarks[0].url, + }), + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: bookmarks[2].url, + }) + ); +}); diff --git a/toolkit/components/places/tests/unit/test_frecency_zero_updated.js b/toolkit/components/places/tests/unit/test_frecency_zero_updated.js new file mode 100644 index 0000000000..44c329635e --- /dev/null +++ b/toolkit/components/places/tests/unit/test_frecency_zero_updated.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests a zero frecency is correctly updated when inserting new valid visits. + +add_task(async function () { + const TEST_URI = NetUtil.newURI("http://example.com/"); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URI, + title: "A title", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); + + // Removing the bookmark should leave an orphan page with zero frecency. + // Note this would usually be expired later by expiration. + await PlacesUtils.bookmarks.remove(bookmark.guid); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + // Now add a valid visit to the page, frecency should increase. + await PlacesTestUtils.addVisits({ uri: TEST_URI }); + Assert.ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + })) > 0 + ); +}); diff --git a/toolkit/components/places/tests/unit/test_getChildIndex.js b/toolkit/components/places/tests/unit/test_getChildIndex.js new file mode 100644 index 0000000000..35eb6fd22b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_getChildIndex.js @@ -0,0 +1,73 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +/* + * Tests nsNavHistoryContainerResultNode::GetChildIndex(aNode) functionality. + */ + +add_task(async function test_get_child_index() { + // Add a bookmark to the menu. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://test.mozilla.org/bookmark/", + title: "Test bookmark", + }); + + // Add a bookmark to unfiled folder. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://test.mozilla.org/unfiled/", + title: "Unfiled bookmark", + }); + + // Get the unfiled bookmark node. + let unfiledNode = getNodeAt(PlacesUtils.bookmarks.unfiledGuid, 0); + if (!unfiledNode) { + do_throw("Unable to find bookmark in hierarchy!"); + } + Assert.equal(unfiledNode.title, "Unfiled bookmark"); + + let hs = PlacesUtils.history; + let query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.menuGuid]); + let options = hs.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let root = hs.executeQuery(query, options).root; + root.containerOpen = true; + + // Check functionality for proper nodes. + for (let i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + print("Now testing: " + node.title); + Assert.equal(root.getChildIndex(node), i); + } + + // Now search for an invalid node and expect an exception. + try { + root.getChildIndex(unfiledNode); + do_throw("Searching for an invalid node should have thrown."); + } catch (ex) { + print("We correctly got an exception."); + } + + root.containerOpen = false; +}); + +function getNodeAt(aFolderGuid, aIndex) { + let hs = PlacesUtils.history; + let query = hs.getNewQuery(); + query.setParents([aFolderGuid]); + let options = hs.getNewQueryOptions(); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + let root = hs.executeQuery(query, options).root; + root.containerOpen = true; + if (root.childCount < aIndex) { + do_throw("Not enough children to find bookmark!"); + } + let node = root.getChild(aIndex); + root.containerOpen = false; + return node; +} diff --git a/toolkit/components/places/tests/unit/test_get_query_param_sql_function.js b/toolkit/components/places/tests/unit/test_get_query_param_sql_function.js new file mode 100644 index 0000000000..072f53ceac --- /dev/null +++ b/toolkit/components/places/tests/unit/test_get_query_param_sql_function.js @@ -0,0 +1,21 @@ +add_task(async function test_get_query_param_sql_function() { + let db = await PlacesUtils.promiseDBConnection(); + await Assert.rejects( + db.execute(`SELECT get_query_param()`), + /wrong number of arguments/ + ); + let rows = await db.execute(`SELECT + get_query_param('a=b&c=d', 'a'), + get_query_param('a=b&c=d', 'c'), + get_query_param('a=b&a=c', 'a'), + get_query_param('a=b&c=d', 'e'), + get_query_param('a', 'a'), + get_query_param(NULL, NULL), + get_query_param('a=b&c=d', NULL), + get_query_param(NULL, 'a')`); + let results = ["b", "d", "b", null, "", null, null, null]; + equal(rows[0].numEntries, results.length); + for (let i = 0; i < results.length; ++i) { + equal(rows[0].getResultByIndex(i), results[i]); + } +}); diff --git a/toolkit/components/places/tests/unit/test_hash.js b/toolkit/components/places/tests/unit/test_hash.js new file mode 100644 index 0000000000..701cb3d151 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_hash.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + // Check particular unicode urls with insertion and selection APIs to ensure + // url hashes match properly. + const URLS = [ + "http://президент.президент/президент/", + "https://www.аррӏе.com/аррӏе/", + "http://名がドメイン/", + ]; + + for (let url of URLS) { + await PlacesTestUtils.addVisits(url); + Assert.ok(await PlacesUtils.history.fetch(url), "Found the added visit"); + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + Assert.ok( + await PlacesUtils.bookmarks.fetch({ url }), + "Found the added bookmark" + ); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url", + { url: new URL(url).href } + ); + Assert.equal(rows.length, 1, "Matched the place from the database"); + let id = rows[0].getResultByName("id"); + + // Now, suppose the urls has been inserted without proper parsing and retry. + // This should normally not happen through the API, but we have evidence + // it somehow happened. + await PlacesUtils.withConnectionWrapper("test_hash.js", async wdb => { + await wdb.execute( + ` + UPDATE moz_places SET url_hash = hash(:url), url = :url + WHERE id = :id + `, + { url, id } + ); + rows = await wdb.execute( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url", + { url } + ); + Assert.equal(rows.length, 1, "Matched the place from the database"); + }); + } +}); diff --git a/toolkit/components/places/tests/unit/test_history.js b/toolkit/components/places/tests/unit/test_history.js new file mode 100644 index 0000000000..a96d1fa5f0 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_history.js @@ -0,0 +1,178 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get history services +var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService +); + +/** + * Checks to see that a URI is in the database. + * + * @param aURI + * The URI to check. + * @returns true if the URI is in the DB, false otherwise. + */ +function uri_in_db(aURI) { + var options = histsvc.getNewQueryOptions(); + options.maxResults = 1; + options.resultType = options.RESULTS_AS_URI; + var query = histsvc.getNewQuery(); + query.uri = aURI; + var result = histsvc.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var cc = root.childCount; + root.containerOpen = false; + return cc == 1; +} + +// main + +add_task(async function test_execute() { + // we have a new profile, so we should have imported bookmarks + Assert.equal(histsvc.databaseStatus, histsvc.DATABASE_STATUS_CREATE); + + // add a visit + var testURI = uri("http://mozilla.com"); + await PlacesTestUtils.addVisits(testURI); + + // now query for the visit, setting sorting and limit such that + // we should retrieve only the visit we just added + var options = histsvc.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.maxResults = 1; + options.resultType = options.RESULTS_AS_VISIT; + var query = histsvc.getNewQuery(); + var result = histsvc.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + var cc = root.childCount; + for (var i = 0; i < cc; ++i) { + var node = root.getChild(i); + // test node properties in RESULTS_AS_VISIT + Assert.equal(node.uri, testURI.spec); + Assert.equal(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI); + } + root.containerOpen = false; + + // add another visit for the same URI, and a third visit for a different URI + var testURI2 = uri("http://google.com/"); + await PlacesTestUtils.addVisits(testURI); + await PlacesTestUtils.addVisits(testURI2); + + options.maxResults = 5; + options.resultType = options.RESULTS_AS_URI; + + // test minVisits + query.minVisits = 0; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 2); + result.root.containerOpen = false; + query.minVisits = 1; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 2); + result.root.containerOpen = false; + query.minVisits = 2; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 1); + query.minVisits = 3; + result.root.containerOpen = false; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 0); + result.root.containerOpen = false; + + // test maxVisits + query.minVisits = -1; + query.maxVisits = -1; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 2); + result.root.containerOpen = false; + query.maxVisits = 0; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 0); + result.root.containerOpen = false; + query.maxVisits = 1; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 1); + result.root.containerOpen = false; + query.maxVisits = 2; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 2); + result.root.containerOpen = false; + query.maxVisits = 3; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 2); + result.root.containerOpen = false; + + // test annotation-based queries + await PlacesUtils.history.update({ + url: "http://mozilla.com/", + annotations: new Map([["testAnno", 123]]), + }); + query.annotation = "testAnno"; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 1); + Assert.equal(result.root.getChild(0).uri, "http://mozilla.com/"); + result.root.containerOpen = false; + + // test annotationIsNot + query.annotationIsNot = true; + result = histsvc.executeQuery(query, options); + result.root.containerOpen = true; + Assert.equal(result.root.childCount, 1); + Assert.equal(result.root.getChild(0).uri, "http://google.com/"); + result.root.containerOpen = false; + + // By default history is enabled. + Assert.ok(!histsvc.historyDisabled); + + // test getPageTitle + await PlacesTestUtils.addVisits({ + uri: uri("http://example.com"), + title: "title", + }); + let placeInfo = await PlacesUtils.history.fetch("http://example.com"); + Assert.equal(placeInfo.title, "title"); + + // query for the visit + Assert.ok(uri_in_db(testURI)); + + // test for schema changes in bug 373239 + // get direct db connection + var db = histsvc.DBConnection; + var q = "SELECT id FROM moz_bookmarks"; + var statement; + try { + statement = db.createStatement(q); + } catch (ex) { + do_throw("bookmarks table does not have id field, schema is too old!"); + } finally { + statement.finalize(); + } + + // bug 394741 - regressed history text searches + await PlacesTestUtils.addVisits(uri("http://mozilla.com")); + options = histsvc.getNewQueryOptions(); + // options.resultType = options.RESULTS_AS_VISIT; + query = histsvc.getNewQuery(); + query.searchTerms = "moz"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.ok(root.childCount > 0); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_history_clear.js b/toolkit/components/places/tests/unit/test_history_clear.js new file mode 100644 index 0000000000..7c121a5b84 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_history_clear.js @@ -0,0 +1,146 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var mDBConn = DBConn(); + +add_task(async function test_history_clear() { + await PlacesTestUtils.addVisits([ + { uri: uri("http://typed.mozilla.org/"), transition: TRANSITION_TYPED }, + { uri: uri("http://link.mozilla.org/"), transition: TRANSITION_LINK }, + { + uri: uri("http://download.mozilla.org/"), + transition: TRANSITION_DOWNLOAD, + }, + { + uri: uri("http://redir_temp.mozilla.org/"), + transition: TRANSITION_REDIRECT_TEMPORARY, + referrer: "http://link.mozilla.org/", + }, + { + uri: uri("http://redir_perm.mozilla.org/"), + transition: TRANSITION_REDIRECT_PERMANENT, + referrer: "http://link.mozilla.org/", + }, + ]); + + // add a place: bookmark + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: `place:parent=${PlacesUtils.bookmarks.tagsGuid}`, + title: "shortcut", + }); + + // Add an expire never annotation + // Actually expire never annotations are removed as soon as a page is removed + // from the database, so this should act as a normal visit. + await PlacesUtils.history.update({ + url: "http://download.mozilla.org/", + annotations: new Map([["never", "never"]]), + }); + + // Add a bookmark + // Bookmarked page should have history cleared and frecency to be recalculated + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://typed.mozilla.org/", + title: "bookmark", + }); + + await PlacesTestUtils.addVisits([ + { uri: uri("http://typed.mozilla.org/"), transition: TRANSITION_BOOKMARK }, + { uri: uri("http://frecency.mozilla.org/"), transition: TRANSITION_LINK }, + ]); + await PlacesTestUtils.promiseAsyncUpdates(); + + // Clear history and wait for the history-cleared event notification. + let promiseClearHistory = + PlacesTestUtils.waitForNotification("history-cleared"); + await PlacesUtils.history.clear(); + await promiseClearHistory; + await PlacesTestUtils.promiseAsyncUpdates(); + + // Check that frecency for not cleared items (bookmarks) has been marked + // as to be recalculated. + let stmt = mDBConn.createStatement( + "SELECT h.id FROM moz_places h WHERE frecency <> 0 AND h.recalc_frecency = 0 " + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + stmt = mDBConn.createStatement( + `SELECT h.id FROM moz_places h WHERE h.recalc_frecency = 1 + AND EXISTS (SELECT id FROM moz_bookmarks WHERE fk = h.id) LIMIT 1` + ); + Assert.ok(stmt.executeStep()); + stmt.finalize(); + + // Check that all visit_counts have been brought to 0 + stmt = mDBConn.createStatement( + "SELECT id FROM moz_places WHERE visit_count <> 0 LIMIT 1" + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that history tables are empty + stmt = mDBConn.createStatement( + "SELECT * FROM (SELECT id FROM moz_historyvisits LIMIT 1)" + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that all moz_places entries except bookmarks and place: have been removed + stmt = mDBConn.createStatement( + `SELECT h.id FROM moz_places h WHERE + url_hash NOT BETWEEN hash('place', 'prefix_lo') AND hash('place', 'prefix_hi') + AND NOT EXISTS (SELECT id FROM moz_bookmarks WHERE fk = h.id) LIMIT 1` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that we only have favicons for retained places + stmt = mDBConn.createStatement( + `SELECT 1 + FROM moz_pages_w_icons + LEFT JOIN moz_places h ON url_hash = page_url_hash AND url = page_url + WHERE h.id ISNULL` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + stmt = mDBConn.createStatement( + `SELECT 1 + FROM moz_icons WHERE id NOT IN ( + SELECT icon_id FROM moz_icons_to_pages + )` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that we only have annotations for retained places + stmt = mDBConn.createStatement( + `SELECT a.id FROM moz_annos a WHERE NOT EXISTS + (SELECT id FROM moz_places WHERE id = a.place_id) LIMIT 1` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that we only have inputhistory for retained places + stmt = mDBConn.createStatement( + `SELECT i.place_id FROM moz_inputhistory i WHERE NOT EXISTS + (SELECT id FROM moz_places WHERE id = i.place_id) LIMIT 1` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); + + // Check that place:uris have frecency 0 + stmt = mDBConn.createStatement( + `SELECT h.id FROM moz_places h + WHERE url_hash BETWEEN hash('place', 'prefix_lo') + AND hash('place', 'prefix_hi') + AND h.frecency <> 0 LIMIT 1` + ); + Assert.ok(!stmt.executeStep()); + stmt.finalize(); +}); diff --git a/toolkit/components/places/tests/unit/test_history_notifications.js b/toolkit/components/places/tests/unit/test_history_notifications.js new file mode 100644 index 0000000000..339080b042 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_history_notifications.js @@ -0,0 +1,50 @@ +const NS_PLACES_INIT_COMPLETE_TOPIC = "places-init-complete"; +let gLockedConn; + +add_task(async function setup() { + // Create a dummy places.sqlite and open an unshared connection on it + let db = Services.dirsvc.get("ProfD", Ci.nsIFile); + db.append("places.sqlite"); + gLockedConn = Services.storage.openUnsharedDatabase(db); + Assert.ok(db.exists(), "The database should have been created"); + + // We need an exclusive lock on the db + gLockedConn.executeSimpleSQL("PRAGMA locking_mode = EXCLUSIVE"); + // Exclusive locking is lazy applied, we need to make a write to activate it + gLockedConn.executeSimpleSQL("PRAGMA USER_VERSION = 1"); +}); + +add_task(async function locked() { + // Try to create history service while the db is locked. + // It should be possible to create the service, but any method using the + // database will fail. + let resolved = false; + let promiseComplete = promiseTopicObserved( + NS_PLACES_INIT_COMPLETE_TOPIC + ).then(() => (resolved = true)); + let history = Cc["@mozilla.org/browser/nav-history-service;1"].createInstance( + Ci.nsINavHistoryService + ); + // The notification shouldn't happen until something tries to use the database. + await new Promise(resolve => do_timeout(100, resolve)); + Assert.equal( + resolved, + false, + "The notification should not have been fired yet" + ); + // This will initialize the database. + Assert.equal(history.databaseStatus, history.DATABASE_STATUS_LOCKED); + await promiseComplete; + + // Close our connection and try to cleanup the file (could fail on Windows) + gLockedConn.close(); + let db = Services.dirsvc.get("ProfD", Ci.nsIFile); + db.append("places.sqlite"); + if (db.exists()) { + try { + db.remove(false); + } catch (e) { + info("Unable to remove dummy places.sqlite"); + } + } +}); diff --git a/toolkit/components/places/tests/unit/test_history_observer.js b/toolkit/components/places/tests/unit/test_history_observer.js new file mode 100644 index 0000000000..1a9323f890 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_history_observer.js @@ -0,0 +1,186 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Registers a one-time places observer for 'page-visited', + * which resolves a promise on being called. + */ +function promiseVisitAdded(callback) { + return new Promise(resolve => { + async function listener(events) { + PlacesObservers.removeListener(["page-visited"], listener); + Assert.equal(events.length, 1, "Right number of visits notified"); + Assert.equal(events[0].type, "page-visited"); + await callback(events[0]); + resolve(); + } + PlacesObservers.addListener(["page-visited"], listener); + }); +} + +/** + * Asynchronous task that adds a visit to the history database. + */ +async function task_add_visit(uri, timestamp, transition) { + uri = uri || NetUtil.newURI("http://firefox.com/"); + timestamp = timestamp || Date.now() * 1000; + await PlacesTestUtils.addVisits({ + uri, + transition: transition || TRANSITION_TYPED, + visitDate: timestamp, + }); + return [uri, timestamp]; +} + +add_task(async function test_visitAdded() { + let promiseNotify = promiseVisitAdded(async function (visit) { + Assert.ok(visit.visitId > 0); + Assert.equal(visit.url, testuri.spec); + Assert.equal(visit.visitTime, testtime / 1000); + Assert.equal(visit.referringVisitId, 0); + Assert.equal(visit.transitionType, TRANSITION_TYPED); + let uri = NetUtil.newURI(visit.url); + await check_guid_for_uri(uri, visit.pageGuid); + Assert.ok(!visit.hidden); + Assert.equal(visit.visitCount, 1); + Assert.equal(visit.typedCount, 1); + }); + let testuri = NetUtil.newURI("http://firefox.com/"); + let testtime = Date.now() * 1000; + await task_add_visit(testuri, testtime); + await promiseNotify; +}); + +add_task(async function test_visitAdded() { + let promiseNotify = promiseVisitAdded(async function (visit) { + Assert.ok(visit.visitId > 0); + Assert.equal(visit.url, testuri.spec); + Assert.equal(visit.visitTime, testtime / 1000); + Assert.equal(visit.referringVisitId, 0); + Assert.equal(visit.transitionType, TRANSITION_FRAMED_LINK); + let uri = NetUtil.newURI(visit.url); + await check_guid_for_uri(uri, visit.pageGuid); + Assert.ok(visit.hidden); + Assert.equal(visit.visitCount, 1); + Assert.equal(visit.typedCount, 0); + }); + let testuri = NetUtil.newURI("http://hidden.firefox.com/"); + let testtime = Date.now() * 1000; + await task_add_visit(testuri, testtime, TRANSITION_FRAMED_LINK); + await promiseNotify; +}); + +add_task(async function test_multiple_onVisit() { + let testuri = NetUtil.newURI("http://self.firefox.com/"); + let promiseNotifications = new Promise(resolve => { + async function listener(aEvents) { + Assert.equal(aEvents.length, 3, "Right number of visits notified"); + for (let i = 0; i < aEvents.length; i++) { + Assert.equal(aEvents[i].type, "page-visited"); + let visit = aEvents[i]; + Assert.equal(testuri.spec, visit.url); + Assert.ok(visit.visitId > 0); + Assert.ok(visit.visitTime > 0); + Assert.ok(!visit.hidden); + let uri = NetUtil.newURI(visit.url); + await check_guid_for_uri(uri, visit.pageGuid); + switch (i) { + case 0: + Assert.equal(visit.referringVisitId, 0); + Assert.equal(visit.transitionType, TRANSITION_LINK); + Assert.equal(visit.visitCount, 1); + Assert.equal(visit.typedCount, 0); + break; + case 1: + Assert.ok(visit.referringVisitId > 0); + Assert.equal(visit.transitionType, TRANSITION_LINK); + Assert.equal(visit.visitCount, 2); + Assert.equal(visit.typedCount, 0); + break; + case 2: + Assert.equal(visit.referringVisitId, 0); + Assert.equal(visit.transitionType, TRANSITION_TYPED); + Assert.equal(visit.visitCount, 3); + Assert.equal(visit.typedCount, 1); + + PlacesObservers.removeListener(["page-visited"], listener); + resolve(); + break; + } + } + } + PlacesObservers.addListener(["page-visited"], listener); + }); + await PlacesTestUtils.addVisits([ + { uri: testuri, transition: TRANSITION_LINK }, + { uri: testuri, referrer: testuri, transition: TRANSITION_LINK }, + { uri: testuri, transition: TRANSITION_TYPED }, + ]); + await promiseNotifications; +}); + +add_task(async function test_pageRemovedFromStore() { + let [testuri] = await task_add_visit(); + let testguid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: testuri, + }); + + const promiseNotify = PlacesTestUtils.waitForNotification("page-removed"); + + await PlacesUtils.history.remove(testuri); + + const events = await promiseNotify; + Assert.equal(events.length, 1, "Right number of page-removed notified"); + Assert.equal(events[0].type, "page-removed"); + Assert.ok(events[0].isRemovedFromStore); + Assert.equal(events[0].url, testuri.spec); + Assert.equal(events[0].pageGuid, testguid); + Assert.equal(events[0].reason, PlacesVisitRemoved.REASON_DELETED); +}); + +add_task(async function test_pageRemovedAllVisits() { + const promiseNotify = PlacesTestUtils.waitForNotification("page-removed"); + + let msecs24hrsAgo = Date.now() - 86400 * 1000; + let [testuri] = await task_add_visit(undefined, msecs24hrsAgo * 1000); + // Add a bookmark so the page is not removed. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test", + url: testuri, + }); + let testguid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", { + url: testuri, + }); + await PlacesUtils.history.remove(testuri); + + const events = await promiseNotify; + Assert.equal(events.length, 1, "Right number of page-removed notified"); + Assert.equal(events[0].type, "page-removed"); + Assert.ok(!events[0].isRemovedFromStore); + Assert.equal(events[0].url, testuri.spec); + // Can't use do_check_guid_for_uri() here because the visit is already gone. + Assert.equal(events[0].pageGuid, testguid); + Assert.equal(events[0].reason, PlacesVisitRemoved.REASON_DELETED); + Assert.ok(!events[0].isPartialVisistsRemoval); // All visits have been removed. +}); + +add_task(async function test_pageTitleChanged() { + const [testuri] = await task_add_visit(); + const title = "test-title"; + + const promiseNotify = + PlacesTestUtils.waitForNotification("page-title-changed"); + + await PlacesTestUtils.addVisits({ + uri: testuri, + title, + }); + + const events = await promiseNotify; + Assert.equal(events.length, 1, "Right number of title changed notified"); + Assert.equal(events[0].type, "page-title-changed"); + Assert.equal(events[0].url, testuri.spec); + Assert.equal(events[0].title, title); + await check_guid_for_uri(testuri, events[0].pageGuid); +}); diff --git a/toolkit/components/places/tests/unit/test_history_sidebar.js b/toolkit/components/places/tests/unit/test_history_sidebar.js new file mode 100644 index 0000000000..868ef79f70 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_history_sidebar.js @@ -0,0 +1,418 @@ +/* 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/. */ + +let nowObj = new Date(); + +/** + * Normalizes a Date to midnight. + * + * @param {Date} inputDate + * @return normalized Date + */ +function toMidnight(inputDate) { + let date = new Date(inputDate); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +} + +/** + * Adds a test URI visit to the database. + * + * @param aURI + * The URI to add a visit for. + * @param aTime + * Reference "now" time. + * @param aDayOffset + * number of days to add, pass a negative value to subtract them. + */ +async function addNormalizedVisit(aURI, aTime, aDayOffset) { + let dateObj = toMidnight(aTime); + // Days where DST changes should be taken into account. + let previousDateObj = new Date(dateObj.getTime() + aDayOffset * 86400000); + let DSTCorrection = + (dateObj.getTimezoneOffset() - previousDateObj.getTimezoneOffset()) * + 60 * + 1000; + // Substract aDayOffset + let PRTimeWithOffset = (previousDateObj.getTime() - DSTCorrection) * 1000; + info( + "Adding visit to " + + aURI.spec + + " at " + + PlacesUtils.toDate(PRTimeWithOffset) + ); + await PlacesTestUtils.addVisits({ + uri: aURI, + visitDate: PRTimeWithOffset, + }); +} + +function openRootForResultType(resultType) { + let options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = resultType; + let query = PlacesUtils.history.getNewQuery(); + let result = PlacesUtils.history.executeQuery(query, options); + let root = result.root; + root.containerOpen = true; + return result; +} + +function daysForMonthsAgo(months) { + let oldTime = toMidnight(new Date()); + // Set day before month, otherwise we could try to calculate 30 February, or + // other nonexistent days. + oldTime.setDate(1); + oldTime.setMonth(nowObj.getMonth() - months); + // Stay larger for eventual timezone issues, add 2 days. + return parseInt((nowObj - oldTime) / (1000 * 60 * 60 * 24)) + 2; +} + +// This test relies on en-US locale +// Offset is number of days +let containers = [ + { label: "Today", offset: 0, visible: true }, + { label: "Yesterday", offset: -1, visible: true }, + { label: "Last 7 days", offset: -2, visible: true }, + { label: "This month", offset: -8, visible: nowObj.getDate() > 8 }, + { label: "", offset: -daysForMonthsAgo(0), visible: true }, + { label: "", offset: -daysForMonthsAgo(1), visible: true }, + { label: "", offset: -daysForMonthsAgo(2), visible: true }, + { label: "", offset: -daysForMonthsAgo(3), visible: true }, + { label: "", offset: -daysForMonthsAgo(4), visible: true }, + { label: "Older than 6 months", offset: -daysForMonthsAgo(5), visible: true }, +]; + +let visibleContainers = containers.filter(container => container.visible); + +/** + * Asynchronous task that fills history and checks containers' labels. + */ +add_task(async function task_fill_history() { + info("*** TEST Fill History"); + // We can't use "now" because our hardcoded offsets would be invalid for some + // date. So we hardcode a date. + for (let i = 0; i < containers.length; i++) { + let container = containers[i]; + let testURI = uri("http://mirror" + i + ".mozilla.com/b"); + await addNormalizedVisit(testURI, nowObj, container.offset); + testURI = uri("http://mirror" + i + ".mozilla.com/a"); + await addNormalizedVisit(testURI, nowObj, container.offset); + testURI = uri("http://mirror" + i + ".google.com/b"); + await addNormalizedVisit(testURI, nowObj, container.offset); + testURI = uri("http://mirror" + i + ".google.com/a"); + await addNormalizedVisit(testURI, nowObj, container.offset); + // Bug 485703 - Hide date containers not containing additional entries + // compared to previous ones. + // Check after every new container is added. + check_visit(container.offset); + } + + let root = openRootForResultType( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ).root; + let cc = root.childCount; + info("Found containers:"); + let previousLabels = []; + for (let i = 0; i < cc; i++) { + let container = visibleContainers[i]; + let node = root.getChild(i); + info(node.title); + if (container.label) { + Assert.equal(node.title, container.label); + } + // Check labels are not repeated. + Assert.ok(!previousLabels.includes(node.title)); + previousLabels.push(node.title); + } + Assert.equal(cc, visibleContainers.length); + root.containerOpen = false; +}); + +/** + * Bug 485703 - Hide date containers not containing additional entries compared + * to previous ones. + */ +function check_visit(aOffset) { + let root = openRootForResultType( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ).root; + + let unexpected = []; + switch (aOffset) { + case 0: + unexpected = ["Yesterday", "Last 7 days", "This month"]; + break; + case -1: + unexpected = ["Last 7 days", "This month"]; + break; + case -2: + unexpected = ["This month"]; + break; + default: + // Other containers are tested later. + } + + info("Found containers:"); + let cc = root.childCount; + for (let i = 0; i < cc; i++) { + let node = root.getChild(i); + info(node.title); + Assert.ok(!unexpected.includes(node.title)); + } + root.containerOpen = false; +} + +/** + * Queries history grouped by date and site, checking containers' labels and + * children. + */ +add_task(async function test_RESULTS_AS_DATE_SITE_QUERY() { + info("*** TEST RESULTS_AS_DATE_SITE_QUERY"); + let result = openRootForResultType( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ); + let root = result.root; + + // Check one of the days + let dayNode = PlacesUtils.asContainer(root.getChild(0)); + dayNode.containerOpen = true; + Assert.equal(dayNode.childCount, 2); + + // Items should be sorted by host + let site1 = PlacesUtils.asContainer(dayNode.getChild(0)); + Assert.equal(site1.title, "mirror0.google.com"); + + let site2 = PlacesUtils.asContainer(dayNode.getChild(1)); + Assert.equal(site2.title, "mirror0.mozilla.com"); + + site1.containerOpen = true; + Assert.equal(site1.childCount, 2); + + // Inside of host sites are sorted by title + let site1visit = site1.getChild(0); + Assert.equal(site1visit.uri, "http://mirror0.google.com/a"); + + // Bug 473157: changing sorting mode should not affect the containers + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING; + + // Check one of the days + dayNode = PlacesUtils.asContainer(root.getChild(0)); + dayNode.containerOpen = true; + Assert.equal(dayNode.childCount, 2); + + // Hosts are still sorted by title + site1 = PlacesUtils.asContainer(dayNode.getChild(0)); + Assert.equal(site1.title, "mirror0.google.com"); + + site2 = PlacesUtils.asContainer(dayNode.getChild(1)); + Assert.equal(site2.title, "mirror0.mozilla.com"); + + site1.containerOpen = true; + Assert.equal(site1.childCount, 2); + + // But URLs are now sorted by title descending + site1visit = site1.getChild(0); + Assert.equal(site1visit.uri, "http://mirror0.google.com/b"); + + site1.containerOpen = false; + dayNode.containerOpen = false; + root.containerOpen = false; +}); + +/** + * Queries history grouped by date, checking containers' labels and children. + */ +add_task(async function test_RESULTS_AS_DATE_QUERY() { + info("*** TEST RESULTS_AS_DATE_QUERY"); + let result = openRootForResultType( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY + ); + let root = result.root; + let cc = root.childCount; + Assert.equal(cc, visibleContainers.length); + info("Found containers:"); + for (let i = 0; i < cc; i++) { + let container = visibleContainers[i]; + let node = root.getChild(i); + info(node.title); + if (container.label) { + Assert.equal(node.title, container.label); + } + } + + // Check one of the days + let dayNode = PlacesUtils.asContainer(root.getChild(0)); + dayNode.containerOpen = true; + Assert.equal(dayNode.childCount, 4); + + // Items should be sorted by title + let visit1 = dayNode.getChild(0); + Assert.equal(visit1.uri, "http://mirror0.google.com/a"); + + let visit2 = dayNode.getChild(3); + Assert.equal(visit2.uri, "http://mirror0.mozilla.com/b"); + + // Bug 473157: changing sorting mode should not affect the containers + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING; + + // Check one of the days + dayNode = PlacesUtils.asContainer(root.getChild(0)); + dayNode.containerOpen = true; + Assert.equal(dayNode.childCount, 4); + + // But URLs are now sorted by title descending + visit1 = dayNode.getChild(0); + Assert.equal(visit1.uri, "http://mirror0.mozilla.com/b"); + + visit2 = dayNode.getChild(3); + Assert.equal(visit2.uri, "http://mirror0.google.com/a"); + + dayNode.containerOpen = false; + root.containerOpen = false; +}); + +/** + * Queries history grouped by site, checking containers' labels and children. + */ +add_task(async function test_RESULTS_AS_SITE_QUERY() { + info("*** TEST RESULTS_AS_SITE_QUERY"); + // add a bookmark with a domain not in the set of visits in the db + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://foobar", + title: "", + }); + + let options = PlacesUtils.history.getNewQueryOptions(); + options.resultType = options.RESULTS_AS_SITE_QUERY; + options.sortingMode = options.SORT_BY_TITLE_ASCENDING; + let query = PlacesUtils.history.getNewQuery(); + let result = PlacesUtils.history.executeQuery(query, options); + let root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, containers.length * 2); + + // Expected results: + // "mirror0.google.com", + // "mirror0.mozilla.com", + // "mirror1.google.com", + // "mirror1.mozilla.com", + // "mirror2.google.com", + // "mirror2.mozilla.com", + // "mirror3.google.com", <== We check for this site (index 6) + // "mirror3.mozilla.com", + // "mirror4.google.com", + // "mirror4.mozilla.com", + // "mirror5.google.com", + // "mirror5.mozilla.com", + // ... + + // Items should be sorted by host + let siteNode = PlacesUtils.asContainer(root.getChild(6)); + Assert.equal(siteNode.title, "mirror3.google.com"); + + siteNode.containerOpen = true; + Assert.equal(siteNode.childCount, 2); + + // Inside of host sites are sorted by title + let visitNode = siteNode.getChild(0); + Assert.equal(visitNode.uri, "http://mirror3.google.com/a"); + + // Bug 473157: changing sorting mode should not affect the containers + result.sortingMode = options.SORT_BY_TITLE_DESCENDING; + siteNode = PlacesUtils.asContainer(root.getChild(6)); + Assert.equal(siteNode.title, "mirror3.google.com"); + + siteNode.containerOpen = true; + Assert.equal(siteNode.childCount, 2); + + // But URLs are now sorted by title descending + let visit = siteNode.getChild(0); + Assert.equal(visit.uri, "http://mirror3.google.com/b"); + + siteNode.containerOpen = false; + root.containerOpen = false; + + // Cleanup. + await PlacesUtils.bookmarks.remove(bookmark.guid); +}); + +/** + * Checks that queries grouped by date do liveupdate correctly. + */ +async function test_date_liveupdate(aResultType) { + let midnight = toMidnight(nowObj); + + // TEST 1. Test that the query correctly updates when it is root. + let root = openRootForResultType(aResultType).root; + Assert.equal(root.childCount, visibleContainers.length); + + // Remove "Today". + await PlacesUtils.history.removeByFilter({ + beginDate: new Date(midnight.getTime()), + endDate: new Date(Date.now()), + }); + Assert.equal(root.childCount, visibleContainers.length - 1); + + // Open "Last 7 days" container, this way we will have a container accepting + // the new visit, but we should still add back "Today" container. + let last7Days = PlacesUtils.asContainer(root.getChild(1)); + last7Days.containerOpen = true; + + // Add a visit for "Today". This should add back the missing "Today" + // container. + await addNormalizedVisit(uri("http://www.mozilla.org/"), nowObj, 0); + Assert.equal(root.childCount, visibleContainers.length); + + last7Days.containerOpen = false; + root.containerOpen = false; + + // TEST 2. Test that the query correctly updates even if it is not root. + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "place:type=" + aResultType, + title: "", + }); + + // Query toolbar and open our query container, then check again liveupdate. + root = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.toolbarGuid).root; + Assert.equal(root.childCount, 1); + let dateContainer = PlacesUtils.asContainer(root.getChild(0)); + dateContainer.containerOpen = true; + + Assert.equal(dateContainer.childCount, visibleContainers.length); + // Remove "Today". + await PlacesUtils.history.removeByFilter({ + beginDate: new Date(midnight.getTime()), + endDate: new Date(Date.now()), + }); + Assert.equal(dateContainer.childCount, visibleContainers.length - 1); + // Add a visit for "Today". + await addNormalizedVisit(uri("http://www.mozilla.org/"), nowObj, 0); + Assert.equal(dateContainer.childCount, visibleContainers.length); + + dateContainer.containerOpen = false; + root.containerOpen = false; + + // Cleanup. + await PlacesUtils.bookmarks.remove(bookmark.guid); +} + +add_task(async function test_history_sidebar() { + await test_date_liveupdate( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY + ); + await test_date_liveupdate( + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY + ); + + // The remaining views are + // RESULTS_AS_URI + SORT_BY_VISITCOUNT_DESCENDING + // -> test_399266.js + // RESULTS_AS_URI + SORT_BY_DATE_DESCENDING + // -> test_385397.js +}); diff --git a/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js b/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js new file mode 100644 index 0000000000..167b8786e4 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js @@ -0,0 +1,326 @@ +async function importFromFixture(fixture, replace) { + let cwd = do_get_cwd().path; + let path = PathUtils.join(cwd, fixture); + + info(`Importing from ${path}`); + await BookmarkJSONUtils.importFromFile(path, { replace }); + await PlacesTestUtils.promiseAsyncUpdates(); +} + +async function treeEquals(guid, expected, message) { + let root = await PlacesUtils.promiseBookmarksTree(guid); + let bookmarks = (function nodeToEntry(node) { + let entry = { guid: node.guid, index: node.index }; + if (node.children) { + entry.children = node.children.map(nodeToEntry); + } + return entry; + })(root); + + info(`Checking if ${guid} tree matches ${JSON.stringify(expected)}`); + info(`Got bookmarks tree for ${guid}: ${JSON.stringify(bookmarks)}`); + + deepEqual(bookmarks, expected, message); +} + +add_task(async function test_restore_mobile_bookmarks_root() { + await importFromFixture( + "mobile_bookmarks_root_import.json", + /* replace */ true + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [{ guid: "X6lUyOspVYwi", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + { guid: "_o8e1_zxTJFg", index: 0 }, + { guid: "QCtSqkVYUbXB", index: 1 }, + ], + }, + ], + }, + "Should restore mobile bookmarks from root" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_mobile_bookmarks_root() { + await importFromFixture( + "mobile_bookmarks_root_import.json", + /* replace */ false + ); + await importFromFixture( + "mobile_bookmarks_root_merge.json", + /* replace */ false + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { guid: "X6lUyOspVYwi", index: 0 }, + { guid: "Utodo9b0oVws", index: 1 }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + // The first two are in ..._import.json, the second two are in + // ..._merge.json + { guid: "_o8e1_zxTJFg", index: 0 }, + { guid: "QCtSqkVYUbXB", index: 1 }, + { guid: "a17yW6-nTxEJ", index: 2 }, + { guid: "xV10h9Wi3FBM", index: 3 }, + ], + }, + ], + }, + "Should merge bookmarks root contents" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_restore_mobile_bookmarks_folder() { + // This tests importing a mobile bookmarks folder with the annotation, + // and the old, random guid. + await importFromFixture( + "mobile_bookmarks_folder_import.json", + /* replace */ true + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { guid: "X6lUyOspVYwi", index: 0 }, + { guid: "XF4yRP6bTuil", index: 1 }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + children: [{ guid: "buy7711R3ZgE", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ guid: "KIa9iKZab2Z5", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + { guid: "_o8e1_zxTJFg", index: 0 }, + { guid: "QCtSqkVYUbXB", index: 1 }, + ], + }, + ], + }, + "Should restore mobile bookmark folder contents into mobile root" + ); + + let queryById = await PlacesUtils.bookmarks.fetch("XF4yRP6bTuil"); + equal( + queryById.url.href, + `place:parent=${PlacesUtils.bookmarks.mobileGuid}`, + "Should rewrite mobile query to point to root GUID" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_mobile_bookmarks_folder() { + await importFromFixture( + "mobile_bookmarks_folder_import.json", + /* replace */ false + ); + await importFromFixture( + "mobile_bookmarks_folder_merge.json", + /* replace */ false + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { guid: "X6lUyOspVYwi", index: 0 }, + { guid: "XF4yRP6bTuil", index: 1 }, + { guid: "Utodo9b0oVws", index: 2 }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + children: [{ guid: "buy7711R3ZgE", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ guid: "KIa9iKZab2Z5", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + { guid: "_o8e1_zxTJFg", index: 0 }, + { guid: "QCtSqkVYUbXB", index: 1 }, + { guid: "a17yW6-nTxEJ", index: 2 }, + { guid: "xV10h9Wi3FBM", index: 3 }, + ], + }, + ], + }, + "Should merge bookmarks folder contents into mobile root" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_restore_multiple_bookmarks_folders() { + await importFromFixture( + "mobile_bookmarks_multiple_folders.json", + /* replace */ true + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { guid: "buy7711R3ZgE", index: 0 }, + { guid: "F_LBgd1fS_uQ", index: 1 }, + { guid: "oIpmQXMWsXvY", index: 2 }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + children: [{ guid: "Utodo9b0oVws", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ guid: "xV10h9Wi3FBM", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + { guid: "a17yW6-nTxEJ", index: 0 }, + { guid: "sSZ86WT9WbN3", index: 1 }, + ], + }, + ], + }, + "Should restore multiple bookmarks folder contents into root" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_import_multiple_bookmarks_folders() { + await importFromFixture( + "mobile_bookmarks_root_import.json", + /* replace */ false + ); + await importFromFixture( + "mobile_bookmarks_multiple_folders.json", + /* replace */ false + ); + + await treeEquals( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + index: 0, + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { guid: "X6lUyOspVYwi", index: 0 }, + { guid: "buy7711R3ZgE", index: 1 }, + { guid: "F_LBgd1fS_uQ", index: 2 }, + { guid: "oIpmQXMWsXvY", index: 3 }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + children: [{ guid: "Utodo9b0oVws", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ guid: "xV10h9Wi3FBM", index: 0 }], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + children: [ + { guid: "_o8e1_zxTJFg", index: 0 }, + { guid: "QCtSqkVYUbXB", index: 1 }, + { guid: "a17yW6-nTxEJ", index: 2 }, + { guid: "sSZ86WT9WbN3", index: 3 }, + ], + }, + ], + }, + "Should merge multiple mobile folders into root" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/unit/test_isPageInDB.js b/toolkit/components/places/tests/unit/test_isPageInDB.js new file mode 100644 index 0000000000..2eda125994 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_isPageInDB.js @@ -0,0 +1,10 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +add_task(async function test_execute() { + var good_uri = uri("http://mozilla.com"); + var bad_uri = uri("http://google.com"); + await PlacesTestUtils.addVisits({ uri: good_uri }); + Assert.ok(await PlacesTestUtils.isPageInDB(good_uri)); + Assert.equal(false, await PlacesTestUtils.isPageInDB(bad_uri)); +}); diff --git a/toolkit/components/places/tests/unit/test_isURIVisited.js b/toolkit/components/places/tests/unit/test_isURIVisited.js new file mode 100644 index 0000000000..cf3a8c139f --- /dev/null +++ b/toolkit/components/places/tests/unit/test_isURIVisited.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests functionality of the isURIVisited API. + +const SCHEMES = { + "http://": true, + "https://": true, + "ftp://": true, + "file:///": true, + "about:": false, + // nsIIOService.newURI() can throw if e.g. the app knows about imap:// + // but the account is not set up and so the URL is invalid for it. + // "imap://": false, + "news://": false, + "mailbox:": false, + "moz-anno:favicon:http://": false, + "view-source:http://": false, + "chrome://browser/content/browser.xhtml?": false, + "resource://": false, + "data:,": false, + "javascript:": false, +}; + +add_task(async function test_isURIVisited() { + let history = Cc["@mozilla.org/browser/history;1"].getService( + Ci.mozIAsyncHistory + ); + + function visitsPromise(uri) { + return new Promise(resolve => { + history.isURIVisited(uri, (receivedURI, visited) => { + resolve([receivedURI, visited]); + }); + }); + } + + for (let scheme in SCHEMES) { + info("Testing scheme " + scheme); + for (let t in PlacesUtils.history.TRANSITIONS) { + if (t == "EMBED") { + continue; + } + info("With transition " + t); + let aTransition = PlacesUtils.history.TRANSITIONS[t]; + + let aURI = Services.io.newURI(scheme + "mozilla.org/"); + + let [receivedURI1, visited1] = await visitsPromise(aURI); + Assert.ok(aURI.equals(receivedURI1)); + Assert.ok(!visited1); + + if (PlacesUtils.history.canAddURI(aURI)) { + await PlacesTestUtils.addVisits([ + { + uri: aURI, + transition: aTransition, + }, + ]); + info("Added visit for " + aURI.spec); + } + + let [receivedURI2, visited2] = await visitsPromise(aURI); + Assert.ok(aURI.equals(receivedURI2)); + Assert.equal(SCHEMES[scheme], visited2); + + await PlacesUtils.history.clear(); + let [receivedURI3, visited3] = await visitsPromise(aURI); + Assert.ok(aURI.equals(receivedURI3)); + Assert.ok(!visited3); + } + } +}); diff --git a/toolkit/components/places/tests/unit/test_isvisited.js b/toolkit/components/places/tests/unit/test_isvisited.js new file mode 100644 index 0000000000..42bd913f55 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_isvisited.js @@ -0,0 +1,69 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_execute() { + var referrer = uri("about:blank"); + + // add a http:// uri + var uri1 = uri("http://mozilla.com"); + await PlacesTestUtils.addVisits({ uri: uri1, referrer }); + await check_guid_for_uri(uri1); + Assert.ok(await PlacesUtils.history.hasVisits(uri1)); + + // add a https:// uri + var uri2 = uri("https://etrade.com"); + await PlacesTestUtils.addVisits({ uri: uri2, referrer }); + await check_guid_for_uri(uri2); + Assert.ok(await PlacesUtils.history.hasVisits(uri2)); + + // add a ftp:// uri + var uri3 = uri("ftp://ftp.mozilla.org"); + await PlacesTestUtils.addVisits({ uri: uri3, referrer }); + await check_guid_for_uri(uri3); + Assert.ok(await PlacesUtils.history.hasVisits(uri3)); + + // check if a nonexistent uri is visited + var uri4 = uri("http://foobarcheese.com"); + Assert.equal(false, await PlacesUtils.history.hasVisits(uri4)); + + // check that certain schemes never show up as visited + // even if we attempt to add them to history + // see CanAddURI() in nsNavHistory.cpp + const URLS = [ + "about:config", + "imap://cyrus.andrew.cmu.edu/archive.imap", + "news://new.mozilla.org/mozilla.dev.apps.firefox", + "mailbox:Inbox", + "moz-anno:favicon:http://mozilla.org/made-up-favicon", + "view-source:http://mozilla.org", + "chrome://browser/content/browser.xhtml", + "resource://gre-resources/hiddenWindow.html", + "data:,Hello%2C%20World!", + "javascript:alert('hello wolrd!');", + "http://localhost/" + "a".repeat(1984), + ]; + for (let currentURL of URLS) { + try { + var cantAddUri = uri(currentURL); + } catch (e) { + // nsIIOService.newURI() can throw if e.g. our app knows about imap:// + // but the account is not set up and so the URL is invalid for us. + // Note this in the log but ignore as it's not the subject of this test. + info("Could not construct URI for '" + currentURL + "'; ignoring"); + } + if (cantAddUri) { + PlacesTestUtils.addVisits({ uri: cantAddUri, referrer }).then( + () => { + do_throw("Should not have added history for invalid URI."); + }, + error => { + Assert.ok(error.message.includes("No items were added to history")); + } + ); + Assert.equal(false, await PlacesUtils.history.hasVisits(cantAddUri)); + } + } +}); diff --git a/toolkit/components/places/tests/unit/test_keywords.js b/toolkit/components/places/tests/unit/test_keywords.js new file mode 100644 index 0000000000..57e81515aa --- /dev/null +++ b/toolkit/components/places/tests/unit/test_keywords.js @@ -0,0 +1,733 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +async function check_keyword(aExpectExists, aHref, aKeyword, aPostData = null) { + // Check case-insensitivity. + aKeyword = aKeyword.toUpperCase(); + + let entry = await PlacesUtils.keywords.fetch(aKeyword); + + Assert.deepEqual( + entry, + await PlacesUtils.keywords.fetch({ keyword: aKeyword }) + ); + + if (aExpectExists) { + Assert.ok(!!entry, "A keyword should exist"); + Assert.equal(entry.url.href, aHref); + Assert.equal(entry.postData, aPostData); + Assert.deepEqual( + entry, + await PlacesUtils.keywords.fetch({ keyword: aKeyword, url: aHref }) + ); + let entries = []; + await PlacesUtils.keywords.fetch({ url: aHref }, e => entries.push(e)); + Assert.ok( + entries.some( + e => e.url.href == aHref && e.keyword == aKeyword.toLowerCase() + ) + ); + } else { + Assert.ok( + !entry || entry.url.href != aHref, + "The given keyword entry should not exist" + ); + if (aHref) { + Assert.equal( + null, + await PlacesUtils.keywords.fetch({ keyword: aKeyword, url: aHref }) + ); + } else { + Assert.equal( + null, + await PlacesUtils.keywords.fetch({ keyword: aKeyword }) + ); + } + } +} + +/** + * Polls the keywords cache waiting for the given keyword entry. + */ +async function promiseKeyword(keyword, expectedHref) { + let href = null; + do { + await new Promise(resolve => do_timeout(100, resolve)); + let entry = await PlacesUtils.keywords.fetch(keyword); + if (entry) { + href = entry.url.href; + } + } while (href != expectedHref); +} + +async function check_no_orphans() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT id FROM moz_keywords k + WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id) + ` + ); + Assert.equal(rows.length, 0); +} + +function expectBookmarkNotifications() { + const observer = { + notifications: [], + _start() { + this._handle = this._handle.bind(this); + PlacesUtils.observers.addListener( + ["bookmark-keyword-changed"], + this._handle + ); + }, + _handle(events) { + for (const event of events) { + this.notifications.push({ + type: event.type, + id: event.id, + itemType: event.itemType, + url: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + keyword: event.keyword, + lastModified: new Date(event.lastModified), + source: event.source, + isTagging: event.isTagging, + }); + } + }, + check(expected) { + PlacesUtils.observers.removeListener( + ["bookmark-keyword-changed"], + this._handle + ); + Assert.deepEqual(this.notifications, expected); + }, + }; + observer._start(); + return observer; +} + +add_task(async function test_invalid_input() { + Assert.throws(() => PlacesUtils.keywords.fetch(null), /Invalid keyword/); + Assert.throws(() => PlacesUtils.keywords.fetch(5), /Invalid keyword/); + Assert.throws(() => PlacesUtils.keywords.fetch(undefined), /Invalid keyword/); + Assert.throws( + () => PlacesUtils.keywords.fetch({ keyword: null }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ keyword: {} }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ keyword: 5 }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({}), + /At least keyword or url must be provided/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ keyword: "test" }, "test"), + /onResult callback must be a valid function/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ url: "test" }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ url: {} }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ url: null }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.fetch({ url: "" }), + /is not a valid URL/ + ); + + Assert.throws( + () => PlacesUtils.keywords.insert(null), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert("test"), + /Input should be a valid object/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert(undefined), + /Input should be a valid object/ + ); + Assert.throws(() => PlacesUtils.keywords.insert({}), /Invalid keyword/); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: null }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: 5 }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "" }), + /Invalid keyword/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", postData: 5 }), + /Invalid POST data/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", postData: {} }), + /Invalid POST data/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test" }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", url: 5 }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", url: "" }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", url: null }), + /is not a valid URL/ + ); + Assert.throws( + () => PlacesUtils.keywords.insert({ keyword: "test", url: "mozilla" }), + /is not a valid URL/ + ); + + Assert.throws(() => PlacesUtils.keywords.remove(null), /Invalid keyword/); + Assert.throws(() => PlacesUtils.keywords.remove(""), /Invalid keyword/); + Assert.throws(() => PlacesUtils.keywords.remove(5), /Invalid keyword/); +}); + +add_task(async function test_addKeyword() { + await check_keyword(false, "http://example.com/", "keyword"); + let fc = await foreign_count("http://example.com/"); + let observer = expectBookmarkNotifications(); + + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + observer.check([]); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 1); // +1 keyword + + // Now remove the keyword. + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.remove("keyword"); + observer.check([]); + + await check_keyword(false, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc); // -1 keyword + + // Check using URL. + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: new URL("http://example.com/"), + }); + await check_keyword(true, "http://example.com/", "keyword"); + await PlacesUtils.keywords.remove("keyword"); + await check_keyword(false, "http://example.com/", "keyword"); + + await check_no_orphans(); +}); + +add_task(async function test_addBookmarkAndKeyword() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + await check_keyword(false, "http://example.com/", "keyword"); + let fc = await foreign_count("http://example.com/"); + let bookmark = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + let observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark.guid), + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // +1 bookmark +1 keyword + + // Now remove the keyword. + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.remove("keyword"); + + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark.guid), + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(false, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 1); // -1 keyword + + // Add again the keyword, then remove the bookmark. + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + + observer = expectBookmarkNotifications(); + await PlacesUtils.bookmarks.remove(bookmark.guid); + // the notification is synchronous but the removal process is async. + // Unfortunately there's nothing explicit we can wait for. + // eslint-disable-next-line no-empty + while (await foreign_count("http://example.com/")) {} + // We don't get any itemChanged notification since the bookmark has been + // removed already. + observer.check([]); + + await check_keyword(false, "http://example.com/", "keyword"); + + await check_no_orphans(); +}); + +add_task(async function test_addKeywordToURIHavingKeyword() { + await check_keyword(false, "http://example.com/", "keyword"); + let fc = await foreign_count("http://example.com/"); + + let observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + observer.check([]); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 1); // +1 keyword + + await PlacesUtils.keywords.insert({ + keyword: "keyword2", + url: "http://example.com/", + postData: "test=1", + }); + + await check_keyword(true, "http://example.com/", "keyword"); + await check_keyword(true, "http://example.com/", "keyword2", "test=1"); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // +1 keyword + let entries = []; + let entry = await PlacesUtils.keywords.fetch( + { url: "http://example.com/" }, + e => entries.push(e) + ); + Assert.equal(entries.length, 2); + Assert.deepEqual(entries[0], entry); + + // Now remove the keywords. + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.remove("keyword"); + await PlacesUtils.keywords.remove("keyword2"); + observer.check([]); + + await check_keyword(false, "http://example.com/", "keyword"); + await check_keyword(false, "http://example.com/", "keyword2"); + Assert.equal(await foreign_count("http://example.com/"), fc); // -1 keyword + + await check_no_orphans(); +}); + +add_task(async function test_addBookmarkToURIHavingKeyword() { + await check_keyword(false, "http://example.com/", "keyword"); + let fc = await foreign_count("http://example.com/"); + let observer = expectBookmarkNotifications(); + + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + observer.check([]); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 1); // +1 keyword + + observer = expectBookmarkNotifications(); + let bookmark = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // +1 bookmark + observer.check([]); + + observer = expectBookmarkNotifications(); + await PlacesUtils.bookmarks.remove(bookmark.guid); + // the notification is synchronous but the removal process is async. + // Unfortunately there's nothing explicit we can wait for. + // eslint-disable-next-line no-empty + while (await foreign_count("http://example.com/")) {} + // We don't get any itemChanged notification since the bookmark has been + // removed already. + observer.check([]); + + await check_keyword(false, "http://example.com/", "keyword"); + + await check_no_orphans(); +}); + +add_task(async function test_sameKeywordDifferentURL() { + let fc1 = await foreign_count("http://example1.com/"); + let bookmark1 = await PlacesUtils.bookmarks.insert({ + url: "http://example1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let fc2 = await foreign_count("http://example2.com/"); + let bookmark2 = await PlacesUtils.bookmarks.insert({ + url: "http://example2.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example1.com/", + }); + + await check_keyword(true, "http://example1.com/", "keyword"); + Assert.equal(await foreign_count("http://example1.com/"), fc1 + 2); // +1 bookmark +1 keyword + await check_keyword(false, "http://example2.com/", "keyword"); + Assert.equal(await foreign_count("http://example2.com/"), fc2 + 1); // +1 bookmark + + // Assign the same keyword to another url. + let observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example2.com/", + }); + + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark1.guid), + itemType: bookmark1.type, + url: bookmark1.url, + guid: bookmark1.guid, + parentGuid: bookmark1.parentGuid, + keyword: "", + lastModified: new Date(bookmark1.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark2.guid), + itemType: bookmark2.type, + url: bookmark2.url, + guid: bookmark2.guid, + parentGuid: bookmark2.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark2.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(false, "http://example1.com/", "keyword"); + Assert.equal(await foreign_count("http://example1.com/"), fc1 + 1); // -1 keyword + await check_keyword(true, "http://example2.com/", "keyword"); + Assert.equal(await foreign_count("http://example2.com/"), fc2 + 2); // +1 keyword + + // Now remove the keyword. + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.remove("keyword"); + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark2.guid), + itemType: bookmark2.type, + url: bookmark2.url, + guid: bookmark2.guid, + parentGuid: bookmark2.parentGuid, + keyword: "", + lastModified: new Date(bookmark2.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + await check_keyword(false, "http://example1.com/", "keyword"); + await check_keyword(false, "http://example2.com/", "keyword"); + Assert.equal(await foreign_count("http://example1.com/"), fc1 + 1); + Assert.equal(await foreign_count("http://example2.com/"), fc2 + 1); // -1 keyword + + await PlacesUtils.bookmarks.remove(bookmark1); + await PlacesUtils.bookmarks.remove(bookmark2); + Assert.equal(await foreign_count("http://example1.com/"), fc1); // -1 bookmark + // eslint-disable-next-line no-empty + while (await foreign_count("http://example2.com/")) {} // -1 keyword + + await check_no_orphans(); +}); + +add_task(async function test_sameURIDifferentKeyword() { + let fc = await foreign_count("http://example.com/"); + + let observer = expectBookmarkNotifications(); + let bookmark = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // +1 bookmark +1 keyword + + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark.guid), + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.insert({ + keyword: "keyword2", + url: "http://example.com/", + }); + await check_keyword(false, "http://example.com/", "keyword"); + await check_keyword(true, "http://example.com/", "keyword2"); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // -1 keyword +1 keyword + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark.guid), + itemType: bookmark.type, + url: bookmark.url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword: "keyword2", + lastModified: new Date(bookmark.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + // Now remove the bookmark. + await PlacesUtils.bookmarks.remove(bookmark); + // eslint-disable-next-line no-empty + while (await foreign_count("http://example.com/")) {} + await check_keyword(false, "http://example.com/", "keyword"); + await check_keyword(false, "http://example.com/", "keyword2"); + + await check_no_orphans(); +}); + +add_task(async function test_deleteKeywordMultipleBookmarks() { + let fc = await foreign_count("http://example.com/"); + + let observer = expectBookmarkNotifications(); + let bookmark1 = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + let bookmark2 = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + }); + + await check_keyword(true, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 3); // +2 bookmark +1 keyword + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark2.guid), + itemType: bookmark2.type, + url: bookmark2.url, + guid: bookmark2.guid, + parentGuid: bookmark2.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark2.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark1.guid), + itemType: bookmark1.type, + url: bookmark1.url, + guid: bookmark1.guid, + parentGuid: bookmark1.parentGuid, + keyword: "keyword", + lastModified: new Date(bookmark1.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + observer = expectBookmarkNotifications(); + await PlacesUtils.keywords.remove("keyword"); + await check_keyword(false, "http://example.com/", "keyword"); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // -1 keyword + observer.check([ + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark2.guid), + itemType: bookmark2.type, + url: bookmark2.url, + guid: bookmark2.guid, + parentGuid: bookmark2.parentGuid, + keyword: "", + lastModified: new Date(bookmark2.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + { + type: "bookmark-keyword-changed", + id: await PlacesUtils.promiseItemId(bookmark1.guid), + itemType: bookmark1.type, + url: bookmark1.url, + guid: bookmark1.guid, + parentGuid: bookmark1.parentGuid, + keyword: "", + lastModified: new Date(bookmark1.lastModified), + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + isTagging: false, + }, + ]); + + // Now remove the bookmarks. + await PlacesUtils.bookmarks.remove(bookmark1); + await PlacesUtils.bookmarks.remove(bookmark2); + Assert.equal(await foreign_count("http://example.com/"), fc); // -2 bookmarks + + await check_no_orphans(); +}); + +add_task(async function test_multipleKeywordsSamePostData() { + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/", + postData: "postData1", + }); + await check_keyword(true, "http://example.com/", "keyword", "postData1"); + // Add another keyword with same postData, should fail. + await PlacesUtils.keywords.insert({ + keyword: "keyword2", + url: "http://example.com/", + postData: "postData1", + }); + await check_keyword(false, "http://example.com/", "keyword", "postData1"); + await check_keyword(true, "http://example.com/", "keyword2", "postData1"); + + await PlacesUtils.keywords.remove("keyword2"); + + await check_no_orphans(); +}); + +add_task(async function test_bookmarkURLChange() { + let fc1 = await foreign_count("http://example1.com/"); + let fc2 = await foreign_count("http://example2.com/"); + let bookmark = await PlacesUtils.bookmarks.insert({ + url: "http://example1.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example1.com/", + }); + + await check_keyword(true, "http://example1.com/", "keyword"); + Assert.equal(await foreign_count("http://example1.com/"), fc1 + 2); // +1 bookmark +1 keyword + + await PlacesUtils.bookmarks.update({ + guid: bookmark.guid, + url: "http://example2.com/", + }); + await promiseKeyword("keyword", "http://example2.com/"); + + await check_keyword(false, "http://example1.com/", "keyword"); + await check_keyword(true, "http://example2.com/", "keyword"); + Assert.equal(await foreign_count("http://example1.com/"), fc1); // -1 bookmark -1 keyword + Assert.equal(await foreign_count("http://example2.com/"), fc2 + 2); // +1 bookmark +1 keyword +}); + +add_task(async function test_tagDoesntPreventKeywordRemoval() { + await check_keyword(false, "http://example.com/", "example"); + let fc = await foreign_count("http://example.com/"); + + let httpBookmark = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + Assert.equal(await foreign_count("http://example.com/"), fc + 1); // +1 bookmark + + PlacesUtils.tagging.tagURI(uri("http://example.com/"), ["example_tag"]); + Assert.equal(await foreign_count("http://example.com/"), fc + 2); // +1 bookmark +1 tag + + await PlacesUtils.keywords.insert({ + keyword: "example", + url: "http://example.com/", + }); + Assert.equal(await foreign_count("http://example.com/"), fc + 3); // +1 bookmark +1 tag +1 keyword + + await check_keyword(true, "http://example.com/", "example"); + + await PlacesUtils.bookmarks.remove(httpBookmark); + + await TestUtils.waitForCondition( + async () => + !(await PlacesUtils.bookmarks.fetch({ url: "http://example.com/" })), + "Wait for bookmark to be removed" + ); + + await check_keyword(false, "http://example.com/", "example"); + Assert.equal(await foreign_count("http://example.com/"), fc); // bookmark, keyword, and tag should all have been removed + + await check_no_orphans(); +}); diff --git a/toolkit/components/places/tests/unit/test_lastModified.js b/toolkit/components/places/tests/unit/test_lastModified.js new file mode 100644 index 0000000000..dc44814548 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_lastModified.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function assert_date_eq(a, b) { + if (typeof a != "number") { + a = PlacesUtils.toPRTime(a); + } + if (typeof b != "number") { + b = PlacesUtils.toPRTime(b); + } + Assert.equal(a, b, "The dates should match"); +} + +/** + * Test that inserting a new bookmark will set lastModified to the same + * values as dateAdded. + */ +add_task(async function test_bookmarkLastModified() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://www.mozilla.org/", + title: "itemTitle", + }); + + let guid = bookmark.guid; + + // Check the bookmark from the database. + bookmark = await PlacesUtils.bookmarks.fetch(guid); + + let dateAdded = PlacesUtils.toPRTime(bookmark.dateAdded); + assert_date_eq(dateAdded, bookmark.lastModified); + + // Change lastModified, then change dateAdded. LastModified should be set + // to the new dateAdded. + // This could randomly fail on virtual machines due to timing issues, so + // we manually increase the time value. See bug 500640 for details. + await PlacesUtils.bookmarks.update({ + guid, + lastModified: PlacesUtils.toDate(dateAdded + 1000), + }); + + bookmark = await PlacesUtils.bookmarks.fetch(guid); + + assert_date_eq(bookmark.lastModified, dateAdded + 1000); + Assert.ok( + bookmark.dateAdded < bookmark.lastModified, + "Date added should be earlier than last modified." + ); + + await PlacesUtils.bookmarks.update({ + guid, + dateAdded: PlacesUtils.toDate(dateAdded + 2000), + }); + + bookmark = await PlacesUtils.bookmarks.fetch(guid); + + assert_date_eq(bookmark.dateAdded, dateAdded + 2000); + assert_date_eq(bookmark.dateAdded, bookmark.lastModified); + + // If dateAdded is set to older than lastModified, then we shouldn't + // update lastModified to keep sync happy. + let origLastModified = bookmark.lastModified; + + await PlacesUtils.bookmarks.update({ + guid, + dateAdded: PlacesUtils.toDate(dateAdded - 10000), + }); + + bookmark = await PlacesUtils.bookmarks.fetch(guid); + + assert_date_eq(bookmark.dateAdded, dateAdded - 10000); + assert_date_eq(bookmark.lastModified, origLastModified); + + await PlacesUtils.bookmarks.remove(guid); +}); diff --git a/toolkit/components/places/tests/unit/test_markpageas.js b/toolkit/components/places/tests/unit/test_markpageas.js new file mode 100644 index 0000000000..03080f4af6 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_markpageas.js @@ -0,0 +1,48 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var gVisits = [ + { url: "http://www.mozilla.com/", transition: TRANSITION_TYPED }, + { url: "http://www.google.com/", transition: TRANSITION_BOOKMARK }, + { url: "http://www.espn.com/", transition: TRANSITION_LINK }, +]; + +add_task(async function test_execute() { + let completionPromise = new Promise(resolveCompletionPromise => { + let visitCount = 0; + function listener(aEvents) { + Assert.equal(aEvents.length, 1, "Right number of visits notified"); + Assert.equal(aEvents[0].type, "page-visited"); + let event = aEvents[0]; + Assert.equal(event.url, gVisits[visitCount].url); + Assert.equal(event.transitionType, gVisits[visitCount].transition); + visitCount++; + + if (visitCount == gVisits.length) { + resolveCompletionPromise(); + PlacesObservers.removeListener(["page-visited"], listener); + } + } + PlacesObservers.addListener(["page-visited"], listener); + }); + + for (var visit of gVisits) { + if (visit.transition == TRANSITION_TYPED) { + PlacesUtils.history.markPageAsTyped(uri(visit.url)); + } else if (visit.transition == TRANSITION_BOOKMARK) { + PlacesUtils.history.markPageAsFollowedBookmark(uri(visit.url)); + } else { + // because it is a top level visit with no referrer, + // it will result in TRANSITION_LINK + } + await PlacesTestUtils.addVisits({ + uri: uri(visit.url), + transition: visit.transition, + }); + } + + await completionPromise; +}); diff --git a/toolkit/components/places/tests/unit/test_metadata.js b/toolkit/components/places/tests/unit/test_metadata.js new file mode 100644 index 0000000000..f202b7f405 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_metadata.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_metadata() { + await PlacesUtils.metadata.set("test/integer", 123); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/integer"), + 123, + "Should store new integer value" + ); + + await PlacesUtils.metadata.set("test/double", 123.45); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/double"), + 123.45, + "Should store new double value" + ); + await PlacesUtils.metadata.set("test/double", 567.89); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/double"), + 567.89, + "Should update existing double value" + ); + + await PlacesUtils.metadata.set("test/boolean", false); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/boolean"), + false, + "Should store new Boolean value" + ); + await PlacesUtils.metadata.set("test/boolean", true); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/boolean"), + true, + "Should update existing Boolean value" + ); + + await PlacesUtils.metadata.set("test/string", "hi"); + Assert.equal( + await PlacesUtils.metadata.get("test/string"), + "hi", + "Should store new string value" + ); + await PlacesUtils.metadata.cache.clear(); + Assert.equal( + await PlacesUtils.metadata.get("test/string"), + "hi", + "Should return string value after clearing cache" + ); + + await Assert.rejects( + PlacesUtils.metadata.get("test/nonexistent"), + /No data stored for key test\/nonexistent/, + "Should reject for a non-existent key and no default value." + ); + Assert.equal( + await PlacesUtils.metadata.get("test/nonexistent", "defaultValue"), + "defaultValue", + "Should return the default value for a non-existent key." + ); + + // Values are untyped; it's OK to store a value of a different type for the + // same key. + await PlacesUtils.metadata.set("test/string", 111); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/string"), + 111, + "Should replace string with integer" + ); + await PlacesUtils.metadata.set("test/string", null); + await Assert.rejects( + PlacesUtils.metadata.get("test/string"), + /No data stored for key test\/string/, + "Should clear value when setting to NULL" + ); + + await PlacesUtils.metadata.delete("test/string", "test/boolean"); + await Assert.rejects( + PlacesUtils.metadata.get("test/string"), + /No data stored for key test\/string/, + "Should delete string value" + ); + await Assert.rejects( + PlacesUtils.metadata.get("test/boolean"), + /No data stored for key test\/boolean/, + "Should delete Boolean value" + ); + Assert.strictEqual( + await PlacesUtils.metadata.get("test/integer"), + 123, + "Should keep undeleted integer value" + ); + + await PlacesTestUtils.clearMetadata(); + await Assert.rejects( + PlacesUtils.metadata.get("test/integer"), + /No data stored for key test\/integer/, + "Should clear integer value" + ); + await Assert.rejects( + PlacesUtils.metadata.get("test/double"), + /No data stored for key test\/double/, + "Should clear double value" + ); +}); + +add_task(async function test_metadata_canonical_keys() { + await PlacesUtils.metadata.set("Test/Integer", 123); + Assert.strictEqual( + await PlacesUtils.metadata.get("tEsT/integer"), + 123, + "New keys should be case-insensitive" + ); + await PlacesUtils.metadata.set("test/integer", 456); + Assert.strictEqual( + await PlacesUtils.metadata.get("TEST/INTEGER"), + 456, + "Existing keys should be case-insensitive" + ); + + await Assert.rejects( + PlacesUtils.metadata.set("", 123), + /Invalid metadata key/, + "Should reject empty keys" + ); + await Assert.rejects( + PlacesUtils.metadata.get(123), + /Invalid metadata key/, + "Should reject numeric keys" + ); + await Assert.rejects( + PlacesUtils.metadata.delete(true), + /Invalid metadata key/, + "Should reject Boolean keys" + ); + await Assert.rejects( + PlacesUtils.metadata.set({}), + /Invalid metadata key/, + "Should reject object keys" + ); + await Assert.rejects( + PlacesUtils.metadata.get(null), + /Invalid metadata key/, + "Should reject null keys" + ); + await Assert.rejects( + PlacesUtils.metadata.delete("!@#$"), + /Invalid metadata key/, + "Should reject keys with invalid characters" + ); +}); + +add_task(async function test_metadata_blobs() { + let blob = new Uint8Array([1, 2, 3]); + await PlacesUtils.metadata.set("test/blob", blob); + + let sameBlob = await PlacesUtils.metadata.get("test/blob"); + Assert.equal( + ChromeUtils.getClassName(sameBlob), + "Uint8Array", + "Should cache typed array for blob value" + ); + Assert.deepEqual(sameBlob, blob, "Should store new blob value"); + + info("Remove blob from cache"); + await PlacesUtils.metadata.cache.clear(); + + let newBlob = await PlacesUtils.metadata.get("test/blob"); + Assert.equal( + ChromeUtils.getClassName(newBlob), + "Uint8Array", + "Should inflate blob into typed array" + ); + Assert.deepEqual( + newBlob, + blob, + "Should return same blob after clearing cache" + ); + + await PlacesTestUtils.clearMetadata(); +}); + +add_task(async function test_metadata_arrays() { + let array = [1, 2, 3, "\u2713 \u00E0 la mode"]; + await PlacesUtils.metadata.set("test/array", array); + + let sameArray = await PlacesUtils.metadata.get("test/array"); + Assert.ok(Array.isArray(sameArray), "Should cache array for array value"); + Assert.deepEqual(sameArray, array, "Should store new array value"); + + info("Remove array from cache"); + await PlacesUtils.metadata.cache.clear(); + + let newArray = await PlacesUtils.metadata.get("test/array"); + Assert.ok(Array.isArray(newArray), "Should inflate into array"); + Assert.deepEqual( + newArray, + array, + "Should return same array after clearing cache" + ); + + await PlacesTestUtils.clearMetadata(); +}); + +add_task(async function test_metadata_objects() { + let object = { foo: 123, bar: "test", meow: "\u2713 \u00E0 la mode" }; + await PlacesUtils.metadata.set("test/object", object); + + let sameObject = await PlacesUtils.metadata.get("test/object"); + Assert.equal( + typeof sameObject, + "object", + "Should cache object for object value" + ); + Assert.deepEqual(sameObject, object, "Should store new object value"); + + info("Remove object from cache"); + await PlacesUtils.metadata.cache.clear(); + + let newObject = await PlacesUtils.metadata.get("test/object"); + Assert.equal(typeof newObject, "object", "Should inflate into object"); + Assert.deepEqual( + newObject, + object, + "Should return same object after clearing cache" + ); + + await PlacesTestUtils.clearMetadata(); +}); + +add_task(async function test_metadata_unparsable() { + await PlacesUtils.withConnectionWrapper("test_medata", db => { + let data = PlacesUtils.metadata._base64Encode("{hjjkhj}"); + + return db.execute(` + INSERT INTO moz_meta (key, value) + VALUES ("test/unparsable", "data:application/json;base64,${data}") + `); + }); + + await Assert.rejects( + PlacesUtils.metadata.get("test/unparsable"), + /SyntaxError: JSON.parse/, + "Should reject for an unparsable value with no default" + ); + Assert.deepEqual( + await PlacesUtils.metadata.get("test/unparsable", { foo: 1 }), + { foo: 1 }, + "Should return the default when encountering an unparsable value." + ); + + await PlacesTestUtils.clearMetadata(); +}); diff --git a/toolkit/components/places/tests/unit/test_missing_builtin_folders.js b/toolkit/components/places/tests/unit/test_missing_builtin_folders.js new file mode 100644 index 0000000000..826cc37ff7 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_missing_builtin_folders.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This file tests that a missing built-in folders (child of root) are correctly + * fixed when the database is loaded. + */ + +const ALL_ROOT_GUIDS = [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.tagsGuid, + PlacesUtils.bookmarks.mobileGuid, +]; + +const INITIAL_ROOT_GUIDS = [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.tagsGuid, + PlacesUtils.bookmarks.unfiledGuid, +]; + +add_task(async function setup() { + // This file has the toolbar and mobile folders missing. + await setupPlacesDatabase("missingBuiltIn.sqlite"); + + // Check database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + + let rows = await db.execute( + ` + SELECT guid FROM moz_bookmarks + WHERE parent = (SELECT id from moz_bookmarks WHERE guid = :guid) + `, + { + guid: PlacesUtils.bookmarks.rootGuid, + } + ); + + let guids = rows.map(row => row.getResultByName("guid")); + Assert.deepEqual( + guids, + INITIAL_ROOT_GUIDS, + "Initial database should have only the expected GUIDs" + ); + + await db.close(); +}); + +add_task(async function test_database_recreates_roots() { + Assert.ok( + PlacesUtils.history.databaseStatus == + PlacesUtils.history.DATABASE_STATUS_OK || + PlacesUtils.history.databaseStatus == + PlacesUtils.history.DATABASE_STATUS_UPGRADED, + "Should successfully access the database for the first time" + ); + + let db = await PlacesUtils.promiseDBConnection(); + let rootId = await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.rootGuid); + for (let guid of ALL_ROOT_GUIDS) { + let rows = await db.execute( + ` + SELECT id, parent FROM moz_bookmarks + WHERE guid = :guid + `, + { guid } + ); + + Assert.equal(rows.length, 1, "Should have exactly one row for the root"); + + Assert.equal( + rows[0].getResultByName("parent"), + rootId, + "Should have been created with the correct parent" + ); + + let root = await PlacesUtils.bookmarks.fetch(guid); + + Assert.equal(root.guid, guid, "GUIDs should match"); + Assert.equal( + root.parentGuid, + PlacesUtils.bookmarks.rootGuid, + "Should have the correct parent GUID" + ); + Assert.equal( + root.type, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Should have the correct type" + ); + + let id = rows[0].getResultByName("id"); + Assert.equal( + await PlacesUtils.promiseItemId(guid), + id, + "Should return the correct id from promiseItemId" + ); + Assert.equal( + await PlacesUtils.promiseItemGuid(id), + guid, + "Should return the correct guid from promiseItemGuid" + ); + } + + let rows = await db.execute( + ` + SELECT 1 FROM moz_bookmarks + WHERE parent = (SELECT id from moz_bookmarks WHERE guid = :guid) + `, + { + guid: PlacesUtils.bookmarks.rootGuid, + } + ); + + Assert.equal( + rows.length, + ALL_ROOT_GUIDS.length, + "Root folder should have the expected number of children" + ); +}); diff --git a/toolkit/components/places/tests/unit/test_missing_root_folder.js b/toolkit/components/places/tests/unit/test_missing_root_folder.js new file mode 100644 index 0000000000..f6f1bb19ef --- /dev/null +++ b/toolkit/components/places/tests/unit/test_missing_root_folder.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This file tests that a missing root folder is correctly fixed when the + * database is loaded. + */ + +const ALL_ROOT_GUIDS = [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.tagsGuid, + PlacesUtils.bookmarks.mobileGuid, +]; + +add_task(async function setup() { + // This file has no root folder. + await setupPlacesDatabase("noRoot.sqlite"); + + // Check database contents to be migrated. + let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME); + let db = await Sqlite.openConnection({ path }); + + let rows = await db.execute( + ` + SELECT guid FROM moz_bookmarks + WHERE guid = :guid + `, + { + guid: PlacesUtils.bookmarks.rootGuid, + } + ); + + Assert.equal(rows.length, 0, "Root folder should not exist"); + + await db.close(); +}); + +add_task(async function test_database_recreates_roots() { + Assert.ok( + PlacesUtils.history.databaseStatus == + PlacesUtils.history.DATABASE_STATUS_OK || + PlacesUtils.history.databaseStatus == + PlacesUtils.history.DATABASE_STATUS_UPGRADED, + "Should successfully access the database for the first time" + ); + + let db = await PlacesUtils.promiseDBConnection(); + + let rows = await db.execute( + ` + SELECT id, parent, type FROM moz_bookmarks + WHERE guid = :guid + `, + { guid: PlacesUtils.bookmarks.rootGuid } + ); + + Assert.equal(rows.length, 1, "Should have added exactly one root"); + Assert.greaterOrEqual( + rows[0].getResultByName("id"), + 1, + "Should have a valid root Id" + ); + Assert.equal( + rows[0].getResultByName("parent"), + 0, + "Should have a parent of id 0" + ); + Assert.equal( + rows[0].getResultByName("type"), + PlacesUtils.bookmarks.TYPE_FOLDER, + "Should have a type of folder" + ); + + let id = rows[0].getResultByName("id"); + Assert.equal( + await PlacesUtils.promiseItemId(PlacesUtils.bookmarks.rootGuid), + id, + "Should return the correct id from promiseItemId" + ); + Assert.equal( + await PlacesUtils.promiseItemGuid(id), + PlacesUtils.bookmarks.rootGuid, + "Should return the correct guid from promiseItemGuid" + ); + + // Note: Currently we do not fix the parent of the folders on initial startup. + // There is a maintenance task that will do it, hence we don't check the parents + // here, just that the built-in folders correctly exist and haven't been + // duplicated. + for (let guid of ALL_ROOT_GUIDS) { + rows = await db.execute( + ` + SELECT id FROM moz_bookmarks + WHERE guid = :guid + `, + { guid } + ); + + Assert.equal(rows.length, 1, "Should have exactly one row for the root"); + + let root = await PlacesUtils.bookmarks.fetch(guid); + + Assert.equal(root.guid, guid, "GUIDs should match"); + } +}); diff --git a/toolkit/components/places/tests/unit/test_multi_observation.js b/toolkit/components/places/tests/unit/test_multi_observation.js new file mode 100644 index 0000000000..d56eabf8f1 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_multi_observation.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test whether registered listener capture proper events. +// We test the following combinations. +// * Listener: listen to single/multi event(s) +// * Event: fire single/multi type of event(s) +// * Timing: fire event(s) at same time/separately +// And also test notifying empty events. + +add_task(async () => { + info("Test for listening to single event and firing single event"); + + const observer = startObservation(["page-visited"]); + + await PlacesUtils.history.insertMany([ + { + title: "test", + url: "http://example.com/test", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/test", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); +}); + +add_task(async () => { + info("Test for listening to multi events with firing single event"); + + const observer = startObservation(["page-visited", "page-title-changed"]); + + await PlacesUtils.history.insertMany([ + { + title: "test", + url: "http://example.com/test", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/test", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); +}); + +add_task(async () => { + info( + "Test for listening to single event with firing multi events at same time" + ); + + const vistedObserver = startObservation(["page-visited"]); + const titleChangedObserver = startObservation(["page-title-changed"]); + + await PlacesUtils.history.insertMany([ + { + title: "will change", + url: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + { + title: "changed", + url: "http://example.com/title", + referrer: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + { + title: "another", + url: "http://example.com/another", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedVisitedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/title", + }, + { + type: "page-visited", + url: "http://example.com/title", + }, + { + type: "page-visited", + url: "http://example.com/another", + }, + ], + ]; + assertFiredEvents(vistedObserver.firedEvents, expectedVisitedFiredEvents); + + const expectedTitleChangedFiredEvents = [ + [ + { + type: "page-title-changed", + url: "http://example.com/title", + title: "changed", + }, + ], + ]; + assertFiredEvents( + titleChangedObserver.firedEvents, + expectedTitleChangedFiredEvents + ); + + await PlacesUtils.history.clear(); +}); + +add_task(async () => { + info( + "Test for listening to single event with firing multi events separately" + ); + + const observer = startObservation(["page-visited"]); + + await PlacesUtils.history.insertMany([ + { + title: "test", + url: "http://example.com/test", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + + await PlacesUtils.history.insertMany([ + { + title: "another", + url: "http://example.com/another", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/test", + }, + ], + [ + { + type: "page-visited", + url: "http://example.com/another", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.remove(bookmark.guid); +}); + +add_task(async () => { + info("Test for listening to multi events with firing single event"); + + const observer = startObservation([ + "page-visited", + "page-title-changed", + "bookmark-added", + ]); + + await PlacesUtils.history.insertMany([ + { + title: "test", + url: "http://example.com/test", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/test", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); +}); + +add_task(async () => { + info( + "Test for listening to multi events with firing multi events at same time" + ); + + const observer = startObservation([ + "page-visited", + "page-title-changed", + "bookmark-added", + ]); + + await PlacesUtils.history.insertMany([ + { + title: "will change", + url: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + { + title: "changed", + url: "http://example.com/title", + referrer: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + { + title: "another", + url: "http://example.com/another", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/title", + }, + { + type: "page-visited", + url: "http://example.com/title", + }, + { + type: "page-title-changed", + url: "http://example.com/title", + title: "changed", + }, + { + type: "page-visited", + url: "http://example.com/another", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); +}); + +add_task(async () => { + info( + "Test for listening to multi events with firing multi events separately" + ); + + const observer = startObservation([ + "page-visited", + "page-title-changed", + "bookmark-added", + ]); + + await PlacesUtils.history.insertMany([ + { + title: "will change", + url: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "a folder", + }); + + await PlacesUtils.history.insertMany([ + { + title: "changed", + url: "http://example.com/title", + referrer: "http://example.com/title", + visits: [{ transition: TRANSITION_LINK }], + }, + ]); + + const expectedFiredEvents = [ + [ + { + type: "page-visited", + url: "http://example.com/title", + }, + ], + [ + { + type: "bookmark-added", + title: "a folder", + }, + ], + [ + { + type: "page-visited", + url: "http://example.com/title", + }, + { + type: "page-title-changed", + url: "http://example.com/title", + title: "changed", + }, + ], + ]; + assertFiredEvents(observer.firedEvents, expectedFiredEvents); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.remove(bookmark.guid); +}); + +add_task(async function test_empty_notifications_array() { + info("Test whether listener does not receive empty events"); + + if (AppConstants.DEBUG) { + info( + "Ignore this test since we added a MOZ_ASSERT for empty events in debug build" + ); + return; + } + + const observer = startObservation(["page-visited"]); + PlacesObservers.notifyListeners([]); + Assert.equal(observer.firedEvents.length, 0, "Listener does not receive any"); +}); + +function startObservation(targets) { + const observer = { + firedEvents: [], + handle(events) { + this.firedEvents.push(events); + }, + }; + + PlacesObservers.addListener(targets, observer.handle.bind(observer)); + + return observer; +} + +function assertFiredEvents(firedEvents, expectedFiredEvents) { + Assert.equal( + firedEvents.length, + expectedFiredEvents.length, + "Number events fired is correct" + ); + + for (let i = 0; i < firedEvents.length; i++) { + info(`Check firedEvents[${i}]`); + const events = firedEvents[i]; + const expectedEvents = expectedFiredEvents[i]; + assertEvents(events, expectedEvents); + } +} + +function assertEvents(events, expectedEvents) { + Assert.equal( + events.length, + expectedEvents.length, + "Number events is correct" + ); + + for (let i = 0; i < events.length; i++) { + info(`Check events[${i}]`); + const event = events[i]; + const expectedEvent = expectedEvents[i]; + + for (let field in expectedEvent) { + Assert.equal(event[field], expectedEvent[field], `${field} is correct`); + } + } +} diff --git a/toolkit/components/places/tests/unit/test_multi_word_tags.js b/toolkit/components/places/tests/unit/test_multi_word_tags.js new file mode 100644 index 0000000000..17a68cf972 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_multi_word_tags.js @@ -0,0 +1,147 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get history service +try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); +} catch (ex) { + do_throw("Could not get history service\n"); +} + +// Get tagging service +try { + var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].getService( + Ci.nsITaggingService + ); +} catch (ex) { + do_throw("Could not get tagging service\n"); +} + +add_task(async function run_test() { + var uri1 = uri("http://site.tld/1"); + var uri2 = uri("http://site.tld/2"); + var uri3 = uri("http://site.tld/3"); + var uri4 = uri("http://site.tld/4"); + var uri5 = uri("http://site.tld/5"); + var uri6 = uri("http://site.tld/6"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { url: uri1 }, + { url: uri2 }, + { url: uri3 }, + { url: uri4 }, + { url: uri5 }, + { url: uri6 }, + ], + }); + + tagssvc.tagURI(uri1, ["foo"]); + tagssvc.tagURI(uri2, ["bar"]); + tagssvc.tagURI(uri3, ["cheese"]); + tagssvc.tagURI(uri4, ["foo bar"]); + tagssvc.tagURI(uri5, ["bar cheese"]); + tagssvc.tagURI(uri6, ["foo bar cheese"]); + + // Search for "item", should get one result + var options = histsvc.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + var query = histsvc.getNewQuery(); + query.searchTerms = "foo"; + var result = histsvc.executeQuery(query, options); + var root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 3); + Assert.equal(root.getChild(0).uri, "http://site.tld/1"); + Assert.equal(root.getChild(1).uri, "http://site.tld/4"); + Assert.equal(root.getChild(2).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "bar"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 4); + Assert.equal(root.getChild(0).uri, "http://site.tld/2"); + Assert.equal(root.getChild(1).uri, "http://site.tld/4"); + Assert.equal(root.getChild(2).uri, "http://site.tld/5"); + Assert.equal(root.getChild(3).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "cheese"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 3); + Assert.equal(root.getChild(0).uri, "http://site.tld/3"); + Assert.equal(root.getChild(1).uri, "http://site.tld/5"); + Assert.equal(root.getChild(2).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "foo bar"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 2); + Assert.equal(root.getChild(0).uri, "http://site.tld/4"); + Assert.equal(root.getChild(1).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "bar foo"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 2); + Assert.equal(root.getChild(0).uri, "http://site.tld/4"); + Assert.equal(root.getChild(1).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "bar cheese"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 2); + Assert.equal(root.getChild(0).uri, "http://site.tld/5"); + Assert.equal(root.getChild(1).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "cheese bar"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 2); + Assert.equal(root.getChild(0).uri, "http://site.tld/5"); + Assert.equal(root.getChild(1).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "foo bar cheese"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + Assert.equal(root.getChild(0).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "cheese foo bar"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + Assert.equal(root.getChild(0).uri, "http://site.tld/6"); + root.containerOpen = false; + + query.searchTerms = "cheese bar foo"; + result = histsvc.executeQuery(query, options); + root = result.root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + Assert.equal(root.getChild(0).uri, "http://site.tld/6"); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_nested_notifications.js b/toolkit/components/places/tests/unit/test_nested_notifications.js new file mode 100644 index 0000000000..cf1e4b77fa --- /dev/null +++ b/toolkit/components/places/tests/unit/test_nested_notifications.js @@ -0,0 +1,178 @@ +/* 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/. */ + +/** + * Test for nested notifications of Places events. + * In this test, we check behavior of listeners of Places event upon firing nested + * notification from inside of listener that received notifications. + */ +add_task(async function () { + // We prepare 6 listeners for the test. + // 1. Listener that added before root notification. + const addRoot = new Observer(); + // 2. Listener that added before root notification + // but removed before first nest notification. + const addRootRemoveFirst = new Observer(); + // 3. Listener that added before root notification + // but removed before second nest notification. + const addRootRemoveSecond = new Observer(); + // 4. Listener that added before first nest notification. + const addFirst = new Observer(); + // 5. Listener that added before first nest notification + // but removed before second nest notification. + const addFirstRemoveSecond = new Observer(); + // 6. Listener that added before second nest notification. + const addSecond = new Observer(); + + // This is a listener listened the root notification + // and do what we have to do for test in the first nest. + const firstNestOperator = () => { + info("Start to operate at first nest"); + + // Remove itself to avoid listening more. + removePlacesListener(firstNestOperator); + + info("Add/Remove test listeners at first nest"); + removePlacesListener(addRootRemoveFirst.handle); + addPlacesListener(addFirst.handle); + addPlacesListener(addFirstRemoveSecond.handle); + + // Add second nest operator. + addPlacesListener(secondNestOperator); + + info("Send notification at first nest"); + notifyPlacesEvent("first"); + }; + + // This is a listener listened the first nest notification + // and do what we have to do for test in the second nest. + const secondNestOperator = () => { + info("Start to operate at second nest"); + + // Remove itself to avoid listening more. + removePlacesListener(secondNestOperator); + + info("Add/Remove test listeners at second nest"); + removePlacesListener(addRootRemoveSecond.handle); + removePlacesListener(addFirstRemoveSecond.handle); + addPlacesListener(addSecond.handle); + + info("Send notification at second nest"); + notifyPlacesEvent("second"); + }; + + info("Add test listeners that handle notification sent at root"); + addPlacesListener(addRoot.handle); + addPlacesListener(addRootRemoveFirst.handle); + addPlacesListener(addRootRemoveSecond.handle); + + // Add first nest operator. + addPlacesListener(firstNestOperator); + + info("Send notification at root"); + notifyPlacesEvent("root"); + + info("Check whether or not test listeners could get expected notifications"); + assertNotifications(addRoot.notifications, [ + [{ guid: "root" }], + [{ guid: "first" }], + [{ guid: "second" }], + ]); + assertNotifications(addRootRemoveFirst.notifications, [[{ guid: "root" }]]); + assertNotifications(addRootRemoveSecond.notifications, [ + [{ guid: "root" }], + [{ guid: "first" }], + ]); + assertNotifications(addFirst.notifications, [ + [{ guid: "first" }], + [{ guid: "second" }], + ]); + assertNotifications(addFirstRemoveSecond.notifications, [ + [{ guid: "first" }], + ]); + assertNotifications(addSecond.notifications, [[{ guid: "second" }]]); +}); + +function addPlacesListener(listener) { + PlacesObservers.addListener(["bookmark-added"], listener); +} + +function removePlacesListener(listener) { + PlacesObservers.removeListener(["bookmark-added"], listener); +} + +function notifyPlacesEvent(guid) { + PlacesObservers.notifyListeners([ + new PlacesBookmarkAddition({ + dateAdded: 0, + guid, + id: -1, + index: 0, + isTagging: false, + itemType: 1, + parentGuid: "fake", + parentId: -2, + source: 0, + title: guid, + url: `http://example.com/${guid}`, + }), + ]); +} + +class Observer { + constructor() { + this.notifications = []; + this.handle = this.handle.bind(this); + } + + handle(events) { + this.notifications.push(events); + } +} + +/** + * Assert notifications the observer received. + * + * @param Array - notifications + * @param Array - expectedNotifications + */ +function assertNotifications(notifications, expectedNotifications) { + Assert.equal( + notifications.length, + expectedNotifications.length, + "Number of notifications is correct" + ); + + for (let i = 0; i < notifications.length; i++) { + info(`Check notifications[${i}]`); + const placesEvents = notifications[i]; + const expectedPlacesEvents = expectedNotifications[i]; + assertPlacesEvents(placesEvents, expectedPlacesEvents); + } +} + +/** + * Assert Places events. + * This function checks given expected event field only. + * + * @param Array - events + * @param Array - expectedEvents + */ +function assertPlacesEvents(events, expectedEvents) { + Assert.equal( + events.length, + expectedEvents.length, + "Number of Places events is correct" + ); + + for (let i = 0; i < events.length; i++) { + info(`Check Places events[${i}]`); + const event = events[i]; + const expectedEvent = expectedEvents[i]; + + for (let field in expectedEvent) { + Assert.equal(event[field], expectedEvent[field], `${field} is correct`); + } + } +} diff --git a/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js b/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js new file mode 100644 index 0000000000..8c061c60d7 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js @@ -0,0 +1,285 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +var resultObserver = { + insertedNode: null, + nodeInserted(parent, node, newIndex) { + this.insertedNode = node; + }, + removedNode: null, + nodeRemoved(parent, node, oldIndex) { + this.removedNode = node; + }, + + newTitle: "", + nodeChangedByTitle: null, + nodeTitleChanged(node, newTitle) { + this.nodeChangedByTitle = node; + this.newTitle = newTitle; + }, + + newAccessCount: 0, + newTime: 0, + nodeChangedByHistoryDetails: null, + nodeHistoryDetailsChanged(node, oldVisitDate, oldVisitCount) { + this.nodeChangedByHistoryDetails = node; + this.newTime = node.time; + this.newAccessCount = node.accessCount; + }, + + movedNode: null, + nodeMoved(node, oldParent, oldIndex, newParent, newIndex) { + this.movedNode = node; + }, + openedContainer: null, + closedContainer: null, + containerStateChanged(aNode, aOldState, aNewState) { + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + this.openedContainer = aNode; + } else if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED) { + this.closedContainer = aNode; + } + }, + invalidatedContainer: null, + invalidateContainer(node) { + this.invalidatedContainer = node; + }, + sortingMode: null, + sortingChanged(sortingMode) { + this.sortingMode = sortingMode; + }, + inBatchMode: false, + batchingCallCount: 0, + batching(aToggleMode) { + Assert.notEqual(this.inBatchMode, aToggleMode); + this.inBatchMode = aToggleMode; + this.batchingCallCount++; + }, + result: null, + reset() { + this.insertedNode = null; + this.removedNode = null; + this.nodeChangedByTitle = null; + this.nodeChangedByHistoryDetails = null; + this.replacedNode = null; + this.movedNode = null; + this.openedContainer = null; + this.closedContainer = null; + this.invalidatedContainer = null; + this.sortingMode = null; + this.inBatchMode = false; + this.batchingCallCount = 0; + }, +}; + +var testURI = uri("http://mozilla.com"); + +add_task(async function check_history_query() { + let options = PlacesUtils.history.getNewQueryOptions(); + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_VISIT; + let query = PlacesUtils.history.getNewQuery(); + let result = PlacesUtils.history.executeQuery(query, options); + result.addObserver(resultObserver); + let root = result.root; + root.containerOpen = true; + + Assert.notEqual(resultObserver.openedContainer, null); + + // nsINavHistoryResultObserver.nodeInserted + // add a visit + await PlacesTestUtils.addVisits(testURI); + Assert.equal(testURI.spec, resultObserver.insertedNode.uri); + + // nsINavHistoryResultObserver.nodeHistoryDetailsChanged + // adding a visit causes nodeHistoryDetailsChanged for the folder + Assert.equal(root.uri, resultObserver.nodeChangedByHistoryDetails.uri); + + // nsINavHistoryResultObserver.itemTitleChanged for a leaf node + await PlacesTestUtils.addVisits({ uri: testURI, title: "baz" }); + Assert.equal(resultObserver.nodeChangedByTitle.title, "baz"); + + // nsINavHistoryResultObserver.nodeRemoved + let removedURI = uri("http://google.com"); + await PlacesTestUtils.addVisits(removedURI); + await PlacesUtils.history.remove(removedURI); + Assert.equal(removedURI.spec, resultObserver.removedNode.uri); + + // nsINavHistoryResultObserver.invalidateContainer + await PlacesUtils.history.removeByFilter({ host: "mozilla.com" }); + // This test is disabled for bug 1089691. It is failing bcause the new API + // doesn't send batching notifications and thus the result doesn't invalidate + // the whole container. + // Assert.equal(root.uri, resultObserver.invalidatedContainer.uri); + + // nsINavHistoryResultObserver.sortingChanged + resultObserver.invalidatedContainer = null; + result.sortingMode = options.SORT_BY_TITLE_ASCENDING; + Assert.equal(resultObserver.sortingMode, options.SORT_BY_TITLE_ASCENDING); + Assert.equal(resultObserver.invalidatedContainer, result.root); + + // nsINavHistoryResultObserver.invalidateContainer + await PlacesUtils.history.clear(); + Assert.equal(root.uri, resultObserver.invalidatedContainer.uri); + + root.containerOpen = false; + Assert.equal(resultObserver.closedContainer, resultObserver.openedContainer); + result.removeObserver(resultObserver); + resultObserver.reset(); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function check_bookmarks_query() { + let options = PlacesUtils.history.getNewQueryOptions(); + let query = PlacesUtils.history.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.menuGuid]); + let result = PlacesUtils.history.executeQuery(query, options); + result.addObserver(resultObserver); + let root = result.root; + root.containerOpen = true; + + Assert.notEqual(resultObserver.openedContainer, null); + + // nsINavHistoryResultObserver.nodeInserted + // add a bookmark + let testBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: testURI, + title: "foo", + }); + Assert.equal("foo", resultObserver.insertedNode.title); + Assert.equal(testURI.spec, resultObserver.insertedNode.uri); + + // nsINavHistoryResultObserver.nodeHistoryDetailsChanged + // adding a visit causes nodeHistoryDetailsChanged for the folder + Assert.equal(root.uri, resultObserver.nodeChangedByHistoryDetails.uri); + + // nsINavHistoryResultObserver.nodeTitleChanged for a leaf node + await PlacesUtils.bookmarks.update({ + guid: testBookmark.guid, + title: "baz", + }); + Assert.equal(resultObserver.nodeChangedByTitle.title, "baz"); + Assert.equal(resultObserver.newTitle, "baz"); + + let testBookmark2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://google.com", + title: "foo", + }); + + await PlacesUtils.bookmarks.update({ + guid: testBookmark2.guid, + index: 0, + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + Assert.equal(resultObserver.movedNode.bookmarkGuid, testBookmark2.guid); + + // nsINavHistoryResultObserver.nodeRemoved + await PlacesUtils.bookmarks.remove(testBookmark2.guid); + Assert.equal(testBookmark2.guid, resultObserver.removedNode.bookmarkGuid); + + // XXX nsINavHistoryResultObserver.invalidateContainer + + // nsINavHistoryResultObserver.sortingChanged + resultObserver.invalidatedContainer = null; + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING; + Assert.equal( + resultObserver.sortingMode, + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING + ); + Assert.equal(resultObserver.invalidatedContainer, result.root); + + root.containerOpen = false; + Assert.equal(resultObserver.closedContainer, resultObserver.openedContainer); + result.removeObserver(resultObserver); + resultObserver.reset(); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function check_mixed_query() { + let options = PlacesUtils.history.getNewQueryOptions(); + let query = PlacesUtils.history.getNewQuery(); + query.onlyBookmarked = true; + let result = PlacesUtils.history.executeQuery(query, options); + result.addObserver(resultObserver); + let root = result.root; + root.containerOpen = true; + + Assert.notEqual(resultObserver.openedContainer, null); + + root.containerOpen = false; + Assert.equal(resultObserver.closedContainer, resultObserver.openedContainer); + result.removeObserver(resultObserver); + resultObserver.reset(); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function check_a_batch_process() { + const options = PlacesUtils.history.getNewQueryOptions(); + const query = PlacesUtils.history.getNewQuery(); + const result = PlacesUtils.history.executeQuery(query, options); + result.addObserver(resultObserver); + + info("Check initial state"); + Assert.equal(resultObserver.inBatchMode, false); + Assert.equal(resultObserver.batchingCallCount, 0); + + info("Check whether batching is called when call onBeginUpdateBatch"); + result.onBeginUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, true); + Assert.equal(resultObserver.batchingCallCount, 1); + + info("Check whether batching is called when call onEndUpdateBatch"); + result.onEndUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, false); + Assert.equal(resultObserver.batchingCallCount, 2); + + result.removeObserver(resultObserver); + resultObserver.reset(); + await PlacesTestUtils.promiseAsyncUpdates(); +}); + +add_task(async function check_multi_batch_processes() { + const options = PlacesUtils.history.getNewQueryOptions(); + const query = PlacesUtils.history.getNewQuery(); + const result = PlacesUtils.history.executeQuery(query, options); + result.addObserver(resultObserver); + + info("Check initial state"); + Assert.equal(resultObserver.inBatchMode, false); + Assert.equal(resultObserver.batchingCallCount, 0); + + info("Check whether batching is called when calling onBeginUpdateBatch"); + result.onBeginUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, true); + Assert.equal(resultObserver.batchingCallCount, 1); + + info( + "Check whether batching is not called when calling onBeginUpdateBatch again" + ); + result.onBeginUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, true); + Assert.equal(resultObserver.batchingCallCount, 1); + + info( + "Check whether batching is not called until calling onEndUpdateBatch the same number times that onBeginUpdateBatch is called" + ); + result.onEndUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, true); + Assert.equal(resultObserver.batchingCallCount, 1); + + info( + "Check whether batching is called when calling onEndUpdateBatch the same number times" + ); + result.onEndUpdateBatch(); + Assert.equal(resultObserver.inBatchMode, false); + Assert.equal(resultObserver.batchingCallCount, 2); + + result.removeObserver(resultObserver); + resultObserver.reset(); + await PlacesTestUtils.promiseAsyncUpdates(); +}); diff --git a/toolkit/components/places/tests/unit/test_null_interfaces.js b/toolkit/components/places/tests/unit/test_null_interfaces.js new file mode 100644 index 0000000000..f5608285d6 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_null_interfaces.js @@ -0,0 +1,105 @@ +/* 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/. */ + +/** + * Test bug 489872 to make sure passing nulls to nsNavHistory doesn't crash. + */ + +// Make an array of services to test, each specifying a class id, interface +// and an array of function names that don't throw when passed nulls +var testServices = [ + [ + "browser/nav-history-service;1", + ["nsINavHistoryService"], + [ + "queryStringToQuery", + "removePagesByTimeframe", + "removePagesFromHost", + "getObservers", + ], + ], + [ + "browser/nav-bookmarks-service;1", + ["nsINavBookmarksService"], + ["createFolder", "getObservers"], + ], + ["browser/favicon-service;1", ["nsIFaviconService"], []], + ["browser/tagging-service;1", ["nsITaggingService"], []], +]; +info(testServices.join("\n")); + +function run_test() { + for (let [cid, ifaces, nothrow] of testServices) { + info(`Running test with ${cid} ${ifaces.join(", ")} ${nothrow}`); + let s = Cc["@mozilla.org/" + cid].getService(Ci.nsISupports); + for (let iface of ifaces) { + s.QueryInterface(Ci[iface]); + } + + let okName = function (name) { + info(`Checking if function is okay to test: ${name}`); + let func = s[name]; + + let mesg = ""; + if (typeof func != "function") { + mesg = "Not a function!"; + } else if (!func.length) { + mesg = "No args needed!"; + } else if (name == "QueryInterface") { + mesg = "Ignore QI!"; + } + + if (mesg) { + info(`${mesg} Skipping: ${name}`); + return false; + } + + return true; + }; + + info(`Generating an array of functions to test service: ${s}`); + for (let n of Object.keys(s) + .filter(i => okName(i)) + .sort()) { + info(`\nTesting ${ifaces.join(", ")} function with null args: ${n}`); + + let func = s[n]; + let num = func.length; + info(`Generating array of nulls for #args: ${num}`); + let args = Array(num).fill(null); + + let tryAgain = true; + while (tryAgain) { + try { + info(`Calling with args: ${JSON.stringify(args)}`); + func.apply(s, args); + + info( + `The function did not throw! Is it one of the nothrow? ${nothrow}` + ); + Assert.notEqual(nothrow.indexOf(n), -1); + + info("Must have been an expected nothrow, so no need to try again"); + tryAgain = false; + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + info(`Caught an expected exception: ${ex.name}`); + info("Moving on to the next test.."); + tryAgain = false; + } else if (ex.result == Cr.NS_ERROR_XPC_NEED_OUT_OBJECT) { + let pos = Number(ex.message.match(/object arg (\d+)/)[1]); + info(`Function call expects an out object at ${pos}`); + args[pos] = {}; + } else if (ex.result == Cr.NS_ERROR_NOT_IMPLEMENTED) { + info(`Method not implemented exception: ${ex.name}`); + info("Moving on to the next test.."); + tryAgain = false; + } else { + throw ex; + } + } + } + } + } +} diff --git a/toolkit/components/places/tests/unit/test_origins.js b/toolkit/components/places/tests/unit/test_origins.js new file mode 100644 index 0000000000..4e2d0e5fa6 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_origins.js @@ -0,0 +1,1122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Makes sure the moz_origins table and origin frecency stats are updated +// correctly. + +"use strict"; + +// Visiting a URL with a new origin should immediately update moz_origins. +add_task(async function visit() { + await checkDB([]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Repeatedly visiting a URL with an initially new origin should update +// moz_origins (with the correct frecency). +add_task(async function visitRepeatedly() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://example.com/" }, + { uri: "http://example.com/" }, + ]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Same as previous, but visits are added sequentially. +add_task(async function visitRepeatedlySequential() { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// After removing an origin's URLs, visiting a URL with the origin should +// immediately update moz_origins. +add_task(async function vistAfterDelete() { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Visiting different URLs with the same origin should update moz_origins, and +// moz_origins.frecency should be the sum of the URL frecencies. +add_task(async function visitDifferentURLsSameOrigin() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/1" }, + { uri: "http://example.com/2" }, + { uri: "http://example.com/3" }, + ]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/1", "http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/1"); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/2"); + await checkDB([["http://", "example.com", ["http://example.com/3"]]]); + await PlacesUtils.history.remove("http://example.com/3"); + await checkDB([]); + await cleanUp(); +}); + +// Same as previous, but visits are added sequentially. +add_task(async function visitDifferentURLsSameOriginSequential() { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/1" }]); + await checkDB([["http://", "example.com", ["http://example.com/1"]]]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/2" }]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/1", "http://example.com/2"], + ], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example.com/3" }]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/1", "http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/1"); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/2"); + await checkDB([["http://", "example.com", ["http://example.com/3"]]]); + await PlacesUtils.history.remove("http://example.com/3"); + await checkDB([]); + await cleanUp(); +}); + +// Repeatedly visiting different URLs with the same origin should update +// moz_origins (with the correct frecencies), and moz_origins.frecency should be +// the sum of the URL frecencies. +add_task(async function visitDifferentURLsSameOriginRepeatedly() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/1" }, + { uri: "http://example.com/1" }, + { uri: "http://example.com/1" }, + { uri: "http://example.com/2" }, + { uri: "http://example.com/2" }, + { uri: "http://example.com/3" }, + ]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/1", "http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/1"); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/2", "http://example.com/3"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/2"); + await checkDB([["http://", "example.com", ["http://example.com/3"]]]); + await PlacesUtils.history.remove("http://example.com/3"); + await checkDB([]); + await cleanUp(); +}); + +// Visiting URLs with different origins should update moz_origins. +add_task(async function visitDifferentOrigins() { + await PlacesTestUtils.addVisits([ + { uri: "http://example1.com/" }, + { uri: "http://example2.com/" }, + { uri: "http://example3.com/" }, + ]); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/"]], + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example1.com/"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example2.com/"); + await checkDB([["http://", "example3.com", ["http://example3.com/"]]]); + await PlacesUtils.history.remove("http://example3.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Same as previous, but visits are added sequentially. +add_task(async function visitDifferentOriginsSequential() { + await PlacesTestUtils.addVisits([{ uri: "http://example1.com/" }]); + await checkDB([["http://", "example1.com", ["http://example1.com/"]]]); + await PlacesTestUtils.addVisits([{ uri: "http://example2.com/" }]); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/"]], + ["http://", "example2.com", ["http://example2.com/"]], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example3.com/" }]); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/"]], + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example1.com/"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example2.com/"); + await checkDB([["http://", "example3.com", ["http://example3.com/"]]]); + await PlacesUtils.history.remove("http://example3.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Repeatedly visiting URLs with different origins should update moz_origins +// (with the correct frecencies). +add_task(async function visitDifferentOriginsRepeatedly() { + await PlacesTestUtils.addVisits([ + { uri: "http://example1.com/" }, + { uri: "http://example1.com/" }, + { uri: "http://example1.com/" }, + { uri: "http://example2.com/" }, + { uri: "http://example2.com/" }, + { uri: "http://example3.com/" }, + ]); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/"]], + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example1.com/"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/"]], + ["http://", "example3.com", ["http://example3.com/"]], + ]); + await PlacesUtils.history.remove("http://example2.com/"); + await checkDB([["http://", "example3.com", ["http://example3.com/"]]]); + await PlacesUtils.history.remove("http://example3.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Visiting URLs, some with the same and some with different origins, should +// update moz_origins. +add_task(async function visitDifferentOriginsDifferentURLs() { + await PlacesTestUtils.addVisits([ + { uri: "http://example1.com/1" }, + { uri: "http://example1.com/2" }, + { uri: "http://example1.com/3" }, + { uri: "http://example2.com/1" }, + { uri: "http://example2.com/2" }, + { uri: "http://example3.com/1" }, + ]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/1"); + await checkDB([ + [ + "http://", + "example1.com", + ["http://example1.com/2", "http://example1.com/3"], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/2"); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/3"]], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/3"); + await checkDB([ + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/1"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/2"]], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/2"); + await checkDB([["http://", "example3.com", ["http://example3.com/1"]]]); + await PlacesUtils.history.remove("http://example3.com/1"); + await checkDB([]); +}); + +// Same as previous, but visits are added sequentially. +add_task(async function visitDifferentOriginsDifferentURLsSequential() { + await PlacesTestUtils.addVisits([{ uri: "http://example1.com/1" }]); + await checkDB([["http://", "example1.com", ["http://example1.com/1"]]]); + await PlacesTestUtils.addVisits([{ uri: "http://example1.com/2" }]); + await checkDB([ + [ + "http://", + "example1.com", + ["http://example1.com/1", "http://example1.com/2"], + ], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example1.com/3" }]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example2.com/1" }]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + ["http://", "example2.com", ["http://example2.com/1"]], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example2.com/2" }]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ]); + await PlacesTestUtils.addVisits([{ uri: "http://example3.com/1" }]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/1"); + await checkDB([ + [ + "http://", + "example1.com", + ["http://example1.com/2", "http://example1.com/3"], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/2"); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/3"]], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/3"); + await checkDB([ + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/1"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/2"]], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/2"); + await checkDB([["http://", "example3.com", ["http://example3.com/1"]]]); + await PlacesUtils.history.remove("http://example3.com/1"); + await checkDB([]); +}); + +// Repeatedly visiting URLs, some with the same and some with different origins, +// should update moz_origins (with the correct frecencies). +add_task(async function visitDifferentOriginsDifferentURLsRepeatedly() { + await PlacesTestUtils.addVisits([ + { uri: "http://example1.com/1" }, + { uri: "http://example1.com/1" }, + { uri: "http://example1.com/1" }, + { uri: "http://example1.com/2" }, + { uri: "http://example1.com/2" }, + { uri: "http://example1.com/3" }, + { uri: "http://example2.com/1" }, + { uri: "http://example2.com/1" }, + { uri: "http://example2.com/1" }, + { uri: "http://example2.com/2" }, + { uri: "http://example2.com/2" }, + { uri: "http://example3.com/1" }, + { uri: "http://example3.com/1" }, + ]); + await checkDB([ + [ + "http://", + "example1.com", + [ + "http://example1.com/1", + "http://example1.com/2", + "http://example1.com/3", + ], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/1"); + await checkDB([ + [ + "http://", + "example1.com", + ["http://example1.com/2", "http://example1.com/3"], + ], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/2"); + await checkDB([ + ["http://", "example1.com", ["http://example1.com/3"]], + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example1.com/3"); + await checkDB([ + [ + "http://", + "example2.com", + ["http://example2.com/1", "http://example2.com/2"], + ], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/1"); + await checkDB([ + ["http://", "example2.com", ["http://example2.com/2"]], + ["http://", "example3.com", ["http://example3.com/1"]], + ]); + await PlacesUtils.history.remove("http://example2.com/2"); + await checkDB([["http://", "example3.com", ["http://example3.com/1"]]]); + await PlacesUtils.history.remove("http://example3.com/1"); + await checkDB([]); +}); + +// Makes sure URIs with the same TLD but different www subdomains are recognized +// as different origins. Makes sure removing one doesn't remove the others. +add_task(async function www1() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://www.example.com/" }, + { uri: "http://www.www.example.com/" }, + ]); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "www.example.com", ["http://www.example.com/"]], + ["http://", "www.www.example.com", ["http://www.www.example.com/"]], + ]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([ + ["http://", "www.example.com", ["http://www.example.com/"]], + ["http://", "www.www.example.com", ["http://www.www.example.com/"]], + ]); + await PlacesUtils.history.remove("http://www.example.com/"); + await checkDB([ + ["http://", "www.www.example.com", ["http://www.www.example.com/"]], + ]); + await PlacesUtils.history.remove("http://www.www.example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Same as www1, but removes URIs in a different order. +add_task(async function www2() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://www.example.com/" }, + { uri: "http://www.www.example.com/" }, + ]); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "www.example.com", ["http://www.example.com/"]], + ["http://", "www.www.example.com", ["http://www.www.example.com/"]], + ]); + await PlacesUtils.history.remove("http://www.www.example.com/"); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "www.example.com", ["http://www.example.com/"]], + ]); + await PlacesUtils.history.remove("http://www.example.com/"); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Makes sure removing an origin without a port doesn't remove the same host +// with a port. +add_task(async function ports1() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://example.com:8888/" }, + ]); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "example.com:8888", ["http://example.com:8888/"]], + ]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([ + ["http://", "example.com:8888", ["http://example.com:8888/"]], + ]); + await PlacesUtils.history.remove("http://example.com:8888/"); + await checkDB([]); + await cleanUp(); +}); + +// Makes sure removing an origin with a port doesn't remove the same host +// without a port. +add_task(async function ports2() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://example.com:8888/" }, + ]); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "example.com:8888", ["http://example.com:8888/"]], + ]); + await PlacesUtils.history.remove("http://example.com:8888/"); + await checkDB([["http://", "example.com", ["http://example.com/"]]]); + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([]); + await cleanUp(); +}); + +// Makes sure multiple URIs with the same origin don't create duplicate origins. +add_task(async function duplicates() { + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://www.example.com/" }, + { uri: "http://www.www.example.com/" }, + { uri: "https://example.com/" }, + { uri: "ftp://example.com/" }, + { uri: "foo://example.com/" }, + { uri: "bar:example.com/" }, + { uri: "http://example.com:8888/" }, + + { uri: "http://example.com/dupe" }, + { uri: "http://www.example.com/dupe" }, + { uri: "http://www.www.example.com/dupe" }, + { uri: "https://example.com/dupe" }, + { uri: "ftp://example.com/dupe" }, + { uri: "foo://example.com/dupe" }, + { uri: "bar:example.com/dupe" }, + { uri: "http://example.com:8888/dupe" }, + ]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/", "http://example.com/dupe"], + ], + [ + "http://", + "www.example.com", + ["http://www.example.com/", "http://www.example.com/dupe"], + ], + [ + "http://", + "www.www.example.com", + ["http://www.www.example.com/", "http://www.www.example.com/dupe"], + ], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("http://example.com/"); + await checkDB([ + ["http://", "example.com", ["http://example.com/dupe"]], + [ + "http://", + "www.example.com", + ["http://www.example.com/", "http://www.example.com/dupe"], + ], + [ + "http://", + "www.www.example.com", + ["http://www.www.example.com/", "http://www.www.example.com/dupe"], + ], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("http://example.com/dupe"); + await checkDB([ + [ + "http://", + "www.example.com", + ["http://www.example.com/", "http://www.example.com/dupe"], + ], + [ + "http://", + "www.www.example.com", + ["http://www.www.example.com/", "http://www.www.example.com/dupe"], + ], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("http://www.example.com/"); + await checkDB([ + ["http://", "www.example.com", ["http://www.example.com/dupe"]], + [ + "http://", + "www.www.example.com", + ["http://www.www.example.com/", "http://www.www.example.com/dupe"], + ], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("http://www.example.com/dupe"); + await checkDB([ + [ + "http://", + "www.www.example.com", + ["http://www.www.example.com/", "http://www.www.example.com/dupe"], + ], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("http://www.www.example.com/"); + await checkDB([ + ["http://", "www.www.example.com", ["http://www.www.example.com/dupe"]], + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("http://www.www.example.com/dupe"); + await checkDB([ + [ + "https://", + "example.com", + ["https://example.com/", "https://example.com/dupe"], + ], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("https://example.com/"); + await checkDB([ + ["https://", "example.com", ["https://example.com/dupe"]], + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("https://example.com/dupe"); + await checkDB([ + ["ftp://", "example.com", ["ftp://example.com/", "ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("ftp://example.com/"); + await checkDB([ + ["ftp://", "example.com", ["ftp://example.com/dupe"]], + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("ftp://example.com/dupe"); + await checkDB([ + ["foo://", "example.com", ["foo://example.com/", "foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("foo://example.com/"); + await checkDB([ + ["foo://", "example.com", ["foo://example.com/dupe"]], + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("foo://example.com/dupe"); + await checkDB([ + ["bar:", "example.com", ["bar:example.com/", "bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("bar:example.com/"); + await checkDB([ + ["bar:", "example.com", ["bar:example.com/dupe"]], + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + await PlacesUtils.history.remove("bar:example.com/dupe"); + await checkDB([ + [ + "http://", + "example.com:8888", + ["http://example.com:8888/", "http://example.com:8888/dupe"], + ], + ]); + + await PlacesUtils.history.remove("http://example.com:8888/"); + await checkDB([ + ["http://", "example.com:8888", ["http://example.com:8888/dupe"]], + ]); + await PlacesUtils.history.remove("http://example.com:8888/dupe"); + await checkDB([]); + + await cleanUp(); +}); + +// Makes sure adding and removing bookmarks creates origins. +add_task(async function addRemoveBookmarks() { + let bookmarks = []; + let urls = ["http://example.com/", "http://www.example.com/"]; + for (let url of urls) { + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }) + ); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "www.example.com", ["http://www.example.com/"]], + ]); + await PlacesUtils.bookmarks.remove(bookmarks[0]); + await PlacesUtils.history.clear(); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([["http://", "www.example.com", ["http://www.example.com/"]]]); + await PlacesUtils.bookmarks.remove(bookmarks[1]); + await PlacesUtils.history.clear(); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([]); + await cleanUp(); +}); + +// Makes sure changing bookmarks also changes the corresponding origins. +add_task(async function changeBookmarks() { + let bookmarks = []; + let urls = ["http://example.com/", "http://www.example.com/"]; + for (let url of urls) { + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }) + ); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([ + ["http://", "example.com", ["http://example.com/"]], + ["http://", "www.example.com", ["http://www.example.com/"]], + ]); + await PlacesUtils.bookmarks.update({ + url: "http://www.example.com/", + guid: bookmarks[0].guid, + }); + await PlacesUtils.history.clear(); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([["http://", "www.example.com", ["http://www.example.com/"]]]); + await cleanUp(); +}); + +// A slightly more complex test to make sure origin frecency stats are updated +// when visits and bookmarks are added and removed. +add_task(async function moreOriginFrecencyStats() { + await checkDB([]); + + // Add a URL 0 visit. + await PlacesTestUtils.addVisits([{ uri: "http://example.com/0" }]); + await checkDB([["http://", "example.com", ["http://example.com/0"]]]); + + // Add a URL 1 visit. + await PlacesTestUtils.addVisits([{ uri: "http://example.com/1" }]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1"], + ], + ]); + + // Add a URL 2 visit. + await PlacesTestUtils.addVisits([{ uri: "http://example.com/2" }]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1", "http://example.com/2"], + ], + ]); + + // Add another URL 2 visit. + await PlacesTestUtils.addVisits([{ uri: "http://example.com/2" }]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1", "http://example.com/2"], + ], + ]); + + // Remove URL 2's visits. + await PlacesUtils.history.remove(["http://example.com/2"]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1"], + ], + ]); + + // Bookmark URL 1. + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "A bookmark", + url: NetUtil.newURI("http://example.com/1"), + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1"], + ], + ]); + + // Remove URL 1's visit. + await PlacesUtils.history.remove(["http://example.com/1"]); + await checkDB([ + [ + "http://", + "example.com", + ["http://example.com/0", "http://example.com/1"], + ], + ]); + + // Remove URL 1's bookmark. Also need to call history.remove() again to + // remove the URL from moz_places. Otherwise it sticks around and keeps + // contributing to the frecency stats. + await PlacesUtils.bookmarks.remove(bookmark); + await PlacesUtils.history.remove("http://example.com/1"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await checkDB([["http://", "example.com", ["http://example.com/0"]]]); + + // Remove URL 0. + await PlacesUtils.history.remove(["http://example.com/0"]); + await checkDB([]); + + await cleanUp(); +}); + +/** + * Returns the expected frecency of the origin of the given URLs, i.e., the sum + * of their frecencies. Each URL is expected to have the same origin. + * + * @param urls + * An array of URL strings. + * @return The expected origin frecency. + */ +async function expectedOriginFrecency(urls) { + let value = 0; + for (let url of urls) { + let v = Math.max( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { url }), + 0 + ); + value += v; + } + return value; +} + +/** + * Asserts that the moz_origins table and the origin frecency stats are correct. + * + * @param expectedOrigins + * An array of expected origins. Each origin in the array is itself an + * array that looks like this: + * [prefix, host, [url1, url2, ..., urln]] + * The element at index 2 is an array of all the URLs with the origin. + * If you don't care about checking frecencies and origin frecency stats, + * this element can be `undefined`. + */ +async function checkDB(expectedOrigins) { + // Frencencies for bookmarks are generated asynchronously but not within the + // await cycle for bookmarks.insert() etc, so wait for them to happen. + await PlacesTestUtils.promiseAsyncUpdates(); + + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT prefix, host, frecency + FROM moz_origins + ORDER BY id ASC + `); + let checkFrecencies = + !expectedOrigins.length || expectedOrigins[0][2] !== undefined; + let actualOrigins = rows.map(row => { + let o = []; + for (let c = 0; c < (checkFrecencies ? 3 : 2); c++) { + o.push(row.getResultByIndex(c)); + } + return o; + }); + let expected = []; + for (let origin of expectedOrigins) { + expected.push( + origin + .slice(0, 2) + .concat(checkFrecencies ? await expectedOriginFrecency(origin[2]) : []) + ); + } + Assert.deepEqual(actualOrigins, expected); + if (checkFrecencies) { + await checkStats(expected.map(o => o[2]).filter(o => o > 0)); + } +} + +/** + * Asserts that the origin frecency stats are correct. + * + * @param expectedOriginFrecencies + * An array of expected origin frecencies. + */ +async function checkStats(expectedOriginFrecencies) { + let stats = await promiseStats(); + Assert.equal(stats.count, expectedOriginFrecencies.length); + Assert.equal( + stats.sum, + expectedOriginFrecencies.reduce((sum, f) => sum + f, 0) + ); + Assert.equal( + stats.squares, + expectedOriginFrecencies.reduce((squares, f) => squares + f * f, 0) + ); +} + +/** + * Returns the origin frecency stats. + * + * @return An object: { count, sum, squares } + */ +async function promiseStats() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_count'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares'), 0) + `); + return { + count: rows[0].getResultByIndex(0), + sum: rows[0].getResultByIndex(1), + squares: rows[0].getResultByIndex(2), + }; +} + +async function cleanUp() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} diff --git a/toolkit/components/places/tests/unit/test_origins_parsing.js b/toolkit/components/places/tests/unit/test_origins_parsing.js new file mode 100644 index 0000000000..35ba8bdd0d --- /dev/null +++ b/toolkit/components/places/tests/unit/test_origins_parsing.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test is a companion to test_origins.js. It adds many URLs to the +// database and makes sure that their prefixes and hosts are correctly parsed. +// This test can take a while to run, which is why it's split out from +// test_origins.js. + +"use strict"; + +add_task(async function parsing() { + let prefixes = ["http://", "https://", "ftp://", "foo://", "bar:"]; + + let userinfos = ["", "user:pass@", "user:pass:word@", "user:@"]; + + let ports = ["", ":8888"]; + + let paths = [ + "", + + "/", + "/1", + "/1/2", + + "?", + "?1", + "#", + "#1", + + "/?", + "/1?", + "/?1", + "/1?2", + + "/#", + "/1#", + "/#1", + "/1#2", + + "/?#", + "/1?#", + "/?1#", + "/?#1", + "/1?2#", + "/1?#2", + "/?1#2", + ]; + + for (let userinfo of userinfos) { + for (let port of ports) { + for (let path of paths) { + info(`Testing userinfo='${userinfo}' port='${port}' path='${path}'`); + let expectedOrigins = prefixes.map(prefix => [ + prefix, + "example.com" + port, + ]); + let uris = expectedOrigins.map( + ([prefix, hostPort]) => prefix + userinfo + hostPort + path + ); + + await PlacesTestUtils.addVisits(uris.map(uri => ({ uri }))); + await checkDB(expectedOrigins); + + // Remove each URI, one at a time, and make sure the remaining origins + // in the database are correct. + for (let i = 0; i < uris.length; i++) { + await PlacesUtils.history.remove(uris[i]); + await checkDB(expectedOrigins.slice(i + 1, expectedOrigins.length)); + } + await cleanUp(); + } + } + } + await checkDB([]); +}); + +/** + * Asserts that the moz_origins table is correct. + * + * @param expectedOrigins + * An array of expected origins. Each origin in the array is itself an + * array that looks like this: [prefix, host] + */ +async function checkDB(expectedOrigins) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT prefix, host + FROM moz_origins + ORDER BY id ASC + `); + let actualOrigins = rows.map(row => { + let o = []; + for (let c = 0; c < 2; c++) { + o.push(row.getResultByIndex(c)); + } + return o; + }); + Assert.deepEqual(actualOrigins, expectedOrigins); +} + +async function cleanUp() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} diff --git a/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js b/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js new file mode 100644 index 0000000000..e1fa64a88c --- /dev/null +++ b/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js @@ -0,0 +1,256 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const histsvc = PlacesUtils.history; + +add_task(async function test_addBookmarksAndCheckGuids() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "http://test1.com/", + title: "1 title", + }, + { + url: "http://test2.com/", + title: "2 title", + }, + { + url: "http://test3.com/", + title: "3 title", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "test folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }, + ], + }); + + let root = PlacesUtils.getFolderContents(bookmarks[0].guid).root; + Assert.equal(root.childCount, 5); + + // check bookmark guids + let bookmarkGuidZero = root.getChild(0).bookmarkGuid; + Assert.equal(bookmarkGuidZero.length, 12); + // bookmarks have bookmark guids + Assert.equal(root.getChild(1).bookmarkGuid.length, 12); + Assert.equal(root.getChild(2).bookmarkGuid.length, 12); + // separator has bookmark guid + Assert.equal(root.getChild(3).bookmarkGuid.length, 12); + // folder has bookmark guid + Assert.equal(root.getChild(4).bookmarkGuid.length, 12); + // all bookmark guids are different. + Assert.notEqual(bookmarkGuidZero, root.getChild(1).bookmarkGuid); + Assert.notEqual(root.getChild(1).bookmarkGuid, root.getChild(2).bookmarkGuid); + Assert.notEqual(root.getChild(2).bookmarkGuid, root.getChild(3).bookmarkGuid); + Assert.notEqual(root.getChild(3).bookmarkGuid, root.getChild(4).bookmarkGuid); + + // check page guids + let pageGuidZero = root.getChild(0).pageGuid; + Assert.equal(pageGuidZero.length, 12); + // bookmarks have page guids + Assert.equal(root.getChild(1).pageGuid.length, 12); + Assert.equal(root.getChild(2).pageGuid.length, 12); + // folder and separator don't have page guids + Assert.equal(root.getChild(3).pageGuid, ""); + Assert.equal(root.getChild(4).pageGuid, ""); + + Assert.notEqual(pageGuidZero, root.getChild(1).pageGuid); + Assert.notEqual(root.getChild(1).pageGuid, root.getChild(2).pageGuid); + + root.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_updateBookmarksAndCheckGuids() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "http://test1.com/", + title: "1 title", + }, + { + title: "test folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }, + ], + }); + + let root = PlacesUtils.getFolderContents(bookmarks[0].guid).root; + Assert.equal(root.childCount, 2); + + // ensure the bookmark and page guids remain the same after modifing other property. + let bookmarkGuidZero = root.getChild(0).bookmarkGuid; + let pageGuidZero = root.getChild(0).pageGuid; + await PlacesUtils.bookmarks.update({ + guid: bookmarks[1].guid, + title: "1 title mod", + }); + Assert.equal(root.getChild(0).title, "1 title mod"); + Assert.equal(root.getChild(0).bookmarkGuid, bookmarkGuidZero); + Assert.equal(root.getChild(0).pageGuid, pageGuidZero); + + let bookmarkGuidOne = root.getChild(1).bookmarkGuid; + let pageGuidOne = root.getChild(1).pageGuid; + + await PlacesUtils.bookmarks.update({ + guid: bookmarks[2].guid, + title: "test foolder 234", + }); + Assert.equal(root.getChild(1).title, "test foolder 234"); + Assert.equal(root.getChild(1).bookmarkGuid, bookmarkGuidOne); + Assert.equal(root.getChild(1).pageGuid, pageGuidOne); + + root.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_addVisitAndCheckGuid() { + // add a visit and test page guid and non-existing bookmark guids. + let sourceURI = uri("http://test4.com/"); + await PlacesTestUtils.addVisits({ uri: sourceURI }); + Assert.equal(await PlacesUtils.bookmarks.fetch({ url: sourceURI }, null)); + + let options = histsvc.getNewQueryOptions(); + let query = histsvc.getNewQuery(); + query.uri = sourceURI; + let root = histsvc.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + + do_check_valid_places_guid(root.getChild(0).pageGuid); + Assert.equal(root.getChild(0).bookmarkGuid, ""); + root.containerOpen = false; + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_addItemsWithInvalidGUIDsFails() { + const INVALID_GUID = "XYZ"; + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + guid: INVALID_GUID, + title: "XYZ folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + do_throw("Adding a folder with an invalid guid should fail"); + } catch (ex) {} + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + try { + PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + guid: INVALID_GUID, + title: "title", + url: "http://test.tld", + }); + do_throw("Adding a bookmark with an invalid guid should fail"); + } catch (ex) {} + + try { + PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + guid: INVALID_GUID, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + do_throw("Adding a separator with an invalid guid should fail"); + } catch (ex) {} + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_addItemsWithGUIDs() { + const FOLDER_GUID = "FOLDER--GUID"; + const BOOKMARK_GUID = "BM------GUID"; + const SEPARATOR_GUID = "SEP-----GUID"; + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: FOLDER_GUID, + children: [ + { + url: "http://test1.com", + title: "1 title", + guid: BOOKMARK_GUID, + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + guid: SEPARATOR_GUID, + }, + ], + }, + ], + }); + + let root = PlacesUtils.getFolderContents(bookmarks[0].guid).root; + Assert.equal(root.childCount, 2); + Assert.equal(root.bookmarkGuid, FOLDER_GUID); + Assert.equal(root.getChild(0).bookmarkGuid, BOOKMARK_GUID); + Assert.equal(root.getChild(1).bookmarkGuid, SEPARATOR_GUID); + + root.containerOpen = false; + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_emptyGUIDFails() { + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + guid: "", + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + do_throw("Adding a folder with an empty guid should fail"); + } catch (ex) {} +}); + +add_task(async function test_usingSameGUIDFails() { + const GUID = "XYZXYZXYZXYZ"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + guid: GUID, + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + guid: GUID, + title: "test folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + do_throw("Using the same guid twice should fail"); + } catch (ex) {} + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/unit/test_placeURIs.js b/toolkit/components/places/tests/unit/test_placeURIs.js new file mode 100644 index 0000000000..093dc89f68 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_placeURIs.js @@ -0,0 +1,18 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function run_test() { + // TODO: Improve testing coverage for QueryToQueryString and QueryStringToQuery + + // Bug 376798 + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + query.setParents([PlacesUtils.bookmarks.rootGuid]); + Assert.equal( + PlacesUtils.history.queryToQueryString(query, options), + `place:parent=${PlacesUtils.bookmarks.rootGuid}` + ); +} diff --git a/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js b/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js new file mode 100644 index 0000000000..9824908cef --- /dev/null +++ b/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js @@ -0,0 +1,282 @@ +/* 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/. */ + +async function check_has_child(aParentGuid, aChildGuid) { + let parentTree = await PlacesUtils.promiseBookmarksTree(aParentGuid); + Assert.ok("children" in parentTree); + Assert.notEqual( + parentTree.children.find(e => e.guid == aChildGuid), + null + ); +} + +async function compareToNode(aItem, aNode, aIsRootItem, aExcludedGuids = []) { + // itemId==-1 indicates a non-bookmark node, which is unexpected. + Assert.notEqual(aNode.itemId, -1); + + function check_unset(...aProps) { + for (let p of aProps) { + if (p in aItem) { + Assert.ok(false, `Unexpected property ${p} with value ${aItem[p]}`); + } + } + } + + function compare_prop(aItemProp, aNodeProp = aItemProp, aOptional = false) { + if (aOptional && aNode[aNodeProp] === null) { + check_unset(aItemProp); + } else { + Assert.strictEqual(aItem[aItemProp], aNode[aNodeProp]); + } + } + + // Bug 1013053 - bookmarkIndex is unavailable for the query's root + if (aNode.bookmarkIndex == -1) { + let bookmark = await PlacesUtils.bookmarks.fetch(aNode.bookmarkGuid); + Assert.strictEqual(aItem.index, bookmark.index); + } else { + compare_prop("index", "bookmarkIndex"); + } + + compare_prop("dateAdded"); + compare_prop("lastModified"); + + if (aIsRootItem && aNode.bookmarkGuid != PlacesUtils.bookmarks.rootGuid) { + Assert.ok("parentGuid" in aItem); + await check_has_child(aItem.parentGuid, aItem.guid); + } else { + check_unset("parentGuid"); + } + + const BOOKMARK_ONLY_PROPS = ["uri", "iconUri", "tags", "charset", "keyword"]; + const FOLDER_ONLY_PROPS = ["children", "root"]; + + let nodesCount = 1; + + switch (aNode.type) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + Assert.equal(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER); + Assert.equal(aItem.typeCode, PlacesUtils.bookmarks.TYPE_FOLDER); + compare_prop("title", "title", true); + check_unset(...BOOKMARK_ONLY_PROPS); + + let expectedChildrenNodes = []; + + PlacesUtils.asContainer(aNode); + if (!aNode.containerOpen) { + aNode.containerOpen = true; + } + + for (let i = 0; i < aNode.childCount; i++) { + let childNode = aNode.getChild(i); + if ( + childNode.itemId == PlacesUtils.tagsFolderId || + aExcludedGuids.includes(childNode.bookmarkGuid) + ) { + continue; + } + expectedChildrenNodes.push(childNode); + } + + if (expectedChildrenNodes.length) { + Assert.ok(Array.isArray(aItem.children)); + Assert.equal(aItem.children.length, expectedChildrenNodes.length); + for (let i = 0; i < aItem.children.length; i++) { + nodesCount += await compareToNode( + aItem.children[i], + expectedChildrenNodes[i], + false, + aExcludedGuids + ); + } + } else { + check_unset("children"); + } + + let rootName = mapItemGuidToInternalRootName(aItem.guid); + if (rootName) { + Assert.equal(aItem.root, rootName); + } else { + check_unset("root"); + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + Assert.equal(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR); + Assert.equal(aItem.typeCode, PlacesUtils.bookmarks.TYPE_SEPARATOR); + check_unset(...BOOKMARK_ONLY_PROPS, ...FOLDER_ONLY_PROPS); + break; + default: + Assert.equal(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE); + Assert.equal(aItem.typeCode, PlacesUtils.bookmarks.TYPE_BOOKMARK); + compare_prop("uri"); + // node.tags's format is "a, b" whilst promiseBoookmarksTree is "a,b" + if (aNode.tags === null) { + check_unset("tags"); + } else { + Assert.equal(aItem.tags, aNode.tags.replace(/, /g, ",")); + } + + if (aNode.icon) { + try { + await compareFavicons(aNode.icon, aItem.iconUri); + } catch (ex) { + info(ex); + todo_check_true(false); + } + } else { + check_unset(aItem.iconUri); + } + + check_unset(...FOLDER_ONLY_PROPS); + + let pageInfo = await PlacesUtils.history.fetch(aNode.uri, { + includeAnnotations: true, + }); + let expectedCharset = pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO); + if (expectedCharset) { + Assert.equal(aItem.charset, expectedCharset); + } else { + check_unset("charset"); + } + + let entry = await PlacesUtils.keywords.fetch({ url: aNode.uri }); + if (entry) { + Assert.equal(aItem.keyword, entry.keyword); + } else { + check_unset("keyword"); + } + + if ("title" in aItem) { + compare_prop("title"); + } else { + Assert.equal(null, aNode.title); + } + } + + if (aIsRootItem) { + Assert.strictEqual(aItem.itemsCount, nodesCount); + } + + return nodesCount; +} + +var itemsCount = 0; +async function new_bookmark(aInfo) { + ++itemsCount; + if (!("url" in aInfo)) { + aInfo.url = uri("http://test.item." + itemsCount); + } + + if (!("title" in aInfo)) { + aInfo.title = "Test Item (bookmark) " + itemsCount; + } + + await PlacesTransactions.NewBookmark(aInfo).transact(); +} + +function new_folder(aInfo) { + if (!("title" in aInfo)) { + aInfo.title = "Test Item (folder) " + itemsCount; + } + return PlacesTransactions.NewFolder(aInfo).transact(); +} + +// Walks a result nodes tree and test promiseBookmarksTree for each node. +// DO NOT COPY THIS LOGIC: It is done here to accomplish a more comprehensive +// test of the API (the entire hierarchy data is available in the very test). +async function test_promiseBookmarksTreeForEachNode( + aNode, + aOptions, + aExcludedGuids +) { + Assert.ok(aNode.bookmarkGuid && !!aNode.bookmarkGuid.length); + let item = await PlacesUtils.promiseBookmarksTree( + aNode.bookmarkGuid, + aOptions + ); + await compareToNode(item, aNode, true, aExcludedGuids); + + if (!PlacesUtils.nodeIsContainer(aNode)) { + return item; + } + + for (let i = 0; i < aNode.childCount; i++) { + let child = aNode.getChild(i); + if (child.itemId != PlacesUtils.tagsFolderId) { + await test_promiseBookmarksTreeForEachNode( + child, + { includeItemIds: true }, + aExcludedGuids + ); + } + } + return item; +} + +async function test_promiseBookmarksTreeAgainstResult( + aItemGuid = PlacesUtils.bookmarks.rootGuid, + aOptions = { includeItemIds: true }, + aExcludedGuids +) { + let node = PlacesUtils.getFolderContents(aItemGuid).root; + return test_promiseBookmarksTreeForEachNode(node, aOptions, aExcludedGuids); +} + +add_task(async function () { + // Add some bookmarks to cover various use cases. + await new_bookmark({ parentGuid: PlacesUtils.bookmarks.toolbarGuid }); + await new_folder({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + annotations: [ + { name: "TestAnnoA", value: "TestVal" }, + { name: "TestAnnoB", value: 0 }, + ], + }); + let sepInfo = { parentGuid: PlacesUtils.bookmarks.menuGuid }; + await PlacesTransactions.NewSeparator(sepInfo).transact(); + let folderGuid = await new_folder({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + await new_bookmark({ + title: null, + parentGuid: folderGuid, + keyword: "test_keyword", + tags: ["TestTagA", "TestTagB"], + annotations: [{ name: "TestAnnoA", value: "TestVal2" }], + }); + let urlWithCharsetAndFavicon = uri("http://charset.and.favicon"); + await new_bookmark({ parentGuid: folderGuid, url: urlWithCharsetAndFavicon }); + await PlacesUtils.history.update({ + url: urlWithCharsetAndFavicon, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, "UTF-16"]]), + }); + await setFaviconForPage(urlWithCharsetAndFavicon, SMALLPNG_DATA_URI); + // Test the default places root without specifying it. + await test_promiseBookmarksTreeAgainstResult(); + + // Do specify it + await test_promiseBookmarksTreeAgainstResult(PlacesUtils.bookmarks.rootGuid); + + // Exclude the bookmarks menu. + // The calllback should be four times - once for the toolbar, once for + // the bookmark we inserted under, and once for the menu (and not + // at all for any of its descendants) and once for the unsorted bookmarks + // folder. However, promiseBookmarksTree is called multiple times, so + // rather than counting the calls, we count the number of unique items + // passed in. + let guidsPassedToExcludeCallback = new Set(); + let placesRootWithoutTheMenu = await test_promiseBookmarksTreeAgainstResult( + PlacesUtils.bookmarks.rootGuid, + { + excludeItemsCallback: aItem => { + guidsPassedToExcludeCallback.add(aItem.guid); + return aItem.root == "bookmarksMenuFolder"; + }, + includeItemIds: true, + }, + [PlacesUtils.bookmarks.menuGuid] + ); + Assert.equal(guidsPassedToExcludeCallback.size, 5); + Assert.equal(placesRootWithoutTheMenu.children.length, 3); +}); diff --git a/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js b/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js new file mode 100644 index 0000000000..d8830c5686 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js @@ -0,0 +1,39 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function () { + let uri1 = uri("http://foo.tld/"); + let uri2 = uri("https://bar.tld/"); + + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "foo title" }, + { uri: uri2, title: "bar title" }, + ]); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri1, + title: null, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri2, + title: null, + }); + + PlacesUtils.tagging.tagURI(uri1, ["tag 1"]); + PlacesUtils.tagging.tagURI(uri2, ["tag 2"]); +}); + +add_task(async function testTagQuery() { + let options = PlacesUtils.history.getNewQueryOptions(); + let query = PlacesUtils.history.getNewQuery(); + query.tags = ["tag 1"]; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Assert.equal(root.childCount, 1); + Assert.equal(root.getChild(0).title, ""); + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_result_sort.js b/toolkit/components/places/tests/unit/test_result_sort.js new file mode 100644 index 0000000000..840b40c339 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_result_sort.js @@ -0,0 +1,112 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const NHQO = Ci.nsINavHistoryQueryOptions; + +add_task(async function test() { + const uri1 = "http://foo.tld/a"; + const uri2 = "http://foo.tld/b"; + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "Result-sort functionality tests root", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "b", + url: uri1, + }, + { + title: "a", + url: uri2, + }, + { + // url of the first child, title of second + title: "a", + url: uri1, + }, + ], + }, + ], + }); + + let guid1 = bookmarks[1].guid; + let guid2 = bookmarks[2].guid; + let guid3 = bookmarks[3].guid; + + // query with natural order + let result = PlacesUtils.getFolderContents(bookmarks[0].guid); + let root = result.root; + + Assert.equal(root.childCount, 3); + + function checkOrder(a, b, c) { + Assert.equal(root.getChild(0).bookmarkGuid, a); + Assert.equal(root.getChild(1).bookmarkGuid, b); + Assert.equal(root.getChild(2).bookmarkGuid, c); + } + + // natural order + info("Natural order"); + checkOrder(guid1, guid2, guid3); + + // title: guid3 should precede guid2 since we fall-back to URI-based sorting + info("Sort by title asc"); + result.sortingMode = NHQO.SORT_BY_TITLE_ASCENDING; + checkOrder(guid3, guid2, guid1); + + // In reverse + info("Sort by title desc"); + result.sortingMode = NHQO.SORT_BY_TITLE_DESCENDING; + checkOrder(guid1, guid2, guid3); + + // uri sort: guid1 should precede guid3 since we fall-back to natural order + info("Sort by uri asc"); + result.sortingMode = NHQO.SORT_BY_URI_ASCENDING; + checkOrder(guid1, guid3, guid2); + + // test live update + info("Change bookmark uri liveupdate"); + await PlacesUtils.bookmarks.update({ + guid: guid1, + url: uri2, + }); + checkOrder(guid3, guid1, guid2); + await PlacesUtils.bookmarks.update({ + guid: guid1, + url: uri1, + }); + checkOrder(guid1, guid3, guid2); + + // XXXtodo: test history sortings (visit count, visit date) + // XXXtodo: test different item types once folderId and bookmarkId are merged. + + // XXXtodo: test dateAdded sort + // XXXtodo: test lastModified sort + + // Add a visit, then check frecency ordering. + + await PlacesTestUtils.addVisits({ uri: uri2, transition: TRANSITION_TYPED }); + + info("Sort by frecency desc"); + result.sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING; + for (let i = 0; i < root.childCount; ++i) { + print(root.getChild(i).uri + " " + root.getChild(i).title); + } + // For guid1 and guid3, since they have same frecency and no visits, fallback + // to sort by the newest bookmark. + checkOrder(guid2, guid3, guid1); + info("Sort by frecency asc"); + result.sortingMode = NHQO.SORT_BY_FRECENCY_ASCENDING; + for (let i = 0; i < root.childCount; ++i) { + print(root.getChild(i).uri + " " + root.getChild(i).title); + } + checkOrder(guid1, guid3, guid2); + + root.containerOpen = false; +}); diff --git a/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js b/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js new file mode 100644 index 0000000000..2291eddeff --- /dev/null +++ b/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js @@ -0,0 +1,106 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +const { history } = PlacesUtils; + +add_task(async function test_addVisitCheckFields() { + let uri = NetUtil.newURI("http://test4.com/"); + await PlacesTestUtils.addVisits([ + { uri }, + { uri, referrer: uri }, + { uri, transition: history.TRANSITION_TYPED }, + ]); + + let options = history.getNewQueryOptions(); + let query = history.getNewQuery(); + + query.uri = uri; + + // Check RESULTS_AS_VISIT node. + options.resultType = options.RESULTS_AS_VISIT; + + let root = history.executeQuery(query, options).root; + root.containerOpen = true; + + equal(root.childCount, 3); + + let child = root.getChild(0); + equal( + child.visitType, + history.TRANSITION_LINK, + "Visit type should be TRANSITION_LINK" + ); + equal(child.visitId, 1, "Visit ID should be 1"); + equal(child.fromVisitId, -1, "Should have no referrer visit ID"); + + child = root.getChild(1); + equal( + child.visitType, + history.TRANSITION_LINK, + "Visit type should be TRANSITION_LINK" + ); + equal(child.visitId, 2, "Visit ID should be 2"); + equal(child.fromVisitId, 1, "First visit should be the referring visit"); + + child = root.getChild(2); + equal( + child.visitType, + history.TRANSITION_TYPED, + "Visit type should be TRANSITION_TYPED" + ); + equal(child.visitId, 3, "Visit ID should be 3"); + equal(child.fromVisitId, -1, "Should have no referrer visit ID"); + + root.containerOpen = false; + + // Check RESULTS_AS_URI node. + options.resultType = options.RESULTS_AS_URI; + + root = history.executeQuery(query, options).root; + root.containerOpen = true; + + equal(root.childCount, 1); + + child = root.getChild(0); + equal(child.visitType, 0, "Visit type should be 0"); + equal(child.visitId, -1, "Visit ID should be -1"); + equal(child.fromVisitId, -1, "Referrer visit id should be -1"); + + root.containerOpen = false; + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_bookmarkFields() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "test title", + url: "http://test4.com", + }, + ], + }, + ], + }); + + let root = PlacesUtils.getFolderContents(bookmarks[0].guid).root; + equal(root.childCount, 1); + + equal(root.visitType, 0, "Visit type should be 0"); + equal(root.visitId, -1, "Visit ID should be -1"); + equal(root.fromVisitId, -1, "Referrer visit id should be -1"); + + let child = root.getChild(0); + equal(child.visitType, 0, "Visit type should be 0"); + equal(child.visitId, -1, "Visit ID should be -1"); + equal(child.fromVisitId, -1, "Referrer visit id should be -1"); + + root.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/toolkit/components/places/tests/unit/test_sql_function_origin.js b/toolkit/components/places/tests/unit/test_sql_function_origin.js new file mode 100644 index 0000000000..0314ff5040 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_sql_function_origin.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the origin-related SQL functions, which are: +// * get_host_and_port +// * get_prefix +// * strip_prefix_and_userinfo + +// Tests actual URL strings. +add_task(async function urls() { + let sets = [ + ["http:"], + ["", "//"], + ["", "user@", "user:@", "user:pass@", "user:pass:word@"], + ["example.com"], + ["", ":8888"], + ["", "/", "/foo"], + ["", "?", "?bar"], + ["", "#", "#baz"], + ]; + let db = await PlacesUtils.promiseDBConnection(); + for (let parts of permute(sets)) { + let spec = parts.join(""); + let funcs = { + get_prefix: parts.slice(0, 2).join(""), + get_host_and_port: parts.slice(3, 5).join(""), + strip_prefix_and_userinfo: parts.slice(3).join(""), + }; + for (let [func, expectedValue] of Object.entries(funcs)) { + let rows = await db.execute(` + SELECT ${func}("${spec}"); + `); + let value = rows[0].getString(0); + Assert.equal(value, expectedValue, `function=${func} spec="${spec}"`); + } + } +}); + +// Tests strings that aren't URLs. +add_task(async function nonURLs() { + let db = await PlacesUtils.promiseDBConnection(); + + let value = ( + await db.execute(` + SELECT get_prefix("hello"); + `) + )[0].getString(0); + Assert.equal(value, ""); + + value = ( + await db.execute(` + SELECT get_host_and_port("hello"); + `) + )[0].getString(0); + Assert.equal(value, "hello"); + + value = ( + await db.execute(` + SELECT strip_prefix_and_userinfo("hello"); + `) + )[0].getString(0); + Assert.equal(value, "hello"); +}); + +function permute(sets = []) { + if (!sets.length) { + return [[]]; + } + let firstSet = sets[0]; + let otherSets = sets.slice(1); + let permutedSequences = []; + let otherPermutedSequences = permute(otherSets); + for (let other of otherPermutedSequences) { + for (let value of firstSet) { + permutedSequences.push([value].concat(other)); + } + } + return permutedSequences; +} diff --git a/toolkit/components/places/tests/unit/test_sql_guid_functions.js b/toolkit/components/places/tests/unit/test_sql_guid_functions.js new file mode 100644 index 0000000000..b4fbdac612 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_sql_guid_functions.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests that the guid function generates a guid of the proper length, + * with no invalid characters. + */ + +/** + * Checks all our invariants about our guids for a given result. + * + * @param aGuid + * The guid to check. + */ +function check_invariants(aGuid) { + info("Checking guid '" + aGuid + "'"); + + do_check_valid_places_guid(aGuid); +} + +// Test Functions + +function test_guid_invariants() { + const kExpectedChars = 64; + const kAllowedChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + Assert.equal(kAllowedChars.length, kExpectedChars); + const kGuidLength = 12; + + let checkedChars = []; + for (let i = 0; i < kGuidLength; i++) { + checkedChars[i] = {}; + for (let j = 0; j < kAllowedChars; j++) { + checkedChars[i][kAllowedChars[j]] = false; + } + } + + // We run this until we've seen every character that we expect to see in every + // position. + let seenChars = 0; + let stmt = DBConn().createStatement("SELECT GENERATE_GUID()"); + while (seenChars != kExpectedChars * kGuidLength) { + Assert.ok(stmt.executeStep()); + let guid = stmt.getString(0); + check_invariants(guid); + + for (let i = 0; i < guid.length; i++) { + let character = guid[i]; + if (!checkedChars[i][character]) { + checkedChars[i][character] = true; + seenChars++; + } + } + stmt.reset(); + } + stmt.finalize(); + + // One last reality check - make sure all of our characters were seen. + for (let i = 0; i < kGuidLength; i++) { + for (let j = 0; j < kAllowedChars; j++) { + Assert.ok(checkedChars[i][kAllowedChars[j]]); + } + } + + run_next_test(); +} + +function test_guid_on_background() { + // We should not assert if we execute this asynchronously. + let stmt = DBConn().createAsyncStatement("SELECT GENERATE_GUID()"); + let checked = false; + stmt.executeAsync({ + handleResult(aResult) { + try { + let row = aResult.getNextRow(); + check_invariants(row.getResultByIndex(0)); + Assert.equal(aResult.getNextRow(), null); + checked = true; + } catch (e) { + do_throw(e); + } + }, + handleCompletion(aReason) { + Assert.equal(aReason, Ci.mozIStorageStatementCallback.REASON_FINISHED); + Assert.ok(checked); + run_next_test(); + }, + }); + stmt.finalize(); +} + +// Test Runner + +[test_guid_invariants, test_guid_on_background].forEach(fn => add_test(fn)); diff --git a/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js b/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js new file mode 100644 index 0000000000..43f899c237 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js @@ -0,0 +1,119 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function AutoCompleteInput(aSearches) { + this.searches = aSearches; +} +AutoCompleteInput.prototype = { + constructor: AutoCompleteInput, + + searches: null, + + minResultsForPopup: 0, + timeout: 10, + searchParam: "", + textValue: "", + disableAutoComplete: false, + completeDefaultIndex: false, + + get searchCount() { + return this.searches.length; + }, + + getSearchAt(aIndex) { + return this.searches[aIndex]; + }, + + onSearchBegin() {}, + onSearchComplete() {}, + + popupOpen: false, + + popup: { + setSelectedIndex(aIndex) {}, + invalidate() {}, + + // nsISupports implementation + QueryInterface: ChromeUtils.generateQI(["nsIAutoCompletePopup"]), + }, + + // nsISupports implementation + QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteInput"]), +}; + +async function ensure_tag_results(results, searchTerm) { + var controller = Cc["@mozilla.org/autocomplete/controller;1"].getService( + Ci.nsIAutoCompleteController + ); + + // Make an AutoCompleteInput that uses our searches + // and confirms results on search complete + var input = new AutoCompleteInput(["places-tag-autocomplete"]); + + controller.input = input; + + return new Promise(resolve => { + var numSearchesStarted = 0; + input.onSearchBegin = function input_onSearchBegin() { + numSearchesStarted++; + Assert.equal(numSearchesStarted, 1); + }; + + input.onSearchComplete = function input_onSearchComplete() { + Assert.equal(numSearchesStarted, 1); + if (results.length) { + Assert.equal( + controller.searchStatus, + Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH + ); + } else { + Assert.equal( + controller.searchStatus, + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH + ); + } + + Assert.equal(controller.matchCount, results.length); + for (var i = 0; i < controller.matchCount; i++) { + Assert.equal(controller.getValueAt(i), results[i]); + } + + resolve(); + }; + + controller.startSearch(searchTerm); + }); +} + +var uri1 = uri("http://site.tld/1"); + +var tests = [ + () => ensure_tag_results(["bar", "Baz", "boo"], "b"), + () => ensure_tag_results(["bar", "Baz"], "ba"), + () => ensure_tag_results(["bar", "Baz"], "Ba"), + () => ensure_tag_results(["bar"], "bar"), + () => ensure_tag_results(["Baz"], "Baz"), + () => ensure_tag_results([], "barb"), + () => ensure_tag_results([], "foo"), + () => + ensure_tag_results(["first tag, bar", "first tag, Baz"], "first tag, ba"), + () => + ensure_tag_results( + ["first tag; bar", "first tag; Baz"], + "first tag; ba" + ), +]; + +/** + * Test tag autocomplete + */ +add_task(async function test_tag_autocomplete() { + PlacesUtils.tagging.tagURI(uri1, ["bar", "Baz", "boo", "*nix"]); + + for (let tagTest of tests) { + await tagTest(); + } +}); diff --git a/toolkit/components/places/tests/unit/test_tagging.js b/toolkit/components/places/tests/unit/test_tagging.js new file mode 100644 index 0000000000..f38db0d0f6 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_tagging.js @@ -0,0 +1,188 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Notice we use createInstance because later we will have to terminate the +// service and restart it. +var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"] + .createInstance() + .QueryInterface(Ci.nsITaggingService); + +function run_test() { + var options = PlacesUtils.history.getNewQueryOptions(); + var query = PlacesUtils.history.getNewQuery(); + + query.setParents([PlacesUtils.bookmarks.tagsGuid]); + var result = PlacesUtils.history.executeQuery(query, options); + var tagRoot = result.root; + tagRoot.containerOpen = true; + + Assert.equal(tagRoot.childCount, 0); + + var uri1 = uri("http://foo.tld/"); + var uri2 = uri("https://bar.tld/"); + + // this also tests that the multiple folders are not created for the same tag + tagssvc.tagURI(uri1, ["tag 1"]); + tagssvc.tagURI(uri2, ["tag 1"]); + Assert.equal(tagRoot.childCount, 1); + + var tag1node = tagRoot + .getChild(0) + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + var tag1itemId = tag1node.itemId; + + Assert.equal(tag1node.title, "tag 1"); + tag1node.containerOpen = true; + Assert.equal(tag1node.childCount, 2); + + // Tagging the same url twice (or even thrice!) with the same tag should be a + // no-op + tagssvc.tagURI(uri1, ["tag 1"]); + Assert.equal(tag1node.childCount, 2); + tagssvc.tagURI(uri1, [tag1itemId]); + Assert.equal(tag1node.childCount, 2); + Assert.equal(tagRoot.childCount, 1); + + // also tests bug 407575 + tagssvc.tagURI(uri1, [tag1itemId, "tag 1", "tag 2", "Tag 1", "Tag 2"]); + Assert.equal(tagRoot.childCount, 2); + Assert.equal(tag1node.childCount, 2); + + // test getTagsForURI + var uri1tags = tagssvc.getTagsForURI(uri1); + Assert.equal(uri1tags.length, 2); + Assert.equal(uri1tags[0], "Tag 1"); + Assert.equal(uri1tags[1], "Tag 2"); + var uri2tags = tagssvc.getTagsForURI(uri2); + Assert.equal(uri2tags.length, 1); + Assert.equal(uri2tags[0], "Tag 1"); + + // test untagging + tagssvc.untagURI(uri1, ["tag 1"]); + Assert.equal(tag1node.childCount, 1); + + let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.id == tagRoot.itemId) + ); + // removing the last uri from a tag should remove the tag-container + tagssvc.untagURI(uri2, ["tag 1"]); + wait.then(() => { + Assert.equal(tagRoot.childCount, 1); + }); + + // cleanup + tag1node.containerOpen = false; + + // get array of tag folder ids => title + // for testing tagging with mixed folder ids and tags + var child = tagRoot.getChild(0); + var tagId = child.itemId; + var tagTitle = child.title; + + // test mixed id/name tagging + // as well as non-id numeric tags + var uri3 = uri("http://testuri/3"); + tagssvc.tagURI(uri3, [tagId, "tag 3", "456"]); + var tags = tagssvc.getTagsForURI(uri3); + Assert.ok(tags.includes(tagTitle)); + Assert.ok(tags.includes("tag 3")); + Assert.ok(tags.includes("456")); + + // test mixed id/name tagging + tagssvc.untagURI(uri3, [tagId, "tag 3", "456"]); + tags = tagssvc.getTagsForURI(uri3); + Assert.equal(tags.length, 0); + + // Terminate tagging service, fire up a new instance and check that existing + // tags are there. This will ensure that any internal caching system is + // correctly filled at startup and we are not losing previously existing tags. + var uri4 = uri("http://testuri/4"); + tagssvc.tagURI(uri4, [tagId, "tag 3", "456"]); + tagssvc = null; + tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].getService( + Ci.nsITaggingService + ); + var uri4Tags = tagssvc.getTagsForURI(uri4); + Assert.equal(uri4Tags.length, 3); + Assert.ok(uri4Tags.includes(tagTitle)); + Assert.ok(uri4Tags.includes("tag 3")); + Assert.ok(uri4Tags.includes("456")); + + // Test sparse arrays. + let curChildCount = tagRoot.childCount; + + try { + tagssvc.tagURI(uri1, [undefined, "tagSparse"]); + Assert.equal(tagRoot.childCount, curChildCount + 1); + } catch (ex) { + do_throw("Passing a sparse array should not throw"); + } + try { + wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.id == tagRoot.itemId) + ); + tagssvc.untagURI(uri1, [undefined, "tagSparse"]); + wait.then(() => { + Assert.equal(tagRoot.childCount, curChildCount); + }); + } catch (ex) { + do_throw("Passing a sparse array should not throw"); + } + + // Test that the API throws for bad arguments. + try { + tagssvc.tagURI(uri1, ["", "test"]); + do_throw("Passing a bad tags array should throw"); + } catch (ex) { + Assert.equal(ex.name, "NS_ERROR_ILLEGAL_VALUE"); + } + try { + tagssvc.untagURI(uri1, ["", "test"]); + do_throw("Passing a bad tags array should throw"); + } catch (ex) { + Assert.equal(ex.name, "NS_ERROR_ILLEGAL_VALUE"); + } + try { + tagssvc.tagURI(uri1, [0, "test"]); + do_throw("Passing a bad tags array should throw"); + } catch (ex) { + Assert.equal(ex.name, "NS_ERROR_ILLEGAL_VALUE"); + } + try { + tagssvc.tagURI(uri1, [0, "test"]); + do_throw("Passing a bad tags array should throw"); + } catch (ex) { + Assert.equal(ex.name, "NS_ERROR_ILLEGAL_VALUE"); + } + + // Tag name length should be limited to PlacesUtils.bookmarks.MAX_TAG_LENGTH (bug407821) + try { + // generate a long tag name. i.e. looooo...oong_tag + var n = PlacesUtils.bookmarks.MAX_TAG_LENGTH; + var someOos = new Array(n).join("o"); + var longTagName = "l" + someOos + "ng_tag"; + + tagssvc.tagURI(uri1, ["short_tag", longTagName]); + do_throw("Passing a bad tags array should throw"); + } catch (ex) { + Assert.equal(ex.name, "NS_ERROR_ILLEGAL_VALUE"); + } + + // cleanup + tagRoot.containerOpen = false; + + // Tagging service should trim tags (Bug967196) + let exampleURI = uri("http://www.example.com/"); + PlacesUtils.tagging.tagURI(exampleURI, [" test "]); + + let exampleTags = PlacesUtils.tagging.getTagsForURI(exampleURI); + Assert.equal(exampleTags.length, 1); + Assert.equal(exampleTags[0], "test"); + + PlacesUtils.tagging.untagURI(exampleURI, ["test"]); + exampleTags = PlacesUtils.tagging.getTagsForURI(exampleURI); + Assert.equal(exampleTags.length, 0); +} diff --git a/toolkit/components/places/tests/unit/test_telemetry.js b/toolkit/components/places/tests/unit/test_telemetry.js new file mode 100644 index 0000000000..b48bb76a7f --- /dev/null +++ b/toolkit/components/places/tests/unit/test_telemetry.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests common Places telemetry probes by faking the telemetry service. + +// Enable the collection (during test) for all products so even products +// that don't collect the data will be able to run the test without failure. +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" +); + +const histograms = { + PLACES_PAGES_COUNT: val => Assert.equal(val, 1), + PLACES_BOOKMARKS_COUNT: val => Assert.equal(val, 1), + PLACES_TAGS_COUNT: val => Assert.equal(val, 1), + PLACES_KEYWORDS_COUNT: val => Assert.equal(val, 1), + PLACES_SORTED_BOOKMARKS_PERC: val => Assert.equal(val, 100), + PLACES_TAGGED_BOOKMARKS_PERC: val => Assert.equal(val, 100), + PLACES_DATABASE_FILESIZE_MB: val => Assert.ok(val > 0), + PLACES_DATABASE_FAVICONS_FILESIZE_MB: val => Assert.ok(val > 0), + PLACES_EXPIRATION_STEPS_TO_CLEAN2: val => Assert.ok(val > 1), + PLACES_IDLE_MAINTENANCE_TIME_MS: val => Assert.ok(val > 0), + PLACES_ANNOS_PAGES_COUNT: val => Assert.equal(val, 1), + PLACES_MAINTENANCE_DAYSFROMLAST: val => Assert.ok(val >= 0), +}; + +const scalars = { + pages_need_frecency_recalculation: 1, // 1 bookmark is added causing recalc. +}; + +/** + * Forces an expiration run. + * + * @param [optional] aLimit + * Limit for the expiration. Pass -1 for unlimited. + * Any other non-positive value will just expire orphans. + * + * @return {Promise} + * @resolves When expiration finishes. + * @rejects Never. + */ +function promiseForceExpirationStep(aLimit) { + let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED); + let expire = Cc["@mozilla.org/places/expiration;1"].getService( + Ci.nsIObserver + ); + expire.observe(null, "places-debug-start-expiration", aLimit); + return promise; +} + +/** + * Returns a PRTime in the past usable to add expirable visits. + * + * param [optional] daysAgo + * Expiration ignores any visit added in the last 7 days, so by default + * this will be set to 7. + * @note to be safe against DST issues we go back one day more. + */ +function getExpirablePRTime(daysAgo = 7) { + let dateObj = new Date(); + // Normalize to midnight + dateObj.setHours(0); + dateObj.setMinutes(0); + dateObj.setSeconds(0); + dateObj.setMilliseconds(0); + dateObj = new Date(dateObj.getTime() - (daysAgo + 1) * 86400000); + return dateObj.getTime() * 1000; +} + +add_task(async function test_execute() { + // Put some trash in the database. + let uri = Services.io.newURI("http://moz.org/"); + + PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "moz test", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "moz test", + url: uri, + }, + ], + }, + ], + }); + + PlacesUtils.tagging.tagURI(uri, ["tag"]); + await PlacesUtils.keywords.insert({ url: uri.spec, keyword: "keyword" }); + + // Set a large annotation. + let content = ""; + while (content.length < 1024) { + content += "0"; + } + + await PlacesUtils.history.update({ + url: uri, + annotations: new Map([["test-anno", content]]), + }); + + await PlacesDBUtils.telemetry(); + + await PlacesTestUtils.promiseAsyncUpdates(); + + // Test expiration probes. + let timeInMicroseconds = getExpirablePRTime(8); + + function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; + } + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits({ + uri: NetUtil.newURI("http://" + i + ".moz.org/"), + visitDate: newTimeInMicroseconds(), + }); + } + Services.prefs.setIntPref("places.history.expiration.max_pages", 0); + await promiseForceExpirationStep(2); + await promiseForceExpirationStep(2); + + // Test idle probes. + await PlacesDBUtils.maintenanceOnIdle(); + + for (let histogramId in histograms) { + info("checking histogram " + histogramId); + let validate = histograms[histogramId]; + let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot(); + validate(snapshot.sum); + Assert.ok(Object.values(snapshot.values).reduce((a, b) => a + b, 0) > 0); + } + for (let scalarName in scalars) { + let scalar = "places." + scalarName; + info("checking scalar " + scalar); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + scalar, + scalars[scalarName], + "Verify scalar value matches" + ); + } +}); diff --git a/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js b/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js new file mode 100644 index 0000000000..fa03c0e06b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js @@ -0,0 +1,211 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 455315 + * https://bugzilla.mozilla.org/show_bug.cgi?id=412132 + * + * Ensures that the frecency of a bookmark's URI is what it should be after the + * bookmark is deleted. + */ + +add_task(async function removed_bookmark() { + info( + "After removing bookmark, frecency of bookmark's URI should be " + + "zero if URI is unvisited and no longer bookmarked." + ); + const TEST_URI = Services.io.newURI("http://example.com/1"); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URI, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Bookmarked => frecency of URI should be != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.remove(bm); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Unvisited URI no longer bookmarked => frecency should = 0"); + Assert.equal( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function removed_but_visited_bookmark() { + info( + "After removing bookmark, frecency of bookmark's URI should " + + "not be zero if URI is visited." + ); + const TEST_URI = Services.io.newURI("http://example.com/1"); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URI, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Bookmarked => frecency of URI should be != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesTestUtils.addVisits(TEST_URI); + await PlacesUtils.bookmarks.remove(bm); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("*Visited* URI no longer bookmarked => frecency should != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function remove_bookmark_still_bookmarked() { + info( + "After removing bookmark, frecency of bookmark's URI should " + + "not be zero if URI is still bookmarked." + ); + const TEST_URI = Services.io.newURI("http://example.com/1"); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark 1 title", + url: TEST_URI, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark 2 title", + url: TEST_URI, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Bookmarked => frecency of URI should be != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.remove(bm1); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("URI still bookmarked => frecency should != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function cleared_parent_of_visited_bookmark() { + info( + "After removing all children from bookmark's parent, frecency " + + "of bookmark's URI should not be zero if URI is visited." + ); + const TEST_URI = Services.io.newURI("http://example.com/1"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark title", + url: TEST_URI, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Bookmarked => frecency of URI should be != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesTestUtils.addVisits(TEST_URI); + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("*Visited* URI no longer bookmarked => frecency should != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function cleared_parent_of_bookmark_still_bookmarked() { + info( + "After removing all children from bookmark's parent, frecency " + + "of bookmark's URI should not be zero if URI is still " + + "bookmarked." + ); + const TEST_URI = Services.io.newURI("http://example.com/1"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "bookmark 1 title", + url: TEST_URI, + }); + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "bookmark 2 folder", + }); + await PlacesUtils.bookmarks.insert({ + title: "bookmark 2 title", + parentGuid: folder.guid, + url: TEST_URI, + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + info("Bookmarked => frecency of URI should be != 0"); + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.remove(folder); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + // URI still bookmarked => frecency should != 0. + Assert.notEqual( + await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: TEST_URI, + }), + 0 + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/toolkit/components/places/tests/unit/test_utils_backups_create.js b/toolkit/components/places/tests/unit/test_utils_backups_create.js new file mode 100644 index 0000000000..a0bc506695 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_utils_backups_create.js @@ -0,0 +1,152 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Check for correct functionality of bookmarks backups + */ + +const NUMBER_OF_BACKUPS = 10; + +async function createBackups(nBackups, dateObj, bookmarksBackupDir) { + // Generate random dates. + let dates = []; + while (dates.length < nBackups) { + // Use last year to ensure today's backup is the newest. + let randomDate = new Date( + dateObj.getFullYear() - 1, + Math.floor(12 * Math.random()), + Math.floor(28 * Math.random()) + ); + if (!dates.includes(randomDate.getTime())) { + dates.push(randomDate.getTime()); + } + } + // Sort dates from oldest to newest. + dates.sort(); + + // Fake backups are created backwards to ensure we won't consider file + // creation time. + // Create fake backups for the newest dates. + for (let i = dates.length - 1; i >= 0; i--) { + let backupFilename = PlacesBackups.getFilenameForDate(new Date(dates[i])); + let backupFile = bookmarksBackupDir.clone(); + backupFile.append(backupFilename); + backupFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0666", 8)); + info("Creating fake backup " + backupFile.leafName); + if (!backupFile.exists()) { + do_throw("Unable to create fake backup " + backupFile.leafName); + } + } + + return dates; +} + +async function checkBackups(dates, bookmarksBackupDir) { + // Check backups. We have 11 dates but we the max number is 10 so the + // oldest backup should have been removed. + for (let i = 0; i < dates.length; i++) { + let backupFilename; + let shouldExist; + let backupFile; + if (i > 0) { + let files = bookmarksBackupDir.directoryEntries; + while (files.hasMoreElements()) { + let entry = files.nextFile; + if (PlacesBackups.filenamesRegex.test(entry.leafName)) { + backupFilename = entry.leafName; + backupFile = entry; + break; + } + } + shouldExist = true; + } else { + backupFilename = PlacesBackups.getFilenameForDate(new Date(dates[i])); + backupFile = bookmarksBackupDir.clone(); + backupFile.append(backupFilename); + shouldExist = false; + } + if (backupFile.exists() != shouldExist) { + do_throw( + "Backup should " + + (shouldExist ? "" : "not") + + " exist: " + + backupFilename + ); + } + } +} + +async function cleanupFiles(bookmarksBackupDir) { + // Cleanup backups folder. + // XXX: Can't use bookmarksBackupDir.remove(true) because file lock happens + // on WIN XP. + let files = bookmarksBackupDir.directoryEntries; + while (files.hasMoreElements()) { + let entry = files.nextFile; + entry.remove(false); + } + // Clear cache to match the manual removing of files + delete PlacesBackups._backupFiles; + Assert.ok(!bookmarksBackupDir.directoryEntries.hasMoreElements()); +} + +add_task(async function test_create_backups() { + let backupFolderPath = await PlacesBackups.getBackupFolder(); + let bookmarksBackupDir = new FileUtils.File(backupFolderPath); + + let dateObj = new Date(); + let dates = await createBackups( + NUMBER_OF_BACKUPS, + dateObj, + bookmarksBackupDir + ); + // Add today's backup. + await PlacesBackups.create(NUMBER_OF_BACKUPS); + dates.push(dateObj.getTime()); + await checkBackups(dates, bookmarksBackupDir); + await cleanupFiles(bookmarksBackupDir); +}); + +add_task(async function test_saveBookmarks_with_no_backups() { + let backupFolderPath = await PlacesBackups.getBackupFolder(); + let bookmarksBackupDir = new FileUtils.File(backupFolderPath); + + Services.prefs.setIntPref("browser.bookmarks.max_backups", 0); + + let filePath = PathUtils.join(do_get_tempdir().path, "backup.json"); + await PlacesBackups.saveBookmarksToJSONFile(filePath); + let files = bookmarksBackupDir.directoryEntries; + Assert.ok(!files.hasMoreElements(), "Should have no backup files."); + await IOUtils.remove(filePath); + // We don't need to call cleanupFiles as we are not creating any + // backups but need to reset the cache. + delete PlacesBackups._backupFiles; +}); + +add_task(async function test_saveBookmarks_with_backups() { + let backupFolderPath = await PlacesBackups.getBackupFolder(); + let bookmarksBackupDir = new FileUtils.File(backupFolderPath); + + Services.prefs.setIntPref("browser.bookmarks.max_backups", NUMBER_OF_BACKUPS); + + let filePath = PathUtils.join(do_get_tempdir().path, "backup.json"); + let dateObj = new Date(); + let dates = await createBackups( + NUMBER_OF_BACKUPS, + dateObj, + bookmarksBackupDir + ); + + await PlacesBackups.saveBookmarksToJSONFile(filePath); + + let backupPath = await PlacesBackups.getMostRecentBackup(); + Assert.ok(await IOUtils.read(backupPath, { decompress: true })); + + dates.push(dateObj.getTime()); + await checkBackups(dates, bookmarksBackupDir); + await IOUtils.remove(filePath); + await cleanupFiles(bookmarksBackupDir); +}); diff --git a/toolkit/components/places/tests/unit/test_utils_backups_hasRecent.js b/toolkit/components/places/tests/unit/test_utils_backups_hasRecent.js new file mode 100644 index 0000000000..77d356b032 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_utils_backups_hasRecent.js @@ -0,0 +1,43 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Check for correct functionality of bookmarks backups + */ + +/** + * Creates a fake empty dated backup. + * @param {Date} date the Date to use for the backup file name + * @param {string} backupsFolderPath the path to the backups folder + * @returns path of the created backup file + */ +async function createFakeBackup(date, backupsFolderPath) { + let backupFilePath = PathUtils.join( + backupsFolderPath, + PlacesBackups.getFilenameForDate(date) + ); + await IOUtils.write(backupFilePath, new Uint8Array()); + return backupFilePath; +} + +add_task(async function test_hasRecentBackup() { + let backupFolderPath = await PlacesBackups.getBackupFolder(); + Assert.ok(!(await PlacesBackups.hasRecentBackup()), "Check no recent backup"); + + await createFakeBackup(new Date(Date.now() - 4 * 86400), backupFolderPath); + Assert.ok(!(await PlacesBackups.hasRecentBackup()), "Check no recent backup"); + PlacesBackups.invalidateCache(); + await createFakeBackup(new Date(Date.now() - 2 * 86400), backupFolderPath); + Assert.ok(await PlacesBackups.hasRecentBackup(), "Check has recent backup"); + PlacesBackups.invalidateCache(); + + try { + await IOUtils.remove(backupFolderPath, { recursive: true }); + } catch (ex) { + // On Windows the files may be locked. + info("Unable to cleanup the backups test folder"); + } +}); diff --git a/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js b/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js new file mode 100644 index 0000000000..2d092ef591 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js @@ -0,0 +1,266 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Check for correct functionality of PlacesUtils.getURLsForContainerNode and + * PlacesUtils.hasChildURIs (those helpers share almost all of their code) + */ + +var PU = PlacesUtils; +var hs = PU.history; + +add_task(async function test_getURLsForContainerNode_folder() { + info("*** TEST: folder"); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + // This is the folder we will check for children. + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + // Create a folder and a query node inside it, these should not be considered + // uri nodes. + children: [ + { + title: "inside folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "place:sort=1", + title: "inside query", + }, + ], + }, + ], + }, + ], + }); + + var query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = hs.getNewQueryOptions(); + + info("Check folder without uri nodes"); + check_uri_nodes(query, options, 0); + + info("Check folder with uri nodes"); + // Add an uri node, this should be considered. + await PlacesUtils.bookmarks.insert({ + parentGuid: bookmarks[0].guid, + url: "http://www.mozilla.org/", + title: "bookmark", + }); + check_uri_nodes(query, options, 1); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_getURLsForContainerNode_folder_excludeItems() { + info("*** TEST: folder in an excludeItems root"); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + // This is the folder we will check for children. + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + // Create a folder and a query node inside it, these should not be considered + // uri nodes. + children: [ + { + title: "inside folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "place:sort=1", + title: "inside query", + }, + ], + }, + ], + }, + ], + }); + + var query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = hs.getNewQueryOptions(); + options.excludeItems = true; + + info("Check folder without uri nodes"); + check_uri_nodes(query, options, 0); + + info("Check folder with uri nodes"); + // Add an uri node, this should be considered. + await PlacesUtils.bookmarks.insert({ + parentGuid: bookmarks[0].guid, + url: "http://www.mozilla.org/", + title: "bookmark", + }); + check_uri_nodes(query, options, 1); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_getURLsForContainerNode_query() { + info("*** TEST: query"); + // This is the query we will check for children. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "inside query", + url: `place:parent=${PlacesUtils.bookmarks.menuGuid}&sort=1`, + }); + + // Create a folder and a query node inside it, these should not be considered + // uri nodes. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "inside folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "place:sort=1", + title: "inside query", + }, + ], + }, + ], + }); + + var query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = hs.getNewQueryOptions(); + + info("Check query without uri nodes"); + check_uri_nodes(query, options, 0); + + info("Check query with uri nodes"); + // Add an uri node, this should be considered. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://www.mozilla.org/", + title: "bookmark", + }); + check_uri_nodes(query, options, 1); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_getURLsForContainerNode_query_excludeItems() { + info("*** TEST: excludeItems Query"); + // This is the query we will check for children. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "inside query", + url: `place:parent=${PlacesUtils.bookmarks.menuGuid}&sort=1`, + }); + + // Create a folder and a query node inside it, these should not be considered + // uri nodes. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "inside folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "place:sort=1", + title: "inside query", + }, + ], + }, + ], + }); + + var query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = hs.getNewQueryOptions(); + options.excludeItems = true; + + info("Check folder without uri nodes"); + check_uri_nodes(query, options, 0); + + info("Check folder with uri nodes"); + // Add an uri node, this should be considered. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://www.mozilla.org/", + title: "bookmark", + }); + check_uri_nodes(query, options, 1); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_getURLsForContainerNode_query_excludeQueries() { + info("*** TEST: !expandQueries Query"); + // This is the query we will check for children. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "inside query", + url: `place:parent=${PlacesUtils.bookmarks.menuGuid}&sort=1`, + }); + + // Create a folder and a query node inside it, these should not be considered + // uri nodes. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "inside folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + url: "place:sort=1", + title: "inside query", + }, + ], + }, + ], + }); + + var query = hs.getNewQuery(); + query.setParents([PlacesUtils.bookmarks.toolbarGuid]); + var options = hs.getNewQueryOptions(); + options.expandQueries = false; + + info("Check folder without uri nodes"); + check_uri_nodes(query, options, 0); + + info("Check folder with uri nodes"); + // Add an uri node, this should be considered. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://www.mozilla.org/", + title: "bookmark", + }); + check_uri_nodes(query, options, 1); +}); + +/** + * Executes a query and checks number of uri nodes in the first container in + * query's results. To correctly test a container ensure that the query will + * return only your container in the first level. + * + * @param aQuery + * nsINavHistoryQuery object defining the query + * @param aOptions + * nsINavHistoryQueryOptions object defining the query's options + * @param aExpectedURINodes + * number of expected uri nodes + */ +function check_uri_nodes(aQuery, aOptions, aExpectedURINodes) { + var result = hs.executeQuery(aQuery, aOptions); + var root = result.root; + root.containerOpen = true; + var node = root.getChild(0); + Assert.equal(PU.hasChildURIs(node), aExpectedURINodes > 0); + Assert.equal(PU.getURLsForContainerNode(node).length, aExpectedURINodes); + root.containerOpen = false; +} diff --git a/toolkit/components/places/tests/unit/test_utils_timeConversion.js b/toolkit/components/places/tests/unit/test_utils_timeConversion.js new file mode 100644 index 0000000000..c71effe709 --- /dev/null +++ b/toolkit/components/places/tests/unit/test_utils_timeConversion.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check time conversion utils. + */ + +add_task(async function toDate() { + Assert.throws(() => PlacesUtils.toDate(), /Invalid value/, "Should throw"); + Assert.throws(() => PlacesUtils.toDate(NaN), /Invalid value/, "Should throw"); + Assert.throws( + () => PlacesUtils.toDate(null), + /Invalid value/, + "Should throw" + ); + Assert.throws(() => PlacesUtils.toDate("1"), /Invalid value/, "Should throw"); + + const now = Date.now(); + const usecs = now * 1000; + Assert.deepEqual(PlacesUtils.toDate(usecs), new Date(now)); +}); + +add_task(async function toPRTime() { + Assert.throws(() => PlacesUtils.toPRTime(), /TypeError/, "Should throw"); + Assert.throws(() => PlacesUtils.toPRTime(null), /TypeError/, "Should throw"); + Assert.throws( + () => PlacesUtils.toPRTime({}), + /Invalid value/, + "Should throw" + ); + Assert.throws( + () => PlacesUtils.toPRTime(NaN), + /Invalid value/, + "Should throw" + ); + Assert.throws( + () => PlacesUtils.toPRTime(new Date(NaN)), + /Invalid value/, + "Should throw" + ); + Assert.throws( + () => PlacesUtils.toPRTime(new URL("https://test.moz")), + /Invalid value/, + "Should throw" + ); + + const now = Date.now(); + const usecs = now * 1000; + Assert.strictEqual(PlacesUtils.toPRTime(now), usecs); + Assert.strictEqual(PlacesUtils.toPRTime(new Date(now)), usecs); +}); diff --git a/toolkit/components/places/tests/unit/test_visitsInDB.js b/toolkit/components/places/tests/unit/test_visitsInDB.js new file mode 100644 index 0000000000..cbd0a04c4b --- /dev/null +++ b/toolkit/components/places/tests/unit/test_visitsInDB.js @@ -0,0 +1,12 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +add_task(async function test_execute() { + const TEST_URI = uri("http://mozilla.com"); + + Assert.equal(0, await PlacesTestUtils.visitsInDB(TEST_URI)); + await PlacesTestUtils.addVisits({ uri: TEST_URI }); + Assert.equal(1, await PlacesTestUtils.visitsInDB(TEST_URI)); + await PlacesTestUtils.addVisits({ uri: TEST_URI }); + Assert.equal(2, await PlacesTestUtils.visitsInDB(TEST_URI)); +}); diff --git a/toolkit/components/places/tests/unit/xpcshell.ini b/toolkit/components/places/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..99f8d04754 --- /dev/null +++ b/toolkit/components/places/tests/unit/xpcshell.ini @@ -0,0 +1,120 @@ +[DEFAULT] +head = head_bookmarks.js +firefox-appdir = browser +prefs = places.loglevel="All" +support-files = + bookmarks.corrupt.html + bookmarks.json + bookmarks_corrupt.json + bookmarks.preplaces.html + bookmarks_html_localized.html + bookmarks_html_singleframe.html + bookmarks_iconuri.json + mobile_bookmarks_folder_import.json + mobile_bookmarks_folder_merge.json + mobile_bookmarks_multiple_folders.json + mobile_bookmarks_root_import.json + mobile_bookmarks_root_merge.json + places.sparse.sqlite + +[test_331487.js] +[test_384370.js] +[test_385397.js] +[test_399266.js] +skip-if = os == "linux" # Bug 821781 +[test_402799.js] +[test_412132.js] +[test_415460.js] +[test_415757.js] +[test_419792_node_tags_property.js] +[test_425563.js] +[test_429505_remove_shortcuts.js] +[test_433317_query_title_update.js] +[test_433525_hasChildren_crash.js] +[test_454977.js] +[test_463863.js] +[test_485442_crash_bug_nsNavHistoryQuery_GetUri.js] +[test_486978_sort_by_date_queries.js] +[test_536081.js] +[test_1085291.js] +[test_1105208.js] +[test_1105866.js] +[test_1606731.js] +[test_PlacesQuery_history.js] +[test_asyncExecuteLegacyQueries.js] +[test_async_transactions.js] +[test_autocomplete_match_fallbackTitle.js] +[test_bookmark-tags-changed_frequency.js] +[test_bookmarks_json.js] +[test_bookmarks_json_corrupt.js] +[test_bookmarks_html.js] +[test_bookmarks_html_corrupt.js] +[test_bookmarks_html_escape_entities.js] +[test_bookmarks_html_import_tags.js] +[test_bookmarks_html_localized.js] +[test_bookmarks_html_singleframe.js] +[test_bookmarks_restore_notification.js] +[test_broken_folderShortcut_result.js] +[test_browserhistory.js] +[test_childlessTags.js] +[test_frecency_decay.js] +[test_frecency_origins_alternative.js] +[test_frecency_origins_recalc.js] +[test_frecency_pages_alternative.js] +prefs = places.frecency.pages.alternative.featureGate=true +[test_frecency_pages_recalc_alt.js] +prefs = places.frecency.pages.alternative.featureGate=true +[test_frecency_recalc_triggers.js] +[test_frecency_recalculator.js] +[test_frecency_unvisited_bookmark.js] +[test_frecency_zero_updated.js] +[test_getChildIndex.js] +[test_hash.js] +[test_history.js] +[test_history_clear.js] +[test_history_notifications.js] +[test_history_observer.js] +[test_history_sidebar.js] +[test_import_mobile_bookmarks.js] +[test_isPageInDB.js] +[test_isURIVisited.js] +[test_isvisited.js] +[test_keywords.js] +[test_lastModified.js] +[test_markpageas.js] +[test_metadata.js] +[test_missing_builtin_folders.js] +support-files = missingBuiltIn.sqlite +[test_missing_root_folder.js] +support-files = noRoot.sqlite +[test_multi_observation.js] +[test_multi_word_tags.js] +[test_nested_notifications.js] +[test_nsINavHistoryViewer.js] +[test_null_interfaces.js] +[test_origins.js] +[test_origins_parsing.js] +[test_pageGuid_bookmarkGuid.js] +[test_frecency_observers.js] +[test_PlacesDBUtils_removeOldCorruptDBs.js] +[test_placeURIs.js] +[test_PlacesUtils_invalidateCachedGuidFor.js] +[test_PlacesUtils_invalidateCachedGuids.js] +[test_PlacesUtils_isRootItem.js] +[test_PlacesUtils_unwrapNodes_place.js] +[test_promiseBookmarksTree.js] +[test_resolveNullBookmarkTitles.js] +[test_result_sort.js] +[test_resultsAsVisit_details.js] +[test_sql_function_origin.js] +[test_sql_guid_functions.js] +[test_tag_autocomplete_search.js] +[test_tagging.js] +[test_telemetry.js] +[test_update_frecency_after_delete.js] +[test_utils_backups_create.js] +[test_utils_backups_hasRecent.js] +[test_utils_getURLsForContainerNode.js] +[test_utils_timeConversion.js] +[test_visitsInDB.js] +[test_get_query_param_sql_function.js] |