зеркало из 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:
Родитель
9634d3a566
Коммит
d652beb76a
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
);
|
Загрузка…
Ссылка в новой задаче