diff options
Diffstat (limited to 'toolkit/components/places/tests/PlacesTestUtils.sys.mjs')
-rw-r--r-- | toolkit/components/places/tests/PlacesTestUtils.sys.mjs | 649 |
1 files changed, 649 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..acd1152b44 --- /dev/null +++ b/toolkit/components/places/tests/PlacesTestUtils.sys.mjs @@ -0,0 +1,649 @@ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +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 seenUrls = new Set(); + 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, + }, + ]; + seenUrls.add(info.url); + infos.push(info); + if ( + !place.transition || + place.transition != lazy.PlacesUtils.history.TRANSITIONS.EMBED + ) { + lastStoredVisit = info; + } + } + await lazy.PlacesUtils.history.insertMany(infos); + if (seenUrls.size > 1) { + // If there's only one URL then history has updated frecency already, + // otherwise we must force a recalculation. + await lazy.PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + } + 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]?.getResultByIndex(0); + }, + + /** + * 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; + } + ); + }, + + async promiseItemId(guid) { + return this.getDatabaseValue("moz_bookmarks", "id", { guid }); + }, + + async promiseItemGuid(id) { + return this.getDatabaseValue("moz_bookmarks", "guid", { id }); + }, + + async promiseManyItemIds(guids) { + let conn = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await conn.executeCached(` + SELECT guid, id FROM moz_bookmarks WHERE guid IN (${guids + .map(guid => "'" + guid + "'") + .join()} + )`); + return new Map( + rows.map(r => [r.getResultByName("guid"), r.getResultByName("id")]) + ); + }, + + _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, + }; + }, +}); |