summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/tests/PlacesTestUtils.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/places/tests/PlacesTestUtils.sys.mjs649
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,
+ };
+ },
+});