868 lines
26 KiB
JavaScript
868 lines
26 KiB
JavaScript
"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({
|
|
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,
|
|
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 }) => 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 }) {
|
|
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() {
|
|
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"
|
|
);
|
|
}
|
|
);
|