summaryrefslogtreecommitdiffstats
path: root/services/sync/tests/unit/head_helpers.js
diff options
context:
space:
mode:
Diffstat (limited to 'services/sync/tests/unit/head_helpers.js')
-rw-r--r--services/sync/tests/unit/head_helpers.js704
1 files changed, 704 insertions, 0 deletions
diff --git a/services/sync/tests/unit/head_helpers.js b/services/sync/tests/unit/head_helpers.js
new file mode 100644
index 0000000000..a871debd76
--- /dev/null
+++ b/services/sync/tests/unit/head_helpers.js
@@ -0,0 +1,704 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from head_appinfo.js */
+/* import-globals-from ../../../common/tests/unit/head_helpers.js */
+/* import-globals-from head_errorhandler_common.js */
+/* import-globals-from head_http_server.js */
+
+// This file expects Service to be defined in the global scope when EHTestsCommon
+// is used (from service.js).
+/* global Service */
+
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+var { SerializableSet, Svc, Utils, getChromeWindow } =
+ ChromeUtils.importESModule("resource://services-sync/util.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { PlacesSyncUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs"
+);
+var { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+var {
+ MockFxaStorageManager,
+ SyncTestingInfrastructure,
+ configureFxAccountIdentity,
+ configureIdentity,
+ encryptPayload,
+ getLoginTelemetryScalar,
+ makeFxAccountsInternalMock,
+ makeIdentityConfig,
+ promiseNamedTimer,
+ promiseZeroTimer,
+ sumHistogram,
+ syncTestLogging,
+ waitForZeroTimer,
+} = ChromeUtils.importESModule(
+ "resource://testing-common/services/sync/utils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+add_setup(async function head_setup() {
+ // Initialize logging. This will sometimes be reset by a pref reset,
+ // so it's also called as part of SyncTestingInfrastructure().
+ syncTestLogging();
+ // If a test imports Service, make sure it is initialized first.
+ if (typeof Service !== "undefined") {
+ await Service.promiseInitialized;
+ }
+});
+
+XPCOMUtils.defineLazyGetter(this, "SyncPingSchema", function () {
+ let { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+ );
+ let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+ let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let schema;
+ try {
+ let schemaFile = do_get_file("sync_ping_schema.json");
+ stream.init(schemaFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+
+ let bytes = NetUtil.readInputStream(stream, stream.available());
+ schema = JSON.parse(new TextDecoder().decode(bytes));
+ } finally {
+ stream.close();
+ }
+
+ // Allow tests to make whatever engines they want, this shouldn't cause
+ // validation failure.
+ schema.definitions.engine.properties.name = { type: "string" };
+ return schema;
+});
+
+XPCOMUtils.defineLazyGetter(this, "SyncPingValidator", function () {
+ const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+ );
+ return new JsonSchema.Validator(SyncPingSchema);
+});
+
+// This is needed for loadAddonTestFunctions().
+var gGlobalScope = this;
+
+function ExtensionsTestPath(path) {
+ if (path[0] != "/") {
+ throw Error("Path must begin with '/': " + path);
+ }
+
+ return "../../../../toolkit/mozapps/extensions/test/xpcshell" + path;
+}
+
+function webExtensionsTestPath(path) {
+ if (path[0] != "/") {
+ throw Error("Path must begin with '/': " + path);
+ }
+
+ return "../../../../toolkit/components/extensions/test/xpcshell" + path;
+}
+
+/**
+ * Loads the WebExtension test functions by importing its test file.
+ */
+function loadWebExtensionTestFunctions() {
+ /* import-globals-from ../../../../toolkit/components/extensions/test/xpcshell/head_sync.js */
+ const path = webExtensionsTestPath("/head_sync.js");
+ let file = do_get_file(path);
+ let uri = Services.io.newFileURI(file);
+ Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
+}
+
+/**
+ * Installs an add-on from an addonInstall
+ *
+ * @param install addonInstall instance to install
+ */
+async function installAddonFromInstall(install) {
+ await install.install();
+
+ Assert.notEqual(null, install.addon);
+ Assert.notEqual(null, install.addon.syncGUID);
+
+ return install.addon;
+}
+
+/**
+ * Convenience function to install an add-on from the extensions unit tests.
+ *
+ * @param file
+ * Add-on file to install.
+ * @param reconciler
+ * addons reconciler, if passed we will wait on the events to be
+ * processed before resolving
+ * @return addon object that was installed
+ */
+async function installAddon(file, reconciler = null) {
+ let install = await AddonManager.getInstallForFile(file);
+ Assert.notEqual(null, install);
+ const addon = await installAddonFromInstall(install);
+ if (reconciler) {
+ await reconciler.queueCaller.promiseCallsComplete();
+ }
+ return addon;
+}
+
+/**
+ * Convenience function to uninstall an add-on.
+ *
+ * @param addon
+ * Addon instance to uninstall
+ * @param reconciler
+ * addons reconciler, if passed we will wait on the events to be
+ * processed before resolving
+ */
+async function uninstallAddon(addon, reconciler = null) {
+ const uninstallPromise = new Promise(res => {
+ let listener = {
+ onUninstalled(uninstalled) {
+ if (uninstalled.id == addon.id) {
+ AddonManager.removeAddonListener(listener);
+ res(uninstalled);
+ }
+ },
+ };
+ AddonManager.addAddonListener(listener);
+ });
+ addon.uninstall();
+ await uninstallPromise;
+ if (reconciler) {
+ await reconciler.queueCaller.promiseCallsComplete();
+ }
+}
+
+async function generateNewKeys(collectionKeys, collections = null) {
+ let wbo = await collectionKeys.generateNewKeysWBO(collections);
+ let modified = new_timestamp();
+ collectionKeys.setContents(wbo.cleartext, modified);
+}
+
+// Helpers for testing open tabs.
+// These reflect part of the internal structure of TabEngine,
+// and stub part of Service.wm.
+
+function mockShouldSkipWindow(win) {
+ return win.closed || win.mockIsPrivate;
+}
+
+function mockGetTabState(tab) {
+ return tab;
+}
+
+function mockGetWindowEnumerator(urls) {
+ let elements = [];
+
+ const numWindows = 1;
+ for (let w = 0; w < numWindows; ++w) {
+ let tabs = [];
+ let win = {
+ closed: false,
+ mockIsPrivate: false,
+ gBrowser: {
+ tabs,
+ },
+ };
+ elements.push(win);
+
+ let lastAccessed = 2000;
+ for (let url of urls) {
+ tabs.push({
+ linkedBrowser: {
+ currentURI: Services.io.newURI(url),
+ contentTitle: "title",
+ },
+ lastAccessed,
+ });
+ lastAccessed += 1000;
+ }
+ }
+
+ // Always include a closed window and a private window.
+ elements.push({
+ closed: true,
+ mockIsPrivate: false,
+ gBrowser: {
+ tabs: [],
+ },
+ });
+
+ elements.push({
+ closed: false,
+ mockIsPrivate: true,
+ gBrowser: {
+ tabs: [],
+ },
+ });
+
+ return elements.values();
+}
+
+// Helper function to get the sync telemetry and add the typically used test
+// engine names to its list of allowed engines.
+function get_sync_test_telemetry() {
+ let { SyncTelemetry } = ChromeUtils.importESModule(
+ "resource://services-sync/telemetry.sys.mjs"
+ );
+ SyncTelemetry.tryRefreshDevices = function () {};
+ let testEngines = ["rotary", "steam", "sterling", "catapult", "nineties"];
+ for (let engineName of testEngines) {
+ SyncTelemetry.allowedEngines.add(engineName);
+ }
+ SyncTelemetry.submissionInterval = -1;
+ return SyncTelemetry;
+}
+
+function assert_valid_ping(record) {
+ // Our JSON validator does not like `undefined` values, even though they will
+ // be skipped when we serialize to JSON.
+ record = JSON.parse(JSON.stringify(record));
+
+ // This is called as the test harness tears down due to shutdown. This
+ // will typically have no recorded syncs, and the validator complains about
+ // it. So ignore such records (but only ignore when *both* shutdown and
+ // no Syncs - either of them not being true might be an actual problem)
+ if (record && (record.why != "shutdown" || !!record.syncs.length)) {
+ const result = SyncPingValidator.validate(record);
+ if (!result.valid) {
+ if (result.errors.length) {
+ // validation failed - using a simple |deepEqual([], errors)| tends to
+ // truncate the validation errors in the output and doesn't show that
+ // the ping actually was - so be helpful.
+ info("telemetry ping validation failed");
+ info("the ping data is: " + JSON.stringify(record, undefined, 2));
+ info(
+ "the validation failures: " +
+ JSON.stringify(result.errors, undefined, 2)
+ );
+ ok(
+ false,
+ "Sync telemetry ping validation failed - see output above for details"
+ );
+ }
+ }
+ equal(record.version, 1);
+ record.syncs.forEach(p => {
+ lessOrEqual(p.when, Date.now());
+ });
+ }
+}
+
+// Asserts that `ping` is a ping that doesn't contain any failure information
+function assert_success_ping(ping) {
+ ok(!!ping);
+ assert_valid_ping(ping);
+ ping.syncs.forEach(record => {
+ ok(!record.failureReason, JSON.stringify(record.failureReason));
+ equal(undefined, record.status);
+ greater(record.engines.length, 0);
+ for (let e of record.engines) {
+ ok(!e.failureReason);
+ equal(undefined, e.status);
+ if (e.validation) {
+ equal(undefined, e.validation.problems);
+ equal(undefined, e.validation.failureReason);
+ }
+ if (e.outgoing) {
+ for (let o of e.outgoing) {
+ equal(undefined, o.failed);
+ notEqual(undefined, o.sent);
+ }
+ }
+ if (e.incoming) {
+ equal(undefined, e.incoming.failed);
+ equal(undefined, e.incoming.newFailed);
+ notEqual(undefined, e.incoming.applied || e.incoming.reconciled);
+ }
+ }
+ });
+}
+
+// Hooks into telemetry to validate all pings after calling.
+function validate_all_future_pings() {
+ let telem = get_sync_test_telemetry();
+ telem.submit = assert_valid_ping;
+}
+
+function wait_for_pings(expectedPings) {
+ return new Promise(resolve => {
+ let telem = get_sync_test_telemetry();
+ let oldSubmit = telem.submit;
+ let pings = [];
+ telem.submit = function (record) {
+ pings.push(record);
+ if (pings.length == expectedPings) {
+ telem.submit = oldSubmit;
+ resolve(pings);
+ }
+ };
+ });
+}
+
+async function wait_for_ping(callback, allowErrorPings, getFullPing = false) {
+ let pingsPromise = wait_for_pings(1);
+ await callback();
+ let [record] = await pingsPromise;
+ if (allowErrorPings) {
+ assert_valid_ping(record);
+ } else {
+ assert_success_ping(record);
+ }
+ if (getFullPing) {
+ return record;
+ }
+ equal(record.syncs.length, 1);
+ return record.syncs[0];
+}
+
+// Perform a sync and validate all telemetry caused by the sync. If fnValidate
+// is null, we just check the ping records success. If fnValidate is specified,
+// then the sync must have recorded just a single sync, and that sync will be
+// passed to the function to be checked.
+async function sync_and_validate_telem(
+ fnValidate = null,
+ wantFullPing = false
+) {
+ let numErrors = 0;
+ let telem = get_sync_test_telemetry();
+ let oldSubmit = telem.submit;
+ try {
+ telem.submit = function (record) {
+ // This is called via an observer, so failures here don't cause the test
+ // to fail :(
+ try {
+ // All pings must be valid.
+ assert_valid_ping(record);
+ if (fnValidate) {
+ // for historical reasons most of these callbacks expect a "sync"
+ // record, not the entire ping.
+ if (wantFullPing) {
+ fnValidate(record);
+ } else {
+ Assert.equal(record.syncs.length, 1);
+ fnValidate(record.syncs[0]);
+ }
+ } else {
+ // no validation function means it must be a "success" ping.
+ assert_success_ping(record);
+ }
+ } catch (ex) {
+ print("Failure in ping validation callback", ex, "\n", ex.stack);
+ numErrors += 1;
+ }
+ };
+ await Service.sync();
+ Assert.ok(numErrors == 0, "There were telemetry validation errors");
+ } finally {
+ telem.submit = oldSubmit;
+ }
+}
+
+// Used for the (many) cases where we do a 'partial' sync, where only a single
+// engine is actually synced, but we still want to ensure we're generating a
+// valid ping. Returns a promise that resolves to the ping, or rejects with the
+// thrown error after calling an optional callback.
+async function sync_engine_and_validate_telem(
+ engine,
+ allowErrorPings,
+ onError,
+ wantFullPing = false
+) {
+ let telem = get_sync_test_telemetry();
+ let caughtError = null;
+ // Clear out status, so failures from previous syncs won't show up in the
+ // telemetry ping.
+ let { Status } = ChromeUtils.importESModule(
+ "resource://services-sync/status.sys.mjs"
+ );
+ Status._engines = {};
+ Status.partial = false;
+ // Ideally we'd clear these out like we do with engines, (probably via
+ // Status.resetSync()), but this causes *numerous* tests to fail, so we just
+ // assume that if no failureReason or engine failures are set, and the
+ // status properties are the same as they were initially, that it's just
+ // a leftover.
+ // This is only an issue since we're triggering the sync of just one engine,
+ // without doing any other parts of the sync.
+ let initialServiceStatus = Status._service;
+ let initialSyncStatus = Status._sync;
+
+ let oldSubmit = telem.submit;
+ let submitPromise = new Promise((resolve, reject) => {
+ telem.submit = function (ping) {
+ telem.submit = oldSubmit;
+ ping.syncs.forEach(record => {
+ if (record && record.status) {
+ // did we see anything to lead us to believe that something bad actually happened
+ let realProblem =
+ record.failureReason ||
+ record.engines.some(e => {
+ if (e.failureReason || e.status) {
+ return true;
+ }
+ if (e.outgoing && e.outgoing.some(o => o.failed > 0)) {
+ return true;
+ }
+ return e.incoming && e.incoming.failed;
+ });
+ if (!realProblem) {
+ // no, so if the status is the same as it was initially, just assume
+ // that its leftover and that we can ignore it.
+ if (record.status.sync && record.status.sync == initialSyncStatus) {
+ delete record.status.sync;
+ }
+ if (
+ record.status.service &&
+ record.status.service == initialServiceStatus
+ ) {
+ delete record.status.service;
+ }
+ if (!record.status.sync && !record.status.service) {
+ delete record.status;
+ }
+ }
+ }
+ });
+ if (allowErrorPings) {
+ assert_valid_ping(ping);
+ } else {
+ assert_success_ping(ping);
+ }
+ equal(ping.syncs.length, 1);
+ if (caughtError) {
+ if (onError) {
+ onError(ping.syncs[0], ping);
+ }
+ reject(caughtError);
+ } else if (wantFullPing) {
+ resolve(ping);
+ } else {
+ resolve(ping.syncs[0]);
+ }
+ };
+ });
+ // neuter the scheduler as it interacts badly with some of the tests - the
+ // engine being synced usually isn't the registered engine, so we see
+ // scored incremented and not removed, which schedules unexpected syncs.
+ let oldObserve = Service.scheduler.observe;
+ Service.scheduler.observe = () => {};
+ try {
+ Svc.Obs.notify("weave:service:sync:start");
+ try {
+ await engine.sync();
+ } catch (e) {
+ caughtError = e;
+ }
+ if (caughtError) {
+ Svc.Obs.notify("weave:service:sync:error", caughtError);
+ } else {
+ Svc.Obs.notify("weave:service:sync:finish");
+ }
+ } finally {
+ Service.scheduler.observe = oldObserve;
+ }
+ return submitPromise;
+}
+
+// Returns a promise that resolves once the specified observer notification
+// has fired.
+function promiseOneObserver(topic, callback) {
+ return new Promise((resolve, reject) => {
+ let observer = function (subject, data) {
+ Svc.Obs.remove(topic, observer);
+ resolve({ subject, data });
+ };
+ Svc.Obs.add(topic, observer);
+ });
+}
+
+async function registerRotaryEngine() {
+ let { RotaryEngine } = ChromeUtils.importESModule(
+ "resource://testing-common/services/sync/rotaryengine.sys.mjs"
+ );
+ await Service.engineManager.clear();
+
+ await Service.engineManager.register(RotaryEngine);
+ let engine = Service.engineManager.get("rotary");
+ let syncID = await engine.resetLocalSyncID();
+ engine.enabled = true;
+
+ return { engine, syncID, tracker: engine._tracker };
+}
+
+// Set the validation prefs to attempt validation every time to avoid non-determinism.
+function enableValidationPrefs(engines = ["bookmarks"]) {
+ for (let engine of engines) {
+ Svc.Prefs.set(`engine.${engine}.validation.interval`, 0);
+ Svc.Prefs.set(`engine.${engine}.validation.percentageChance`, 100);
+ Svc.Prefs.set(`engine.${engine}.validation.maxRecords`, -1);
+ Svc.Prefs.set(`engine.${engine}.validation.enabled`, true);
+ }
+}
+
+async function serverForEnginesWithKeys(users, engines, callback) {
+ // Generate and store a fake default key bundle to avoid resetting the client
+ // before the first sync.
+ let wbo = await Service.collectionKeys.generateNewKeysWBO();
+ let modified = new_timestamp();
+ Service.collectionKeys.setContents(wbo.cleartext, modified);
+
+ let allEngines = [Service.clientsEngine].concat(engines);
+
+ let globalEngines = {};
+ for (let engine of allEngines) {
+ let syncID = await engine.resetLocalSyncID();
+ globalEngines[engine.name] = { version: engine.version, syncID };
+ }
+
+ let contents = {
+ meta: {
+ global: {
+ syncID: Service.syncID,
+ storageVersion: STORAGE_VERSION,
+ engines: globalEngines,
+ },
+ },
+ crypto: {
+ keys: encryptPayload(wbo.cleartext),
+ },
+ };
+ for (let engine of allEngines) {
+ contents[engine.name] = {};
+ }
+
+ return serverForUsers(users, contents, callback);
+}
+
+async function serverForFoo(engine, callback) {
+ // The bookmarks engine *always* tracks changes, meaning we might try
+ // and sync due to the bookmarks we ourselves create! Worse, because we
+ // do an engine sync only, there's no locking - so we end up with multiple
+ // syncs running. Neuter that by making the threshold very large.
+ Service.scheduler.syncThreshold = 10000000;
+ return serverForEnginesWithKeys({ foo: "password" }, engine, callback);
+}
+
+// Places notifies history observers asynchronously, so `addVisits` might return
+// before the tracker receives the notification. This helper registers an
+// observer that resolves once the expected notification fires.
+async function promiseVisit(expectedType, expectedURI) {
+ return new Promise(resolve => {
+ function done(type, uri) {
+ if (uri == expectedURI.spec && type == expectedType) {
+ PlacesObservers.removeListener(
+ ["page-visited", "page-removed"],
+ observer.handlePlacesEvents
+ );
+ resolve();
+ }
+ }
+ let observer = {
+ handlePlacesEvents(events) {
+ Assert.equal(events.length, 1);
+
+ if (events[0].type === "page-visited") {
+ done("added", events[0].url);
+ } else if (events[0].type === "page-removed") {
+ Assert.ok(events[0].isRemovedFromStore);
+ done("removed", events[0].url);
+ }
+ },
+ };
+ PlacesObservers.addListener(
+ ["page-visited", "page-removed"],
+ observer.handlePlacesEvents
+ );
+ });
+}
+
+async function addVisit(
+ suffix,
+ referrer = null,
+ transition = PlacesUtils.history.TRANSITION_LINK
+) {
+ let uriString = "http://getfirefox.com/" + suffix;
+ let uri = CommonUtils.makeURI(uriString);
+ _("Adding visit for URI " + uriString);
+
+ let visitAddedPromise = promiseVisit("added", uri);
+ await PlacesTestUtils.addVisits({
+ uri,
+ visitDate: Date.now() * 1000,
+ transition,
+ referrer,
+ });
+ await visitAddedPromise;
+
+ return uri;
+}
+
+function bookmarkNodesToInfos(nodes) {
+ return nodes.map(node => {
+ let info = {
+ guid: node.guid,
+ index: node.index,
+ };
+ if (node.children) {
+ info.children = bookmarkNodesToInfos(node.children);
+ }
+ return info;
+ });
+}
+
+async function assertBookmarksTreeMatches(rootGuid, expected, message) {
+ let root = await PlacesUtils.promiseBookmarksTree(rootGuid, {
+ includeItemIds: true,
+ });
+ let actual = bookmarkNodesToInfos(root.children);
+
+ if (!ObjectUtils.deepEqual(actual, expected)) {
+ _(`Expected structure for ${rootGuid}`, JSON.stringify(expected));
+ _(`Actual structure for ${rootGuid}`, JSON.stringify(actual));
+ throw new Assert.constructor.AssertionError({ actual, expected, message });
+ }
+}
+
+function add_bookmark_test(task) {
+ const { BookmarksEngine } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/bookmarks.sys.mjs"
+ );
+
+ add_task(async function () {
+ _(`Running bookmarks test ${task.name}`);
+ let engine = new BookmarksEngine(Service);
+ await engine.initialize();
+ await engine._resetClient();
+ try {
+ await task(engine);
+ } finally {
+ await engine.finalize();
+ }
+ });
+}