diff options
Diffstat (limited to 'toolkit/components/places/tests/PlacesTestUtils.sys.mjs')
-rw-r--r-- | toolkit/components/places/tests/PlacesTestUtils.sys.mjs | 566 |
1 files changed, 566 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..3db8480e99 --- /dev/null +++ b/toolkit/components/places/tests/PlacesTestUtils.sys.mjs @@ -0,0 +1,566 @@ +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; + 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; + if (typeof place.referrer == "string") { + referrer = Services.io.newURI(place.referrer); + } else if (place.referrer) { + referrer = URL.isInstance(place.referrer) + ? Services.io.newURI(place.referrer.href) + : place.referrer; + } + 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) { + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let db = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url", + { url } + ); + return !!rows.length; + }, + + /** + * 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); + }, + + /** + * Asynchronously returns the required DB field for a specified page. + * @param aURI + * nsIURI or address to look for. + * + * @return {Promise} + * @resolves Returns the field value. + * @rejects JavaScript exception. + */ + fieldInDB(aURI, field) { + let url = aURI instanceof Ci.nsIURI ? new URL(aURI.spec) : new URL(aURI); + return lazy.PlacesUtils.withConnectionWrapper( + "PlacesTestUtils.jsm: fieldInDb", + async db => { + let rows = await db.executeCached( + `SELECT ${field} FROM moz_places + WHERE url_hash = hash(:url) AND url = :url`, + { url: url.href } + ); + 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")), + })); + }, + + waitForNotification(notification, conditionFn, type = "bookmarks") { + if (type == "places") { + return new Promise(resolve => { + function listener(events) { + if (!conditionFn || conditionFn(events)) { + PlacesObservers.removeListener([notification], listener); + resolve(events); + } + } + PlacesObservers.addListener([notification], listener); + }); + } + + return new Promise(resolve => { + let proxifiedObserver = new Proxy( + {}, + { + get: (target, name) => { + if (name == "QueryInterface") { + return ChromeUtils.generateQI([Ci.nsINavBookmarkObserver]); + } + if (name == notification) { + return (...args) => { + if (!conditionFn || conditionFn.apply(this, args)) { + lazy.PlacesUtils[type].removeObserver(proxifiedObserver); + resolve(); + } + }; + } + if (name == "skipTags") { + return false; + } + return () => false; + }, + } + ); + lazy.PlacesUtils[type].addObserver(proxifiedObserver); + }); + }, + + /** + * A debugging helper that dumps the contents of an SQLite table. + * + * @param {Sqlite.OpenedConnection} db + * The mirror database connection. + * @param {String} table + * The table name. + */ + async dumpTable(db, table, columns = null) { + 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; + }, +}); |