478 lines
16 KiB
JavaScript
478 lines
16 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
|
|
* vim: sw=4 ts=4 sts=4 et
|
|
* 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 file tests several things.
|
|
//
|
|
// 1. We verify that we can forcefully opt-in to (particular branches of)
|
|
// experiments, that the resulting Firefox Messaging Experiment applies, and
|
|
// that the Firefox Messaging System respects lifetime frequency caps.
|
|
// 2. We verify that Nimbus randomization works with specific Normandy
|
|
// randomization IDs.
|
|
// 3. We verify that relevant opt-out prefs disable the Nimbus and Firefox
|
|
// Messaging System experience.
|
|
|
|
const { ASRouterTargeting } = ChromeUtils.importESModule(
|
|
"resource:///modules/asrouter/ASRouterTargeting.sys.mjs"
|
|
);
|
|
const { NimbusTestUtils } = ChromeUtils.importESModule(
|
|
"resource://testing-common/NimbusTestUtils.sys.mjs"
|
|
);
|
|
|
|
NimbusTestUtils.init(this);
|
|
|
|
// These randomization IDs were extracted by hand from Firefox instances.
|
|
// Randomization is sufficiently stable to hard-code these IDs rather than
|
|
// generating new ones at test time.
|
|
const BRANCH_MAP = {
|
|
"treatment-a": {
|
|
randomizationId: "d0e95fc3-fb15-4bc4-8151-a89582a56e29",
|
|
title: "Treatment A",
|
|
text: "Body A",
|
|
},
|
|
"treatment-b": {
|
|
randomizationId: "90a60347-66cc-4716-9fef-cf49dd992d51",
|
|
title: "Treatment B",
|
|
text: "Body B",
|
|
},
|
|
};
|
|
|
|
setupProfileService();
|
|
|
|
let taskProfile;
|
|
let manager;
|
|
let cleanup;
|
|
|
|
// Arrange a dummy Remote Settings server so that no non-local network
|
|
// connections are opened.
|
|
// And arrange dummy task profile.
|
|
add_setup(async () => {
|
|
info("Setting up profile service");
|
|
let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
|
|
Ci.nsIToolkitProfileService
|
|
);
|
|
|
|
let taskProfD = do_get_profile();
|
|
taskProfD.append("test_backgroundtask_experiments_task");
|
|
taskProfile = profileService.createUniqueProfile(
|
|
taskProfD,
|
|
"test_backgroundtask_experiments_task"
|
|
);
|
|
|
|
registerCleanupFunction(() => {
|
|
taskProfile.remove(true);
|
|
});
|
|
|
|
// Arrange fake experiment enrollment details.
|
|
({ manager, cleanup } = await NimbusTestUtils.setupTest());
|
|
|
|
await manager.enroll(
|
|
NimbusTestUtils.factories.recipe.withFeatureConfig("foo", {
|
|
branchSlug: "treatment",
|
|
featureId: "testFeature",
|
|
}),
|
|
"test"
|
|
);
|
|
await manager.unenroll("foo");
|
|
await manager.enroll(
|
|
NimbusTestUtils.factories.recipe.withFeatureConfig("bar", {
|
|
branchSlug: "treatment",
|
|
featureId: "testFeature",
|
|
}),
|
|
"test"
|
|
);
|
|
await manager.unenroll("bar");
|
|
await manager.enroll(
|
|
NimbusTestUtils.factories.recipe.withFeatureConfig("baz", {
|
|
branchSlug: "treatment",
|
|
featureId: "testFeature",
|
|
}),
|
|
"test"
|
|
);
|
|
|
|
await manager.enroll(
|
|
NimbusTestUtils.factories.recipe("rol1", { isRollout: true }),
|
|
"test"
|
|
);
|
|
await manager.unenroll("rol1");
|
|
await manager.enroll(
|
|
NimbusTestUtils.factories.recipe("rol2", { isRollout: true }),
|
|
"test"
|
|
);
|
|
});
|
|
|
|
registerCleanupFunction(async () => {
|
|
await manager.unenroll("baz");
|
|
await manager.unenroll("rol2");
|
|
await cleanup();
|
|
});
|
|
|
|
function resetProfile(profile) {
|
|
profile.rootDir.remove(true);
|
|
profile.rootDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700);
|
|
info(`Reset profile '${profile.rootDir.path}'`);
|
|
}
|
|
|
|
// Run the "message" background task with some default configuration. Return
|
|
// "info" items output from the task as an array and as a map.
|
|
async function doMessage({ extraArgs = [], extraEnv = {} } = {}) {
|
|
let sentinel = Services.uuid.generateUUID().toString();
|
|
sentinel = sentinel.substring(1, sentinel.length - 1);
|
|
|
|
let infoArray = [];
|
|
let exitCode = await do_backgroundtask("message", {
|
|
extraArgs: [
|
|
"--sentinel",
|
|
sentinel,
|
|
// Use a test-specific non-ephemeral profile, not the system-wide shared
|
|
// task-specific profile.
|
|
"--profile",
|
|
taskProfile.rootDir.path,
|
|
// Don't actually show a toast notification.
|
|
"--disable-alerts-service",
|
|
// Don't contact Remote Settings server. Can be overridden by subsequent
|
|
// `--experiments ...`.
|
|
"--no-experiments",
|
|
...extraArgs,
|
|
],
|
|
extraEnv: {
|
|
MOZ_LOG: "Dump:5,BackgroundTasks:5",
|
|
...extraEnv,
|
|
},
|
|
onStdoutLine: line => {
|
|
if (line.includes(sentinel)) {
|
|
let info = JSON.parse(line.split(sentinel)[1]);
|
|
infoArray.push(info);
|
|
}
|
|
},
|
|
});
|
|
|
|
Assert.equal(
|
|
0,
|
|
exitCode,
|
|
"The message background task exited with exit code 0"
|
|
);
|
|
|
|
// Turn [{x:...}, {y:...}] into {x:..., y:...}.
|
|
let infoMap = Object.assign({}, ...infoArray);
|
|
|
|
return { infoArray, infoMap };
|
|
}
|
|
|
|
// Opt-in to an experiment. Verify that the experiment state is non-ephemeral,
|
|
// i.e., persisted. Verify that messages are shown until we hit the lifetime
|
|
// frequency caps.
|
|
//
|
|
// It's awkward to inspect the `ASRouter.sys.mjs` internal state directly in
|
|
// this manner, but this is the pattern for testing such things at the time of
|
|
// writing.
|
|
add_task(async function test_backgroundtask_caps() {
|
|
let experimentFile = do_get_file("experiment.json");
|
|
let experimentFileURI = Services.io.newFileURI(experimentFile);
|
|
|
|
let { infoMap } = await doMessage({
|
|
extraArgs: [
|
|
// Opt-in to an experiment from a file.
|
|
"--url",
|
|
`${experimentFileURI.spec}?optin_branch=treatment-a`,
|
|
],
|
|
});
|
|
|
|
// Verify that the correct experiment and branch generated an impression.
|
|
let impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]);
|
|
Assert.equal(impressions["test-experiment:treatment-a"].length, 1);
|
|
|
|
// Verify that the correct toast notification was shown.
|
|
let alert = infoMap.showAlert.args[0];
|
|
Assert.equal(alert.title, "Treatment A");
|
|
Assert.equal(alert.text, "Body A");
|
|
Assert.equal(alert.name, "optin-test-experiment:treatment-a");
|
|
|
|
// Now, do it again. No need to opt-in to the experiment this time.
|
|
({ infoMap } = await doMessage({}));
|
|
|
|
// Verify that only the correct experiment and branch generated an impression.
|
|
impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]);
|
|
Assert.equal(impressions["test-experiment:treatment-a"].length, 2);
|
|
|
|
// Verify that the correct toast notification was shown.
|
|
alert = infoMap.showAlert.args[0];
|
|
Assert.equal(alert.title, "Treatment A");
|
|
Assert.equal(alert.text, "Body A");
|
|
Assert.equal(alert.name, "optin-test-experiment:treatment-a");
|
|
|
|
// A third time. We'll hit the lifetime frequency cap (which is 2).
|
|
({ infoMap } = await doMessage({}));
|
|
|
|
// Verify that the correct experiment and branch impressions are untouched.
|
|
impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.deepEqual(Object.keys(impressions), ["test-experiment:treatment-a"]);
|
|
Assert.equal(impressions["test-experiment:treatment-a"].length, 2);
|
|
|
|
// Verify that no toast notication was shown.
|
|
Assert.ok(!("showAlert" in infoMap), "No alert shown");
|
|
});
|
|
|
|
// Test that background tasks are enrolled into branches based on the Normandy
|
|
// randomization ID as expected. Run the message task with a hard-coded list of
|
|
// a single experiment and known randomization IDs, and verify that the enrolled
|
|
// branches are as expected.
|
|
add_task(async function test_backgroundtask_randomization() {
|
|
let experimentFile = do_get_file("experiment.json");
|
|
|
|
for (let [branchSlug, branchDetails] of Object.entries(BRANCH_MAP)) {
|
|
// Start fresh each time.
|
|
resetProfile(taskProfile);
|
|
|
|
// Invoke twice; verify the branch is consistent each time.
|
|
for (let count = 1; count <= 2; count++) {
|
|
let { infoMap } = await doMessage({
|
|
extraArgs: [
|
|
// Read experiments from a file.
|
|
"--experiments",
|
|
experimentFile.path,
|
|
// Fixed randomization ID yields a deterministic enrollment branch assignment.
|
|
"--randomizationId",
|
|
branchDetails.randomizationId,
|
|
],
|
|
});
|
|
|
|
// Verify that only the correct experiment and branch generated an impression.
|
|
let impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.deepEqual(Object.keys(impressions), [
|
|
`test-experiment:${branchSlug}`,
|
|
]);
|
|
Assert.equal(impressions[`test-experiment:${branchSlug}`].length, count);
|
|
|
|
// Verify that the correct toast notification was shown.
|
|
let alert = infoMap.showAlert.args[0];
|
|
Assert.equal(alert.title, branchDetails.title, "Title is correct");
|
|
Assert.equal(alert.text, branchDetails.text, "Text is correct");
|
|
Assert.equal(
|
|
alert.name,
|
|
`test-experiment:${branchSlug}`,
|
|
"Name (tag) is correct"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Test that background tasks respect the datareporting and studies opt-out
|
|
// preferences.
|
|
add_task(async function test_backgroundtask_optout_preferences() {
|
|
let experimentFile = do_get_file("experiment.json");
|
|
|
|
let OPTION_MAP = {
|
|
"--no-datareporting": {
|
|
"datareporting.healthreport.uploadEnabled": false,
|
|
"app.shield.optoutstudies.enabled": true,
|
|
},
|
|
"--no-optoutstudies": {
|
|
"datareporting.healthreport.uploadEnabled": true,
|
|
"app.shield.optoutstudies.enabled": false,
|
|
},
|
|
};
|
|
|
|
for (let [option, expectedPrefs] of Object.entries(OPTION_MAP)) {
|
|
// Start fresh each time.
|
|
resetProfile(taskProfile);
|
|
|
|
let { infoMap } = await doMessage({
|
|
extraArgs: [
|
|
option,
|
|
// Read experiments from a file. Opting in to an experiment with
|
|
// `--url` does not consult relevant preferences.
|
|
"--experiments",
|
|
experimentFile.path,
|
|
],
|
|
});
|
|
|
|
Assert.deepEqual(infoMap.taskProfilePrefs, expectedPrefs);
|
|
|
|
// Verify that no experiment generated an impression.
|
|
let impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.deepEqual(
|
|
impressions,
|
|
[],
|
|
`No impressions generated with ${option}`
|
|
);
|
|
|
|
// Verify that no toast notication was shown.
|
|
Assert.ok(!("showAlert" in infoMap), `No alert shown with ${option}`);
|
|
}
|
|
});
|
|
|
|
const TARGETING_LIST = [
|
|
// Target based on background task details.
|
|
["isBackgroundTaskMode", 1],
|
|
["backgroundTaskName == 'message'", 1],
|
|
["backgroundTaskName == 'unrecognized'", 0],
|
|
// Filter based on `defaultProfile` targeting snapshot.
|
|
["(currentDate|date - defaultProfile.currentDate|date) > 0", 1],
|
|
["(currentDate|date - defaultProfile.currentDate|date) > 999999", 0],
|
|
// Filter based on `defaultProfile` experiment enrollment details.
|
|
["'baz' in defaultProfile.activeExperiments", 1],
|
|
["'bar' in defaultProfile.previousExperiments", 1],
|
|
["'rol2' in defaultProfile.activeRollouts", 1],
|
|
["'rol1' in defaultProfile.previousRollouts", 1],
|
|
["defaultProfile.enrollmentsMap['baz'] == 'treatment'", 1],
|
|
["defaultProfile.enrollmentsMap['bar'] == 'treatment'", 1],
|
|
["'unknown' in defaultProfile.enrollmentsMap", 0],
|
|
];
|
|
|
|
// Test that background tasks targeting works for Nimbus experiments.
|
|
add_task(async function test_backgroundtask_Nimbus_targeting() {
|
|
let experimentFile = do_get_file("experiment.json");
|
|
let experimentData = await IOUtils.readJSON(experimentFile.path);
|
|
|
|
// We can't take a full environment snapshot under `xpcshell`. Select a few
|
|
// items that do work.
|
|
let target = {
|
|
currentDate: ASRouterTargeting.Environment.currentDate,
|
|
firefoxVersion: ASRouterTargeting.Environment.firefoxVersion,
|
|
};
|
|
let targetSnapshot = await ASRouterTargeting.getEnvironmentSnapshot({
|
|
targets: [manager.createTargetingContext(), target],
|
|
});
|
|
|
|
for (let [targeting, expectedLength] of TARGETING_LIST) {
|
|
// Start fresh each time.
|
|
resetProfile(taskProfile);
|
|
|
|
let snapshotFile = taskProfile.rootDir.clone();
|
|
snapshotFile.append("targeting.snapshot.json");
|
|
await IOUtils.writeJSON(snapshotFile.path, targetSnapshot);
|
|
|
|
// Write updated experiment data.
|
|
experimentData.data.targeting = targeting;
|
|
let targetingExperimentFile = taskProfile.rootDir.clone();
|
|
targetingExperimentFile.append("targeting.experiment.json");
|
|
await IOUtils.writeJSON(targetingExperimentFile.path, experimentData);
|
|
|
|
let { infoMap } = await doMessage({
|
|
extraArgs: [
|
|
"--experiments",
|
|
targetingExperimentFile.path,
|
|
"--targeting-snapshot",
|
|
snapshotFile.path,
|
|
],
|
|
});
|
|
|
|
// Verify that the given targeting generated the expected number of impressions.
|
|
let impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.equal(
|
|
Object.keys(impressions).length,
|
|
expectedLength,
|
|
`${expectedLength} impressions generated with targeting '${targeting}'`
|
|
);
|
|
}
|
|
});
|
|
|
|
// Test that background tasks targeting works for Firefox Messaging System branches.
|
|
add_task(async function test_backgroundtask_Messaging_targeting() {
|
|
// Don't target the Nimbus experiment at all. Use a consistent
|
|
// randomization ID to always enroll in the first branch. Target
|
|
// the first branch of the Firefox Messaging Experiment to the given
|
|
// targeting. Therefore, we either get the first branch if the
|
|
// targeting matches, or nothing at all.
|
|
|
|
let treatmentARandomizationId = BRANCH_MAP["treatment-a"].randomizationId;
|
|
|
|
let experimentFile = do_get_file("experiment.json");
|
|
let experimentData = await IOUtils.readJSON(experimentFile.path);
|
|
|
|
// We can't take a full environment snapshot under `xpcshell`. Select a few
|
|
// items that do work.
|
|
let target = {
|
|
currentDate: ASRouterTargeting.Environment.currentDate,
|
|
firefoxVersion: ASRouterTargeting.Environment.firefoxVersion,
|
|
};
|
|
let targetSnapshot = await ASRouterTargeting.getEnvironmentSnapshot({
|
|
targets: [manager.createTargetingContext(), target],
|
|
});
|
|
|
|
for (let [targeting, expectedLength] of TARGETING_LIST) {
|
|
// Start fresh each time.
|
|
resetProfile(taskProfile);
|
|
|
|
let snapshotFile = taskProfile.rootDir.clone();
|
|
snapshotFile.append("targeting.snapshot.json");
|
|
await IOUtils.writeJSON(snapshotFile.path, targetSnapshot);
|
|
|
|
// Write updated experiment data.
|
|
experimentData.data.targeting = "true";
|
|
experimentData.data.branches[0].features[0].value.targeting = targeting;
|
|
|
|
let targetingExperimentFile = taskProfile.rootDir.clone();
|
|
targetingExperimentFile.append("targeting.experiment.json");
|
|
await IOUtils.writeJSON(targetingExperimentFile.path, experimentData);
|
|
|
|
let { infoMap } = await doMessage({
|
|
extraArgs: [
|
|
"--experiments",
|
|
targetingExperimentFile.path,
|
|
"--targeting-snapshot",
|
|
snapshotFile.path,
|
|
"--randomizationId",
|
|
treatmentARandomizationId,
|
|
],
|
|
});
|
|
|
|
// Verify that the given targeting generated the expected number of impressions.
|
|
let impressions = infoMap.ASRouterState.messageImpressions;
|
|
Assert.equal(
|
|
Object.keys(impressions).length,
|
|
expectedLength,
|
|
`${expectedLength} impressions generated with targeting '${targeting}'`
|
|
);
|
|
|
|
if (expectedLength > 0) {
|
|
// Verify that the correct toast notification was shown.
|
|
let alert = infoMap.showAlert.args[0];
|
|
Assert.equal(
|
|
alert.title,
|
|
BRANCH_MAP["treatment-a"].title,
|
|
"Title is correct"
|
|
);
|
|
Assert.equal(
|
|
alert.text,
|
|
BRANCH_MAP["treatment-a"].text,
|
|
"Text is correct"
|
|
);
|
|
Assert.equal(
|
|
alert.name,
|
|
`test-experiment:treatment-a`,
|
|
"Name (tag) is correct"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Verify that `RemoteSettingsClient.sync` is invoked before any
|
|
// `RemoteSettingsClient.get` invocations. This ensures the Remote Settings
|
|
// recipe collection is not allowed to go stale.
|
|
add_task(
|
|
async function test_backgroundtask_RemoteSettingsClient_invokes_sync() {
|
|
let { infoArray, infoMap } = await doMessage({});
|
|
|
|
Assert.ok(
|
|
"RemoteSettingsClient.get" in infoMap,
|
|
"RemoteSettingsClient.get was invoked"
|
|
);
|
|
|
|
for (let info of infoArray) {
|
|
if ("RemoteSettingsClient.get" in info) {
|
|
const { options: calledOptions } = info["RemoteSettingsClient.get"];
|
|
Assert.ok(
|
|
calledOptions.forceSync,
|
|
"RemoteSettingsClient.get was first called with `forceSync`"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
);
|