Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
"use strict";
const { BaseAction } = ChromeUtils.importESModule(
"resource://normandy/actions/BaseAction.sys.mjs"
);
const { ClientEnvironment } = ChromeUtils.importESModule(
"resource://normandy/lib/ClientEnvironment.sys.mjs"
);
const { Heartbeat } = ChromeUtils.importESModule(
"resource://normandy/lib/Heartbeat.sys.mjs"
);
const { Uptake } = ChromeUtils.importESModule(
"resource://normandy/lib/Uptake.sys.mjs"
);
const { NormandyTestUtils } = ChromeUtils.importESModule(
);
const HOUR_IN_MS = 60 * 60 * 1000;
function heartbeatRecipeFactory(overrides = {}) {
const defaults = {
revision_id: 1,
name: "Test Recipe",
action: "show-heartbeat",
arguments: {
surveyId: "a survey",
message: "test message",
engagementButtonLabel: "",
thanksMessage: "thanks!",
learnMoreMessage: "Learn More",
repeatOption: "once",
},
};
if (overrides.arguments) {
defaults.arguments = Object.assign(defaults.arguments, overrides.arguments);
delete overrides.arguments;
}
return recipeFactory(Object.assign(defaults, overrides));
}
// Test that a normal heartbeat works as expected
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testHappyPath({ heartbeatClassStub, heartbeatInstanceStub }) {
const recipe = heartbeatRecipeFactory();
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
await action.finalize();
is(
action.state,
ShowHeartbeatAction.STATE_FINALIZED,
"Action should be finalized"
);
is(action.lastError, null, "No errors should have been thrown");
const options = heartbeatClassStub.args[0][1];
Assert.deepEqual(
heartbeatClassStub.args,
[
[
heartbeatClassStub.args[0][0], // target window
{
surveyId: options.surveyId,
message: recipe.arguments.message,
engagementButtonLabel: recipe.arguments.engagementButtonLabel,
thanksMessage: recipe.arguments.thanksMessage,
learnMoreMessage: recipe.arguments.learnMoreMessage,
learnMoreUrl: recipe.arguments.learnMoreUrl,
postAnswerUrl: options.postAnswerUrl,
flowId: options.flowId,
surveyVersion: recipe.revision_id,
},
],
],
"expected arguments were passed"
);
ok(NormandyTestUtils.isUuid(options.flowId, "flowId should be a uuid"));
// postAnswerUrl gains several query string parameters. Check that the prefix is right
ok(options.postAnswerUrl.startsWith(recipe.arguments.postAnswerUrl));
ok(
heartbeatInstanceStub.eventEmitter.once.calledWith("Voted"),
"Voted event handler should be registered"
);
ok(
heartbeatInstanceStub.eventEmitter.once.calledWith("Engaged"),
"Engaged event handler should be registered"
);
}
);
/* Test that heartbeat doesn't show if an unrelated heartbeat has shown recently. */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testRepeatGeneral({ heartbeatClassStub }) {
const allHeartbeatStorage = new Storage("normandy-heartbeat");
await allHeartbeatStorage.setItem("lastShown", Date.now());
const recipe = heartbeatRecipeFactory();
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(
heartbeatClassStub.args.length,
0,
"Heartbeat should not be called once"
);
}
);
/* Test that a heartbeat shows if an unrelated heartbeat showed more than 24 hours ago. */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testRepeatUnrelated({ heartbeatClassStub }) {
const allHeartbeatStorage = new Storage("normandy-heartbeat");
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 25 * HOUR_IN_MS
);
const recipe = heartbeatRecipeFactory();
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(heartbeatClassStub.args.length, 1, "Heartbeat should be called once");
}
);
/* Test that a repeat=once recipe is not shown again, even more than 24 hours ago. */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testRepeatTypeOnce({ heartbeatClassStub }) {
const recipe = heartbeatRecipeFactory({
arguments: { repeatOption: "once" },
});
const recipeStorage = new Storage(recipe.id);
await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called");
}
);
/* Test that a repeat=xdays recipe is shown again, only after the expected number of days. */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testRepeatTypeXdays({ heartbeatClassStub }) {
const recipe = heartbeatRecipeFactory({
arguments: {
repeatOption: "xdays",
repeatEvery: 2,
},
});
const recipeStorage = new Storage(recipe.id);
const allHeartbeatStorage = new Storage("normandy-heartbeat");
await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 25 * HOUR_IN_MS
);
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called");
await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 50 * HOUR_IN_MS
);
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(
heartbeatClassStub.args.length,
1,
"Heartbeat should have been called once"
);
}
);
/* Test that a repeat=nag recipe is shown again until lastInteraction is set */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testRepeatTypeNag({ heartbeatClassStub }) {
const recipe = heartbeatRecipeFactory({
arguments: { repeatOption: "nag" },
});
const recipeStorage = new Storage(recipe.id);
const allHeartbeatStorage = new Storage("normandy-heartbeat");
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 25 * HOUR_IN_MS
);
await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
const action = new ShowHeartbeatAction();
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(heartbeatClassStub.args.length, 1, "Heartbeat should be called");
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 50 * HOUR_IN_MS
);
await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(heartbeatClassStub.args.length, 2, "Heartbeat should be called again");
await allHeartbeatStorage.setItem(
"lastShown",
Date.now() - 75 * HOUR_IN_MS
);
await recipeStorage.setItem("lastShown", Date.now() - 75 * HOUR_IN_MS);
await recipeStorage.setItem(
"lastInteraction",
Date.now() - 50 * HOUR_IN_MS
);
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
is(action.lastError, null, "No errors should have been thrown");
is(
heartbeatClassStub.args.length,
2,
"Heartbeat should not be called again"
);
}
);
/* generatePostAnswerURL shouldn't annotate empty strings */
add_task(async function postAnswerEmptyString() {
const recipe = heartbeatRecipeFactory({ arguments: { postAnswerUrl: "" } });
const action = new ShowHeartbeatAction();
is(
await action.generatePostAnswerURL(recipe),
"",
"an empty string should not be annotated"
);
});
/* generatePostAnswerURL should include the right details */
add_task(async function postAnswerUrl() {
const recipe = heartbeatRecipeFactory({
arguments: {
includeTelemetryUUID: false,
message: "Hello, World!",
},
});
const action = new ShowHeartbeatAction();
const url = new URL(await action.generatePostAnswerURL(recipe));
is(
url.searchParams.get("survey_id"),
"42",
"Pre-existing search parameters should be preserved"
);
is(
url.searchParams.get("fxVersion"),
Services.appinfo.version,
"Firefox version should be included"
);
is(
url.searchParams.get("surveyversion"),
Services.appinfo.version,
"Survey version should also be the Firefox version"
);
ok(
["0", "1"].includes(url.searchParams.get("syncSetup")),
`syncSetup should be 0 or 1, got ${url.searchParams.get("syncSetup")}`
);
is(
url.searchParams.get("updateChannel"),
UpdateUtils.getUpdateChannel("false"),
"Update channel should be included"
);
ok(!url.searchParams.has("userId"), "no user id should be included");
is(
url.searchParams.get("utm_campaign"),
"Hello%2CWorld!",
"utm_campaign should be an encoded version of the message"
);
is(
url.searchParams.get("utm_medium"),
"show-heartbeat",
"utm_medium should be the action name"
);
is(
url.searchParams.get("utm_source"),
"firefox",
"utm_source should be firefox"
);
});
/* generatePostAnswerURL shouldn't override existing values in the url */
add_task(async function postAnswerUrlNoOverwite() {
const recipe = heartbeatRecipeFactory({
},
});
const action = new ShowHeartbeatAction();
const url = new URL(await action.generatePostAnswerURL(recipe));
is(
url.searchParams.get("utm_source"),
"shady_tims_firey_fox",
"utm_source should not be overwritten"
);
});
/* generatePostAnswerURL should only include userId if requested */
add_task(async function postAnswerUrlUserIdIfRequested() {
const recipeWithId = heartbeatRecipeFactory({
arguments: { includeTelemetryUUID: true },
});
const recipeWithoutId = heartbeatRecipeFactory({
arguments: { includeTelemetryUUID: false },
});
const action = new ShowHeartbeatAction();
const urlWithId = new URL(await action.generatePostAnswerURL(recipeWithId));
is(
urlWithId.searchParams.get("userId"),
ClientEnvironment.userId,
"clientId should be included"
);
const urlWithoutId = new URL(
await action.generatePostAnswerURL(recipeWithoutId)
);
ok(!urlWithoutId.searchParams.has("userId"), "userId should not be included");
});
/* generateSurveyId should include userId only if requested */
decorate_task(
withStubbedHeartbeat(),
withClearStorage(),
async function testGenerateSurveyId() {
const recipeWithoutId = heartbeatRecipeFactory({
arguments: { surveyId: "test-id", includeTelemetryUUID: false },
});
const recipeWithId = heartbeatRecipeFactory({
arguments: { surveyId: "test-id", includeTelemetryUUID: true },
});
const action = new ShowHeartbeatAction();
is(
action.generateSurveyId(recipeWithoutId),
"test-id",
"userId should not be included if not requested"
);
is(
action.generateSurveyId(recipeWithId),
`test-id::${ClientEnvironment.userId}`,
"userId should be included if requested"
);
}
);