Bug 1440778 - Implement show-heartbeat as internal Normandy action r=Gijs

The original, server-side implementation of this action was at
68d3e55a9d/client/actions/show-heartbeat/index.js

Differential Revision: https://phabricator.services.mozilla.com/D12345

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Michael Cooper 2018-11-27 21:42:44 +00:00
Родитель b8286de46c
Коммит 8afcdbd07c
12 изменённых файлов: 607 добавлений и 98 удалений

Просмотреть файл

@ -0,0 +1,209 @@
/* 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/. */
"use strict";
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
ChromeUtils.defineModuleGetter(this, "BrowserWindowTracker", "resource:///modules/BrowserWindowTracker.jsm");
ChromeUtils.defineModuleGetter(this, "ClientEnvironment", "resource://normandy/lib/ClientEnvironment.jsm");
ChromeUtils.defineModuleGetter(this, "Heartbeat", "resource://normandy/lib/Heartbeat.jsm");
ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
ChromeUtils.defineModuleGetter(this, "Storage", "resource://normandy/lib/Storage.jsm");
ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "uuidGenerator", "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
var EXPORTED_SYMBOLS = ["ShowHeartbeatAction"];
XPCOMUtils.defineLazyGetter(this, "gAllRecipeStorage", function() {
return new Storage("normandy-heartbeat");
});
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS;
class ShowHeartbeatAction extends BaseAction {
get schema() {
return ActionSchemas["show-heartbeat"];
}
async _run(recipe) {
const {
message,
engagementButtonLabel,
thanksMessage,
learnMoreMessage,
learnMoreUrl,
} = recipe.arguments;
const recipeStorage = new Storage(recipe.id);
if (!await this.shouldShow(recipeStorage, recipe)) {
return;
}
this.log.debug(`Heartbeat for recipe ${recipe.id} showing prompt "${message}"`);
const targetWindow = BrowserWindowTracker.getTopWindow();
if (!targetWindow) {
throw new Error("No window to show heartbeat in");
}
const heartbeat = new Heartbeat(targetWindow, {
surveyId: this.generateSurveyId(recipe),
message,
engagementButtonLabel,
thanksMessage,
learnMoreMessage,
learnMoreUrl,
postAnswerUrl: await this.generatePostAnswerURL(recipe),
flowId: this.uuid(),
surveyVersion: recipe.revision_id,
});
heartbeat.eventEmitter.once("Voted", this.updateLastInteraction.bind(this, recipeStorage));
heartbeat.eventEmitter.once("Engaged", this.updateLastInteraction.bind(this, recipeStorage));
let now = Date.now();
await Promise.all([
gAllRecipeStorage.setItem("lastShown", now),
recipeStorage.setItem("lastShown", now),
]);
}
async shouldShow(recipeStorage, recipe) {
const { repeatOption, repeatEvery } = recipe.arguments;
// Don't show any heartbeats to a user more than once per throttle period
let lastShown = await gAllRecipeStorage.getItem("lastShown");
if (lastShown) {
const duration = new Date() - lastShown;
if (duration < HEARTBEAT_THROTTLE) {
// show the number of hours since the last heartbeat, with at most 1 decimal point.
const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
this.log.debug(`A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.`);
return false;
}
}
switch (repeatOption) {
case "once": {
// Don't show if we've ever shown before
if (await recipeStorage.getItem("lastShown")) {
this.log.debug(`Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.`);
return false;
}
}
case "nag": {
// Show a heartbeat again only if the user has not interacted with it before
if (await recipeStorage.getItem("lastInteraction")) {
this.log.debug(`Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.`);
return false;
}
}
case "xdays": {
// Show this heartbeat again if it has been at least `repeatEvery` days since the last time it was shown.
let lastShown = await gAllRecipeStorage.getItem("lastShown");
if (lastShown) {
lastShown = new Date(lastShown);
const duration = new Date() - lastShown;
if (duration < repeatEvery * DAY_IN_MS) {
// show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point.
const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
this.log.debug(
`Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)`
);
return false;
}
}
}
}
return true;
}
/**
* Returns a surveyId value. If recipe calls to include the Normandy client
* ID, then the client ID is attached to the surveyId in the format
* `${surveyId}::${userId}`.
*
* @return {String} Survey ID, possibly with user UUID
*/
generateSurveyId(recipe) {
const { includeTelemetryUUID, surveyId } = recipe.arguments;
if (includeTelemetryUUID) {
return `${surveyId}::${ClientEnvironment.userId}`;
}
return surveyId;
}
/*
* Generate a UUID without surrounding brackets, as expected by Heartbeat
* telemetry.
*/
uuid() {
let rv = uuidGenerator.generateUUID().toString();
return rv.slice(1, rv.length - 1);
}
/**
* Generate the appropriate post-answer URL for a recipe.
* @param recipe
* @return {String} URL with post-answer query params
*/
async generatePostAnswerURL(recipe) {
const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
// Don`t bother with empty URLs.
if (!postAnswerUrl) {
return postAnswerUrl;
}
const userId = ClientEnvironment.userId;
const searchEngine = await new Promise(resolve => {
Services.search.init(rv => {
if (Components.isSuccessCode(rv)) {
resolve(Services.search.defaultEngine.identifier);
}
});
});
const args = {
fxVersion: Services.appinfo.version,
isDefaultBrowser: ShellService.isDefaultBrowser() ? 1 : 0,
searchEngine,
source: "heartbeat",
// `surveyversion` used to be the version of the heartbeat action when it
// was hosted on a server. Keeping it around for compatibility.
surveyversion: Services.appinfo.version,
syncSetup: Services.prefs.prefHasUserValue("services.sync.username") ? 1 : 0,
updateChannel: UpdateUtils.getUpdateChannel(false),
utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")),
utm_medium: recipe.action,
utm_source: "firefox",
};
if (includeTelemetryUUID) {
args.userId = userId;
}
let url = new URL(postAnswerUrl);
// create a URL object to append arguments to
for (const [key, val] of Object.entries(args)) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, val);
}
}
// return the address with encoded queries
return url.toString();
}
updateLastInteraction(recipeStorage) {
recipeStorage.setItem("lastInteraction", Date.now());
}
}

Просмотреть файл

@ -95,6 +95,72 @@ const ActionSchemas = {
},
},
},
"show-heartbeat": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Show a Heartbeat survey.",
"description": "This action shows a single survey.",
"type": "object",
"required": [
"surveyId",
"message",
"thanksMessage",
"postAnswerUrl",
"learnMoreMessage",
"learnMoreUrl",
],
"properties": {
"repeatOption": {
"type": "string",
"enum": ["once", "xdays", "nag"],
"description": "Determines how often a prompt is shown executes.",
"default": "once",
},
"repeatEvery": {
"description": "For repeatOption=xdays, how often (in days) the prompt is displayed.",
"default": null,
"type": ["number", "null"],
},
"includeTelemetryUUID": {
"type": "boolean",
"description": "Include unique user ID in post-answer-url and Telemetry",
"default": false,
},
"surveyId": {
"type": "string",
"description": "Slug uniquely identifying this survey in telemetry",
},
"message": {
"description": "Message to show to the user",
"type": "string",
},
"engagementButtonLabel": {
"description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
"default": null,
"type": ["string", "null"],
},
"thanksMessage": {
"description": "Thanks message to show to the user after they've rated Firefox",
"type": "string",
},
"postAnswerUrl": {
"description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
"default": null,
"type": ["string", "null"],
},
"learnMoreMessage": {
"description": "Message to show to the user to learn more",
"default": null,
"type": ["string", "null"],
},
"learnMoreUrl": {
"description": "URL to show to the user when they click Learn More",
"default": null,
"type": ["string", "null"],
},
},
},
};
// Legacy name used on Normandy server

Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "@mozilla/normandy-action-argument-schemas",
"version": "0.5.0",
"version": "0.6.0",
"description": "Schemas for Normandy action arguments",
"main": "index.js",
"author": "Michael Cooper <mcooper@mozilla.com>",

Просмотреть файл

@ -3,12 +3,13 @@ ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
Uptake: "resource://normandy/lib/Uptake.jsm",
AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
PreferenceRollbackAction: "resource://normandy/actions/PreferenceRollbackAction.jsm",
PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
ShowHeartbeatAction: "resource://normandy/actions/ShowHeartbeatAction.jsm",
Uptake: "resource://normandy/lib/Uptake.jsm",
});
var EXPORTED_SYMBOLS = ["ActionsManager"];
@ -34,9 +35,10 @@ class ActionsManager {
this.localActions = {
"addon-study": addonStudyAction,
"console-log": new ConsoleLogAction(),
"preference-rollout": new PreferenceRolloutAction(),
"opt-out-study": addonStudyAction, // Legacy name used for addon-study on Normandy server
"preference-rollback": new PreferenceRollbackAction(),
"opt-out-study": addonStudyAction, // Legacy name used on Normandy server
"preference-rollout": new PreferenceRolloutAction(),
"show-heartbeat": new ShowHeartbeatAction(),
};
}

Просмотреть файл

@ -9,18 +9,10 @@ var EXPORTED_SYMBOLS = ["EventEmitter"];
const log = LogManager.getLogger("event-emitter");
var EventEmitter = function(sandboxManager) {
var EventEmitter = function() {
const listeners = {};
return {
createSandboxedEmitter() {
return sandboxManager.cloneInto({
on: this.on.bind(this),
off: this.off.bind(this),
once: this.once.bind(this),
}, {cloneFunctions: true});
},
emit(eventName, event) {
// Fire events async
Promise.resolve()
@ -32,7 +24,12 @@ var EventEmitter = function(sandboxManager) {
// Clone callbacks array to avoid problems with mutation while iterating
const callbacks = Array.from(listeners[eventName]);
for (const cb of callbacks) {
cb(sandboxManager.cloneInto(event));
// Clone event so it can't by modified by the handler
let eventToPass = event;
if (typeof event === "object") {
eventToPass = Object.assign({}, event);
}
cb(eventToPass);
}
});
},

Просмотреть файл

@ -48,9 +48,6 @@ CleanupManager.addCleanupHandler(() => {
*
* @param chromeWindow
* The chrome window that the heartbeat notification is displayed in.
* @param sandboxManager
* The manager for the sandbox this was called from. Heartbeat will
* increment the hold counter on the manager.
* @param {Object} options Options object.
* @param {String} options.message
* The message, or question, to display on the notification.
@ -60,7 +57,7 @@ CleanupManager.addCleanupHandler(() => {
* An identifier for this rating flow. Please note that this is only used to
* identify the notification box.
* @param {String} [options.engagementButtonLabel=null]
* The text of the engagement button to use instad of stars. If this is null
* The text of the engagement button to use instead of stars. If this is null
* or invalid, rating stars are used.
* @param {String} [options.learnMoreMessage=null]
* The label of the learn more link. No link will be shown if this is null.
@ -77,9 +74,9 @@ CleanupManager.addCleanupHandler(() => {
* The url to visit after the user answers the question.
*/
var Heartbeat = class {
constructor(chromeWindow, sandboxManager, options) {
constructor(chromeWindow, options) {
if (typeof options.flowId !== "string") {
throw new Error("flowId must be a string");
throw new Error(`flowId must be a string, but got ${JSON.stringify(options.flowId)}, a ${typeof options.flowId}`);
}
if (!options.flowId) {
@ -87,17 +84,13 @@ var Heartbeat = class {
}
if (typeof options.message !== "string") {
throw new Error("message must be a string");
throw new Error(`message must be a string, but got ${JSON.stringify(options.message)}, a ${typeof options.message}`);
}
if (!options.message) {
throw new Error("message must not be an empty string");
}
if (!sandboxManager) {
throw new Error("sandboxManager must be provided");
}
if (options.postAnswerUrl) {
options.postAnswerUrl = new URL(options.postAnswerUrl);
} else {
@ -113,8 +106,7 @@ var Heartbeat = class {
}
this.chromeWindow = chromeWindow;
this.eventEmitter = new EventEmitter(sandboxManager);
this.sandboxManager = sandboxManager;
this.eventEmitter = new EventEmitter();
this.options = options;
this.surveyResults = {};
this.buttons = null;
@ -237,13 +229,12 @@ var Heartbeat = class {
this.close();
}, surveyDuration);
this.sandboxManager.addHold("heartbeat");
CleanupManager.addCleanupHandler(this.close);
}
maybeNotifyHeartbeat(name, data = {}) {
if (this.pingSent) {
log.warn("Heartbeat event recieved after Telemetry ping sent. name:", name, "data:", data);
log.warn("Heartbeat event received after Telemetry ping sent. name:", name, "data:", data);
return;
}
@ -374,8 +365,6 @@ var Heartbeat = class {
// Kill the timers which might call things after we've cleaned up:
this.endTimerIfPresent("surveyEndTimer");
this.endTimerIfPresent("engagementCloseTimer");
this.sandboxManager.removeHold("heartbeat");
// remove listeners
this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
// remove references for garbage collection
@ -386,7 +375,6 @@ var Heartbeat = class {
this.rightSpacer = null;
this.learnMore = null;
this.eventEmitter = null;
this.sandboxManager = null;
// Ensure we don't re-enter and release the CleanupManager's reference to us:
CleanupManager.removeCleanupHandler(this.close);
}

Просмотреть файл

@ -11,7 +11,6 @@ ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
ChromeUtils.import("resource://normandy/lib/Storage.jsm");
ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm");
ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm");
ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm");
@ -23,7 +22,6 @@ const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUID
var EXPORTED_SYMBOLS = ["NormandyDriver"];
const log = LogManager.getLogger("normandy-driver");
const actionLog = LogManager.getLogger("normandy-driver.actions");
var NormandyDriver = function(sandboxManager) {
@ -57,23 +55,6 @@ var NormandyDriver = function(sandboxManager) {
actionLog[level](message);
},
showHeartbeat(options) {
log.info(`Showing heartbeat prompt "${options.message}"`);
const aWindow = Services.wm.getMostRecentWindow("navigator:browser");
if (!aWindow) {
return sandbox.Promise.reject(new sandbox.Error("No window to show heartbeat in"));
}
const internalOptions = Object.assign({}, options, {testing: this.testing});
const heartbeat = new Heartbeat(aWindow, sandboxManager, internalOptions);
return sandbox.Promise.resolve(heartbeat.eventEmitter.createSandboxedEmitter());
},
saveHeartbeatFlow() {
// no-op required by spec
},
client() {
const appinfo = {
version: Services.appinfo.version,

Просмотреть файл

@ -48,7 +48,7 @@ var Storage = class {
/**
* Sets an item in the prefixed storage.
* @returns {Promise}
* @resolves When the operation is completed succesfully
* @resolves When the operation is completed successfully
* @rejects Javascript exception.
*/
async setItem(name, value) {
@ -63,7 +63,7 @@ var Storage = class {
/**
* Removes a single item from the prefixed storage.
* @returns {Promise}
* @resolves When the operation is completed succesfully
* @resolves When the operation is completed successfully
* @rejects Javascript exception.
*/
async removeItem(name) {
@ -77,7 +77,7 @@ var Storage = class {
/**
* Clears all storage for the prefix.
* @returns {Promise}
* @resolves When the operation is completed succesfully
* @resolves When the operation is completed successfully
* @rejects Javascript exception.
*/
async clear() {

Просмотреть файл

@ -11,6 +11,7 @@ skip-if = !healthreport || !telemetry
[browser_actions_ConsoleLogAction.js]
[browser_actions_PreferenceRolloutAction.js]
[browser_actions_PreferenceRollbackAction.js]
[browser_actions_ShowHeartbeatAction.js]
[browser_ActionSandboxManager.js]
[browser_ActionsManager.js]
[browser_AddonStudies.js]

Просмотреть файл

@ -10,25 +10,24 @@ const evidence = {
log: "",
};
function listenerA(x = 1) {
function listenerA(x) {
evidence.a += x;
evidence.log += "a";
}
function listenerB(x = 1) {
function listenerB(x) {
evidence.b += x;
evidence.log += "b";
}
function listenerC(x = 1) {
function listenerC(x) {
evidence.c += x;
evidence.log += "c";
}
decorate_task(
withSandboxManager(Assert),
async function(sandboxManager) {
const eventEmitter = new EventEmitter(sandboxManager);
async function() {
const eventEmitter = new EventEmitter();
// Fire an unrelated event, to make sure nothing goes wrong
eventEmitter.on("nothing");
@ -39,7 +38,7 @@ decorate_task(
eventEmitter.once("event", listenerC);
// one event for all listeners
eventEmitter.emit("event");
eventEmitter.emit("event", 1);
// another event for a and b, since c should have turned off already
eventEmitter.emit("event", 10);
@ -98,35 +97,3 @@ decorate_task(
is(data.count, 0, "Event data cannot be mutated by handlers.");
}
);
decorate_task(
withSandboxManager(Assert),
async function sandboxedEmitter(sandboxManager) {
const eventEmitter = new EventEmitter(sandboxManager);
// Event handlers inside the sandbox should be run in response to
// events triggered outside the sandbox.
sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
sandboxManager.evalInSandbox(`
this.eventCounts = {on: 0, once: 0};
emitter.on("event", value => {
this.eventCounts.on += value;
});
emitter.once("eventOnce", value => {
this.eventCounts.once += value;
});
`);
eventEmitter.emit("event", 5);
eventEmitter.emit("event", 10);
eventEmitter.emit("eventOnce", 5);
eventEmitter.emit("eventOnce", 10);
await Promise.resolve();
const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
Assert.deepEqual(eventCounts, {
on: 15,
once: 5,
}, "Events emitted outside a sandbox trigger handlers within a sandbox.");
}
);

Просмотреть файл

@ -82,7 +82,7 @@ add_task(async function() {
const notificationBox = targetWindow.gHighPriorityNotificationBox;
const preCount = notificationBox.allNotifications.length;
const hb = new Heartbeat(targetWindow, sandboxManager, {
const hb = new Heartbeat(targetWindow, {
testing: true,
flowId: "test",
message: "test",
@ -128,7 +128,7 @@ add_task(async function() {
add_task(async function() {
const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
const notificationBox = targetWindow.gHighPriorityNotificationBox;
const hb = new Heartbeat(targetWindow, sandboxManager, {
const hb = new Heartbeat(targetWindow, {
testing: true,
flowId: "test",
message: "test",
@ -170,7 +170,7 @@ add_task(async function() {
add_task(async function() {
const targetWindow = await BrowserTestUtils.openNewBrowserWindow();
const hb = new Heartbeat(targetWindow, sandboxManager, {
const hb = new Heartbeat(targetWindow, {
testing: true,
flowId: "test",
message: "test",

Просмотреть файл

@ -0,0 +1,298 @@
"use strict";
ChromeUtils.import("resource://normandy/actions/ShowHeartbeatAction.jsm", this);
ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm", this);
ChromeUtils.import("resource://normandy/lib/Storage.jsm", this);
ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
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(testFunction) {
return async function wrappedTestFunction(...args) {
const backstage = ChromeUtils.import("resource://normandy/actions/ShowHeartbeatAction.jsm", {});
const originalHeartbeat = backstage.Heartbeat;
const heartbeatInstanceStub = new MockHeartbeat();
const heartbeatClassStub = sinon.stub();
heartbeatClassStub.returns(heartbeatInstanceStub);
backstage.Heartbeat = heartbeatClassStub;
try {
await testFunction({heartbeatClassStub, heartbeatInstanceStub}, ...args);
} finally {
backstage.Heartbeat = originalHeartbeat;
}
};
}
function withClearStorage(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.runRecipe(recipe);
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",
);
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i;
ok(options.flowId.match(uuidRegex), "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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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.runRecipe(recipe);
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({ heartbeatClassStub }) {
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");
}
);