/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* This is a JavaScript module (JSM) to be imported via
 * ChromeUtils.import() and acts as a singleton. Only the following
 * listed symbols will exposed on import, and only when and where imported.
 */

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Addon: "resource://tps/modules/addons.sys.mjs",
  AddonValidator: "resource://services-sync/engines/addons.sys.mjs",
  Address: "resource://tps/modules/formautofill.sys.mjs",
  Async: "resource://services-common/async.sys.mjs",
  Authentication: "resource://tps/auth/fxaccounts.sys.mjs",
  Bookmark: "resource://tps/modules/bookmarks.sys.mjs",
  BookmarkFolder: "resource://tps/modules/bookmarks.sys.mjs",
  BookmarkValidator: "resource://tps/modules/bookmarkValidator.sys.mjs",
  BrowserTabs: "resource://tps/modules/tabs.sys.mjs",
  BrowserWindows: "resource://tps/modules/windows.sys.mjs",
  CommonUtils: "resource://services-common/utils.sys.mjs",
  CreditCard: "resource://tps/modules/formautofill.sys.mjs",
  DumpAddresses: "resource://tps/modules/formautofill.sys.mjs",
  DumpBookmarks: "resource://tps/modules/bookmarks.sys.mjs",
  DumpCreditCards: "resource://tps/modules/formautofill.sys.mjs",
  DumpHistory: "resource://tps/modules/history.sys.mjs",
  DumpPasswords: "resource://tps/modules/passwords.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
  FormData: "resource://tps/modules/forms.sys.mjs",
  FormValidator: "resource://services-sync/engines/forms.sys.mjs",
  HistoryEntry: "resource://tps/modules/history.sys.mjs",
  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
  Livemark: "resource://tps/modules/bookmarks.sys.mjs",
  Log: "resource://gre/modules/Log.sys.mjs",
  Logger: "resource://tps/logger.sys.mjs",
  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
  Password: "resource://tps/modules/passwords.sys.mjs",
  PasswordValidator: "resource://services-sync/engines/passwords.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  Preference: "resource://tps/modules/prefs.sys.mjs",
  STATUS_OK: "resource://services-sync/constants.sys.mjs",
  Separator: "resource://tps/modules/bookmarks.sys.mjs",
  SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
  Svc: "resource://services-sync/util.sys.mjs",
  SyncTelemetry: "resource://services-sync/telemetry.sys.mjs",
  WEAVE_VERSION: "resource://services-sync/constants.sys.mjs",
  Weave: "resource://services-sync/main.sys.mjs",
  extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "fileProtocolHandler", () => {
  let fileHandler = Services.io.getProtocolHandler("file");
  return fileHandler.QueryInterface(Ci.nsIFileProtocolHandler);
});

ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", () => {
  return new TextDecoder();
});

// Options for wiping data during a sync
const SYNC_RESET_CLIENT = "resetClient";
const SYNC_WIPE_CLIENT = "wipeClient";
const SYNC_WIPE_REMOTE = "wipeRemote";

// Actions a test can perform
const ACTION_ADD = "add";
const ACTION_DELETE = "delete";
const ACTION_MODIFY = "modify";
const ACTION_SET_ENABLED = "set-enabled";
const ACTION_SYNC = "sync";
const ACTION_SYNC_RESET_CLIENT = SYNC_RESET_CLIENT;
const ACTION_SYNC_WIPE_CLIENT = SYNC_WIPE_CLIENT;
const ACTION_SYNC_WIPE_REMOTE = SYNC_WIPE_REMOTE;
const ACTION_VERIFY = "verify";
const ACTION_VERIFY_NOT = "verify-not";

const OBSERVER_TOPICS = [
  "fxaccounts:onlogin",
  "fxaccounts:onlogout",
  "profile-before-change",
  "weave:service:tracking-started",
  "weave:service:tracking-stopped",
  "weave:service:login:error",
  "weave:service:setup-complete",
  "weave:service:sync:finish",
  "weave:service:sync:delayed",
  "weave:service:sync:error",
  "weave:service:sync:start",
  "weave:service:resyncs-finished",
  "places-browser-init-complete",
];

export var TPS = {
  _currentAction: -1,
  _currentPhase: -1,
  _enabledEngines: null,
  _errors: 0,
  _isTracking: false,
  _phaseFinished: false,
  _phaselist: {},
  _setupComplete: false,
  _syncActive: false,
  _syncCount: 0,
  _syncsReportedViaTelemetry: 0,
  _syncErrors: 0,
  _syncWipeAction: null,
  _tabsAdded: 0,
  _tabsFinished: 0,
  _test: null,
  _triggeredSync: false,
  _msSinceEpoch: 0,
  _requestedQuit: false,
  shouldValidateAddons: false,
  shouldValidateBookmarks: false,
  shouldValidatePasswords: false,
  shouldValidateForms: false,
  _placesInitDeferred: Promise.withResolvers(),
  ACTIONS: [
    ACTION_ADD,
    ACTION_DELETE,
    ACTION_MODIFY,
    ACTION_SET_ENABLED,
    ACTION_SYNC,
    ACTION_SYNC_RESET_CLIENT,
    ACTION_SYNC_WIPE_CLIENT,
    ACTION_SYNC_WIPE_REMOTE,
    ACTION_VERIFY,
    ACTION_VERIFY_NOT,
  ],

  _init: function TPS__init() {
    this.delayAutoSync();

    OBSERVER_TOPICS.forEach(function (aTopic) {
      Services.obs.addObserver(this, aTopic, true);
    }, this);

    // Some engines bump their score during their sync, which then causes
    // another sync immediately (notably, prefs and addons). We don't want
    // this to happen, and there's no obvious preference to kill it - so
    // we do this nasty hack to ensure the global score is always zero.
    Services.prefs.addObserver("services.sync.globalScore", () => {
      if (lazy.Weave.Service.scheduler.globalScore != 0) {
        lazy.Weave.Service.scheduler.globalScore = 0;
      }
    });
  },

  DumpError(msg, exc = null) {
    this._errors++;
    let errInfo;
    if (exc) {
      errInfo = lazy.Log.exceptionStr(exc); // includes details and stack-trace.
    } else {
      // always write a stack even if no error passed.
      errInfo = lazy.Log.stackTrace(new Error());
    }
    lazy.Logger.logError(`[phase ${this._currentPhase}] ${msg} - ${errInfo}`);
    this.quit();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIObserver",
    "nsISupportsWeakReference",
  ]),

  observe: function TPS__observe(subject, topic) {
    try {
      lazy.Logger.logInfo("----------event observed: " + topic);

      switch (topic) {
        case "profile-before-change":
          OBSERVER_TOPICS.forEach(function (topic) {
            Services.obs.removeObserver(this, topic);
          }, this);

          lazy.Logger.close();

          break;

        case "places-browser-init-complete":
          this._placesInitDeferred.resolve();
          break;

        case "weave:service:setup-complete":
          this._setupComplete = true;

          if (this._syncWipeAction) {
            lazy.Weave.Svc.PrefBranch.setStringPref(
              "firstSync",
              this._syncWipeAction
            );
            this._syncWipeAction = null;
          }

          break;

        case "weave:service:sync:error":
          this._syncActive = false;

          this.delayAutoSync();

          // If this is the first sync error, retry...
          if (this._syncErrors === 0) {
            lazy.Logger.logInfo("Sync error; retrying...");
            this._syncErrors++;
            lazy.CommonUtils.nextTick(() => {
              this.RunNextTestAction().catch(err => {
                this.DumpError("RunNextTestActionFailed", err);
              });
            });
          } else {
            this._triggeredSync = false;
            this.DumpError("Sync error; aborting test");
          }

          break;

        case "weave:service:resyncs-finished":
          this._syncActive = false;
          this._syncErrors = 0;
          this._triggeredSync = false;

          this.delayAutoSync();
          break;

        case "weave:service:sync:start":
          // Ensure that the sync operation has been started by TPS
          if (!this._triggeredSync) {
            this.DumpError(
              "Automatic sync got triggered, which is not allowed."
            );
          }

          this._syncActive = true;
          break;

        case "weave:service:tracking-started":
          this._isTracking = true;
          break;

        case "weave:service:tracking-stopped":
          this._isTracking = false;
          break;

        case "fxaccounts:onlogin":
          // A user signed in - for TPS that always means sync - so configure
          // that.
          lazy.Weave.Service.configure().catch(e => {
            this.DumpError("Configuring sync failed.", e);
          });
          break;

        default:
          lazy.Logger.logInfo(`unhandled event: ${topic}`);
      }
    } catch (e) {
      this.DumpError("Observer failed", e);
    }
  },

  /**
   * Given that we cannot completely disable the automatic sync operations, we
   * massively delay the next sync. Sync operations have to only happen when
   * directly called via TPS.Sync()!
   */
  delayAutoSync: function TPS_delayAutoSync() {
    lazy.Weave.Svc.PrefBranch.setIntPref("scheduler.immediateInterval", 7200);
    lazy.Weave.Svc.PrefBranch.setIntPref("scheduler.idleInterval", 7200);
    lazy.Weave.Svc.PrefBranch.setIntPref("scheduler.activeInterval", 7200);
    lazy.Weave.Svc.PrefBranch.setIntPref("syncThreshold", 10000000);
  },

  quit: function TPS__quit() {
    lazy.Logger.logInfo("quitting");
    this._requestedQuit = true;
    this.goQuitApplication();
  },

  async HandleWindows(aWindow, action) {
    lazy.Logger.logInfo(
      "executing action " +
        action.toUpperCase() +
        " on window " +
        JSON.stringify(aWindow)
    );
    switch (action) {
      case ACTION_ADD:
        await lazy.BrowserWindows.Add(aWindow.private);
        break;
    }
    lazy.Logger.logPass(
      "executing action " + action.toUpperCase() + " on windows"
    );
  },

  async HandleTabs(tabs, action) {
    for (let tab of tabs) {
      lazy.Logger.logInfo(
        "executing action " +
          action.toUpperCase() +
          " on tab " +
          JSON.stringify(tab)
      );
      switch (action) {
        case ACTION_ADD:
          await lazy.BrowserTabs.Add(tab.uri);
          break;
        case ACTION_VERIFY:
          lazy.Logger.AssertTrue(
            typeof tab.profile != "undefined",
            "profile must be defined when verifying tabs"
          );
          lazy.Logger.AssertTrue(
            await lazy.BrowserTabs.Find(tab.uri, tab.title, tab.profile),
            "error locating tab"
          );
          break;
        case ACTION_VERIFY_NOT:
          lazy.Logger.AssertTrue(
            typeof tab.profile != "undefined",
            "profile must be defined when verifying tabs"
          );
          lazy.Logger.AssertTrue(
            await !lazy.BrowserTabs.Find(tab.uri, tab.title, tab.profile),
            "tab found which was expected to be absent"
          );
          break;
        default:
          lazy.Logger.AssertTrue(false, "invalid action: " + action);
      }
    }
    lazy.Logger.logPass(
      "executing action " + action.toUpperCase() + " on tabs"
    );
  },

  async HandlePrefs(prefs, action) {
    for (let pref of prefs) {
      lazy.Logger.logInfo(
        "executing action " +
          action.toUpperCase() +
          " on pref " +
          JSON.stringify(pref)
      );
      let preference = new lazy.Preference(pref);
      switch (action) {
        case ACTION_MODIFY:
          preference.Modify();
          break;
        case ACTION_VERIFY:
          preference.Find();
          break;
        default:
          lazy.Logger.AssertTrue(false, "invalid action: " + action);
      }
    }
    lazy.Logger.logPass(
      "executing action " + action.toUpperCase() + " on pref"
    );
  },

  async HandleForms(data, action) {
    this.shouldValidateForms = true;
    for (let datum of data) {
      lazy.Logger.logInfo(
        "executing action " +
          action.toUpperCase() +
          " on form entry " +
          JSON.stringify(datum)
      );
      let formdata = new lazy.FormData(datum, this._msSinceEpoch);
      switch (action) {
        case ACTION_ADD:
          await formdata.Create();
          break;
        case ACTION_DELETE:
          await formdata.Remove();
          break;
        case ACTION_VERIFY:
          lazy.Logger.AssertTrue(await formdata.Find(), "form data not found");
          break;
        case ACTION_VERIFY_NOT:
          lazy.Logger.AssertTrue(
            !(await formdata.Find()),
            "form data found, but it shouldn't be present"
          );
          break;
        default:
          lazy.Logger.AssertTrue(false, "invalid action: " + action);
      }
    }
    lazy.Logger.logPass(
      "executing action " + action.toUpperCase() + " on formdata"
    );
  },

  async HandleHistory(entries, action) {
    try {
      for (let entry of entries) {
        const entryString = JSON.stringify(entry);
        lazy.Logger.logInfo(
          "executing action " +
            action.toUpperCase() +
            " on history entry " +
            entryString
        );
        switch (action) {
          case ACTION_ADD:
            await lazy.HistoryEntry.Add(entry, this._msSinceEpoch);
            break;
          case ACTION_DELETE:
            await lazy.HistoryEntry.Delete(entry, this._msSinceEpoch);
            break;
          case ACTION_VERIFY:
            lazy.Logger.AssertTrue(
              await lazy.HistoryEntry.Find(entry, this._msSinceEpoch),
              "Uri visits not found in history database: " + entryString
            );
            break;
          case ACTION_VERIFY_NOT:
            lazy.Logger.AssertTrue(
              !(await lazy.HistoryEntry.Find(entry, this._msSinceEpoch)),
              "Uri visits found in history database, but they shouldn't be: " +
                entryString
            );
            break;
          default:
            lazy.Logger.AssertTrue(false, "invalid action: " + action);
        }
      }
      lazy.Logger.logPass(
        "executing action " + action.toUpperCase() + " on history"
      );
    } catch (e) {
      await lazy.DumpHistory();
      throw e;
    }
  },

  async HandlePasswords(passwords, action) {
    this.shouldValidatePasswords = true;
    try {
      for (let password of passwords) {
        lazy.Logger.logInfo(
          "executing action " +
            action.toUpperCase() +
            " on password " +
            JSON.stringify(password)
        );
        let passwordOb = new lazy.Password(password);
        switch (action) {
          case ACTION_ADD:
            lazy.Logger.AssertTrue(
              (await passwordOb.Create()) > -1,
              "error adding password"
            );
            break;
          case ACTION_VERIFY:
            lazy.Logger.AssertTrue(
              (await passwordOb.Find()) != -1,
              "password not found"
            );
            break;
          case ACTION_VERIFY_NOT:
            lazy.Logger.AssertTrue(
              (await passwordOb.Find()) == -1,
              "password found, but it shouldn't exist"
            );
            break;
          case ACTION_DELETE:
            lazy.Logger.AssertTrue(
              (await passwordOb.Find()) != -1,
              "password not found"
            );
            passwordOb.Remove();
            break;
          case ACTION_MODIFY:
            if (passwordOb.updateProps != null) {
              lazy.Logger.AssertTrue(
                (await passwordOb.Find()) != -1,
                "password not found"
              );
              passwordOb.Update();
            }
            break;
          default:
            lazy.Logger.AssertTrue(false, "invalid action: " + action);
        }
      }
      lazy.Logger.logPass(
        "executing action " + action.toUpperCase() + " on passwords"
      );
    } catch (e) {
      await lazy.DumpPasswords();
      throw e;
    }
  },

  async HandleAddons(addons, action, state) {
    this.shouldValidateAddons = true;
    for (let entry of addons) {
      lazy.Logger.logInfo(
        "executing action " +
          action.toUpperCase() +
          " on addon " +
          JSON.stringify(entry)
      );
      let addon = new lazy.Addon(this, entry);
      switch (action) {
        case ACTION_ADD:
          await addon.install();
          break;
        case ACTION_DELETE:
          await addon.uninstall();
          break;
        case ACTION_VERIFY:
          lazy.Logger.AssertTrue(
            await addon.find(state),
            "addon " + addon.id + " not found"
          );
          break;
        case ACTION_VERIFY_NOT:
          lazy.Logger.AssertFalse(
            await addon.find(state),
            "addon " + addon.id + " is present, but it shouldn't be"
          );
          break;
        case ACTION_SET_ENABLED:
          lazy.Logger.AssertTrue(
            await addon.setEnabled(state),
            "addon " + addon.id + " not found"
          );
          break;
        default:
          throw new Error("Unknown action for add-on: " + action);
      }
    }
    lazy.Logger.logPass(
      "executing action " + action.toUpperCase() + " on addons"
    );
  },

  async HandleBookmarks(bookmarks, action) {
    // wait for default bookmarks to be created.
    await this._placesInitDeferred.promise;
    this.shouldValidateBookmarks = true;
    try {
      let items = [];
      for (let folder in bookmarks) {
        let last_item_pos = -1;
        for (let bookmark of bookmarks[folder]) {
          lazy.Logger.clearPotentialError();
          let placesItem;
          bookmark.location = folder;

          if (last_item_pos != -1) {
            bookmark.last_item_pos = last_item_pos;
          }
          let itemGuid = null;

          if (action != ACTION_MODIFY && action != ACTION_DELETE) {
            lazy.Logger.logInfo(
              "executing action " +
                action.toUpperCase() +
                " on bookmark " +
                JSON.stringify(bookmark)
            );
          }

          if ("uri" in bookmark) {
            placesItem = new lazy.Bookmark(bookmark);
          } else if ("folder" in bookmark) {
            placesItem = new lazy.BookmarkFolder(bookmark);
          } else if ("livemark" in bookmark) {
            placesItem = new lazy.Livemark(bookmark);
          } else if ("separator" in bookmark) {
            placesItem = new lazy.Separator(bookmark);
          }

          if (action == ACTION_ADD) {
            itemGuid = await placesItem.Create();
          } else {
            itemGuid = await placesItem.Find();
            if (action == ACTION_VERIFY_NOT) {
              lazy.Logger.AssertTrue(
                itemGuid == null,
                "places item exists but it shouldn't: " +
                  JSON.stringify(bookmark)
              );
            } else {
              lazy.Logger.AssertTrue(itemGuid, "places item not found", true);
            }
          }

          last_item_pos = await placesItem.GetItemIndex();
          items.push(placesItem);
        }
      }

      if (action == ACTION_DELETE || action == ACTION_MODIFY) {
        for (let item of items) {
          lazy.Logger.logInfo(
            "executing action " +
              action.toUpperCase() +
              " on bookmark " +
              JSON.stringify(item)
          );
          switch (action) {
            case ACTION_DELETE:
              await item.Remove();
              break;
            case ACTION_MODIFY:
              if (item.updateProps != null) {
                await item.Update();
              }
              break;
          }
        }
      }

      lazy.Logger.logPass(
        "executing action " + action.toUpperCase() + " on bookmarks"
      );
    } catch (e) {
      await lazy.DumpBookmarks();
      throw e;
    }
  },

  async HandleAddresses(addresses, action) {
    try {
      for (let address of addresses) {
        lazy.Logger.logInfo(
          "executing action " +
            action.toUpperCase() +
            " on address " +
            JSON.stringify(address)
        );
        let addressOb = new lazy.Address(address);
        switch (action) {
          case ACTION_ADD:
            await addressOb.Create();
            break;
          case ACTION_MODIFY:
            await addressOb.Update();
            break;
          case ACTION_VERIFY:
            lazy.Logger.AssertTrue(await addressOb.Find(), "address not found");
            break;
          case ACTION_VERIFY_NOT:
            lazy.Logger.AssertTrue(
              !(await addressOb.Find()),
              "address found, but it shouldn't exist"
            );
            break;
          case ACTION_DELETE:
            lazy.Logger.AssertTrue(await addressOb.Find(), "address not found");
            await addressOb.Remove();
            break;
          default:
            lazy.Logger.AssertTrue(false, "invalid action: " + action);
        }
      }
      lazy.Logger.logPass(
        "executing action " + action.toUpperCase() + " on addresses"
      );
    } catch (e) {
      await lazy.DumpAddresses();
      throw e;
    }
  },

  async HandleCreditCards(creditCards, action) {
    try {
      for (let creditCard of creditCards) {
        lazy.Logger.logInfo(
          "executing action " +
            action.toUpperCase() +
            " on creditCard " +
            JSON.stringify(creditCard)
        );
        let creditCardOb = new lazy.CreditCard(creditCard);
        switch (action) {
          case ACTION_ADD:
            await creditCardOb.Create();
            break;
          case ACTION_MODIFY:
            await creditCardOb.Update();
            break;
          case ACTION_VERIFY:
            lazy.Logger.AssertTrue(
              await creditCardOb.Find(),
              "creditCard not found"
            );
            break;
          case ACTION_VERIFY_NOT:
            lazy.Logger.AssertTrue(
              !(await creditCardOb.Find()),
              "creditCard found, but it shouldn't exist"
            );
            break;
          case ACTION_DELETE:
            lazy.Logger.AssertTrue(
              await creditCardOb.Find(),
              "creditCard not found"
            );
            await creditCardOb.Remove();
            break;
          default:
            lazy.Logger.AssertTrue(false, "invalid action: " + action);
        }
      }
      lazy.Logger.logPass(
        "executing action " + action.toUpperCase() + " on creditCards"
      );
    } catch (e) {
      await lazy.DumpCreditCards();
      throw e;
    }
  },

  async Cleanup() {
    try {
      await this.WipeServer();
    } catch (ex) {
      lazy.Logger.logError(
        "Failed to wipe server: " + lazy.Log.exceptionStr(ex)
      );
    }
    try {
      if (await lazy.Authentication.isLoggedIn()) {
        // signout and wait for Sync to completely reset itself.
        lazy.Logger.logInfo("signing out");
        let waiter = this.promiseObserver("weave:service:start-over:finish");
        await lazy.Authentication.signOut();
        await waiter;
        lazy.Logger.logInfo("signout complete");
      }
      await lazy.Authentication.deleteEmail(this.config.fx_account.username);
    } catch (e) {
      lazy.Logger.logError("Failed to sign out: " + lazy.Log.exceptionStr(e));
    }
  },

  /**
   * Use Sync's bookmark validation code to see if we've corrupted the tree.
   */
  async ValidateBookmarks() {
    let getServerBookmarkState = async () => {
      let bookmarkEngine = lazy.Weave.Service.engineManager.get("bookmarks");
      let collection = bookmarkEngine.itemSource();
      let collectionKey =
        bookmarkEngine.service.collectionKeys.keyForCollection(
          bookmarkEngine.name
        );
      collection.full = true;
      let items = [];
      let resp = await collection.get();
      for (let json of resp.obj) {
        let record = new collection._recordObj();
        record.deserialize(json);
        await record.decrypt(collectionKey);
        items.push(record.cleartext);
      }
      return items;
    };
    let serverRecordDumpStr;
    try {
      lazy.Logger.logInfo("About to perform bookmark validation");
      let clientTree = await lazy.PlacesUtils.promiseBookmarksTree("", {
        includeItemIds: true,
      });
      let serverRecords = await getServerBookmarkState();
      // We can't wait until catch to stringify this, since at that point it will have cycles.
      serverRecordDumpStr = JSON.stringify(serverRecords);

      let validator = new lazy.BookmarkValidator();
      let { problemData } = await validator.compareServerWithClient(
        serverRecords,
        clientTree
      );

      for (let { name, count } of problemData.getSummary()) {
        // Exclude mobile showing up on the server hackily so that we don't
        // report it every time, see bug 1273234 and 1274394 for more information.
        if (
          name === "serverUnexpected" &&
          problemData.serverUnexpected.includes("mobile")
        ) {
          --count;
        }
        if (count) {
          // Log this out before we assert. This is useful in the context of TPS logs, since we
          // can see the IDs in the test files.
          lazy.Logger.logInfo(
            `Validation problem: "${name}": ${JSON.stringify(
              problemData[name]
            )}`
          );
        }
        lazy.Logger.AssertEqual(
          count,
          0,
          `Bookmark validation error of type ${name}`
        );
      }
    } catch (e) {
      // Dump the client records (should always be doable)
      lazy.DumpBookmarks();
      // Dump the server records if gotten them already.
      if (serverRecordDumpStr) {
        lazy.Logger.logInfo(
          "Server bookmark records:\n" + serverRecordDumpStr + "\n"
        );
      }
      this.DumpError("Bookmark validation failed", e);
    }
    lazy.Logger.logInfo("Bookmark validation finished");
  },

  async ValidateCollection(engineName, ValidatorType) {
    let serverRecordDumpStr;
    let clientRecordDumpStr;
    try {
      lazy.Logger.logInfo(`About to perform validation for "${engineName}"`);
      let engine = lazy.Weave.Service.engineManager.get(engineName);
      let validator = new ValidatorType(engine);
      let serverRecords = await validator.getServerItems(engine);
      let clientRecords = await validator.getClientItems();
      try {
        // This substantially improves the logs for addons while not making a
        // substantial difference for the other two
        clientRecordDumpStr = JSON.stringify(
          clientRecords.map(r => {
            let res = validator.normalizeClientItem(r);
            delete res.original; // Try and prevent cyclic references
            return res;
          })
        );
      } catch (e) {
        // ignore the error, the dump string is just here to make debugging easier.
        clientRecordDumpStr = "<Cyclic value>";
      }
      try {
        serverRecordDumpStr = JSON.stringify(serverRecords);
      } catch (e) {
        // as above
        serverRecordDumpStr = "<Cyclic value>";
      }
      let { problemData } = await validator.compareClientWithServer(
        clientRecords,
        serverRecords
      );
      for (let { name, count } of problemData.getSummary()) {
        if (count) {
          lazy.Logger.logInfo(
            `Validation problem: "${name}": ${JSON.stringify(
              problemData[name]
            )}`
          );
        }
        lazy.Logger.AssertEqual(
          count,
          0,
          `Validation error for "${engineName}" of type "${name}"`
        );
      }
    } catch (e) {
      // Dump the client records if possible
      if (clientRecordDumpStr) {
        lazy.Logger.logInfo(
          `Client state for ${engineName}:\n${clientRecordDumpStr}\n`
        );
      }
      // Dump the server records if gotten them already.
      if (serverRecordDumpStr) {
        lazy.Logger.logInfo(
          `Server state for ${engineName}:\n${serverRecordDumpStr}\n`
        );
      }
      this.DumpError(`Validation failed for ${engineName}`, e);
    }
    lazy.Logger.logInfo(`Validation finished for ${engineName}`);
  },

  ValidatePasswords() {
    return this.ValidateCollection("passwords", lazy.PasswordValidator);
  },

  ValidateForms() {
    return this.ValidateCollection("forms", lazy.FormValidator);
  },

  ValidateAddons() {
    return this.ValidateCollection("addons", lazy.AddonValidator);
  },

  async RunNextTestAction() {
    lazy.Logger.logInfo("Running next test action");
    try {
      if (this._currentAction >= this._phaselist[this._currentPhase].length) {
        // Run necessary validations and then finish up
        lazy.Logger.logInfo("No more actions - running validations...");
        if (this.shouldValidateBookmarks) {
          await this.ValidateBookmarks();
        }
        if (this.shouldValidatePasswords) {
          await this.ValidatePasswords();
        }
        if (this.shouldValidateForms) {
          await this.ValidateForms();
        }
        if (this.shouldValidateAddons) {
          await this.ValidateAddons();
        }
        // Force this early so that we run the validation and detect missing pings
        // *before* we start shutting down, since if we do it after, the python
        // code won't notice the failure.
        lazy.SyncTelemetry.shutdown();
        // we're all done
        lazy.Logger.logInfo(
          "test phase " +
            this._currentPhase +
            ": " +
            (this._errors ? "FAIL" : "PASS")
        );
        this._phaseFinished = true;
        this.quit();
        return;
      }
      this.seconds_since_epoch = Services.prefs.getIntPref(
        "tps.seconds_since_epoch"
      );
      if (this.seconds_since_epoch) {
        // Places dislikes it if we add visits in the future. We pretend the
        // real time is 1 minute ago to avoid issues caused by places using a
        // different clock than the one that set the seconds_since_epoch pref.
        this._msSinceEpoch = (this.seconds_since_epoch - 60) * 1000;
      } else {
        this.DumpError("seconds-since-epoch not set");
        return;
      }

      let phase = this._phaselist[this._currentPhase];
      let action = phase[this._currentAction];
      lazy.Logger.logInfo("starting action: " + action[0].name);
      await action[0].apply(this, action.slice(1));

      this._currentAction++;
    } catch (e) {
      if (lazy.Async.isShutdownException(e)) {
        if (this._requestedQuit) {
          lazy.Logger.logInfo("Sync aborted due to requested shutdown");
        } else {
          this.DumpError(
            "Sync aborted due to shutdown, but we didn't request it"
          );
        }
      } else {
        this.DumpError("RunNextTestAction failed", e);
      }
      return;
    }
    await this.RunNextTestAction();
  },

  _getFileRelativeToSourceRoot(testFileURL, relativePath) {
    let file = lazy.fileProtocolHandler.getFileFromURLSpec(testFileURL);
    let root = file.parent.parent.parent.parent.parent; // <root>/services/sync/tests/tps/test_foo.js // <root>/services/sync/tests/tps // <root>/services/sync/tests // <root>/services/sync // <root>/services // <root>
    root.appendRelativePath(relativePath);
    root.normalize();
    return root;
  },

  _pingValidator: null,

  // Default ping validator that always says the ping passes. This should be
  // overridden unless the `testing.tps.skipPingValidation` pref is true.
  get pingValidator() {
    return this._pingValidator
      ? this._pingValidator
      : {
          validate() {
            lazy.Logger.logInfo(
              "Not validating ping -- disabled by pref or failure to load schema"
            );
            return { valid: true, errors: [] };
          },
        };
  },

  // Attempt to load the sync_ping_schema.json and initialize `this.pingValidator`
  // based on the source of the tps file. Assumes that it's at "../unit/sync_ping_schema.json"
  // relative to the directory the tps test file (testFile) is contained in.
  _tryLoadPingSchema(testFile) {
    if (Services.prefs.getBoolPref("testing.tps.skipPingValidation", false)) {
      return;
    }
    try {
      let schemaFile = this._getFileRelativeToSourceRoot(
        testFile,
        "services/sync/tests/unit/sync_ping_schema.json"
      );

      let stream = Cc[
        "@mozilla.org/network/file-input-stream;1"
      ].createInstance(Ci.nsIFileInputStream);

      stream.init(
        schemaFile,
        lazy.FileUtils.MODE_RDONLY,
        lazy.FileUtils.PERMS_FILE,
        0
      );

      let bytes = lazy.NetUtil.readInputStream(stream, stream.available());
      let schema = JSON.parse(lazy.gTextDecoder.decode(bytes));
      lazy.Logger.logInfo("Successfully loaded schema");

      this._pingValidator = new lazy.JsonSchema.Validator(schema);
    } catch (e) {
      this.DumpError(
        `Failed to load ping schema relative to "${testFile}".`,
        e
      );
    }
  },

  /**
   * Runs a single test phase.
   *
   * This is the main entry point for each phase of a test. The TPS command
   * line driver loads this module and calls into the function with the
   * arguments from the command line.
   *
   * When a phase is executed, the file is loaded as JavaScript into the
   * current object.
   *
   * The following keys in the options argument have meaning:
   *
   *   - ignoreUnusedEngines  If true, unused engines will be unloaded from
   *                          Sync. This makes output easier to parse and is
   *                          useful for debugging test failures.
   *
   * @param  file
   *         String URI of the file to open.
   * @param  phase
   *         String name of the phase to run.
   * @param  logpath
   *         String path of the log file to write to.
   * @param  options
   *         Object defining addition run-time options.
   */
  async RunTestPhase(file, phase, logpath, options) {
    try {
      let settings = options || {};

      lazy.Logger.init(logpath);
      lazy.Logger.logInfo("Sync version: " + lazy.WEAVE_VERSION);
      lazy.Logger.logInfo("Firefox buildid: " + Services.appinfo.appBuildID);
      lazy.Logger.logInfo("Firefox version: " + Services.appinfo.version);
      lazy.Logger.logInfo(
        "Firefox source revision: " +
          (AppConstants.SOURCE_REVISION_URL || "unknown")
      );
      lazy.Logger.logInfo("Firefox platform: " + AppConstants.platform);

      // do some sync housekeeping
      if (lazy.Weave.Service.isLoggedIn) {
        this.DumpError("Sync logged in on startup...profile may be dirty");
        return;
      }

      // Wait for Sync service to become ready.
      if (!lazy.Weave.Status.ready) {
        this.waitForEvent("weave:service:ready");
      }

      await lazy.Weave.Service.promiseInitialized;

      // We only want to do this if we modified the bookmarks this phase.
      this.shouldValidateBookmarks = false;

      // Always give Sync an extra tick to initialize. If we waited for the
      // service:ready event, this is required to ensure all handlers have
      // executed.
      await lazy.Async.promiseYield();
      await this._executeTestPhase(file, phase, settings);
    } catch (e) {
      this.DumpError("RunTestPhase failed", e);
    }
  },

  /**
   * Executes a single test phase.
   *
   * This is called by RunTestPhase() after the environment is validated.
   */
  async _executeTestPhase(file, phase, settings) {
    try {
      this.config = JSON.parse(Services.prefs.getStringPref("tps.config"));
      // parse the test file
      Services.scriptloader.loadSubScript(file, this);
      this._currentPhase = phase;
      // cleanup phases are in the format `cleanup-${profileName}`.
      if (this._currentPhase.startsWith("cleanup-")) {
        let profileToClean = this._currentPhase.slice("cleanup-".length);
        this.phases[this._currentPhase] = profileToClean;
        this.Phase(this._currentPhase, [[this.Cleanup]]);
      } else {
        // Don't bother doing this for cleanup phases.
        this._tryLoadPingSchema(file);
      }
      let this_phase = this._phaselist[this._currentPhase];

      if (this_phase == undefined) {
        this.DumpError("invalid phase " + this._currentPhase);
        return;
      }

      if (this.phases[this._currentPhase] == undefined) {
        this.DumpError("no profile defined for phase " + this._currentPhase);
        return;
      }

      // If we have restricted the active engines, unregister engines we don't
      // care about.
      if (settings.ignoreUnusedEngines && Array.isArray(this._enabledEngines)) {
        let names = {};
        for (let name of this._enabledEngines) {
          names[name] = true;
        }
        for (let engine of lazy.Weave.Service.engineManager.getEnabled()) {
          if (!(engine.name in names)) {
            lazy.Logger.logInfo("Unregistering unused engine: " + engine.name);
            await lazy.Weave.Service.engineManager.unregister(engine);
          }
        }
      }
      lazy.Logger.logInfo("Starting phase " + this._currentPhase);

      lazy.Logger.logInfo(
        "setting client.name to " + this.phases[this._currentPhase]
      );
      lazy.Weave.Svc.PrefBranch.setStringPref(
        "client.name",
        this.phases[this._currentPhase]
      );

      this._interceptSyncTelemetry();

      // start processing the test actions
      this._currentAction = 0;
      await lazy.SessionStore.promiseAllWindowsRestored;
      await this.RunNextTestAction();
    } catch (e) {
      this.DumpError("_executeTestPhase failed", e);
    }
  },

  /**
   * Override sync telemetry functions so that we can detect errors generating
   * the sync ping, and count how many pings we report.
   */
  _interceptSyncTelemetry() {
    let originalObserve = lazy.SyncTelemetry.observe;
    let self = this;
    lazy.SyncTelemetry.observe = function () {
      try {
        originalObserve.apply(this, arguments);
      } catch (e) {
        self.DumpError("Error when generating sync telemetry", e);
      }
    };
    lazy.SyncTelemetry.submit = record => {
      lazy.Logger.logInfo(
        "Intercepted sync telemetry submission: " + JSON.stringify(record)
      );
      this._syncsReportedViaTelemetry +=
        record.syncs.length + (record.discarded || 0);
      if (record.discarded) {
        if (record.syncs.length != lazy.SyncTelemetry.maxPayloadCount) {
          this.DumpError(
            "Syncs discarded from ping before maximum payload count reached"
          );
        }
      }
      // If this is the shutdown ping, check and see that the telemetry saw all the syncs.
      if (record.why === "shutdown") {
        // If we happen to sync outside of tps manually causing it, its not an
        // error in the telemetry, so we only complain if we didn't see all of them.
        if (this._syncsReportedViaTelemetry < this._syncCount) {
          this.DumpError(
            `Telemetry missed syncs: Saw ${this._syncsReportedViaTelemetry}, should have >= ${this._syncCount}.`
          );
        }
      }
      if (!record.syncs.length) {
        // Note: we're overwriting submit, so this is called even for pings that
        // may have no data (which wouldn't be submitted to telemetry and would
        // fail validation).
        return;
      }
      // Our ping may have some undefined values, which we rely on JSON stripping
      // out as part of the ping submission - but our validator fails with them,
      // so round-trip via JSON here to avoid that.
      record = JSON.parse(JSON.stringify(record));
      const result = this.pingValidator.validate(record);
      if (!result.valid) {
        // Note that we already logged the record.
        this.DumpError(
          "Sync ping validation failed with errors: " +
            JSON.stringify(result.errors)
        );
      }
    };
  },

  /**
   * Register a single phase with the test harness.
   *
   * This is called when loading individual test files.
   *
   * @param  phasename
   *         String name of the phase being loaded.
   * @param  fnlist
   *         Array of functions/actions to perform.
   */
  Phase: function Test__Phase(phasename, fnlist) {
    if (Object.keys(this._phaselist).length === 0) {
      // This is the first phase we should force a log in
      fnlist.unshift([this.Login]);
    }
    this._phaselist[phasename] = fnlist;
  },

  /**
   * Restrict enabled Sync engines to a specified set.
   *
   * This can be called by a test to limit what engines are enabled. It is
   * recommended to call it to reduce the overhead and log clutter for the
   * test.
   *
   * The "clients" engine is special and is always enabled, so there is no
   * need to specify it.
   *
   * @param  names
   *         Array of Strings for engines to make active during the test.
   */
  EnableEngines: function EnableEngines(names) {
    if (!Array.isArray(names)) {
      throw new Error(
        "Argument to RestrictEngines() is not an array: " + typeof names
      );
    }

    this._enabledEngines = names;
  },

  /**
   * Returns a promise that resolves when a specific observer notification is
   * resolved. This is similar to the various waitFor* functions, although is
   * typically safer if you need to do some other work that may make the event
   * fire.
   *
   * eg:
   *    doSomething(); // causes the event to be fired.
   *    await promiseObserver("something");
   * is risky as the call to doSomething may trigger the event before the
   * promiseObserver call is made. Contrast with:
   *
   *   let waiter = promiseObserver("something");
   *   doSomething(); // causes the event to be fired.
   *   await waiter;  // will return as soon as the event fires, even if it fires
   *                  // before this function is called.
   *
   * @param aEventName
   *        String event to wait for.
   */
  promiseObserver(aEventName) {
    return new Promise(resolve => {
      lazy.Logger.logInfo("Setting up wait for " + aEventName + "...");
      let handler = () => {
        lazy.Logger.logInfo("Observed " + aEventName);
        lazy.Svc.Obs.remove(aEventName, handler);
        resolve();
      };
      lazy.Svc.Obs.add(aEventName, handler);
    });
  },

  /**
   * Wait for the named event to be observed.
   *
   * Note that in general, you should probably use promiseObserver unless you
   * are 100% sure that the event being waited on can only be sent after this
   * call adds the listener.
   *
   * @param aEventName
   *        String event to wait for.
   */
  async waitForEvent(aEventName) {
    await this.promiseObserver(aEventName);
  },

  /**
   * Waits for Sync to logged in before returning
   */
  async waitForSetupComplete() {
    if (!this._setupComplete) {
      await this.waitForEvent("weave:service:setup-complete");
    }
  },

  /**
   * Waits for Sync to be finished before returning
   */
  async waitForSyncFinished() {
    if (lazy.Weave.Service.locked) {
      await this.waitForEvent("weave:service:resyncs-finished");
    }
  },

  /**
   * Waits for Sync to start tracking before returning.
   */
  async waitForTracking() {
    if (!this._isTracking) {
      await this.waitForEvent("weave:service:tracking-started");
    }
  },

  /**
   * Login on the server
   */
  async Login() {
    if (await lazy.Authentication.isReady()) {
      return;
    }

    lazy.Logger.logInfo("Setting client credentials and login.");
    await lazy.Authentication.signIn(this.config.fx_account);
    await this.waitForSetupComplete();
    lazy.Logger.AssertEqual(
      lazy.Weave.Status.service,
      lazy.STATUS_OK,
      "Weave status OK"
    );
    await this.waitForTracking();
  },

  /**
   * Triggers a sync operation
   *
   * @param {String} [wipeAction]
   *        Type of wipe to perform (resetClient, wipeClient, wipeRemote)
   *
   */
  async Sync(wipeAction) {
    if (this._syncActive) {
      this.DumpError("Sync currently active which should be impossible");
      return;
    }
    lazy.Logger.logInfo(
      "Executing Sync" + (wipeAction ? ": " + wipeAction : "")
    );

    // Force a wipe action if requested. In case of an initial sync the pref
    // will be overwritten by Sync itself (see bug 992198), so ensure that we
    // also handle it via the "weave:service:setup-complete" notification.
    if (wipeAction) {
      this._syncWipeAction = wipeAction;
      lazy.Weave.Svc.PrefBranch.setStringPref("firstSync", wipeAction);
    } else {
      lazy.Weave.Svc.PrefBranch.clearUserPref("firstSync");
    }
    if (!(await lazy.Weave.Service.login())) {
      // We need to complete verification.
      lazy.Logger.logInfo("Logging in before performing sync");
      await this.Login();
    }
    ++this._syncCount;

    lazy.Logger.logInfo(
      "Executing Sync" + (wipeAction ? ": " + wipeAction : "")
    );
    this._triggeredSync = true;
    await lazy.Weave.Service.sync();
    lazy.Logger.logInfo("Sync is complete");
    // wait a second for things to settle...
    await new Promise(resolve => {
      lazy.CommonUtils.namedTimer(resolve, 1000, this, "postsync");
    });
  },

  async WipeServer() {
    lazy.Logger.logInfo("Wiping data from server.");

    await this.Login();
    await lazy.Weave.Service.login();
    await lazy.Weave.Service.wipeServer();
  },

  /**
   * Action which ensures changes are being tracked before returning.
   */
  async EnsureTracking() {
    await this.Login();
    await this.waitForTracking();
  },

  Addons: {
    async install(addons) {
      await TPS.HandleAddons(addons, ACTION_ADD);
    },
    async setEnabled(addons, state) {
      await TPS.HandleAddons(addons, ACTION_SET_ENABLED, state);
    },
    async uninstall(addons) {
      await TPS.HandleAddons(addons, ACTION_DELETE);
    },
    async verify(addons, state) {
      await TPS.HandleAddons(addons, ACTION_VERIFY, state);
    },
    async verifyNot(addons) {
      await TPS.HandleAddons(addons, ACTION_VERIFY_NOT);
    },
    skipValidation() {
      TPS.shouldValidateAddons = false;
    },
  },

  Addresses: {
    async add(addresses) {
      await this.HandleAddresses(addresses, ACTION_ADD);
    },
    async modify(addresses) {
      await this.HandleAddresses(addresses, ACTION_MODIFY);
    },
    async delete(addresses) {
      await this.HandleAddresses(addresses, ACTION_DELETE);
    },
    async verify(addresses) {
      await this.HandleAddresses(addresses, ACTION_VERIFY);
    },
    async verifyNot(addresses) {
      await this.HandleAddresses(addresses, ACTION_VERIFY_NOT);
    },
  },

  Bookmarks: {
    async add(bookmarks) {
      await TPS.HandleBookmarks(bookmarks, ACTION_ADD);
    },
    async modify(bookmarks) {
      await TPS.HandleBookmarks(bookmarks, ACTION_MODIFY);
    },
    async delete(bookmarks) {
      await TPS.HandleBookmarks(bookmarks, ACTION_DELETE);
    },
    async verify(bookmarks) {
      await TPS.HandleBookmarks(bookmarks, ACTION_VERIFY);
    },
    async verifyNot(bookmarks) {
      await TPS.HandleBookmarks(bookmarks, ACTION_VERIFY_NOT);
    },
    skipValidation() {
      TPS.shouldValidateBookmarks = false;
    },
  },
  CreditCards: {
    async add(creditCards) {
      await this.HandleCreditCards(creditCards, ACTION_ADD);
    },
    async modify(creditCards) {
      await this.HandleCreditCards(creditCards, ACTION_MODIFY);
    },
    async delete(creditCards) {
      await this.HandleCreditCards(creditCards, ACTION_DELETE);
    },
    async verify(creditCards) {
      await this.HandleCreditCards(creditCards, ACTION_VERIFY);
    },
    async verifyNot(creditCards) {
      await this.HandleCreditCards(creditCards, ACTION_VERIFY_NOT);
    },
  },

  Formdata: {
    async add(formdata) {
      await this.HandleForms(formdata, ACTION_ADD);
    },
    async delete(formdata) {
      await this.HandleForms(formdata, ACTION_DELETE);
    },
    async verify(formdata) {
      await this.HandleForms(formdata, ACTION_VERIFY);
    },
    async verifyNot(formdata) {
      await this.HandleForms(formdata, ACTION_VERIFY_NOT);
    },
  },
  History: {
    async add(history) {
      await this.HandleHistory(history, ACTION_ADD);
    },
    async delete(history) {
      await this.HandleHistory(history, ACTION_DELETE);
    },
    async verify(history) {
      await this.HandleHistory(history, ACTION_VERIFY);
    },
    async verifyNot(history) {
      await this.HandleHistory(history, ACTION_VERIFY_NOT);
    },
  },
  Passwords: {
    async add(passwords) {
      await this.HandlePasswords(passwords, ACTION_ADD);
    },
    async modify(passwords) {
      await this.HandlePasswords(passwords, ACTION_MODIFY);
    },
    async delete(passwords) {
      await this.HandlePasswords(passwords, ACTION_DELETE);
    },
    async verify(passwords) {
      await this.HandlePasswords(passwords, ACTION_VERIFY);
    },
    async verifyNot(passwords) {
      await this.HandlePasswords(passwords, ACTION_VERIFY_NOT);
    },
    skipValidation() {
      TPS.shouldValidatePasswords = false;
    },
  },
  Prefs: {
    async modify(prefs) {
      await TPS.HandlePrefs(prefs, ACTION_MODIFY);
    },
    async verify(prefs) {
      await TPS.HandlePrefs(prefs, ACTION_VERIFY);
    },
  },
  Tabs: {
    async add(tabs) {
      await TPS.HandleTabs(tabs, ACTION_ADD);
    },
    async verify(tabs) {
      await TPS.HandleTabs(tabs, ACTION_VERIFY);
    },
    async verifyNot(tabs) {
      await TPS.HandleTabs(tabs, ACTION_VERIFY_NOT);
    },
  },
  Windows: {
    async add(aWindow) {
      await TPS.HandleWindows(aWindow, ACTION_ADD);
    },
  },

  // Jumping through loads of hoops via calling back into a "HandleXXX" method
  // and adding an ACTION_XXX indirection adds no value - let's KISS!
  // eslint-disable-next-line no-unused-vars
  ExtStorage: {
    async set(id, data) {
      lazy.Logger.logInfo(`setting data for '${id}': ${data}`);
      await lazy.extensionStorageSync.set({ id }, data);
    },
    async verify(id, keys, data) {
      let got = await lazy.extensionStorageSync.get({ id }, keys);
      lazy.Logger.AssertEqual(got, data, `data for '${id}'/${keys}`);
    },
  },
};

// Initialize TPS
TPS._init();
