forked from mirrors/gecko-dev
424 lines
13 KiB
JavaScript
424 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
const { BaseAction } = ChromeUtils.import(
|
|
"resource://normandy/actions/BaseAction.jsm"
|
|
);
|
|
const { ShowHeartbeatAction } = ChromeUtils.import(
|
|
"resource://normandy/actions/ShowHeartbeatAction.jsm"
|
|
);
|
|
const { ClientEnvironment } = ChromeUtils.import(
|
|
"resource://normandy/lib/ClientEnvironment.jsm"
|
|
);
|
|
const { Heartbeat } = ChromeUtils.import(
|
|
"resource://normandy/lib/Heartbeat.jsm"
|
|
);
|
|
const { Storage } = ChromeUtils.import("resource://normandy/lib/Storage.jsm");
|
|
const { Uptake } = ChromeUtils.import("resource://normandy/lib/Uptake.jsm");
|
|
const { NormandyTestUtils } = ChromeUtils.import(
|
|
"resource://testing-common/NormandyTestUtils.jsm"
|
|
);
|
|
|
|
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!",
|
|
postAnswerUrl: "http://example.com",
|
|
learnMoreMessage: "Learn More",
|
|
learnMoreUrl: "http://example.com",
|
|
repeatOption: "once",
|
|
},
|
|
};
|
|
|
|
if (overrides.arguments) {
|
|
defaults.arguments = Object.assign(defaults.arguments, overrides.arguments);
|
|
delete overrides.arguments;
|
|
}
|
|
|
|
return recipeFactory(Object.assign(defaults, overrides));
|
|
}
|
|
|
|
class MockHeartbeat {
|
|
constructor() {
|
|
this.eventEmitter = new MockEventEmitter();
|
|
}
|
|
}
|
|
|
|
class MockEventEmitter {
|
|
constructor() {
|
|
this.once = sinon.stub();
|
|
}
|
|
}
|
|
|
|
function withStubbedHeartbeat() {
|
|
return function(testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const heartbeatInstanceStub = new MockHeartbeat();
|
|
const heartbeatClassStub = sinon.stub();
|
|
heartbeatClassStub.returns(heartbeatInstanceStub);
|
|
ShowHeartbeatAction.overrideHeartbeatForTests(heartbeatClassStub);
|
|
|
|
try {
|
|
await testFunction({
|
|
...args,
|
|
heartbeatClassStub,
|
|
heartbeatInstanceStub,
|
|
});
|
|
} finally {
|
|
ShowHeartbeatAction.overrideHeartbeatForTests();
|
|
}
|
|
};
|
|
};
|
|
}
|
|
|
|
function withClearStorage() {
|
|
return function(testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
Storage.clearAllStorage();
|
|
try {
|
|
await testFunction(args);
|
|
} finally {
|
|
Storage.clearAllStorage();
|
|
}
|
|
};
|
|
};
|
|
}
|
|
|
|
// 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: {
|
|
postAnswerUrl: "https://example.com/survey?survey_id=42",
|
|
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({
|
|
arguments: {
|
|
postAnswerUrl:
|
|
"https://example.com/survey?utm_source=shady_tims_firey_fox",
|
|
},
|
|
});
|
|
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"
|
|
);
|
|
}
|
|
);
|