Source code

Revision control

Copy as Markdown

Other Tools

/* Any copyright is dedicated to the Public Domain.
// Import common head.
{
/* import-globals-from ../head_common.js */
let commonFile = do_get_file("../head_common.js", false);
let uri = Services.io.newFileURI(commonFile);
Services.scriptloader.loadSubScript(uri.spec, this);
}
// Put any other stuff relative to this test folder below.
var { CanonicalJSON } = ChromeUtils.importESModule(
);
var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
var { PlacesSyncUtils } = ChromeUtils.importESModule(
);
var { SyncedBookmarksMirror } = ChromeUtils.importESModule(
);
var { CommonUtils } = ChromeUtils.importESModule(
);
var { FileTestUtils } = ChromeUtils.importESModule(
);
var {
HTTP_400,
HTTP_401,
HTTP_402,
HTTP_403,
HTTP_404,
HTTP_405,
HTTP_406,
HTTP_407,
HTTP_408,
HTTP_409,
HTTP_410,
HTTP_411,
HTTP_412,
HTTP_413,
HTTP_414,
HTTP_415,
HTTP_417,
HTTP_500,
HTTP_501,
HTTP_502,
HTTP_503,
HTTP_504,
HTTP_505,
HttpError,
HttpServer,
} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");
// These titles are defined in Database::CreateBookmarkRoots
const BookmarksMenuTitle = "menu";
const BookmarksToolbarTitle = "toolbar";
const UnfiledBookmarksTitle = "unfiled";
const MobileBookmarksTitle = "mobile";
function run_test() {
let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror");
bufLog.level = Log.Level.All;
let sqliteLog = Log.repository.getLogger("Sqlite");
sqliteLog.level = Log.Level.Error;
let formatter = new Log.BasicFormatter();
let appender = new Log.DumpAppender(formatter);
appender.level = Log.Level.All;
for (let log of [bufLog, sqliteLog]) {
log.addAppender(appender);
}
do_get_profile();
run_next_test();
}
// A test helper to insert local roots directly into Places, since the public
// bookmarks APIs no longer support custom roots.
async function insertLocalRoot({ guid, title }) {
await PlacesUtils.withConnectionWrapper(
"insertLocalRoot",
async function (db) {
let dateAdded = PlacesUtils.toPRTime(new Date());
await db.execute(
`
INSERT INTO moz_bookmarks(guid, type, parent, position, title,
dateAdded, lastModified)
VALUES(:guid, :type, (SELECT id FROM moz_bookmarks
WHERE guid = :parentGuid),
(SELECT COUNT(*) FROM moz_bookmarks
WHERE parent = (SELECT id FROM moz_bookmarks
WHERE guid = :parentGuid)),
:title, :dateAdded, :dateAdded)`,
{
guid,
type: PlacesUtils.bookmarks.TYPE_FOLDER,
parentGuid: PlacesUtils.bookmarks.rootGuid,
title,
dateAdded,
}
);
}
);
}
// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext.
// This exists to avoid importing `record.js` from Sync.
function makeRecord(cleartext) {
return new Proxy(
{ cleartext },
{
get(target, property) {
if (property == "cleartext") {
return target.cleartext;
}
if (property == "cleartextToString") {
return () => JSON.stringify(target.cleartext);
}
return target.cleartext[property];
},
set(target, property, value) {
if (property == "cleartext") {
target.cleartext = value;
} else if (property != "cleartextToString") {
target.cleartext[property] = value;
}
},
has(target, property) {
return property == "cleartext" || property in target.cleartext;
},
deleteProperty() {},
ownKeys(target) {
return ["cleartext", ...Reflect.ownKeys(target)];
},
}
);
}
async function storeRecords(buf, records, options) {
await buf.store(records.map(makeRecord), options);
}
async function storeChangesInMirror(buf, changesToUpload) {
let cleartexts = [];
for (let recordId in changesToUpload) {
changesToUpload[recordId].synced = true;
cleartexts.push(changesToUpload[recordId].cleartext);
}
await storeRecords(buf, cleartexts, { needsMerge: false });
await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload);
}
function inspectChangeRecords(changeRecords) {
let results = { updated: [], deleted: [] };
for (let [id, record] of Object.entries(changeRecords)) {
(record.tombstone ? results.deleted : results.updated).push(id);
}
results.updated.sort();
results.deleted.sort();
return results;
}
async function promiseManyDatesAdded(guids) {
let datesAdded = new Map();
let db = await PlacesUtils.promiseDBConnection();
for (let chunk of PlacesUtils.chunkArray(guids, 100)) {
let rows = await db.executeCached(
`
SELECT guid, dateAdded FROM moz_bookmarks
WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`,
chunk
);
if (rows.length != chunk.length) {
throw new TypeError("Can't fetch date added for nonexistent items");
}
for (let row of rows) {
let dateAdded = row.getResultByName("dateAdded") / 1000;
datesAdded.set(row.getResultByName("guid"), dateAdded);
}
}
return datesAdded;
}
async function fetchLocalTree(rootGuid) {
function bookmarkNodeToInfo(node) {
let { guid, index, title, typeCode: type } = node;
let itemInfo = { guid, index, title, type };
if (node.annos) {
let syncableAnnos = node.annos.filter(anno =>
[PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes(
anno.name
)
);
if (syncableAnnos.length) {
itemInfo.annos = syncableAnnos;
}
}
if (node.uri) {
itemInfo.url = node.uri;
}
if (node.keyword) {
itemInfo.keyword = node.keyword;
}
if (node.children) {
itemInfo.children = node.children.map(bookmarkNodeToInfo);
}
if (node.tags) {
itemInfo.tags = node.tags.split(",").sort();
}
return itemInfo;
}
let root = await PlacesUtils.promiseBookmarksTree(rootGuid);
return bookmarkNodeToInfo(root);
}
async function assertLocalTree(rootGuid, expected, message) {
let actual = await fetchLocalTree(rootGuid);
if (!ObjectUtils.deepEqual(actual, expected)) {
info(
`Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}`
);
info(
`Actual structure for ${rootGuid}: ${CanonicalJSON.stringify(actual)}`
);
throw new Assert.constructor.AssertionError({ actual, expected, message });
}
}
function makeLivemarkServer() {
let server = new HttpServer();
server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
server.start(-1);
return {
server,
get site() {
let { identity } = server;
let host = identity.primaryHost.includes(":")
? `[${identity.primaryHost}]`
: identity.primaryHost;
return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
},
stopServer() {
return new Promise(resolve => server.stop(resolve));
},
};
}
function shuffle(array) {
let results = [];
for (let i = 0; i < array.length; ++i) {
let randomIndex = Math.floor(Math.random() * (i + 1));
results[i] = results[randomIndex];
results[randomIndex] = array[i];
}
return results;
}
async function fetchAllKeywords(info) {
let entries = [];
await PlacesUtils.keywords.fetch(info, entry => entries.push(entry));
return entries;
}
async function openMirror(name, options = {}) {
let buf = await SyncedBookmarksMirror.open({
path: `${name}_buf.sqlite`,
recordStepTelemetry(...args) {
if (options.recordStepTelemetry) {
options.recordStepTelemetry.call(this, ...args);
}
},
recordValidationTelemetry(...args) {
if (options.recordValidationTelemetry) {
options.recordValidationTelemetry.call(this, ...args);
}
},
});
return buf;
}
function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) {
this.notifications = [];
this.ignoreDates = ignoreDates;
this.skipTags = skipTags;
this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
}
BookmarkObserver.prototype = {
handlePlacesEvents(events) {
for (let event of events) {
switch (event.type) {
case "bookmark-added": {
if (this.skipTags && event.isTagging) {
continue;
}
let params = {
itemId: event.id,
parentId: event.parentId,
index: event.index,
type: event.itemType,
urlHref: event.url,
title: event.title,
guid: event.guid,
parentGuid: event.parentGuid,
source: event.source,
tags: event.tags,
frecency: event.frecency,
hidden: event.hidden,
visitCount: event.visitCount,
};
if (!this.ignoreDates) {
params.dateAdded = event.dateAdded;
params.lastVisitDate = event.lastVisitDate;
}
this.notifications.push({ name: "bookmark-added", params });
break;
}
case "bookmark-removed": {
if (this.skipTags && event.isTagging) {
continue;
}
// Since we are now skipping tags on the listener side we don't
// prevent unTagging notifications from going out. These events cause empty
// tags folders to be removed which creates another bookmark-removed notification
if (
this.skipTags &&
event.parentGuid == PlacesUtils.bookmarks.tagsGuid
) {
continue;
}
let params = {
itemId: event.id,
parentId: event.parentId,
index: event.index,
type: event.itemType,
urlHref: event.url || null,
title: event.title,
guid: event.guid,
parentGuid: event.parentGuid,
source: event.source,
};
this.notifications.push({ name: "bookmark-removed", params });
break;
}
case "bookmark-moved": {
const params = {
itemId: event.id,
type: event.itemType,
urlHref: event.url,
source: event.source,
guid: event.guid,
newIndex: event.index,
newParentGuid: event.parentGuid,
oldIndex: event.oldIndex,
oldParentGuid: event.oldParentGuid,
isTagging: event.isTagging,
title: event.title,
tags: event.tags,
frecency: event.frecency,
hidden: event.hidden,
visitCount: event.visitCount,
dateAdded: event.dateAdded,
lastVisitDate: event.lastVisitDate,
};
this.notifications.push({ name: "bookmark-moved", params });
break;
}
case "bookmark-guid-changed": {
const params = {
itemId: event.id,
type: event.itemType,
urlHref: event.url,
guid: event.guid,
parentGuid: event.parentGuid,
source: event.source,
isTagging: event.isTagging,
};
this.notifications.push({ name: "bookmark-guid-changed", params });
break;
}
case "bookmark-title-changed": {
const params = {
itemId: event.id,
guid: event.guid,
title: event.title,
parentGuid: event.parentGuid,
};
this.notifications.push({ name: "bookmark-title-changed", params });
break;
}
case "bookmark-url-changed": {
const params = {
itemId: event.id,
type: event.itemType,
urlHref: event.url,
guid: event.guid,
parentGuid: event.parentGuid,
source: event.source,
isTagging: event.isTagging,
};
this.notifications.push({ name: "bookmark-url-changed", params });
break;
}
}
}
},
check(expectedNotifications) {
PlacesUtils.observers.removeListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-guid-changed",
"bookmark-title-changed",
"bookmark-url-changed",
],
this.handlePlacesEvents
);
if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) {
info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`);
info(`Actual notifications: ${JSON.stringify(this.notifications)}`);
throw new Assert.constructor.AssertionError({
actual: this.notifications,
expected: expectedNotifications,
});
}
},
};
function expectBookmarkChangeNotifications(options) {
let observer = new BookmarkObserver(options);
PlacesUtils.observers.addListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-guid-changed",
"bookmark-title-changed",
"bookmark-url-changed",
],
observer.handlePlacesEvents
);
return observer;
}
// Copies a support file to a temporary fixture file, allowing the support
// file to be reused for multiple tests.
async function setupFixtureFile(fixturePath) {
let fixtureFile = do_get_file(fixturePath);
let tempFile = FileTestUtils.getTempFile(fixturePath);
await IOUtils.copy(fixtureFile.path, tempFile.path);
return tempFile;
}