Source code

Revision control

Other Tools

"use strict";
var EXPORTED_SYMBOLS = ["PlacesTestUtils"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
);
ChromeUtils.defineModuleGetter(
this,
"TestUtils",
);
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: 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 ||
obj instanceof URL ||
typeof obj == "string"
) {
place = { uri: obj };
} else if (typeof obj == "object" && obj.uri) {
place = obj;
} else {
throw new Error("Unsupported type passed to addVisits");
}
let info = { url: place.uri };
let spec =
place.uri instanceof Ci.nsIURI
? place.uri.spec
: new URL(place.uri).href;
info.title = "title" in place ? place.title : "test visit for " + spec;
if (typeof place.referrer == "string") {
place.referrer = Services.io.newURI(place.referrer);
} else if (place.referrer && place.referrer instanceof URL) {
place.referrer = Services.io.newURI(place.referrer.href);
}
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 = PlacesUtils.toDate(visitDate);
}
} else {
visitDate = new Date();
}
info.visits = [
{
transition: place.transition,
date: visitDate,
referrer: place.referrer,
},
];
infos.push(info);
if (
!place.transition ||
place.transition != PlacesUtils.history.TRANSITIONS.EMBED
) {
lastStoredVisit = info;
}
}
await PlacesUtils.history.insertMany(infos);
if (lastStoredVisit) {
await TestUtils.waitForCondition(
() => 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 {
PlacesUtils.favicons.setAndFetchFaviconForPage(
uri,
faviconURI,
false,
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");
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 PlacesUtils.bookmarks.insert({
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
title: aBookmarkObj.title || "A bookmark",
url: aBookmarkObj.uri,
});
if (aBookmarkObj.keyword) {
await 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);
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 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 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 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 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 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: 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 PlacesUtils.withConnectionWrapper(
"PlacesTestUtils: setBookmarkSyncFields",
function(db) {
return db.executeTransaction(async function() {
for (let info of aFieldInfos) {
if (!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
? PlacesUtils.toPRTime(info.lastModified)
: null,
dateAdded:
"dateAdded" in info
? PlacesUtils.toPRTime(info.dateAdded)
: null,
}
);
}
});
}
);
},
async fetchBookmarkSyncFields(...aGuids) {
let db = await 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: PlacesUtils.toDate(
rows[0].getResultByName("lastModified")
),
dateAdded: PlacesUtils.toDate(rows[0].getResultByName("dateAdded")),
});
}
return results;
},
async fetchSyncTombstones() {
let db = await 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: 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();
}
}
PlacesObservers.addListener([notification], listener);
});
}
let iface =
type == "bookmarks"
? Ci.nsINavBookmarkObserver
: Ci.nsINavHistoryObserver;
return new Promise(resolve => {
let proxifiedObserver = new Proxy(
{},
{
get: (target, name) => {
if (name == "QueryInterface") {
return ChromeUtils.generateQI([iface]);
}
if (name == notification) {
return (...args) => {
if (!conditionFn || conditionFn.apply(this, args)) {
PlacesUtils[type].removeObserver(proxifiedObserver);
resolve();
}
};
}
if (name == "skipTags") {
return false;
}
return () => false;
},
}
);
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) {
let rows = await db.execute(`SELECT * FROM ${table}`);
dump(`Table ${table} contains ${rows.length} rows\n`);
let results = [];
for (let row of rows) {
let numColumns = row.numEntries;
let rowValues = [];
for (let i = 0; i < numColumns; ++i) {
switch (row.getTypeOfIndex(i)) {
case Ci.mozIStorageValueArray.VALUE_TYPE_NULL:
rowValues.push("NULL");
break;
case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER:
rowValues.push(row.getInt64(i));
break;
case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT:
rowValues.push(row.getDouble(i));
break;
case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT:
rowValues.push(JSON.stringify(row.getString(i)));
break;
}
}
results.push(rowValues.join("\t"));
}
results.push("\n");
dump(results.join("\n"));
},
/**
* Removes all stored metadata.
*/
clearMetadata() {
return PlacesUtils.withConnectionWrapper(
"PlacesTestUtils: clearMetadata",
async db => {
await db.execute(`DELETE FROM moz_meta`);
PlacesUtils.metadata.cache.clear();
}
);
},
/**
* 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;
},
});