"use strict";

const { NormandyTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/NormandyTestUtils.sys.mjs"
);
const { FilterExpressions } = ChromeUtils.importESModule(
  "resource://gre/modules/components-utils/FilterExpressions.sys.mjs"
);

const { Normandy } = ChromeUtils.importESModule(
  "resource://normandy/Normandy.sys.mjs"
);
const { BaseAction } = ChromeUtils.importESModule(
  "resource://normandy/actions/BaseAction.sys.mjs"
);
const { RecipeRunner } = ChromeUtils.importESModule(
  "resource://normandy/lib/RecipeRunner.sys.mjs"
);
const { ClientEnvironment } = ChromeUtils.importESModule(
  "resource://normandy/lib/ClientEnvironment.sys.mjs"
);
const { CleanupManager } = ChromeUtils.importESModule(
  "resource://normandy/lib/CleanupManager.sys.mjs"
);
const { ActionsManager } = ChromeUtils.importESModule(
  "resource://normandy/lib/ActionsManager.sys.mjs"
);

const { RemoteSettings } = ChromeUtils.importESModule(
  "resource://services-settings/remote-settings.sys.mjs"
);

add_task(async function getFilterContext() {
  const recipe = { id: 17, arguments: { foo: "bar" }, unrelated: false };
  const context = RecipeRunner.getFilterContext(recipe);

  // Test for expected properties in the filter expression context.
  const expectedNormandyKeys = [
    "channel",
    "country",
    "distribution",
    "doNotTrack",
    "isDefaultBrowser",
    "locale",
    "plugins",
    "recipe",
    "request_time",
    "searchEngine",
    "syncDesktopDevices",
    "syncMobileDevices",
    "syncSetup",
    "syncTotalDevices",
    "telemetry",
    "userId",
    "version",
  ];
  for (const key of expectedNormandyKeys) {
    ok(key in context.env, `env.${key} is available`);
    ok(key in context.normandy, `normandy.${key} is available`);
  }
  Assert.deepEqual(
    context.normandy,
    context.env,
    "context offers normandy as backwards-compatible alias for context.environment"
  );

  is(
    context.env.recipe.id,
    recipe.id,
    "environment.recipe is the recipe passed to getFilterContext"
  );
  is(
    ClientEnvironment.recipe,
    undefined,
    "ClientEnvironment has not been mutated"
  );
  delete recipe.unrelated;
  Assert.deepEqual(
    context.env.recipe,
    recipe,
    "environment.recipe drops unrecognized attributes from the recipe"
  );

  // Filter context attributes are cached.
  await SpecialPowers.pushPrefEnv({
    set: [["app.normandy.user_id", "some id"]],
  });
  is(context.env.userId, "some id", "User id is read from prefs when accessed");
  await SpecialPowers.pushPrefEnv({
    set: [["app.normandy.user_id", "real id"]],
  });
  is(context.env.userId, "some id", "userId was cached");
});

decorate_task(
  withStub(NormandyApi, "verifyObjectSignature"),
  async function test_getRecipeSuitability_filterExpressions() {
    const check = filter =>
      RecipeRunner.getRecipeSuitability({ filter_expression: filter });

    // Errors must result in a false return value.
    is(
      await check("invalid ( + 5yntax"),
      BaseAction.suitability.FILTER_ERROR,
      "Invalid filter expressions return false"
    );

    // Non-boolean filter results result in a true return value.
    is(
      await check("[1, 2, 3]"),
      BaseAction.suitability.FILTER_MATCH,
      "Non-boolean filter expressions return true"
    );

    // The given recipe must be available to the filter context.
    const recipe = { filter_expression: "normandy.recipe.id == 7", id: 7 };
    is(
      await RecipeRunner.getRecipeSuitability(recipe),
      BaseAction.suitability.FILTER_MATCH,
      "The recipe is available in the filter context"
    );
    recipe.id = 4;
    is(
      await RecipeRunner.getRecipeSuitability(recipe),
      BaseAction.suitability.FILTER_MISMATCH,
      "The recipe is available in the filter context"
    );
  }
);

decorate_task(
  withStub(FilterExpressions, "eval"),
  withStub(NormandyApi, "verifyObjectSignature"),
  async function test_getRecipeSuitability_canHandleExceptions({ evalStub }) {
    evalStub.throws("this filter was broken somehow");
    const someRecipe = {
      id: "1",
      action: "action",
      filter_expression: "broken",
    };
    const result = await RecipeRunner.getRecipeSuitability(someRecipe);

    is(
      result,
      BaseAction.suitability.FILTER_ERROR,
      "broken filters are reported"
    );
  }
);

decorate_task(
  withSpy(FilterExpressions, "eval"),
  withStub(RecipeRunner, "getCapabilities"),
  withStub(NormandyApi, "verifyObjectSignature"),
  async function test_getRecipeSuitability_checksCapabilities({
    evalSpy,
    getCapabilitiesStub,
  }) {
    getCapabilitiesStub.returns(new Set(["test-capability"]));

    is(
      await RecipeRunner.getRecipeSuitability({
        filter_expression: "true",
      }),
      BaseAction.suitability.FILTER_MATCH,
      "Recipes with no capabilities should pass"
    );
    ok(evalSpy.called, "Filter should be evaluated");

    evalSpy.resetHistory();
    is(
      await RecipeRunner.getRecipeSuitability({
        capabilities: [],
        filter_expression: "true",
      }),
      BaseAction.suitability.FILTER_MATCH,
      "Recipes with empty capabilities should pass"
    );
    ok(evalSpy.called, "Filter should be evaluated");

    evalSpy.resetHistory();
    is(
      await RecipeRunner.getRecipeSuitability({
        capabilities: ["test-capability"],
        filter_expression: "true",
      }),
      BaseAction.suitability.FILTER_MATCH,
      "Recipes with a matching capability should pass"
    );
    ok(evalSpy.called, "Filter should be evaluated");

    evalSpy.resetHistory();
    is(
      await RecipeRunner.getRecipeSuitability({
        capabilities: ["impossible-capability"],
        filter_expression: "true",
      }),
      BaseAction.suitability.CAPABILITIES_MISMATCH,
      "Recipes with non-matching capabilities should not pass"
    );
    ok(!evalSpy.called, "Filter should not be evaluated");
  }
);

decorate_task(
  withMockNormandyApi(),
  withStub(ClientEnvironment, "getClientClassification"),
  async function testClientClassificationCache({
    getClientClassificationStub,
  }) {
    getClientClassificationStub.returns(Promise.resolve(false));

    await SpecialPowers.pushPrefEnv({
      set: [["app.normandy.api_url", "https://example.com/selfsupport-dummy"]],
    });

    // When the experiment pref is false, eagerly call getClientClassification.
    await SpecialPowers.pushPrefEnv({
      set: [["app.normandy.experiments.lazy_classify", false]],
    });
    ok(
      !getClientClassificationStub.called,
      "getClientClassification hasn't been called"
    );
    await RecipeRunner.run();
    ok(
      getClientClassificationStub.called,
      "getClientClassification was called eagerly"
    );

    // When the experiment pref is true, do not eagerly call getClientClassification.
    await SpecialPowers.pushPrefEnv({
      set: [["app.normandy.experiments.lazy_classify", true]],
    });
    getClientClassificationStub.reset();
    ok(
      !getClientClassificationStub.called,

      "getClientClassification hasn't been called"
    );
    await RecipeRunner.run();
    ok(
      !getClientClassificationStub.called,

      "getClientClassification was not called eagerly"
    );
  }
);

decorate_task(
  withStub(ActionsManager.prototype, "finalize"),
  NormandyTestUtils.withMockRecipeCollection([]),
  async function testRunEvents() {
    const startPromise = TestUtils.topicObserved("recipe-runner:start");
    const endPromise = TestUtils.topicObserved("recipe-runner:end");

    await RecipeRunner.run();

    // Will timeout if notifications were not received.
    await startPromise;
    await endPromise;
    ok(true, "The test should pass without timing out");
  }
);

decorate_task(
  withStub(RecipeRunner, "getCapabilities"),
  withStub(NormandyApi, "verifyObjectSignature"),
  NormandyTestUtils.withMockRecipeCollection([{ id: 1 }]),
  async function test_run_includesCapabilities({ getCapabilitiesStub }) {
    getCapabilitiesStub.returns(new Set(["test-capability"]));
    await RecipeRunner.run();
    ok(getCapabilitiesStub.called, "getCapabilities should be called");
  }
);

decorate_task(
  withStub(NormandyApi, "verifyObjectSignature"),
  withStub(ActionsManager.prototype, "processRecipe"),
  withStub(ActionsManager.prototype, "finalize"),
  async function testReadFromRemoteSettings({
    verifyObjectSignatureStub,
    processRecipeStub,
  }) {
    const matchRecipe = {
      id: 1,
      name: "match",
      action: "matchAction",
      filter_expression: "true",
    };
    const noMatchRecipe = {
      id: 2,
      name: "noMatch",
      action: "noMatchAction",
      filter_expression: "false",
    };
    const missingRecipe = {
      id: 3,
      name: "missing",
      action: "missingAction",
      filter_expression: "true",
    };

    const db = await RecipeRunner._remoteSettingsClientForTesting.db;
    const fakeSig = { signature: "abc" };
    await db.importChanges({}, Date.now(), [
      { id: "match", recipe: matchRecipe, signature: fakeSig },
      {
        id: "noMatch",
        recipe: noMatchRecipe,
        signature: fakeSig,
      },
      {
        id: "missing",
        recipe: missingRecipe,
        signature: fakeSig,
      },
    ]);

    let recipesFromRS = (
      await RecipeRunner._remoteSettingsClientForTesting.get()
    ).map(({ recipe }) => recipe);
    // Sort the records by id so that they match the order in the assertion
    recipesFromRS.sort((a, b) => a.id - b.id);
    Assert.deepEqual(
      recipesFromRS,
      [matchRecipe, noMatchRecipe, missingRecipe],
      "The recipes should be accesible from Remote Settings"
    );

    await RecipeRunner.run();

    Assert.deepEqual(
      verifyObjectSignatureStub.args,
      [
        [matchRecipe, fakeSig, "recipe"],
        [missingRecipe, fakeSig, "recipe"],
        [noMatchRecipe, fakeSig, "recipe"],
      ],
      "all recipes should have their signature verified"
    );
    Assert.deepEqual(
      processRecipeStub.args,
      [
        [matchRecipe, BaseAction.suitability.FILTER_MATCH],
        [missingRecipe, BaseAction.suitability.FILTER_MATCH],
        [noMatchRecipe, BaseAction.suitability.FILTER_MISMATCH],
      ],
      "Recipes should be reported with the correct suitabilities"
    );
  }
);

decorate_task(
  withStub(NormandyApi, "verifyObjectSignature"),
  withStub(ActionsManager.prototype, "processRecipe"),
  withStub(RecipeRunner, "getCapabilities"),
  async function testReadFromRemoteSettings({
    processRecipeStub,
    getCapabilitiesStub,
  }) {
    getCapabilitiesStub.returns(new Set(["compatible"]));
    const compatibleRecipe = {
      name: "match",
      filter_expression: "true",
      capabilities: ["compatible"],
    };
    const incompatibleRecipe = {
      name: "noMatch",
      filter_expression: "true",
      capabilities: ["incompatible"],
    };

    const db = await RecipeRunner._remoteSettingsClientForTesting.db;
    const fakeSig = { signature: "abc" };
    await db.importChanges(
      {},
      Date.now(),
      [
        {
          id: "match",
          recipe: compatibleRecipe,
          signature: fakeSig,
        },
        {
          id: "noMatch",
          recipe: incompatibleRecipe,
          signature: fakeSig,
        },
      ],
      {
        clear: true,
      }
    );

    await RecipeRunner.run();

    Assert.deepEqual(
      processRecipeStub.args,
      [
        [compatibleRecipe, BaseAction.suitability.FILTER_MATCH],
        [incompatibleRecipe, BaseAction.suitability.CAPABILITIES_MISMATCH],
      ],
      "recipes should be marked if their capabilities aren't compatible"
    );
  }
);

decorate_task(
  withStub(ActionsManager.prototype, "processRecipe"),
  withStub(NormandyApi, "verifyObjectSignature"),
  NormandyTestUtils.withMockRecipeCollection(),
  async function testBadSignatureFromRemoteSettings({
    processRecipeStub,
    verifyObjectSignatureStub,
    mockRecipeCollection,
  }) {
    verifyObjectSignatureStub.throws(new Error("fake signature error"));
    const badSigRecipe = {
      id: 1,
      name: "badSig",
      action: "matchAction",
      filter_expression: "true",
    };
    await mockRecipeCollection.addRecipes([badSigRecipe]);

    await RecipeRunner.run();

    Assert.deepEqual(processRecipeStub.args, [
      [badSigRecipe, BaseAction.suitability.SIGNATURE_ERROR],
    ]);
  }
);

// Test init() during normal operation
decorate_task(
  withPrefEnv({
    set: [
      ["app.normandy.dev_mode", false],
      ["app.normandy.first_run", false],
    ],
  }),
  withStub(RecipeRunner, "run"),
  withStub(RecipeRunner, "registerTimer"),
  async function testInit({ runStub, registerTimerStub }) {
    await RecipeRunner.init();
    ok(
      !runStub.called,
      "RecipeRunner.run should not be called immediately when not in dev mode or first run"
    );
    ok(registerTimerStub.called, "RecipeRunner.init registers a timer");
  }
);

// test init() in dev mode
decorate_task(
  withPrefEnv({
    set: [["app.normandy.dev_mode", true]],
  }),
  withStub(RecipeRunner, "run"),
  withStub(RecipeRunner, "registerTimer"),
  withStub(RecipeRunner._remoteSettingsClientForTesting, "sync"),
  async function testInitDevMode({ runStub, registerTimerStub, syncStub }) {
    await RecipeRunner.init();
    Assert.deepEqual(
      runStub.args,
      [[{ trigger: "devMode" }]],
      "RecipeRunner.run should be called immediately when in dev mode"
    );
    ok(registerTimerStub.called, "RecipeRunner.init should register a timer");
    ok(
      syncStub.called,
      "RecipeRunner.init should sync remote settings in dev mode"
    );
  }
);

// Test init() first run
decorate_task(
  withPrefEnv({
    set: [
      ["app.normandy.dev_mode", false],
      ["app.normandy.first_run", true],
    ],
  }),
  withStub(RecipeRunner, "run"),
  withStub(RecipeRunner, "registerTimer"),
  withStub(RecipeRunner, "watchPrefs"),
  async function testInitFirstRun({ runStub, registerTimerStub }) {
    await RecipeRunner.init();
    Assert.deepEqual(
      runStub.args,
      [[{ trigger: "firstRun" }]],
      "RecipeRunner.run is called immediately on first run"
    );
    ok(
      !Services.prefs.getBoolPref("app.normandy.first_run"),
      "On first run, the first run pref is set to false"
    );
    ok(
      registerTimerStub.called,
      "RecipeRunner.registerTimer registers a timer"
    );

    // RecipeRunner.init() sets this pref to false, but SpecialPowers
    // relies on the preferences it manages to actually change when it
    // tries to change them. Settings this back to true here allows
    // that to happen. Not doing this causes popPrefEnv to hang forever.
    Services.prefs.setBoolPref("app.normandy.first_run", true);
  }
);

// Test that prefs are watched correctly
decorate_task(
  withPrefEnv({
    set: [
      ["app.normandy.dev_mode", false],
      ["app.normandy.first_run", false],
      ["app.normandy.enabled", true],
    ],
  }),
  withStub(RecipeRunner, "run"),
  withStub(RecipeRunner, "enable"),
  withStub(RecipeRunner, "disable"),
  withStub(CleanupManager, "addCleanupHandler"),

  async function testPrefWatching({ runStub, enableStub, disableStub }) {
    await RecipeRunner.init();
    is(enableStub.callCount, 1, "Enable should be called initially");
    is(disableStub.callCount, 0, "Disable should not be called initially");

    await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", false]] });
    is(enableStub.callCount, 1, "Enable should not be called again");
    is(
      disableStub.callCount,
      1,
      "RecipeRunner should disable when Shield is disabled"
    );

    await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", true]] });
    is(
      enableStub.callCount,
      2,
      "RecipeRunner should re-enable when Shield is enabled"
    );
    is(disableStub.callCount, 1, "Disable should not be called again");

    await SpecialPowers.pushPrefEnv({
      set: [["app.normandy.api_url", "http://example.com"]],
    }); // does not start with https://
    is(enableStub.callCount, 2, "Enable should not be called again");
    is(
      disableStub.callCount,
      2,
      "RecipeRunner should disable when an invalid api url is given"
    );

    await SpecialPowers.pushPrefEnv({
      set: [["app.normandy.api_url", "https://example.com"]],
    }); // ends with https://
    is(
      enableStub.callCount,
      3,
      "RecipeRunner should re-enable when a valid api url is given"
    );
    is(disableStub.callCount, 2, "Disable should not be called again");

    is(
      runStub.callCount,
      0,
      "RecipeRunner.run should not be called during this test"
    );
  }
);

// Test that enable and disable are idempotent
decorate_task(
  withStub(RecipeRunner, "registerTimer"),
  withStub(RecipeRunner, "unregisterTimer"),
  async function testPrefWatching({ registerTimerStub }) {
    const originalEnabled = RecipeRunner.enabled;

    try {
      RecipeRunner.enabled = false;
      RecipeRunner.enable();
      RecipeRunner.enable();
      RecipeRunner.enable();
      is(registerTimerStub.callCount, 1, "Enable should be idempotent");

      RecipeRunner.enabled = true;
      RecipeRunner.disable();
      RecipeRunner.disable();
      RecipeRunner.disable();
      is(registerTimerStub.callCount, 1, "Disable should be idempotent");
    } finally {
      RecipeRunner.enabled = originalEnabled;
    }
  }
);

decorate_task(
  withPrefEnv({
    set: [["app.normandy.onsync_skew_sec", 0]],
  }),
  withStub(RecipeRunner, "run"),
  async function testRunOnSyncRemoteSettings({ runStub }) {
    const rsClient = RecipeRunner._remoteSettingsClientForTesting;
    await RecipeRunner.init();
    ok(
      RecipeRunner._alreadySetUpRemoteSettings,
      "remote settings should be set up in the runner"
    );

    // Runner disabled
    RecipeRunner.disable();
    await rsClient.emit("sync", {});
    ok(!runStub.called, "run() should not be called if disabled");
    runStub.reset();

    // Runner enabled
    RecipeRunner.enable();
    await rsClient.emit("sync", {});
    ok(runStub.called, "run() should be called if enabled");
    runStub.reset();

    // Runner disabled
    RecipeRunner.disable();
    await rsClient.emit("sync", {});
    ok(!runStub.called, "run() should not be called if disabled");
    runStub.reset();

    // Runner re-enabled
    RecipeRunner.enable();
    await rsClient.emit("sync", {});
    ok(runStub.called, "run() should be called if runner is re-enabled");
  }
);

decorate_task(
  withPrefEnv({
    set: [
      ["app.normandy.onsync_skew_sec", 600], // 10 minutes, much longer than the test will take to run
    ],
  }),
  withStub(RecipeRunner, "run"),
  async function testOnSyncRunDelayed({ runStub }) {
    ok(
      !RecipeRunner._syncSkewTimeout,
      "precondition: No timer should be active"
    );
    const rsClient = RecipeRunner._remoteSettingsClientForTesting;
    await rsClient.emit("sync", {});
    ok(runStub.notCalled, "run() should be not called yet");
    ok(RecipeRunner._syncSkewTimeout, "A timer should be set");
    clearInterval(RecipeRunner._syncSkewTimeout); // cleanup
  }
);

decorate_task(
  withStub(RecipeRunner._remoteSettingsClientForTesting, "get"),
  async function testRunCanRunOnlyOnce({ getStub }) {
    getStub.returns(
      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      new Promise(resolve => setTimeout(() => resolve([]), 10))
    );

    // Run 2 in parallel.
    await Promise.all([RecipeRunner.run(), RecipeRunner.run()]);

    is(getStub.callCount, 1, "run() is no-op if already running");
  }
);

decorate_task(
  withPrefEnv({
    set: [
      // Enable update timer logs.
      ["app.update.log", true],
      ["app.normandy.api_url", "https://example.com"],
      ["app.normandy.first_run", false],
      ["app.normandy.onsync_skew_sec", 0],
    ],
  }),
  withSpy(RecipeRunner, "run"),
  withStub(ActionsManager.prototype, "finalize"),
  async function testSyncDelaysTimer({ runSpy }) {
    // Mark any existing timer as having run just now.
    for (const { value } of Services.catMan.enumerateCategory("update-timer")) {
      const timerID = value.split(",")[2];
      console.log(`Mark timer ${timerID} as ran recently`);
      // See https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8
      const timerLastUpdatePref = `app.update.lastUpdateTime.${timerID}`;
      const lastUpdateTime = Math.round(Date.now() / 1000);
      Services.prefs.setIntPref(timerLastUpdatePref, lastUpdateTime);
    }

    // Give our timer a short duration so that it executes quickly.
    // This needs to be more than 1 second as we will call UpdateTimerManager's
    // notify method twice in a row and verify that our timer is only called
    // once, but because the timestamps are rounded to seconds, just a few
    // additional ms could result in a higher value that would cause the timer
    // to be called again almost immediately if our timer duration was only 1s.
    const kTimerDuration = 2;
    Services.prefs.setIntPref(
      "app.normandy.run_interval_seconds",
      kTimerDuration
    );
    // This will refresh the timer interval.
    RecipeRunner.unregisterTimer();
    // Ensure our timer is ready to run now.
    Services.prefs.setIntPref(
      "app.update.lastUpdateTime.recipe-client-addon-run",
      Math.round(Date.now() / 1000) - kTimerDuration
    );
    RecipeRunner.registerTimer();

    is(runSpy.callCount, 0, "run() shouldn't have run yet");

    // Simulate timer notification.
    runSpy.resetHistory();
    const service = Cc["@mozilla.org/updates/timer-manager;1"].getService(
      Ci.nsITimerCallback
    );
    const newTimer = () => {
      const t = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
      t.initWithCallback(() => {}, 10, Ci.nsITimer.TYPE_ONE_SHOT);
      return t;
    };

    // Run timer once, to make sure this test works as expected.
    const startTime = Date.now();
    const endPromise = TestUtils.topicObserved("recipe-runner:end");
    service.notify(newTimer());
    await endPromise; // will timeout if run() not called.
    const timerLatency = Math.max(Date.now() - startTime, 1);
    is(runSpy.callCount, 1, "run() should be called from timer");

    // Run once from sync event.
    runSpy.resetHistory();
    const rsClient = RecipeRunner._remoteSettingsClientForTesting;
    await rsClient.emit("sync", {}); // waits for listeners to run.
    is(runSpy.callCount, 1, "run() should be called from sync");

    // Trigger timer again. This should not run recipes again, since a sync just happened
    runSpy.resetHistory();
    is(runSpy.callCount, 0, "run() does not run again from timer");
    service.notify(newTimer());
    // Wait at least as long as the latency we had above. Ten times as a margin.
    is(runSpy.callCount, 0, "run() does not run again from timer");
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    await new Promise(resolve => setTimeout(resolve, timerLatency * 10));
    is(runSpy.callCount, 0, "run() does not run again from timer");
    RecipeRunner.disable();
  }
);

// Test that the capabilities for context variables are generated correctly.
decorate_task(async function testAutomaticCapabilities() {
  const capabilities = await RecipeRunner.getCapabilities();

  ok(
    capabilities.has("jexl.context.env.country"),
    "context variables from Normandy's client context should be included"
  );
  ok(
    capabilities.has("jexl.context.env.version"),
    "context variables from the superclass context should be included"
  );
  ok(
    !capabilities.has("jexl.context.env.getClientClassification"),
    "non-getter functions should not be included"
  );
  ok(
    !capabilities.has("jexl.context.env.prototype"),
    "built-in, non-enumerable properties should not be included"
  );
});

// Test that recipe runner won't run if Normandy hasn't been initialized.
decorate_task(
  withStub(ActionsManager.prototype, "finalize"),
  NormandyTestUtils.withMockRecipeCollection([]),
  async function testRunEvents() {
    const observer = sinon.spy();
    Services.obs.addObserver(observer, "recipe-runner:start");

    const originalPrefsApplied = Normandy.defaultPrefsHaveBeenApplied;
    Normandy.defaultPrefsHaveBeenApplied = Promise.withResolvers();

    const recipeRunnerPromise = RecipeRunner.run();
    await Promise.resolve();
    ok(
      !observer.called,
      "RecipeRunner.run shouldn't run if Normandy isn't initialized"
    );

    Normandy.defaultPrefsHaveBeenApplied.resolve();
    await recipeRunnerPromise;
    ok(
      observer.called,
      "RecipeRunner.run should run after Normandy has initialized"
    );

    // cleanup
    Services.obs.removeObserver(observer, "recipe-runner:start");
    Normandy.defaultPrefsHaveBeenApplied = originalPrefsApplied;
  }
);

// If no recipes are found on the server, the action manager should be informed of that
decorate_task(
  withSpy(ActionsManager.prototype, "finalize"),
  NormandyTestUtils.withMockRecipeCollection([]),
  async function testNoRecipes({ finalizeSpy }) {
    await RecipeRunner.run();
    Assert.deepEqual(
      finalizeSpy.args,
      [[{ noRecipes: true }]],
      "Action manager should know there were no recipes received"
    );
  }
);

// If some recipes are found on the server, the action manager should be informed of that
decorate_task(
  withSpy(ActionsManager.prototype, "finalize"),
  NormandyTestUtils.withMockRecipeCollection([{ id: 1 }]),
  async function testSomeRecipes({ finalizeSpy }) {
    await RecipeRunner.run();
    Assert.deepEqual(
      finalizeSpy.args,
      [[{ noRecipes: false }]],
      "Action manager should know there were recipes received"
    );
  }
);
