diff --git a/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm b/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm new file mode 100644 index 000000000000..abb339323327 --- /dev/null +++ b/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm @@ -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://gre/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()); + } +} diff --git a/toolkit/components/normandy/actions/schemas/index.js b/toolkit/components/normandy/actions/schemas/index.js index 70b381d56b7a..8febbdf7b7f8 100644 --- a/toolkit/components/normandy/actions/schemas/index.js +++ b/toolkit/components/normandy/actions/schemas/index.js @@ -95,6 +95,87 @@ 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, + "anyOf": [ + { "type": "number", "minimum": 1 }, + { "type": "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, + "anyOf": [ + { "type": "string" }, + { "type": "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, + "anyOf": [ + { "type": "string" }, + { "type": "null" }, + ], + }, + "learnMoreMessage": { + "description": "Message to show to the user to learn more", + "default": null, + "anyOf": [ + { "type": "string" }, + { "type": "null" }, + ], + }, + "learnMoreUrl": { + "description": "URL to show to the user when they click Learn More", + "default": null, + "anyOf": [ + { "type": "string" }, + { "type": "null" }, + ], + }, + }, + }, }; // Legacy name used on Normandy server diff --git a/toolkit/components/normandy/actions/schemas/package.json b/toolkit/components/normandy/actions/schemas/package.json index 4b669f84aeb3..b07a62f4e794 100644 --- a/toolkit/components/normandy/actions/schemas/package.json +++ b/toolkit/components/normandy/actions/schemas/package.json @@ -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 ", diff --git a/toolkit/components/normandy/lib/ActionsManager.jsm b/toolkit/components/normandy/lib/ActionsManager.jsm index f27490ca8ac1..c941ae8d4bdd 100644 --- a/toolkit/components/normandy/lib/ActionsManager.jsm +++ b/toolkit/components/normandy/lib/ActionsManager.jsm @@ -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(), }; } diff --git a/toolkit/components/normandy/lib/EventEmitter.jsm b/toolkit/components/normandy/lib/EventEmitter.jsm index 88cb06c588ab..c984b6224c30 100644 --- a/toolkit/components/normandy/lib/EventEmitter.jsm +++ b/toolkit/components/normandy/lib/EventEmitter.jsm @@ -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,7 @@ 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)); + cb(event); } }); }, diff --git a/toolkit/components/normandy/lib/Heartbeat.jsm b/toolkit/components/normandy/lib/Heartbeat.jsm index c0b418a85785..eb27922beb56 100644 --- a/toolkit/components/normandy/lib/Heartbeat.jsm +++ b/toolkit/components/normandy/lib/Heartbeat.jsm @@ -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,7 +74,7 @@ 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"); } @@ -94,10 +91,6 @@ var Heartbeat = class { 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); } diff --git a/toolkit/components/normandy/lib/NormandyDriver.jsm b/toolkit/components/normandy/lib/NormandyDriver.jsm index 2755f439288f..2ebb10f98c4c 100644 --- a/toolkit/components/normandy/lib/NormandyDriver.jsm +++ b/toolkit/components/normandy/lib/NormandyDriver.jsm @@ -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, diff --git a/toolkit/components/normandy/lib/Storage.jsm b/toolkit/components/normandy/lib/Storage.jsm index 1d61f1355c67..3fc1974c0f80 100644 --- a/toolkit/components/normandy/lib/Storage.jsm +++ b/toolkit/components/normandy/lib/Storage.jsm @@ -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() { diff --git a/toolkit/components/normandy/test/browser/browser.ini b/toolkit/components/normandy/test/browser/browser.ini index 173d31ddd412..4a47111de79f 100644 --- a/toolkit/components/normandy/test/browser/browser.ini +++ b/toolkit/components/normandy/test/browser/browser.ini @@ -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] 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 000000000000..6157a8da293b --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js @@ -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"); + } +);