diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/normandy/test/browser/browser_RecipeRunner.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/test/browser/browser_RecipeRunner.js')
-rw-r--r-- | toolkit/components/normandy/test/browser/browser_RecipeRunner.js | 874 |
1 files changed, 874 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/browser/browser_RecipeRunner.js b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js new file mode 100644 index 0000000000..00f0f81c51 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js @@ -0,0 +1,874 @@ +"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 { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.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"); +}); + +add_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(Uptake, "reportRecipe"), + withStub(NormandyApi, "verifyObjectSignature"), + async function test_getRecipeSuitability_canHandleExceptions({ + evalStub, + reportRecipeStub, + }) { + 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" + ); + Assert.deepEqual(reportRecipeStub.args, [ + [someRecipe, Uptake.RECIPE_FILTER_BROKEN], + ]); + } +); + +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({ + mockNormandyApi, + 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(Uptake, "reportRunner"), + 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"), + withStub(Uptake, "reportRecipe"), + async function testReadFromRemoteSettings({ + verifyObjectSignatureStub, + processRecipeStub, + finalizeStub, + reportRecipeStub, + }) { + 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, signature }) => 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" + ); + Assert.deepEqual( + reportRecipeStub.args, + [[noMatchRecipe, Uptake.RECIPE_DIDNT_MATCH_FILTER]], + "Filtered-out recipes should be reported" + ); + } +); + +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"), + withStub(Uptake, "reportRecipe"), + NormandyTestUtils.withMockRecipeCollection(), + async function testBadSignatureFromRemoteSettings({ + processRecipeStub, + verifyObjectSignatureStub, + reportRecipeStub, + 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], + ]); + Assert.deepEqual( + reportRecipeStub.args, + [[badSigRecipe, Uptake.RECIPE_INVALID_SIGNATURE]], + "The recipe should have its uptake status recorded" + ); + } +); + +// Test init() during normal operation +decorate_task( + withPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["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: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["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: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["app.normandy.dev_mode", false], + ["app.normandy.first_run", true], + ], + }), + withStub(RecipeRunner, "run"), + withStub(RecipeRunner, "registerTimer"), + withStub(RecipeRunner, "watchPrefs"), + async function testInitFirstRun({ + runStub, + registerTimerStub, + watchPrefsStub, + }) { + 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"), + withStub(Uptake, "reportRunner"), + 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(Uptake, "reportRunner"), + withStub(ActionsManager.prototype, "finalize"), + NormandyTestUtils.withMockRecipeCollection([]), + async function testRunEvents({ reportRunnerStub, finalizeStub }) { + 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" + ); + } +); |