summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js')
-rw-r--r--toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js377
1 files changed, 377 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
new file mode 100644
index 0000000000..393f31b5ae
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
@@ -0,0 +1,377 @@
+"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(
+ "resource://testing-common/NormandyTestUtils.sys.mjs"
+);
+
+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));
+}
+
+// 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"
+ );
+ }
+);