461 lines
14 KiB
JavaScript
461 lines
14 KiB
JavaScript
/* Any copyright is dedicated to the Public Domain.
|
|
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
|
|
|
// 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(
|
|
"resource://gre/modules/CanonicalJSON.sys.mjs"
|
|
);
|
|
var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
|
|
|
|
var { PlacesSyncUtils } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/PlacesSyncUtils.sys.mjs"
|
|
);
|
|
var { SyncedBookmarksMirror } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/SyncedBookmarksMirror.sys.mjs"
|
|
);
|
|
var { CommonUtils } = ChromeUtils.importESModule(
|
|
"resource://services-common/utils.sys.mjs"
|
|
);
|
|
var { FileTestUtils } = ChromeUtils.importESModule(
|
|
"resource://testing-common/FileTestUtils.sys.mjs"
|
|
);
|
|
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;
|
|
}
|