Source code

Revision control

Copy as Markdown

Other Tools

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
PlacesUtils: "resource://gre/modules/PlacesUtils.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.exposableURI = Services.io.createExposableURI(
Services.io.newURI(spec)
);
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.exposableURI),
"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");
}
let uri = Services.io.newURI(key);
let faviconURI = Services.io.newURI(val);
if (!faviconURI.schemeIs("data")) {
throw new Error(`Favicon URL should be data URL [${faviconURI.spec}]`);
}
faviconPromises.push(
lazy.PlacesUtils.favicons.setFaviconForPage(uri, faviconURI, faviconURI)
);
}
await Promise.all(faviconPromises);
},
/*
* Helper function to call PlacesUtils.favicons.setFaviconForPage() and waits
* finishing setting. This function throws an error if the status of
* PlacesUtils.favicons.setFaviconForPage() is not success.
*
* @param {string or nsIURI} pageURI
* @param {string or nsIURI} faviconURI
* @param {string or nsIURI} faviconDataURL
* @param {Number} [optional] expiration
* @return {Promise} waits for finishing setting
*/
setFaviconForPage(
pageURI,
faviconURI,
faviconDataURL,
expiration = 0,
isRichIcon = false
) {
return lazy.PlacesUtils.favicons.setFaviconForPage(
pageURI instanceof Ci.nsIURI ? pageURI : Services.io.newURI(pageURI),
faviconURI instanceof Ci.nsIURI
? faviconURI
: Services.io.newURI(faviconURI),
faviconDataURL instanceof Ci.nsIURI
? faviconDataURL
: Services.io.newURI(faviconDataURL),
expiration,
isRichIcon
);
},
/**
* Get favicon data for given URL from database.
*
* @param {nsIURI} faviconURI
* nsIURI for the favicon
* @return {nsIURI} data URL
*/
async getFaviconDataURLFromDB(faviconURI) {
const db = await lazy.PlacesUtils.promiseDBConnection();
const rows = await db.executeCached(
`SELECT data, width
FROM moz_icons
WHERE fixed_icon_url_hash = hash(fixup_url(:url))
AND icon_url = :url
ORDER BY width DESC`,
{ url: faviconURI.spec }
);
if (!rows.length) {
return null;
}
const row = rows[0];
const data = row.getResultByName("data");
if (!data.length) {
return null;
}
const UINT64_MAX = 65535;
const width = row.getResultByName("width");
const contentType = width === UINT64_MAX ? "image/svg+xml" : "image/png";
return await PlacesTestUtils.fileDataToDataURL(data, contentType);
},
/**
* Get favicon data for given URL from network.
*
* @param {nsIURI} faviconURI
* nsIURI for the favicon.
* @param {nsIPrincipal} [optional] loadingPrincipal
* The principal to load from network. If no, use system principal.
* @return {nsIURI} data URL
*
* @note This fetching code is for test-code only and should not be copied to
* production code, as a proper principal and loadGroup, or ohttp, should
* be used by the browser when fetching from the network.
*/
async getFaviconDataURLFromNetwork(
faviconURI,
loadingPrincipal = Services.scriptSecurityManager.getSystemPrincipal()
) {
if (faviconURI.schemeIs("data")) {
return faviconURI;
}
let channel = lazy.NetUtil.newChannel({
uri: faviconURI,
loadingPrincipal,
securityFlags:
Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT |
Ci.nsILoadInfo.SEC_ALLOW_CHROME |
Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT,
contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
});
let resolver = Promise.withResolvers();
lazy.NetUtil.asyncFetch(channel, async (input, status, request) => {
if (!Components.isSuccessCode(status)) {
resolver.reject(status);
return;
}
try {
let data = lazy.NetUtil.readInputStream(input, input.available());
let contentType = request.QueryInterface(Ci.nsIChannel).contentType;
input.close();
let dataURL = await PlacesTestUtils.fileDataToDataURL(
data,
contentType
);
resolver.resolve(dataURL);
} catch (e) {
resolver.reject(e);
}
});
return resolver.promise;
},
/**
* 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();
});
},
/**
* Converts the given data to the data URL.
*
* @param data
* The file data.
* @param mimeType
* The mime type of the file content.
* @return Promise that retunes data URL.
*/
async fileDataToDataURL(data, mimeType) {
const dataURL = await new Promise(resolve => {
const buffer = new Uint8ClampedArray(data);
const blob = new Blob([buffer], { type: mimeType });
const reader = new FileReader();
reader.onload = e => {
resolve(Services.io.newURI(e.target.result));
};
reader.readAsDataURL(blob);
});
return dataURL;
},
/**
* 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. It's possible to
* pass an array as value where the first element is an operator
* (e.g. "<", ">") and the second element is the actual value.
* @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. It's possible to
* pass an array as value where the first element is an operator
* (e.g. "<", ">") and the second element is the actual value.
* @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 if (Array.isArray(value)) {
// First element is the operator, second element is the value.
let [op, actualValue] = value;
fragments.push(`${column} ${op} :${column}`);
value = actualValue;
} else {
fragments.push(`${column} = :${column}`);
}
params[column] = value;
}
return {
fragment: fragments.length ? `WHERE ${fragments.join(" AND ")}` : "",
params,
};
},
});