зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
b8286de46c
Коммит
8afcdbd07c
|
@ -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
|
// Legacy name used on Normandy server
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mozilla/normandy-action-argument-schemas",
|
"name": "@mozilla/normandy-action-argument-schemas",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "Schemas for Normandy action arguments",
|
"description": "Schemas for Normandy action arguments",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Michael Cooper <mcooper@mozilla.com>",
|
"author": "Michael Cooper <mcooper@mozilla.com>",
|
||||||
|
|
|
@ -3,12 +3,13 @@ ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
|
||||||
|
|
||||||
XPCOMUtils.defineLazyModuleGetters(this, {
|
XPCOMUtils.defineLazyModuleGetters(this, {
|
||||||
ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
|
ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
|
||||||
NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
|
|
||||||
Uptake: "resource://normandy/lib/Uptake.jsm",
|
|
||||||
AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
|
AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
|
||||||
ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.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",
|
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"];
|
var EXPORTED_SYMBOLS = ["ActionsManager"];
|
||||||
|
@ -34,9 +35,10 @@ class ActionsManager {
|
||||||
this.localActions = {
|
this.localActions = {
|
||||||
"addon-study": addonStudyAction,
|
"addon-study": addonStudyAction,
|
||||||
"console-log": new ConsoleLogAction(),
|
"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(),
|
"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");
|
const log = LogManager.getLogger("event-emitter");
|
||||||
|
|
||||||
var EventEmitter = function(sandboxManager) {
|
var EventEmitter = function() {
|
||||||
const listeners = {};
|
const listeners = {};
|
||||||
|
|
||||||
return {
|
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) {
|
emit(eventName, event) {
|
||||||
// Fire events async
|
// Fire events async
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
|
@ -32,7 +24,12 @@ var EventEmitter = function(sandboxManager) {
|
||||||
// Clone callbacks array to avoid problems with mutation while iterating
|
// Clone callbacks array to avoid problems with mutation while iterating
|
||||||
const callbacks = Array.from(listeners[eventName]);
|
const callbacks = Array.from(listeners[eventName]);
|
||||||
for (const cb of callbacks) {
|
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
|
* @param chromeWindow
|
||||||
* The chrome window that the heartbeat notification is displayed in.
|
* 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 {Object} options Options object.
|
||||||
* @param {String} options.message
|
* @param {String} options.message
|
||||||
* The message, or question, to display on the notification.
|
* 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
|
* An identifier for this rating flow. Please note that this is only used to
|
||||||
* identify the notification box.
|
* identify the notification box.
|
||||||
* @param {String} [options.engagementButtonLabel=null]
|
* @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.
|
* or invalid, rating stars are used.
|
||||||
* @param {String} [options.learnMoreMessage=null]
|
* @param {String} [options.learnMoreMessage=null]
|
||||||
* The label of the learn more link. No link will be shown if this is 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.
|
* The url to visit after the user answers the question.
|
||||||
*/
|
*/
|
||||||
var Heartbeat = class {
|
var Heartbeat = class {
|
||||||
constructor(chromeWindow, sandboxManager, options) {
|
constructor(chromeWindow, options) {
|
||||||
if (typeof options.flowId !== "string") {
|
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) {
|
if (!options.flowId) {
|
||||||
|
@ -87,17 +84,13 @@ var Heartbeat = class {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof options.message !== "string") {
|
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) {
|
if (!options.message) {
|
||||||
throw new Error("message must not be an empty string");
|
throw new Error("message must not be an empty string");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sandboxManager) {
|
|
||||||
throw new Error("sandboxManager must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.postAnswerUrl) {
|
if (options.postAnswerUrl) {
|
||||||
options.postAnswerUrl = new URL(options.postAnswerUrl);
|
options.postAnswerUrl = new URL(options.postAnswerUrl);
|
||||||
} else {
|
} else {
|
||||||
|
@ -113,8 +106,7 @@ var Heartbeat = class {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.chromeWindow = chromeWindow;
|
this.chromeWindow = chromeWindow;
|
||||||
this.eventEmitter = new EventEmitter(sandboxManager);
|
this.eventEmitter = new EventEmitter();
|
||||||
this.sandboxManager = sandboxManager;
|
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.surveyResults = {};
|
this.surveyResults = {};
|
||||||
this.buttons = null;
|
this.buttons = null;
|
||||||
|
@ -237,13 +229,12 @@ var Heartbeat = class {
|
||||||
this.close();
|
this.close();
|
||||||
}, surveyDuration);
|
}, surveyDuration);
|
||||||
|
|
||||||
this.sandboxManager.addHold("heartbeat");
|
|
||||||
CleanupManager.addCleanupHandler(this.close);
|
CleanupManager.addCleanupHandler(this.close);
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeNotifyHeartbeat(name, data = {}) {
|
maybeNotifyHeartbeat(name, data = {}) {
|
||||||
if (this.pingSent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -374,8 +365,6 @@ var Heartbeat = class {
|
||||||
// Kill the timers which might call things after we've cleaned up:
|
// Kill the timers which might call things after we've cleaned up:
|
||||||
this.endTimerIfPresent("surveyEndTimer");
|
this.endTimerIfPresent("surveyEndTimer");
|
||||||
this.endTimerIfPresent("engagementCloseTimer");
|
this.endTimerIfPresent("engagementCloseTimer");
|
||||||
|
|
||||||
this.sandboxManager.removeHold("heartbeat");
|
|
||||||
// remove listeners
|
// remove listeners
|
||||||
this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
|
this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
|
||||||
// remove references for garbage collection
|
// remove references for garbage collection
|
||||||
|
@ -386,7 +375,6 @@ var Heartbeat = class {
|
||||||
this.rightSpacer = null;
|
this.rightSpacer = null;
|
||||||
this.learnMore = null;
|
this.learnMore = null;
|
||||||
this.eventEmitter = null;
|
this.eventEmitter = null;
|
||||||
this.sandboxManager = null;
|
|
||||||
// Ensure we don't re-enter and release the CleanupManager's reference to us:
|
// Ensure we don't re-enter and release the CleanupManager's reference to us:
|
||||||
CleanupManager.removeCleanupHandler(this.close);
|
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://gre/modules/Timer.jsm");
|
||||||
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
|
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
|
||||||
ChromeUtils.import("resource://normandy/lib/Storage.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/ClientEnvironment.jsm");
|
||||||
ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.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"];
|
var EXPORTED_SYMBOLS = ["NormandyDriver"];
|
||||||
|
|
||||||
const log = LogManager.getLogger("normandy-driver");
|
|
||||||
const actionLog = LogManager.getLogger("normandy-driver.actions");
|
const actionLog = LogManager.getLogger("normandy-driver.actions");
|
||||||
|
|
||||||
var NormandyDriver = function(sandboxManager) {
|
var NormandyDriver = function(sandboxManager) {
|
||||||
|
@ -57,23 +55,6 @@ var NormandyDriver = function(sandboxManager) {
|
||||||
actionLog[level](message);
|
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() {
|
client() {
|
||||||
const appinfo = {
|
const appinfo = {
|
||||||
version: Services.appinfo.version,
|
version: Services.appinfo.version,
|
||||||
|
|
|
@ -48,7 +48,7 @@ var Storage = class {
|
||||||
/**
|
/**
|
||||||
* Sets an item in the prefixed storage.
|
* Sets an item in the prefixed storage.
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
* @resolves When the operation is completed succesfully
|
* @resolves When the operation is completed successfully
|
||||||
* @rejects Javascript exception.
|
* @rejects Javascript exception.
|
||||||
*/
|
*/
|
||||||
async setItem(name, value) {
|
async setItem(name, value) {
|
||||||
|
@ -63,7 +63,7 @@ var Storage = class {
|
||||||
/**
|
/**
|
||||||
* Removes a single item from the prefixed storage.
|
* Removes a single item from the prefixed storage.
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
* @resolves When the operation is completed succesfully
|
* @resolves When the operation is completed successfully
|
||||||
* @rejects Javascript exception.
|
* @rejects Javascript exception.
|
||||||
*/
|
*/
|
||||||
async removeItem(name) {
|
async removeItem(name) {
|
||||||
|
@ -77,7 +77,7 @@ var Storage = class {
|
||||||
/**
|
/**
|
||||||
* Clears all storage for the prefix.
|
* Clears all storage for the prefix.
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
* @resolves When the operation is completed succesfully
|
* @resolves When the operation is completed successfully
|
||||||
* @rejects Javascript exception.
|
* @rejects Javascript exception.
|
||||||
*/
|
*/
|
||||||
async clear() {
|
async clear() {
|
||||||
|
|
|
@ -11,6 +11,7 @@ skip-if = !healthreport || !telemetry
|
||||||
[browser_actions_ConsoleLogAction.js]
|
[browser_actions_ConsoleLogAction.js]
|
||||||
[browser_actions_PreferenceRolloutAction.js]
|
[browser_actions_PreferenceRolloutAction.js]
|
||||||
[browser_actions_PreferenceRollbackAction.js]
|
[browser_actions_PreferenceRollbackAction.js]
|
||||||
|
[browser_actions_ShowHeartbeatAction.js]
|
||||||
[browser_ActionSandboxManager.js]
|
[browser_ActionSandboxManager.js]
|
||||||
[browser_ActionsManager.js]
|
[browser_ActionsManager.js]
|
||||||
[browser_AddonStudies.js]
|
[browser_AddonStudies.js]
|
||||||
|
|
|
@ -10,25 +10,24 @@ const evidence = {
|
||||||
log: "",
|
log: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function listenerA(x = 1) {
|
function listenerA(x) {
|
||||||
evidence.a += x;
|
evidence.a += x;
|
||||||
evidence.log += "a";
|
evidence.log += "a";
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenerB(x = 1) {
|
function listenerB(x) {
|
||||||
evidence.b += x;
|
evidence.b += x;
|
||||||
evidence.log += "b";
|
evidence.log += "b";
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenerC(x = 1) {
|
function listenerC(x) {
|
||||||
evidence.c += x;
|
evidence.c += x;
|
||||||
evidence.log += "c";
|
evidence.log += "c";
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate_task(
|
decorate_task(
|
||||||
withSandboxManager(Assert),
|
async function() {
|
||||||
async function(sandboxManager) {
|
const eventEmitter = new EventEmitter();
|
||||||
const eventEmitter = new EventEmitter(sandboxManager);
|
|
||||||
|
|
||||||
// Fire an unrelated event, to make sure nothing goes wrong
|
// Fire an unrelated event, to make sure nothing goes wrong
|
||||||
eventEmitter.on("nothing");
|
eventEmitter.on("nothing");
|
||||||
|
@ -39,7 +38,7 @@ decorate_task(
|
||||||
eventEmitter.once("event", listenerC);
|
eventEmitter.once("event", listenerC);
|
||||||
|
|
||||||
// one event for all listeners
|
// 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
|
// another event for a and b, since c should have turned off already
|
||||||
eventEmitter.emit("event", 10);
|
eventEmitter.emit("event", 10);
|
||||||
|
|
||||||
|
@ -98,35 +97,3 @@ decorate_task(
|
||||||
is(data.count, 0, "Event data cannot be mutated by handlers.");
|
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 notificationBox = targetWindow.gHighPriorityNotificationBox;
|
||||||
|
|
||||||
const preCount = notificationBox.allNotifications.length;
|
const preCount = notificationBox.allNotifications.length;
|
||||||
const hb = new Heartbeat(targetWindow, sandboxManager, {
|
const hb = new Heartbeat(targetWindow, {
|
||||||
testing: true,
|
testing: true,
|
||||||
flowId: "test",
|
flowId: "test",
|
||||||
message: "test",
|
message: "test",
|
||||||
|
@ -128,7 +128,7 @@ add_task(async function() {
|
||||||
add_task(async function() {
|
add_task(async function() {
|
||||||
const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
||||||
const notificationBox = targetWindow.gHighPriorityNotificationBox;
|
const notificationBox = targetWindow.gHighPriorityNotificationBox;
|
||||||
const hb = new Heartbeat(targetWindow, sandboxManager, {
|
const hb = new Heartbeat(targetWindow, {
|
||||||
testing: true,
|
testing: true,
|
||||||
flowId: "test",
|
flowId: "test",
|
||||||
message: "test",
|
message: "test",
|
||||||
|
@ -170,7 +170,7 @@ add_task(async function() {
|
||||||
add_task(async function() {
|
add_task(async function() {
|
||||||
const targetWindow = await BrowserTestUtils.openNewBrowserWindow();
|
const targetWindow = await BrowserTestUtils.openNewBrowserWindow();
|
||||||
|
|
||||||
const hb = new Heartbeat(targetWindow, sandboxManager, {
|
const hb = new Heartbeat(targetWindow, {
|
||||||
testing: true,
|
testing: true,
|
||||||
flowId: "test",
|
flowId: "test",
|
||||||
message: "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");
|
||||||
|
}
|
||||||
|
);
|
Загрузка…
Ссылка в новой задаче