--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -5,16 +5,17 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DIRS += [
'aushelper',
'e10srollout',
'pdfjs',
'pocket',
'webcompat',
+ 'shield-recipe-client',
]
# Only include the following system add-ons if building Aurora or Nightly
if 'a' in CONFIG['GRE_MILESTONE']:
DIRS += [
'flyweb',
'formautofill',
'presentation',
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -0,0 +1,102 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+
+const REASONS = {
+ APP_STARTUP: 1, // The application is starting up.
+ APP_SHUTDOWN: 2, // The application is shutting down.
+ ADDON_ENABLE: 3, // The add-on is being enabled.
+ ADDON_DISABLE: 4, // The add-on is being disabled. (Also sent during uninstallation)
+ ADDON_INSTALL: 5, // The add-on is being installed.
+ ADDON_UNINSTALL: 6, // The add-on is being uninstalled.
+ ADDON_UPGRADE: 7, // The add-on is being upgraded.
+ ADDON_DOWNGRADE: 8, //The add-on is being downgraded.
+};
+
+const PREF_BRANCH = "extensions.shield-recipe-client.";
+const PREFS = {
+ api_url: "https://self-repair.mozilla.org/api/v1",
+ dev_mode: false,
+ enabled: true,
+ startup_delay_seconds: 300,
+};
+const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
+const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
+
+let shouldRun = true;
+
+this.install = function() {
+ // Self Repair only checks its pref on start, so if we disable it, wait until
+ // next startup to run, unless the dev_mode preference is set.
+ if (Preferences.get(PREF_SELF_SUPPORT_ENABLED, true)) {
+ Preferences.set(PREF_SELF_SUPPORT_ENABLED, false);
+ if (!Services.prefs.getBoolPref(PREF_DEV_MODE, false)) {
+ shouldRun = false;
+ }
+ }
+};
+
+this.startup = function() {
+ setDefaultPrefs();
+
+ if (!shouldRun) {
+ return;
+ }
+
+ Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm");
+ RecipeRunner.init();
+};
+
+this.shutdown = function(data, reason) {
+ Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+ CleanupManager.cleanup();
+
+ if (reason === REASONS.ADDON_DISABLE || reason === REASONS.ADDON_UNINSTALL) {
+ Services.prefs.setBoolPref(PREF_SELF_SUPPORT_ENABLED, true);
+ }
+
+ const modules = [
+ "data/EventEmitter.js",
+ "lib/CleanupManager.jsm",
+ "lib/EnvExpressions.jsm",
+ "lib/Heartbeat.jsm",
+ "lib/NormandyApi.jsm",
+ "lib/NormandyDriver.jsm",
+ "lib/RecipeRunner.jsm",
+ "lib/Sampling.jsm",
+ "lib/SandboxManager.jsm",
+ "lib/Storage.jsm",
+ ];
+ for (const module in modules) {
+ Cu.unload(`resource://shield-recipe-client/${module}`);
+ }
+};
+
+this.uninstall = function() {
+};
+
+function setDefaultPrefs() {
+ const branch = Services.prefs.getDefaultBranch(PREF_BRANCH);
+ for (const [key, val] of Object.entries(PREFS)) {
+ // If someone beat us to setting a default, don't overwrite it.
+ if (branch.getPrefType(key) !== branch.PREF_INVALID)
+ continue;
+ switch (typeof val) {
+ case "boolean":
+ branch.setBoolPref(key, val);
+ break;
+ case "number":
+ branch.setIntPref(key, val);
+ break;
+ case "string":
+ branch.setCharPref(key, val);
+ break;
+ }
+ }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/data/EventEmitter.js
@@ -0,0 +1,60 @@
+/* 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/. */
+
+// This file is meant to run inside action sandboxes
+
+"use strict";
+
+
+this.EventEmitter = function(driver) {
+ if (!driver) {
+ throw new Error("driver must be provided");
+ }
+
+ const listeners = {};
+
+ return {
+ emit(eventName, event) {
+ // Fire events async
+ Promise.resolve()
+ .then(() => {
+ if (!(eventName in listeners)) {
+ driver.log(`EventEmitter: Event fired with no listeners: ${eventName}`);
+ return;
+ }
+ // freeze event to prevent handlers from modifying it
+ const frozenEvent = Object.freeze(event);
+ // Clone callbacks array to avoid problems with mutation while iterating
+ const callbacks = Array.from(listeners[eventName]);
+ for (const cb of callbacks) {
+ cb(frozenEvent);
+ }
+ });
+ },
+
+ on(eventName, callback) {
+ if (!(eventName in listeners)) {
+ listeners[eventName] = [];
+ }
+ listeners[eventName].push(callback);
+ },
+
+ off(eventName, callback) {
+ if (eventName in listeners) {
+ const index = listeners[eventName].indexOf(callback);
+ if (index !== -1) {
+ listeners[eventName].splice(index, 1);
+ }
+ }
+ },
+
+ once(eventName, callback) {
+ const inner = event => {
+ callback(event);
+ this.off(eventName, inner);
+ };
+ this.on(eventName, inner);
+ },
+ };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/install.rdf.in
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+#filter substitution
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+ <Description about="urn:mozilla:install-manifest">
+ <em:id>shield-recipe-client@mozilla.org</em:id>
+ <em:type>2</em:type>
+ <em:bootstrap>true</em:bootstrap>
+ <em:unpack>false</em:unpack>
+ <em:version>1.0.0</em:version>
+ <em:name>Shield Recipe Client</em:name>
+ <em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
+ <em:multiprocessCompatible>true</em:multiprocessCompatible>
+
+ <em:targetApplication>
+ <Description>
+ <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
+ <em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
+ <em:maxVersion>@MOZ_APP_MAXVERSION@</em:maxVersion>
+ </Description>
+ </em:targetApplication>
+ </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/jar.mn
@@ -0,0 +1,9 @@
+# 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/.
+
+[features/shield-recipe-client@mozilla.org] chrome.jar:
+% resource shield-recipe-client %content/
+ content/lib/ (./lib/*)
+ content/data/ (./data/*)
+ content/node_modules/jexl/ (./node_modules/jexl/*)
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/CleanupManager.jsm
@@ -0,0 +1,21 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["CleanupManager"];
+
+const cleanupHandlers = [];
+
+this.CleanupManager = {
+ addCleanupHandler(handler) {
+ cleanupHandlers.push(handler);
+ },
+
+ cleanup() {
+ for (const handler of cleanupHandlers) {
+ handler();
+ }
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
@@ -0,0 +1,65 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/TelemetryArchive.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["EnvExpressions"];
+
+XPCOMUtils.defineLazyGetter(this, "nodeRequire", () => {
+ const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
+ const loader = new Loader({
+ paths: {
+ "": "resource://shield-recipe-client/node_modules/",
+ },
+ });
+ return new Require(loader, {});
+});
+
+XPCOMUtils.defineLazyGetter(this, "jexl", () => {
+ const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
+ const jexl = new Jexl();
+ jexl.addTransforms({
+ date: dateString => new Date(dateString),
+ stableSample: Sampling.stableSample,
+ });
+ return jexl;
+});
+
+const getLatestTelemetry = Task.async(function *() {
+ const pings = yield TelemetryArchive.promiseArchivedPingList();
+
+ // get most recent ping per type
+ const mostRecentPings = {};
+ for (const ping of pings) {
+ if (ping.type in mostRecentPings) {
+ if (mostRecentPings[ping.type].timeStampCreated < ping.timeStampCreated) {
+ mostRecentPings[ping.type] = ping;
+ }
+ } else {
+ mostRecentPings[ping.type] = ping;
+ }
+ }
+
+ const telemetry = {};
+ for (const key in mostRecentPings) {
+ const ping = mostRecentPings[key];
+ telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
+ }
+ return telemetry;
+});
+
+this.EnvExpressions = {
+ eval(expr, extraContext = {}) {
+ const context = Object.assign({telemetry: getLatestTelemetry()}, extraContext);
+ const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
+ return jexl.eval(onelineExpr, context);
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
@@ -0,0 +1,346 @@
+/* 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";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+Cu.importGlobalProperties(["URL"]); /* globals URL */
+
+this.EXPORTED_SYMBOLS = ["Heartbeat"];
+
+const log = Log.repository.getLogger("shield-recipe-client");
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
+const NOTIFICATION_TIME = 3000;
+
+/**
+ * Show the Heartbeat UI to request user feedback.
+ *
+ * @param chromeWindow
+ * The chrome window that the heartbeat notification is displayed in.
+ * @param eventEmitter
+ * An EventEmitter instance to report status to.
+ * @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.
+ * @param {String} options.thanksMessage
+ * The thank you message to display after user votes.
+ * @param {String} options.flowId
+ * 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
+ * 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.
+ * @param {String} [options.learnMoreUrl=null]
+ * The learn more URL to open when clicking on the learn more link. No learn more
+ * will be shown if this is an invalid URL.
+ * @param {String} [options.surveyId]
+ * An ID for the survey, reflected in the Telemetry ping.
+ * @param {Number} [options.surveyVersion]
+ * Survey's version number, reflected in the Telemetry ping.
+ * @param {boolean} [options.testing]
+ * Whether this is a test survey, reflected in the Telemetry ping.
+ * @param {String} [options.postAnswerURL=null]
+ * The url to visit after the user answers the question.
+ */
+this.Heartbeat = class {
+ constructor(chromeWindow, eventEmitter, sandboxManager, options) {
+ if (typeof options.flowId !== "string") {
+ throw new Error("flowId must be a string");
+ }
+
+ if (!options.flowId) {
+ throw new Error("flowId must not be an empty string");
+ }
+
+ if (typeof options.message !== "string") {
+ throw new Error("message must be a string");
+ }
+
+ if (!options.message) {
+ throw new Error("message must not be an empty string");
+ }
+
+ if (!sandboxManager) {
+ throw new Error("sandboxManager must be provided");
+ }
+
+ if (options.postAnswerUrl) {
+ options.postAnswerUrl = new URL(options.postAnswerUrl);
+ } else {
+ options.postAnswerUrl = null;
+ }
+
+ if (options.learnMoreUrl) {
+ try {
+ options.learnMoreUrl = new URL(options.learnMoreUrl);
+ } catch (e) {
+ options.learnMoreUrl = null;
+ }
+ }
+
+ this.chromeWindow = chromeWindow;
+ this.eventEmitter = eventEmitter;
+ this.sandboxManager = sandboxManager;
+ this.options = options;
+ this.surveyResults = {};
+ this.buttons = null;
+
+ // so event handlers are consistent
+ this.handleWindowClosed = this.handleWindowClosed.bind(this);
+
+ if (this.options.engagementButtonLabel) {
+ this.buttons = [{
+ label: this.options.engagementButtonLabel,
+ callback: () => {
+ // Let the consumer know user engaged.
+ this.maybeNotifyHeartbeat("Engaged");
+
+ this.userEngaged({
+ type: "button",
+ flowId: this.options.flowId,
+ });
+
+ // Return true so that the notification bar doesn't close itself since
+ // we have a thank you message to show.
+ return true;
+ },
+ }];
+ }
+
+ this.notificationBox = this.chromeWindow.document.querySelector("#high-priority-global-notificationbox");
+ this.notice = this.notificationBox.appendNotification(
+ this.options.message,
+ "heartbeat-" + this.options.flowId,
+ "chrome://browser/skin/heartbeat-icon.svg",
+ this.notificationBox.PRIORITY_INFO_HIGH,
+ this.buttons,
+ eventType => {
+ if (eventType !== "removed") {
+ return;
+ }
+ this.maybeNotifyHeartbeat("NotificationClosed");
+ }
+ );
+
+ // Holds the rating UI
+ const frag = this.chromeWindow.document.createDocumentFragment();
+
+ // Build the heartbeat stars
+ if (!this.options.engagementButtonLabel) {
+ const numStars = this.options.engagementButtonLabel ? 0 : 5;
+ const ratingContainer = this.chromeWindow.document.createElement("hbox");
+ ratingContainer.id = "star-rating-container";
+
+ for (let i = 0; i < numStars; i++) {
+ // create a star rating element
+ const ratingElement = this.chromeWindow.document.createElement("toolbarbutton");
+
+ // style it
+ const starIndex = numStars - i;
+ ratingElement.className = "plain star-x";
+ ratingElement.id = "star" + starIndex;
+ ratingElement.setAttribute("data-score", starIndex);
+
+ // Add the click handler
+ ratingElement.addEventListener("click", ev => {
+ const rating = parseInt(ev.target.getAttribute("data-score"));
+ this.maybeNotifyHeartbeat("Voted", {score: rating});
+ this.userEngaged({type: "stars", score: rating, flowId: this.options.flowId});
+ });
+
+ ratingContainer.appendChild(ratingElement);
+ }
+
+ frag.appendChild(ratingContainer);
+ }
+
+ this.messageImage = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageImage");
+ this.messageImage.classList.add("heartbeat", "pulse-onshow");
+
+ this.messageText = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageText");
+ this.messageText.classList.add("heartbeat");
+
+ // Make sure the stars are not pushed to the right by the spacer.
+ const rightSpacer = this.chromeWindow.document.createElement("spacer");
+ rightSpacer.flex = 20;
+ frag.appendChild(rightSpacer);
+
+ // collapse the space before the stars
+ this.messageText.flex = 0;
+ const leftSpacer = this.messageText.nextSibling;
+ leftSpacer.flex = 0;
+
+ // Add Learn More Link
+ if (this.options.learnMoreMessage && this.options.learnMoreUrl) {
+ const learnMore = this.chromeWindow.document.createElement("label");
+ learnMore.className = "text-link";
+ learnMore.href = this.options.learnMoreUrl.toString();
+ learnMore.setAttribute("value", this.options.learnMoreMessage);
+ learnMore.addEventListener("click", () => this.maybeNotifyHeartbeat("LearnMore"));
+ frag.appendChild(learnMore);
+ }
+
+ // Append the fragment and apply the styling
+ this.notice.appendChild(frag);
+ this.notice.classList.add("heartbeat");
+
+ // Let the consumer know the notification was shown.
+ this.maybeNotifyHeartbeat("NotificationOffered");
+ this.chromeWindow.addEventListener("SSWindowClosing", this.handleWindowClosed);
+
+ const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000;
+ this.surveyEndTimer = setTimeout(() => {
+ this.maybeNotifyHeartbeat("SurveyExpired");
+ 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);
+ return;
+ }
+
+ const timestamp = Date.now();
+ let sendPing = false;
+ let cleanup = false;
+
+ const phases = {
+ NotificationOffered: () => {
+ this.surveyResults.flowId = this.options.flowId;
+ this.surveyResults.offeredTS = timestamp;
+ },
+ LearnMore: () => {
+ if (!this.surveyResults.learnMoreTS) {
+ this.surveyResults.learnMoreTS = timestamp;
+ }
+ },
+ Engaged: () => {
+ this.surveyResults.engagedTS = timestamp;
+ },
+ Voted: () => {
+ this.surveyResults.votedTS = timestamp;
+ this.surveyResults.score = data.score;
+ },
+ SurveyExpired: () => {
+ this.surveyResults.expiredTS = timestamp;
+ },
+ NotificationClosed: () => {
+ this.surveyResults.closedTS = timestamp;
+ cleanup = true;
+ sendPing = true;
+ },
+ WindowClosed: () => {
+ this.surveyResults.windowClosedTS = timestamp;
+ cleanup = true;
+ sendPing = true;
+ },
+ default: () => {
+ log.error("Unrecognized Heartbeat event:", name);
+ },
+ };
+
+ (phases[name] || phases.default)();
+
+ data.timestamp = timestamp;
+ data.flowId = this.options.flowId;
+ this.eventEmitter.emit(name, Cu.cloneInto(data, this.sandboxManager.sandbox));
+
+ if (sendPing) {
+ // Send the ping to Telemetry
+ const payload = Object.assign({version: 1}, this.surveyResults);
+ for (const meta of ["surveyId", "surveyVersion", "testing"]) {
+ if (this.options.hasOwnProperty(meta)) {
+ payload[meta] = this.options[meta];
+ }
+ }
+
+ log.debug("Sending telemetry");
+ TelemetryController.submitExternalPing("heartbeat", payload, {
+ addClientId: true,
+ addEnvironment: true,
+ });
+
+ // only for testing
+ this.eventEmitter.emit("TelemetrySent", Cu.cloneInto(payload, this.sandboxManager.sandbox));
+
+ // Survey is complete, clear out the expiry timer & survey configuration
+ if (this.surveyEndTimer) {
+ clearTimeout(this.surveyEndTimer);
+ this.surveyEndTimer = null;
+ }
+
+ this.pingSent = true;
+ this.surveyResults = null;
+ }
+
+ if (cleanup) {
+ this.cleanup();
+ }
+ }
+
+ userEngaged(engagementParams) {
+ // Make the heartbeat icon pulse twice
+ this.notice.label = this.options.thanksMessage;
+ this.messageImage.classList.remove("pulse-onshow");
+ this.messageImage.classList.add("pulse-twice");
+
+ // Remove all the children of the notice (rating container, and the flex)
+ while (this.notice.firstChild) {
+ this.notice.firstChild.remove();
+ }
+
+ // Open the engagement tab if we have a valid engagement URL.
+ if (this.options.postAnswerUrl) {
+ for (const key in engagementParams) {
+ this.options.postAnswerUrl.searchParams.append(key, engagementParams[key]);
+ }
+ // Open the engagement URL in a new tab.
+ this.chromeWindow.gBrowser.selectedTab = this.chromeWindow.gBrowser.addTab(this.options.postAnswerUrl.toString());
+ }
+
+ if (this.surveyEndTimer) {
+ clearTimeout(this.surveyEndTimer);
+ this.surveyEndTimer = null;
+ }
+
+ setTimeout(() => this.close(), NOTIFICATION_TIME);
+ }
+
+ handleWindowClosed() {
+ this.maybeNotifyHeartbeat("WindowClosed");
+ }
+
+ close() {
+ this.notificationBox.removeNotification(this.notice);
+ this.cleanup();
+ }
+
+ cleanup() {
+ this.sandboxManager.removeHold("heartbeat");
+ // remove listeners
+ this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
+ // remove references for garbage collection
+ this.chromeWindow = null;
+ this.notificationBox = null;
+ this.notification = null;
+ this.eventEmitter = null;
+ this.sandboxManager = null;
+ }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -0,0 +1,99 @@
+/* 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/. */
+/* globals URLSearchParams */
+
+"use strict";
+
+const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["NormandyApi"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
+
+this.NormandyApi = {
+ apiCall(method, endpoint, data = {}) {
+ const api_url = prefs.getCharPref("api_url");
+ let url = `${api_url}/${endpoint}`;
+ method = method.toLowerCase();
+
+ if (method === "get") {
+ if (data === {}) {
+ const paramObj = new URLSearchParams();
+ for (const key in data) {
+ paramObj.append(key, data[key]);
+ }
+ url += "?" + paramObj.toString();
+ }
+ data = undefined;
+ }
+
+ const headers = {"Accept": "application/json"};
+ return fetch(url, {
+ body: JSON.stringify(data),
+ headers,
+ });
+ },
+
+ get(endpoint, data) {
+ return this.apiCall("get", endpoint, data);
+ },
+
+ post(endpoint, data) {
+ return this.apiCall("post", endpoint, data);
+ },
+
+ fetchRecipes: Task.async(function* (filters = {}) {
+ const recipeResponse = yield this.get("recipe/signed/", filters);
+ const rawText = yield recipeResponse.text();
+ const recipesWithSigs = JSON.parse(rawText);
+
+ const verifiedRecipes = [];
+
+ for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
+ const serialized = CanonicalJSON.stringify(recipe);
+ if (!rawText.includes(serialized)) {
+ log.debug(rawText, serialized);
+ throw new Error("Canonical recipe serialization does not match!");
+ }
+
+ const certChainResponse = yield fetch(x5u);
+ const certChain = yield certChainResponse.text();
+ const builtSignature = `p384ecdsa=${signature}`;
+
+ const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+ .createInstance(Ci.nsIContentSignatureVerifier);
+
+ if (!verifier.verifyContentSignature(serialized, builtSignature, certChain, "normandy.content-signature.mozilla.org")) {
+ throw new Error("Recipe signature is not valid");
+ }
+ verifiedRecipes.push(recipe);
+ }
+
+ log.debug(`Fetched ${verifiedRecipes.length} recipes from the server:`, verifiedRecipes.map(r => r.name).join(", "));
+
+ return verifiedRecipes;
+ }),
+
+ /**
+ * Fetch metadata about this client determined by the server.
+ * @return {object} Metadata specified by the server
+ */
+ classifyClient() {
+ return this.get("classify_client/")
+ .then(response => response.json())
+ .then(clientData => {
+ clientData.request_time = new Date(clientData.request_time);
+ return clientData;
+ });
+ },
+
+ fetchAction(name) {
+ return this.get(`action/${name}/`).then(req => req.json());
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -0,0 +1,141 @@
+/* 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";
+/* globals Components */
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource:///modules/ShellService.jsm");
+Cu.import("resource://gre/modules/AddonManager.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
+
+const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+this.EXPORTED_SYMBOLS = ["NormandyDriver"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const actionLog = Log.repository.getLogger("extensions.shield-recipe-client.actions");
+
+this.NormandyDriver = function(sandboxManager, extraContext = {}) {
+ if (!sandboxManager) {
+ throw new Error("sandboxManager is required");
+ }
+ const {sandbox} = sandboxManager;
+
+ return {
+ testing: false,
+
+ get locale() {
+ return Cc["@mozilla.org/chrome/chrome-registry;1"]
+ .getService(Ci.nsIXULChromeRegistry)
+ .getSelectedLocale("browser");
+ },
+
+ log(message, level = "debug") {
+ const levels = ["debug", "info", "warn", "error"];
+ if (levels.indexOf(level) === -1) {
+ throw new Error(`Invalid log level "${level}"`);
+ }
+ 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 sandboxedDriver = Cu.cloneInto(this, sandbox, {cloneFunctions: true});
+ const ee = new sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+ const internalOptions = Object.assign({}, options, {testing: this.testing});
+ new Heartbeat(aWindow, ee, sandboxManager, internalOptions);
+ return sandbox.Promise.resolve(ee);
+ },
+
+ saveHeartbeatFlow() {
+ // no-op required by spec
+ },
+
+ client() {
+ const appinfo = {
+ version: Services.appinfo.version,
+ channel: Services.appinfo.defaultUpdateChannel,
+ isDefaultBrowser: ShellService.isDefaultBrowser() || null,
+ searchEngine: null,
+ syncSetup: Preferences.isSet("services.sync.username"),
+ plugins: {},
+ doNotTrack: Preferences.get("privacy.donottrackheader.enabled", false),
+ };
+
+ const searchEnginePromise = new Promise(resolve => {
+ Services.search.init(rv => {
+ if (Components.isSuccessCode(rv)) {
+ appinfo.searchEngine = Services.search.defaultEngine.identifier;
+ }
+ resolve();
+ });
+ });
+
+ const pluginsPromise = new Promise(resolve => {
+ AddonManager.getAddonsByTypes(["plugin"], plugins => {
+ plugins.forEach(plugin => appinfo.plugins[plugin.name] = plugin);
+ resolve();
+ });
+ });
+
+ return new sandbox.Promise(resolve => {
+ Promise.all([searchEnginePromise, pluginsPromise]).then(() => {
+ resolve(Cu.cloneInto(appinfo, sandbox));
+ });
+ });
+ },
+
+ uuid() {
+ let ret = generateUUID().toString();
+ ret = ret.slice(1, ret.length - 1);
+ return ret;
+ },
+
+ createStorage(keyPrefix) {
+ let storage;
+ try {
+ storage = Storage.makeStorage(keyPrefix, sandbox);
+ } catch (e) {
+ log.error(e.stack);
+ throw e;
+ }
+ return storage;
+ },
+
+ location() {
+ const location = Cu.cloneInto({countryCode: extraContext.country}, sandbox);
+ return sandbox.Promise.resolve(location);
+ },
+
+ setTimeout(cb, time) {
+ if (typeof cb !== "function") {
+ throw new sandbox.Error(`setTimeout must be called with a function, got "${typeof cb}"`);
+ }
+ const token = setTimeout(() => {
+ cb();
+ sandboxManager.removeHold(`setTimeout-${token}`);
+ }, time);
+ sandboxManager.addHold(`setTimeout-${token}`);
+ return Cu.cloneInto(token, sandbox);
+ },
+
+ clearTimeout(token) {
+ clearTimeout(token);
+ sandboxManager.removeHold(`setTimeout-${token}`);
+ },
+ };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -0,0 +1,162 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout */
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+Cu.importGlobalProperties(["fetch"]); /* globals fetch */
+
+this.EXPORTED_SYMBOLS = ["RecipeRunner"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
+
+this.RecipeRunner = {
+ init() {
+ if (!this.checkPrefs()) {
+ return;
+ }
+
+ let delay;
+ if (prefs.getBoolPref("dev_mode")) {
+ delay = 0;
+ } else {
+ // startup delay is in seconds
+ delay = prefs.getIntPref("startup_delay_seconds") * 1000;
+ }
+
+ setTimeout(this.start.bind(this), delay);
+ },
+
+ checkPrefs() {
+ // Only run if Unified Telemetry is enabled.
+ if (!Services.prefs.getBoolPref("toolkit.telemetry.unified")) {
+ log.info("Disabling RecipeRunner because Unified Telemetry is disabled.");
+ return false;
+ }
+
+ if (!prefs.getBoolPref("enabled")) {
+ log.info("Recipe Client is disabled.");
+ return false;
+ }
+
+ const apiUrl = prefs.getCharPref("api_url");
+ if (!apiUrl || !apiUrl.startsWith("https://")) {
+ log.error(`Non HTTPS URL provided for extensions.shield-recipe-client.api_url: ${apiUrl}`);
+ return false;
+ }
+
+ return true;
+ },
+
+ start: Task.async(function* () {
+ let recipes;
+ try {
+ recipes = yield NormandyApi.fetchRecipes({enabled: true});
+ } catch (e) {
+ const apiUrl = prefs.getCharPref("api_url");
+ log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
+ return;
+ }
+
+ let extraContext;
+ try {
+ extraContext = yield this.getExtraContext();
+ } catch (e) {
+ log.warning(`Couldn't get extra filter context: ${e}`);
+ extraContext = {};
+ }
+
+ const recipesToRun = [];
+
+ for (const recipe of recipes) {
+ if (yield this.checkFilter(recipe, extraContext)) {
+ recipesToRun.push(recipe);
+ }
+ }
+
+ if (recipesToRun.length === 0) {
+ log.debug("No recipes to execute");
+ } else {
+ for (const recipe of recipesToRun) {
+ try {
+ log.debug(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+ yield this.executeRecipe(recipe, extraContext);
+ } catch (e) {
+ log.error(`Could not execute recipe ${recipe.name}:`, e);
+ }
+ }
+ }
+ }),
+
+ getExtraContext() {
+ return NormandyApi.classifyClient()
+ .then(clientData => ({normandy: clientData}));
+ },
+
+ /**
+ * Evaluate a recipe's filter expression against the environment.
+ * @param {object} recipe
+ * @param {string} recipe.filter The expression to evaluate against the environment.
+ * @param {object} extraContext Any extra context to provide to the filter environment.
+ * @return {boolean} The result of evaluating the filter, cast to a bool.
+ */
+ checkFilter(recipe, extraContext) {
+ return EnvExpressions.eval(recipe.filter_expression, extraContext)
+ .then(result => {
+ return !!result;
+ })
+ .catch(error => {
+ log.error(`Error checking filter for "${recipe.name}"`);
+ log.error(`Filter: "${recipe.filter_expression}"`);
+ log.error(`Error: "${error}"`);
+ });
+ },
+
+ /**
+ * Execute a recipe by fetching it action and executing it.
+ * @param {Object} recipe A recipe to execute
+ * @promise Resolves when the action has executed
+ */
+ executeRecipe: Task.async(function* (recipe, extraContext) {
+ const sandboxManager = new SandboxManager();
+ const {sandbox} = sandboxManager;
+
+ const action = yield NormandyApi.fetchAction(recipe.action);
+ const response = yield fetch(action.implementation_url);
+
+ const actionScript = yield response.text();
+ const prepScript = `
+ var pendingAction = null;
+
+ function registerAction(name, Action) {
+ let a = new Action(sandboxedDriver, sandboxedRecipe);
+ pendingAction = a.execute()
+ .catch(err => sandboxedDriver.log(err, 'error'));
+ };
+
+ window.registerAction = registerAction;
+ window.setTimeout = sandboxedDriver.setTimeout;
+ window.clearTimeout = sandboxedDriver.clearTimeout;
+ `;
+
+ const driver = new NormandyDriver(sandboxManager, extraContext);
+ sandbox.sandboxedDriver = Cu.cloneInto(driver, sandbox, {cloneFunctions: true});
+ sandbox.sandboxedRecipe = Cu.cloneInto(recipe, sandbox);
+
+ Cu.evalInSandbox(prepScript, sandbox);
+ Cu.evalInSandbox(actionScript, sandbox);
+
+ sandboxManager.addHold("recipeExecution");
+ sandbox.pendingAction.then(() => sandboxManager.removeHold("recipeExecution"));
+ }),
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Sampling.jsm
@@ -0,0 +1,81 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.importGlobalProperties(["crypto", "TextEncoder"]);
+
+this.EXPORTED_SYMBOLS = ["Sampling"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+
+/**
+ * Map from the range [0, 1] to [0, max(sha256)].
+ * @param {number} frac A float from 0.0 to 1.0.
+ * @return {string} A 48 bit number represented in hex, padded to 12 characters.
+ */
+function fractionToKey(frac) {
+ const hashBits = 48;
+ const hashLength = hashBits / 4;
+
+ if (frac < 0 || frac > 1) {
+ throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
+ }
+
+ const mult = Math.pow(2, hashBits) - 1;
+ const inDecimal = Math.floor(frac * mult);
+ let hexDigits = inDecimal.toString(16);
+ if (hexDigits.length < hashLength) {
+ // Left pad with zeroes
+ // If N zeroes are needed, generate an array of nulls N+1 elements long,
+ // and inserts zeroes between each null.
+ hexDigits = Array(hashLength - hexDigits.length + 1).join("0") + hexDigits;
+ }
+
+ // Saturate at 2**48 - 1
+ if (hexDigits.length > hashLength) {
+ hexDigits = Array(hashLength + 1).join("f");
+ }
+
+ return hexDigits;
+}
+
+function bufferToHex(buffer) {
+ const hexCodes = [];
+ const view = new DataView(buffer);
+ for (let i = 0; i < view.byteLength; i += 4) {
+ // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
+ const value = view.getUint32(i);
+ // toString(16) will give the hex representation of the number without padding
+ hexCodes.push(value.toString(16).padStart(8, "0"));
+ }
+
+ // Join all the hex strings into one
+ return hexCodes.join("");
+}
+
+this.Sampling = {
+ stableSample(input, rate) {
+ const hasher = crypto.subtle;
+
+ return hasher.digest("SHA-256", new TextEncoder("utf-8").encode(JSON.stringify(input)))
+ .then(hash => {
+ // truncate hash to 12 characters (2^48)
+ const inputHash = bufferToHex(hash).slice(0, 12);
+ const samplePoint = fractionToKey(rate);
+
+ if (samplePoint.length !== 12 || inputHash.length !== 12) {
+ throw new Error("Unexpected hash length");
+ }
+
+ return inputHash < samplePoint;
+
+ })
+ .catch(error => {
+ log.error(`Error: ${error}`);
+ });
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
@@ -0,0 +1,65 @@
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["SandboxManager"];
+
+this.SandboxManager = class {
+ constructor() {
+ this._sandbox = makeSandbox();
+ this.holds = [];
+ }
+
+ get sandbox() {
+ if (this._sandbox) {
+ return this._sandbox;
+ }
+ throw new Error("Tried to use sandbox after it was nuked");
+ }
+
+ addHold(name) {
+ this.holds.push(name);
+ }
+
+ removeHold(name) {
+ const index = this.holds.indexOf(name);
+ if (index === -1) {
+ throw new Error(`Tried to remove non-existant hold "${name}"`);
+ }
+ this.holds.splice(index, 1);
+ this.tryCleanup();
+ }
+
+ tryCleanup() {
+ if (this.holds.length === 0) {
+ const sandbox = this._sandbox;
+ this._sandbox = null;
+ Cu.nukeSandbox(sandbox);
+ }
+ }
+
+ isNuked() {
+ // Do this in a promise, so other async things can resolve.
+ return new Promise((resolve, reject) => {
+ if (!this._sandbox) {
+ resolve();
+ } else {
+ reject(new Error(`Sandbox is not nuked. Holds left: ${this.holds}`));
+ }
+ });
+ }
+};
+
+
+function makeSandbox() {
+ const sandbox = new Cu.Sandbox(null, {
+ wantComponents: false,
+ wantGlobalProperties: ["URL", "URLSearchParams"],
+ });
+
+ sandbox.window = Cu.cloneInto({}, sandbox);
+
+ const url = "resource://shield-recipe-client/data/EventEmitter.js";
+ Services.scriptloader.loadSubScript(url, sandbox);
+
+ return sandbox;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Storage.jsm
@@ -0,0 +1,134 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
+
+this.EXPORTED_SYMBOLS = ["Storage"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+let storePromise;
+
+function loadStorage() {
+ if (storePromise === undefined) {
+ const path = OS.Path.join(OS.Constants.Path.profileDir, "shield-recipe-client.json");
+ const storage = new JSONFile({path});
+ storePromise = Task.spawn(function* () {
+ yield storage.load();
+ return storage;
+ });
+ }
+ return storePromise;
+}
+
+this.Storage = {
+ makeStorage(prefix, sandbox) {
+ if (!sandbox) {
+ throw new Error("No sandbox passed");
+ }
+
+ const storageInterface = {
+ /**
+ * Sets an item in the prefixed storage.
+ * @returns {Promise}
+ * @resolves With the stored value, or null.
+ * @rejects Javascript exception.
+ */
+ getItem(keySuffix) {
+ return new sandbox.Promise((resolve, reject) => {
+ loadStorage()
+ .then(store => {
+ const namespace = store.data[prefix] || {};
+ const value = namespace[keySuffix] || null;
+ resolve(Cu.cloneInto(value, sandbox));
+ })
+ .catch(err => {
+ log.error(err);
+ reject(new sandbox.Error());
+ });
+ });
+ },
+
+ /**
+ * Sets an item in the prefixed storage.
+ * @returns {Promise}
+ * @resolves When the operation is completed succesfully
+ * @rejects Javascript exception.
+ */
+ setItem(keySuffix, value) {
+ return new sandbox.Promise((resolve, reject) => {
+ loadStorage()
+ .then(store => {
+ if (!(prefix in store.data)) {
+ store.data[prefix] = {};
+ }
+ store.data[prefix][keySuffix] = value;
+ store.saveSoon();
+ resolve();
+ })
+ .catch(err => {
+ log.error(err);
+ reject(new sandbox.Error());
+ });
+ });
+ },
+
+ /**
+ * Removes a single item from the prefixed storage.
+ * @returns {Promise}
+ * @resolves When the operation is completed succesfully
+ * @rejects Javascript exception.
+ */
+ removeItem(keySuffix) {
+ return new sandbox.Promise((resolve, reject) => {
+ loadStorage()
+ .then(store => {
+ if (!(prefix in store.data)) {
+ return;
+ }
+ delete store.data[prefix][keySuffix];
+ store.saveSoon();
+ resolve();
+ })
+ .catch(err => {
+ log.error(err);
+ reject(new sandbox.Error());
+ });
+ });
+ },
+
+ /**
+ * Clears all storage for the prefix.
+ * @returns {Promise}
+ * @resolves When the operation is completed succesfully
+ * @rejects Javascript exception.
+ */
+ clear() {
+ return new sandbox.Promise((resolve, reject) => {
+ return loadStorage()
+ .then(store => {
+ store.data[prefix] = {};
+ store.saveSoon();
+ resolve();
+ })
+ .catch(err => {
+ log.error(err);
+ reject(new sandbox.Error());
+ });
+ });
+ },
+ };
+
+ return Cu.cloneInto(storageInterface, sandbox, {
+ cloneFunctions: true,
+ });
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
+DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
+
+FINAL_TARGET_FILES.features['shield-recipe-client@mozilla.org'] += [
+ 'bootstrap.js',
+]
+
+FINAL_TARGET_PP_FILES.features['shield-recipe-client@mozilla.org'] += [
+ 'install.rdf.in'
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ 'test/browser.ini',
+]
+
+JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
@@ -0,0 +1,19 @@
+Copyright (c) 2015 TechnologyAdvice
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
@@ -0,0 +1,225 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var Evaluator = require('./evaluator/Evaluator'),
+ Lexer = require('./Lexer'),
+ Parser = require('./parser/Parser'),
+ defaultGrammar = require('./grammar').elements;
+
+/**
+ * Jexl is the Javascript Expression Language, capable of parsing and
+ * evaluating basic to complex expression strings, combined with advanced
+ * xpath-like drilldown into native Javascript objects.
+ * @constructor
+ */
+function Jexl() {
+ this._customGrammar = null;
+ this._lexer = null;
+ this._transforms = {};
+}
+
+/**
+ * Adds a binary operator to Jexl at the specified precedence. The higher the
+ * precedence, the earlier the operator is applied in the order of operations.
+ * For example, * has a higher precedence than +, because multiplication comes
+ * before division.
+ *
+ * Please see grammar.js for a listing of all default operators and their
+ * precedence values in order to choose the appropriate precedence for the
+ * new operator.
+ * @param {string} operator The operator string to be added
+ * @param {number} precedence The operator's precedence
+ * @param {function} fn A function to run to calculate the result. The function
+ * will be called with two arguments: left and right, denoting the values
+ * on either side of the operator. It should return either the resulting
+ * value, or a Promise that resolves with the resulting value.
+ */
+Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
+ this._addGrammarElement(operator, {
+ type: 'binaryOp',
+ precedence: precedence,
+ eval: fn
+ });
+};
+
+/**
+ * Adds a unary operator to Jexl. Unary operators are currently only supported
+ * on the left side of the value on which it will operate.
+ * @param {string} operator The operator string to be added
+ * @param {function} fn A function to run to calculate the result. The function
+ * will be called with one argument: the literal value to the right of the
+ * operator. It should return either the resulting value, or a Promise
+ * that resolves with the resulting value.
+ */
+Jexl.prototype.addUnaryOp = function(operator, fn) {
+ this._addGrammarElement(operator, {
+ type: 'unaryOp',
+ weight: Infinity,
+ eval: fn
+ });
+};
+
+/**
+ * Adds or replaces a transform function in this Jexl instance.
+ * @param {string} name The name of the transform function, as it will be used
+ * within Jexl expressions
+ * @param {function} fn The function to be executed when this transform is
+ * invoked. It will be provided with two arguments:
+ * - {*} value: The value to be transformed
+ * - {{}} args: The arguments for this transform
+ * - {function} cb: A callback function to be called with an error
+ * if the transform fails, or a null first argument and the
+ * transformed value as the second argument on success.
+ */
+Jexl.prototype.addTransform = function(name, fn) {
+ this._transforms[name] = fn;
+};
+
+/**
+ * Syntactic sugar for calling {@link #addTransform} repeatedly. This function
+ * accepts a map of one or more transform names to their transform function.
+ * @param {{}} map A map of transform names to transform functions
+ */
+Jexl.prototype.addTransforms = function(map) {
+ for (var key in map) {
+ if (map.hasOwnProperty(key))
+ this._transforms[key] = map[key];
+ }
+};
+
+/**
+ * Retrieves a previously set transform function.
+ * @param {string} name The name of the transform function
+ * @returns {function} The transform function
+ */
+Jexl.prototype.getTransform = function(name) {
+ return this._transforms[name];
+};
+
+/**
+ * Evaluates a Jexl string within an optional context.
+ * @param {string} expression The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ * made accessible to the Jexl expression when evaluating it
+ * @param {function} [cb] An optional callback function to be executed when
+ * evaluation is complete. It will be supplied with two arguments:
+ * - {Error|null} err: Present if an error occurred
+ * - {*} result: The result of the evaluation
+ * @returns {Promise<*>} resolves with the result of the evaluation. Note that
+ * if a callback is supplied, the returned promise will already have
+ * a '.catch' attached to it in order to pass the error to the callback.
+ */
+Jexl.prototype.eval = function(expression, context, cb) {
+ if (typeof context === 'function') {
+ cb = context;
+ context = {};
+ }
+ else if (!context)
+ context = {};
+ var valPromise = this._eval(expression, context);
+ if (cb) {
+ // setTimeout is used for the callback to break out of the Promise's
+ // try/catch in case the callback throws.
+ var called = false;
+ return valPromise.then(function(val) {
+ called = true;
+ setTimeout(cb.bind(null, null, val), 0);
+ }).catch(function(err) {
+ if (!called)
+ setTimeout(cb.bind(null, err), 0);
+ });
+ }
+ return valPromise;
+};
+
+/**
+ * Removes a binary or unary operator from the Jexl grammar.
+ * @param {string} operator The operator string to be removed
+ */
+Jexl.prototype.removeOp = function(operator) {
+ var grammar = this._getCustomGrammar();
+ if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
+ grammar[operator].type == 'unaryOp')) {
+ delete grammar[operator];
+ this._lexer = null;
+ }
+};
+
+/**
+ * Adds an element to the grammar map used by this Jexl instance, cloning
+ * the default grammar first if necessary.
+ * @param {string} str The key string to be added
+ * @param {{type: <string>}} obj A map of configuration options for this
+ * grammar element
+ * @private
+ */
+Jexl.prototype._addGrammarElement = function(str, obj) {
+ var grammar = this._getCustomGrammar();
+ grammar[str] = obj;
+ this._lexer = null;
+};
+
+/**
+ * Evaluates a Jexl string in the given context.
+ * @param {string} exp The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ * made accessible to the Jexl expression when evaluating it
+ * @returns {Promise<*>} resolves with the result of the evaluation.
+ * @private
+ */
+Jexl.prototype._eval = function(exp, context) {
+ var self = this,
+ grammar = this._getGrammar(),
+ parser = new Parser(grammar),
+ evaluator = new Evaluator(grammar, this._transforms, context);
+ return Promise.resolve().then(function() {
+ parser.addTokens(self._getLexer().tokenize(exp));
+ return evaluator.eval(parser.complete());
+ });
+};
+
+/**
+ * Gets the custom grammar object, creating it first if necessary. New custom
+ * grammars are created by executing a shallow clone of the default grammar
+ * map. The returned map is available to be changed.
+ * @returns {{}} a customizable grammar map.
+ * @private
+ */
+Jexl.prototype._getCustomGrammar = function() {
+ if (!this._customGrammar) {
+ this._customGrammar = {};
+ for (var key in defaultGrammar) {
+ if (defaultGrammar.hasOwnProperty(key))
+ this._customGrammar[key] = defaultGrammar[key];
+ }
+ }
+ return this._customGrammar;
+};
+
+/**
+ * Gets the grammar map currently being used by Jexl; either the default map,
+ * or a locally customized version. The returned map should never be changed
+ * in any way.
+ * @returns {{}} the grammar map currently in use.
+ * @private
+ */
+Jexl.prototype._getGrammar = function() {
+ return this._customGrammar || defaultGrammar;
+};
+
+/**
+ * Gets a Lexer instance as a singleton in reference to this Jexl instance.
+ * @returns {Lexer} an instance of Lexer, initialized with a grammar
+ * appropriate to this Jexl instance.
+ * @private
+ */
+Jexl.prototype._getLexer = function() {
+ if (!this._lexer)
+ this._lexer = new Lexer(this._getGrammar());
+ return this._lexer;
+};
+
+module.exports = new Jexl();
+module.exports.Jexl = Jexl;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
@@ -0,0 +1,244 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
+ identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
+ escEscRegex = /\\\\/,
+ preOpRegexElems = [
+ // Strings
+ "'(?:(?:\\\\')?[^'])*'",
+ '"(?:(?:\\\\")?[^"])*"',
+ // Whitespace
+ '\\s+',
+ // Booleans
+ '\\btrue\\b',
+ '\\bfalse\\b'
+ ],
+ postOpRegexElems = [
+ // Identifiers
+ '\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
+ // Numerics (without negative symbol)
+ '(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
+ ],
+ minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
+ 'question', 'colon'];
+
+/**
+ * Lexer is a collection of stateless, statically-accessed functions for the
+ * lexical parsing of a Jexl string. Its responsibility is to identify the
+ * "parts of speech" of a Jexl expression, and tokenize and label each, but
+ * to do only the most minimal syntax checking; the only errors the Lexer
+ * should be concerned with are if it's unable to identify the utility of
+ * any of its tokens. Errors stemming from these tokens not being in a
+ * sensible configuration should be left for the Parser to handle.
+ * @type {{}}
+ */
+function Lexer(grammar) {
+ this._grammar = grammar;
+}
+
+/**
+ * Splits a Jexl expression string into an array of expression elements.
+ * @param {string} str A Jexl expression string
+ * @returns {Array<string>} An array of substrings defining the functional
+ * elements of the expression.
+ */
+Lexer.prototype.getElements = function(str) {
+ var regex = this._getSplitRegex();
+ return str.split(regex).filter(function(elem) {
+ // Remove empty strings
+ return elem;
+ });
+};
+
+/**
+ * Converts an array of expression elements into an array of tokens. Note that
+ * the resulting array may not equal the element array in length, as any
+ * elements that consist only of whitespace get appended to the previous
+ * token's "raw" property. For the structure of a token object, please see
+ * {@link Lexer#tokenize}.
+ * @param {Array<string>} elements An array of Jexl expression elements to be
+ * converted to tokens
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ */
+Lexer.prototype.getTokens = function(elements) {
+ var tokens = [],
+ negate = false;
+ for (var i = 0; i < elements.length; i++) {
+ if (this._isWhitespace(elements[i])) {
+ if (tokens.length)
+ tokens[tokens.length - 1].raw += elements[i];
+ }
+ else if (elements[i] === '-' && this._isNegative(tokens))
+ negate = true;
+ else {
+ if (negate) {
+ elements[i] = '-' + elements[i];
+ negate = false;
+ }
+ tokens.push(this._createToken(elements[i]));
+ }
+ }
+ // Catch a - at the end of the string. Let the parser handle that issue.
+ if (negate)
+ tokens.push(this._createToken('-'));
+ return tokens;
+};
+
+/**
+ * Converts a Jexl string into an array of tokens. Each token is an object
+ * in the following format:
+ *
+ * {
+ * type: <string>,
+ * [name]: <string>,
+ * value: <boolean|number|string>,
+ * raw: <string>
+ * }
+ *
+ * Type is one of the following:
+ *
+ * literal, identifier, binaryOp, unaryOp
+ *
+ * OR, if the token is a control character its type is the name of the element
+ * defined in the Grammar.
+ *
+ * Name appears only if the token is a control string found in
+ * {@link grammar#elements}, and is set to the name of the element.
+ *
+ * Value is the value of the token in the correct type (boolean or numeric as
+ * appropriate). Raw is the string representation of this value taken directly
+ * from the expression string, including any trailing spaces.
+ * @param {string} str The Jexl string to be tokenized
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ * @throws {Error} if the provided string contains an invalid token.
+ */
+Lexer.prototype.tokenize = function(str) {
+ var elements = this.getElements(str);
+ return this.getTokens(elements);
+};
+
+/**
+ * Creates a new token object from an element of a Jexl string. See
+ * {@link Lexer#tokenize} for a description of the token object.
+ * @param {string} element The element from which a token should be made
+ * @returns {{value: number|boolean|string, [name]: string, type: string,
+ * raw: string}} a token object describing the provided element.
+ * @throws {Error} if the provided string is not a valid expression element.
+ * @private
+ */
+Lexer.prototype._createToken = function(element) {
+ var token = {
+ type: 'literal',
+ value: element,
+ raw: element
+ };
+ if (element[0] == '"' || element[0] == "'")
+ token.value = this._unquote(element);
+ else if (element.match(numericRegex))
+ token.value = parseFloat(element);
+ else if (element === 'true' || element === 'false')
+ token.value = element === 'true';
+ else if (this._grammar[element])
+ token.type = this._grammar[element].type;
+ else if (element.match(identRegex))
+ token.type = 'identifier';
+ else
+ throw new Error("Invalid expression token: " + element);
+ return token;
+};
+
+/**
+ * Escapes a string so that it can be treated as a string literal within a
+ * regular expression.
+ * @param {string} str The string to be escaped
+ * @returns {string} the RegExp-escaped string.
+ * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
+ * @private
+ */
+Lexer.prototype._escapeRegExp = function(str) {
+ str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ if (str.match(identRegex))
+ str = '\\b' + str + '\\b';
+ return str;
+};
+
+/**
+ * Gets a RegEx object appropriate for splitting a Jexl string into its core
+ * elements.
+ * @returns {RegExp} An element-splitting RegExp object
+ * @private
+ */
+Lexer.prototype._getSplitRegex = function() {
+ if (!this._splitRegex) {
+ var elemArray = Object.keys(this._grammar);
+ // Sort by most characters to least, then regex escape each
+ elemArray = elemArray.sort(function(a ,b) {
+ return b.length - a.length;
+ }).map(function(elem) {
+ return this._escapeRegExp(elem);
+ }, this);
+ this._splitRegex = new RegExp('(' + [
+ preOpRegexElems.join('|'),
+ elemArray.join('|'),
+ postOpRegexElems.join('|')
+ ].join('|') + ')');
+ }
+ return this._splitRegex;
+};
+
+/**
+ * Determines whether the addition of a '-' token should be interpreted as a
+ * negative symbol for an upcoming number, given an array of tokens already
+ * processed.
+ * @param {Array<Object>} tokens An array of tokens already processed
+ * @returns {boolean} true if adding a '-' should be considered a negative
+ * symbol; false otherwise
+ * @private
+ */
+Lexer.prototype._isNegative = function(tokens) {
+ if (!tokens.length)
+ return true;
+ return minusNegatesAfter.some(function(type) {
+ return type === tokens[tokens.length - 1].type;
+ });
+};
+
+/**
+ * A utility function to determine if a string consists of only space
+ * characters.
+ * @param {string} str A string to be tested
+ * @returns {boolean} true if the string is empty or consists of only spaces;
+ * false otherwise.
+ * @private
+ */
+Lexer.prototype._isWhitespace = function(str) {
+ for (var i = 0; i < str.length; i++) {
+ if (str[i] != ' ')
+ return false;
+ }
+ return true;
+};
+
+/**
+ * Removes the beginning and trailing quotes from a string, unescapes any
+ * escaped quotes on its interior, and unescapes any escaped escape characters.
+ * Note that this function is not defensive; it assumes that the provided
+ * string is not empty, and that its first and last characters are actually
+ * quotes.
+ * @param {string} str A string whose first and last characters are quotes
+ * @returns {string} a string with the surrounding quotes stripped and escapes
+ * properly processed.
+ * @private
+ */
+Lexer.prototype._unquote = function(str) {
+ var quote = str[0],
+ escQuoteRegex = new RegExp('\\\\' + quote, 'g');
+ return str.substr(1, str.length - 2)
+ .replace(escQuoteRegex, quote)
+ .replace(escEscRegex, '\\');
+};
+
+module.exports = Lexer;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
@@ -0,0 +1,153 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers');
+
+/**
+ * The Evaluator takes a Jexl expression tree as generated by the
+ * {@link Parser} and calculates its value within a given context. The
+ * collection of transforms, context, and a relative context to be used as the
+ * root for relative identifiers, are all specific to an Evaluator instance.
+ * When any of these things change, a new instance is required. However, a
+ * single instance can be used to simultaneously evaluate many different
+ * expressions, and does not have to be reinstantiated for each.
+ * @param {{}} grammar A grammar map against which to evaluate the expression
+ * tree
+ * @param {{}} [transforms] A map of transform names to transform functions. A
+ * transform function takes two arguments:
+ * - {*} val: A value to be transformed
+ * - {{}} args: A map of argument keys to their evaluated values, as
+ * specified in the expression string
+ * The transform function should return either the transformed value, or
+ * a Promises/A+ Promise object that resolves with the value and rejects
+ * or throws only when an unrecoverable error occurs. Transforms should
+ * generally return undefined when they don't make sense to be used on the
+ * given value type, rather than throw/reject. An error is only
+ * appropriate when the transform would normally return a value, but
+ * cannot due to some other failure.
+ * @param {{}} [context] A map of variable keys to their values. This will be
+ * accessed to resolve the value of each non-relative identifier. Any
+ * Promise values will be passed to the expression as their resolved
+ * value.
+ * @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
+ * to resolve the value of a relative identifier.
+ * @constructor
+ */
+var Evaluator = function(grammar, transforms, context, relativeContext) {
+ this._grammar = grammar;
+ this._transforms = transforms || {};
+ this._context = context || {};
+ this._relContext = relativeContext || this._context;
+};
+
+/**
+ * Evaluates an expression tree within the configured context.
+ * @param {{}} ast An expression tree object
+ * @returns {Promise<*>} resolves with the resulting value of the expression.
+ */
+Evaluator.prototype.eval = function(ast) {
+ var self = this;
+ return Promise.resolve().then(function() {
+ return handlers[ast.type].call(self, ast);
+ });
+};
+
+/**
+ * Simultaneously evaluates each expression within an array, and delivers the
+ * response as an array with the resulting values at the same indexes as their
+ * originating expressions.
+ * @param {Array<string>} arr An array of expression strings to be evaluated
+ * @returns {Promise<Array<{}>>} resolves with the result array
+ */
+Evaluator.prototype.evalArray = function(arr) {
+ return Promise.all(arr.map(function(elem) {
+ return this.eval(elem);
+ }, this));
+};
+
+/**
+ * Simultaneously evaluates each expression within a map, and delivers the
+ * response as a map with the same keys, but with the evaluated result for each
+ * as their value.
+ * @param {{}} map A map of expression names to expression trees to be
+ * evaluated
+ * @returns {Promise<{}>} resolves with the result map.
+ */
+Evaluator.prototype.evalMap = function(map) {
+ var keys = Object.keys(map),
+ result = {};
+ var asts = keys.map(function(key) {
+ return this.eval(map[key]);
+ }, this);
+ return Promise.all(asts).then(function(vals) {
+ vals.forEach(function(val, idx) {
+ result[keys[idx]] = val;
+ });
+ return result;
+ });
+};
+
+/**
+ * Applies a filter expression with relative identifier elements to a subject.
+ * The intent is for the subject to be an array of subjects that will be
+ * individually used as the relative context against the provided expression
+ * tree. Only the elements whose expressions result in a truthy value will be
+ * included in the resulting array.
+ *
+ * If the subject is not an array of values, it will be converted to a single-
+ * element array before running the filter.
+ * @param {*} subject The value to be filtered; usually an array. If this value is
+ * not an array, it will be converted to an array with this value as the
+ * only element.
+ * @param {{}} expr The expression tree to run against each subject. If the
+ * tree evaluates to a truthy result, then the value will be included in
+ * the returned array; otherwise, it will be eliminated.
+ * @returns {Promise<Array>} resolves with an array of values that passed the
+ * expression filter.
+ * @private
+ */
+Evaluator.prototype._filterRelative = function(subject, expr) {
+ var promises = [];
+ if (!Array.isArray(subject))
+ subject = [subject];
+ subject.forEach(function(elem) {
+ var evalInst = new Evaluator(this._grammar, this._transforms,
+ this._context, elem);
+ promises.push(evalInst.eval(expr));
+ }, this);
+ return Promise.all(promises).then(function(values) {
+ var results = [];
+ values.forEach(function(value, idx) {
+ if (value)
+ results.push(subject[idx]);
+ });
+ return results;
+ });
+};
+
+/**
+ * Applies a static filter expression to a subject value. If the filter
+ * expression evaluates to boolean true, the subject is returned; if false,
+ * undefined.
+ *
+ * For any other resulting value of the expression, this function will attempt
+ * to respond with the property at that name or index of the subject.
+ * @param {*} subject The value to be filtered. Usually an Array (for which
+ * the expression would generally resolve to a numeric index) or an
+ * Object (for which the expression would generally resolve to a string
+ * indicating a property name)
+ * @param {{}} expr The expression tree to run against the subject
+ * @returns {Promise<*>} resolves with the value of the drill-down.
+ * @private
+ */
+Evaluator.prototype._filterStatic = function(subject, expr) {
+ return this.eval(expr).then(function(res) {
+ if (typeof res === 'boolean')
+ return res ? subject : undefined;
+ return subject[res];
+ });
+};
+
+module.exports = Evaluator;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
@@ -0,0 +1,159 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Evaluates an ArrayLiteral by returning its value, with each element
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ * ObjectLiteral as the top node
+ * @returns {Promise.<[]>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ArrayLiteral = function(ast) {
+ return this.evalArray(ast.value);
+};
+
+/**
+ * Evaluates a BinaryExpression node by running the Grammar's evaluator for
+ * the given operator.
+ * @param {{type: 'BinaryExpression', operator: <string>, left: {},
+ * right: {}}} ast An expression tree with a BinaryExpression as the top
+ * node
+ * @returns {Promise<*>} resolves with the value of the BinaryExpression.
+ * @private
+ */
+exports.BinaryExpression = function(ast) {
+ var self = this;
+ return Promise.all([
+ this.eval(ast.left),
+ this.eval(ast.right)
+ ]).then(function(arr) {
+ return self._grammar[ast.operator].eval(arr[0], arr[1]);
+ });
+};
+
+/**
+ * Evaluates a ConditionalExpression node by first evaluating its test branch,
+ * and resolving with the consequent branch if the test is truthy, or the
+ * alternate branch if it is not. If there is no consequent branch, the test
+ * result will be used instead.
+ * @param {{type: 'ConditionalExpression', test: {}, consequent: {},
+ * alternate: {}}} ast An expression tree with a ConditionalExpression as
+ * the top node
+ * @private
+ */
+exports.ConditionalExpression = function(ast) {
+ var self = this;
+ return this.eval(ast.test).then(function(res) {
+ if (res) {
+ if (ast.consequent)
+ return self.eval(ast.consequent);
+ return res;
+ }
+ return self.eval(ast.alternate);
+ });
+};
+
+/**
+ * Evaluates a FilterExpression by applying it to the subject value.
+ * @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
+ * subject: {}}} ast An expression tree with a FilterExpression as the top
+ * node
+ * @returns {Promise<*>} resolves with the value of the FilterExpression.
+ * @private
+ */
+exports.FilterExpression = function(ast) {
+ var self = this;
+ return this.eval(ast.subject).then(function(subject) {
+ if (ast.relative)
+ return self._filterRelative(subject, ast.expr);
+ return self._filterStatic(subject, ast.expr);
+ });
+};
+
+/**
+ * Evaluates an Identifier by either stemming from the evaluated 'from'
+ * expression tree or accessing the context provided when this Evaluator was
+ * constructed.
+ * @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
+ * tree with an Identifier as the top node
+ * @returns {Promise<*>|*} either the identifier's value, or a Promise that
+ * will resolve with the identifier's value.
+ * @private
+ */
+exports.Identifier = function(ast) {
+ if (ast.from) {
+ return this.eval(ast.from).then(function(context) {
+ if (context === undefined)
+ return undefined;
+ if (Array.isArray(context))
+ context = context[0];
+ return context[ast.value];
+ });
+ }
+ else {
+ return ast.relative ? this._relContext[ast.value] :
+ this._context[ast.value];
+ }
+};
+
+/**
+ * Evaluates a Literal by returning its value property.
+ * @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
+ * tree with a Literal as its only node
+ * @returns {string|number|boolean} The value of the Literal node
+ * @private
+ */
+exports.Literal = function(ast) {
+ return ast.value;
+};
+
+/**
+ * Evaluates an ObjectLiteral by returning its value, with each key
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ * ObjectLiteral as the top node
+ * @returns {Promise<{}>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ObjectLiteral = function(ast) {
+ return this.evalMap(ast.value);
+};
+
+/**
+ * Evaluates a Transform node by applying a function from the transforms map
+ * to the subject value.
+ * @param {{type: 'Transform', name: <string>, subject: {}}} ast An
+ * expression tree with a Transform as the top node
+ * @returns {Promise<*>|*} the value of the transformation, or a Promise that
+ * will resolve with the transformed value.
+ * @private
+ */
+exports.Transform = function(ast) {
+ var transform = this._transforms[ast.name];
+ if (!transform)
+ throw new Error("Transform '" + ast.name + "' is not defined.");
+ return Promise.all([
+ this.eval(ast.subject),
+ this.evalArray(ast.args || [])
+ ]).then(function(arr) {
+ return transform.apply(null, [arr[0]].concat(arr[1]));
+ });
+};
+
+/**
+ * Evaluates a Unary expression by passing the right side through the
+ * operator's eval function.
+ * @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
+ * expression tree with a UnaryExpression as the top node
+ * @returns {Promise<*>} resolves with the value of the UnaryExpression.
+ * @constructor
+ */
+exports.UnaryExpression = function(ast) {
+ var self = this;
+ return this.eval(ast.right).then(function(right) {
+ return self._grammar[ast.operator].eval(right);
+ });
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
@@ -0,0 +1,66 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * A map of all expression elements to their properties. Note that changes
+ * here may require changes in the Lexer or Parser.
+ * @type {{}}
+ */
+exports.elements = {
+ '.': {type: 'dot'},
+ '[': {type: 'openBracket'},
+ ']': {type: 'closeBracket'},
+ '|': {type: 'pipe'},
+ '{': {type: 'openCurl'},
+ '}': {type: 'closeCurl'},
+ ':': {type: 'colon'},
+ ',': {type: 'comma'},
+ '(': {type: 'openParen'},
+ ')': {type: 'closeParen'},
+ '?': {type: 'question'},
+ '+': {type: 'binaryOp', precedence: 30,
+ eval: function(left, right) { return left + right; }},
+ '-': {type: 'binaryOp', precedence: 30,
+ eval: function(left, right) { return left - right; }},
+ '*': {type: 'binaryOp', precedence: 40,
+ eval: function(left, right) { return left * right; }},
+ '/': {type: 'binaryOp', precedence: 40,
+ eval: function(left, right) { return left / right; }},
+ '//': {type: 'binaryOp', precedence: 40,
+ eval: function(left, right) { return Math.floor(left / right); }},
+ '%': {type: 'binaryOp', precedence: 50,
+ eval: function(left, right) { return left % right; }},
+ '^': {type: 'binaryOp', precedence: 50,
+ eval: function(left, right) { return Math.pow(left, right); }},
+ '==': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left == right; }},
+ '!=': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left != right; }},
+ '>': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left > right; }},
+ '>=': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left >= right; }},
+ '<': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left < right; }},
+ '<=': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) { return left <= right; }},
+ '&&': {type: 'binaryOp', precedence: 10,
+ eval: function(left, right) { return left && right; }},
+ '||': {type: 'binaryOp', precedence: 10,
+ eval: function(left, right) { return left || right; }},
+ 'in': {type: 'binaryOp', precedence: 20,
+ eval: function(left, right) {
+ if (typeof right === 'string')
+ return right.indexOf(left) !== -1;
+ if (Array.isArray(right)) {
+ return right.some(function(elem) {
+ return elem == left;
+ });
+ }
+ return false;
+ }},
+ '!': {type: 'unaryOp', precedence: Infinity,
+ eval: function(right) { return !right; }}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
@@ -0,0 +1,188 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers'),
+ states = require('./states').states;
+
+/**
+ * The Parser is a state machine that converts tokens from the {@link Lexer}
+ * into an Abstract Syntax Tree (AST), capable of being evaluated in any
+ * context by the {@link Evaluator}. The Parser expects that all tokens
+ * provided to it are legal and typed properly according to the grammar, but
+ * accepts that the tokens may still be in an invalid order or in some other
+ * unparsable configuration that requires it to throw an Error.
+ * @param {{}} grammar The grammar map to use to parse Jexl strings
+ * @param {string} [prefix] A string prefix to prepend to the expression string
+ * for error messaging purposes. This is useful for when a new Parser is
+ * instantiated to parse an subexpression, as the parent Parser's
+ * expression string thus far can be passed for a more user-friendly
+ * error message.
+ * @param {{}} [stopMap] A mapping of token types to any truthy value. When the
+ * token type is encountered, the parser will return the mapped value
+ * instead of boolean false.
+ * @constructor
+ */
+function Parser(grammar, prefix, stopMap) {
+ this._grammar = grammar;
+ this._state = 'expectOperand';
+ this._tree = null;
+ this._exprStr = prefix || '';
+ this._relative = false;
+ this._stopMap = stopMap || {};
+}
+
+/**
+ * Processes a new token into the AST and manages the transitions of the state
+ * machine.
+ * @param {{type: <string>}} token A token object, as provided by the
+ * {@link Lexer#tokenize} function.
+ * @throws {Error} if a token is added when the Parser has been marked as
+ * complete by {@link #complete}, or if an unexpected token type is added.
+ * @returns {boolean|*} the stopState value if this parser encountered a token
+ * in the stopState mapb; false if tokens can continue.
+ */
+Parser.prototype.addToken = function(token) {
+ if (this._state == 'complete')
+ throw new Error('Cannot add a new token to a completed Parser');
+ var state = states[this._state],
+ startExpr = this._exprStr;
+ this._exprStr += token.raw;
+ if (state.subHandler) {
+ if (!this._subParser)
+ this._startSubExpression(startExpr);
+ var stopState = this._subParser.addToken(token);
+ if (stopState) {
+ this._endSubExpression();
+ if (this._parentStop)
+ return stopState;
+ this._state = stopState;
+ }
+ }
+ else if (state.tokenTypes[token.type]) {
+ var typeOpts = state.tokenTypes[token.type],
+ handleFunc = handlers[token.type];
+ if (typeOpts.handler)
+ handleFunc = typeOpts.handler;
+ if (handleFunc)
+ handleFunc.call(this, token);
+ if (typeOpts.toState)
+ this._state = typeOpts.toState;
+ }
+ else if (this._stopMap[token.type])
+ return this._stopMap[token.type];
+ else {
+ throw new Error('Token ' + token.raw + ' (' + token.type +
+ ') unexpected in expression: ' + this._exprStr);
+ }
+ return false;
+};
+
+/**
+ * Processes an array of tokens iteratively through the {@link #addToken}
+ * function.
+ * @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
+ * the {@link Lexer#tokenize} function.
+ */
+Parser.prototype.addTokens = function(tokens) {
+ tokens.forEach(this.addToken, this);
+};
+
+/**
+ * Marks this Parser instance as completed and retrieves the full AST.
+ * @returns {{}|null} a full expression tree, ready for evaluation by the
+ * {@link Evaluator#eval} function, or null if no tokens were passed to
+ * the parser before complete was called
+ * @throws {Error} if the parser is not in a state where it's legal to end
+ * the expression, indicating that the expression is incomplete
+ */
+Parser.prototype.complete = function() {
+ if (this._cursor && !states[this._state].completable)
+ throw new Error('Unexpected end of expression: ' + this._exprStr);
+ if (this._subParser)
+ this._endSubExpression();
+ this._state = 'complete';
+ return this._cursor ? this._tree : null;
+};
+
+/**
+ * Indicates whether the expression tree contains a relative path identifier.
+ * @returns {boolean} true if a relative identifier exists; false otherwise.
+ */
+Parser.prototype.isRelative = function() {
+ return this._relative;
+};
+
+/**
+ * Ends a subexpression by completing the subParser and passing its result
+ * to the subHandler configured in the current state.
+ * @private
+ */
+Parser.prototype._endSubExpression = function() {
+ states[this._state].subHandler.call(this, this._subParser.complete());
+ this._subParser = null;
+};
+
+/**
+ * Places a new tree node at the current position of the cursor (to the 'right'
+ * property) and then advances the cursor to the new node. This function also
+ * handles setting the parent of the new node.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeAtCursor = function(node) {
+ if (!this._cursor)
+ this._tree = node;
+ else {
+ this._cursor.right = node;
+ this._setParent(node, this._cursor);
+ }
+ this._cursor = node;
+};
+
+/**
+ * Places a tree node before the current position of the cursor, replacing
+ * the node that the cursor currently points to. This should only be called in
+ * cases where the cursor is known to exist, and the provided node already
+ * contains a pointer to what's at the cursor currently.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeBeforeCursor = function(node) {
+ this._cursor = this._cursor._parent;
+ this._placeAtCursor(node);
+};
+
+/**
+ * Sets the parent of a node by creating a non-enumerable _parent property
+ * that points to the supplied parent argument.
+ * @param {{type: <string>}} node A node of the AST on which to set a new
+ * parent
+ * @param {{type: <string>}} parent An existing node of the AST to serve as the
+ * parent of the new node
+ * @private
+ */
+Parser.prototype._setParent = function(node, parent) {
+ Object.defineProperty(node, '_parent', {
+ value: parent,
+ writable: true
+ });
+};
+
+/**
+ * Prepares the Parser to accept a subexpression by (re)instantiating the
+ * subParser.
+ * @param {string} [exprStr] The expression string to prefix to the new Parser
+ * @private
+ */
+Parser.prototype._startSubExpression = function(exprStr) {
+ var endStates = states[this._state].endStates;
+ if (!endStates) {
+ this._parentStop = true;
+ endStates = this._stopMap;
+ }
+ this._subParser = new Parser(this._grammar, exprStr, endStates);
+};
+
+module.exports = Parser;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
@@ -0,0 +1,210 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Handles a subexpression that's used to define a transform argument's value.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.argVal = function(ast) {
+ this._cursor.args.push(ast);
+};
+
+/**
+ * Handles new array literals by adding them as a new node in the AST,
+ * initialized with an empty array.
+ */
+exports.arrayStart = function() {
+ this._placeAtCursor({
+ type: 'ArrayLiteral',
+ value: []
+ });
+};
+
+/**
+ * Handles a subexpression representing an element of an array literal.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.arrayVal = function(ast) {
+ if (ast)
+ this._cursor.value.push(ast);
+};
+
+/**
+ * Handles tokens of type 'binaryOp', indicating an operation that has two
+ * inputs: a left side and a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.binaryOp = function(token) {
+ var precedence = this._grammar[token.value].precedence || 0,
+ parent = this._cursor._parent;
+ while (parent && parent.operator &&
+ this._grammar[parent.operator].precedence >= precedence) {
+ this._cursor = parent;
+ parent = parent._parent;
+ }
+ var node = {
+ type: 'BinaryExpression',
+ operator: token.value,
+ left: this._cursor
+ };
+ this._setParent(this._cursor, node);
+ this._cursor = parent;
+ this._placeAtCursor(node);
+};
+
+/**
+ * Handles successive nodes in an identifier chain. More specifically, it
+ * sets values that determine how the following identifier gets placed in the
+ * AST.
+ */
+exports.dot = function() {
+ this._nextIdentEncapsulate = this._cursor &&
+ (this._cursor.type != 'BinaryExpression' ||
+ (this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
+ this._cursor.type != 'UnaryExpression';
+ this._nextIdentRelative = !this._cursor ||
+ (this._cursor && !this._nextIdentEncapsulate);
+ if (this._nextIdentRelative)
+ this._relative = true;
+};
+
+/**
+ * Handles a subexpression used for filtering an array returned by an
+ * identifier chain.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.filter = function(ast) {
+ this._placeBeforeCursor({
+ type: 'FilterExpression',
+ expr: ast,
+ relative: this._subParser.isRelative(),
+ subject: this._cursor
+ });
+};
+
+/**
+ * Handles identifier tokens by adding them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.identifier = function(token) {
+ var node = {
+ type: 'Identifier',
+ value: token.value
+ };
+ if (this._nextIdentEncapsulate) {
+ node.from = this._cursor;
+ this._placeBeforeCursor(node);
+ this._nextIdentEncapsulate = false;
+ }
+ else {
+ if (this._nextIdentRelative)
+ node.relative = true;
+ this._placeAtCursor(node);
+ }
+};
+
+/**
+ * Handles literal values, such as strings, booleans, and numerics, by adding
+ * them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.literal = function(token) {
+ this._placeAtCursor({
+ type: 'Literal',
+ value: token.value
+ });
+};
+
+/**
+ * Queues a new object literal key to be written once a value is collected.
+ * @param {{type: <string>}} token A token object
+ */
+exports.objKey = function(token) {
+ this._curObjKey = token.value;
+};
+
+/**
+ * Handles new object literals by adding them as a new node in the AST,
+ * initialized with an empty object.
+ */
+exports.objStart = function() {
+ this._placeAtCursor({
+ type: 'ObjectLiteral',
+ value: {}
+ });
+};
+
+/**
+ * Handles an object value by adding its AST to the queued key on the object
+ * literal node currently at the cursor.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.objVal = function(ast) {
+ this._cursor.value[this._curObjKey] = ast;
+};
+
+/**
+ * Handles traditional subexpressions, delineated with the groupStart and
+ * groupEnd elements.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.subExpression = function(ast) {
+ this._placeAtCursor(ast);
+};
+
+/**
+ * Handles a completed alternate subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryEnd = function(ast) {
+ this._cursor.alternate = ast;
+};
+
+/**
+ * Handles a completed consequent subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryMid = function(ast) {
+ this._cursor.consequent = ast;
+};
+
+/**
+ * Handles the start of a new ternary expression by encapsulating the entire
+ * AST in a ConditionalExpression node, and using the existing tree as the
+ * test element.
+ */
+exports.ternaryStart = function() {
+ this._tree = {
+ type: 'ConditionalExpression',
+ test: this._tree
+ };
+ this._cursor = this._tree;
+};
+
+/**
+ * Handles identifier tokens when used to indicate the name of a transform to
+ * be applied.
+ * @param {{type: <string>}} token A token object
+ */
+exports.transform = function(token) {
+ this._placeBeforeCursor({
+ type: 'Transform',
+ name: token.value,
+ args: [],
+ subject: this._cursor
+ });
+};
+
+/**
+ * Handles token of type 'unaryOp', indicating that the operation has only
+ * one input: a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.unaryOp = function(token) {
+ this._placeAtCursor({
+ type: 'UnaryExpression',
+ operator: token.value
+ });
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
@@ -0,0 +1,154 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var h = require('./handlers');
+
+/**
+ * A mapping of all states in the finite state machine to a set of instructions
+ * for handling or transitioning into other states. Each state can be handled
+ * in one of two schemes: a tokenType map, or a subHandler.
+ *
+ * Standard expression elements are handled through the tokenType object. This
+ * is an object map of all legal token types to encounter in this state (and
+ * any unexpected token types will generate a thrown error) to an options
+ * object that defines how they're handled. The available options are:
+ *
+ * {string} toState: The name of the state to which to transition
+ * immediately after handling this token
+ * {string} handler: The handler function to call when this token type is
+ * encountered in this state. If omitted, the default handler
+ * matching the token's "type" property will be called. If the handler
+ * function does not exist, no call will be made and no error will be
+ * generated. This is useful for tokens whose sole purpose is to
+ * transition to other states.
+ *
+ * States that consume a subexpression should define a subHandler, the
+ * function to be called with an expression tree argument when the
+ * subexpression is complete. Completeness is determined through the
+ * endStates object, which maps tokens on which an expression should end to the
+ * state to which to transition once the subHandler function has been called.
+ *
+ * Additionally, any state in which it is legal to mark the AST as completed
+ * should have a 'completable' property set to boolean true. Attempting to
+ * call {@link Parser#complete} in any state without this property will result
+ * in a thrown Error.
+ *
+ * @type {{}}
+ */
+exports.states = {
+ expectOperand: {
+ tokenTypes: {
+ literal: {toState: 'expectBinOp'},
+ identifier: {toState: 'identifier'},
+ unaryOp: {},
+ openParen: {toState: 'subExpression'},
+ openCurl: {toState: 'expectObjKey', handler: h.objStart},
+ dot: {toState: 'traverse'},
+ openBracket: {toState: 'arrayVal', handler: h.arrayStart}
+ }
+ },
+ expectBinOp: {
+ tokenTypes: {
+ binaryOp: {toState: 'expectOperand'},
+ pipe: {toState: 'expectTransform'},
+ dot: {toState: 'traverse'},
+ question: {toState: 'ternaryMid', handler: h.ternaryStart}
+ },
+ completable: true
+ },
+ expectTransform: {
+ tokenTypes: {
+ identifier: {toState: 'postTransform', handler: h.transform}
+ }
+ },
+ expectObjKey: {
+ tokenTypes: {
+ identifier: {toState: 'expectKeyValSep', handler: h.objKey},
+ closeCurl: {toState: 'expectBinOp'}
+ }
+ },
+ expectKeyValSep: {
+ tokenTypes: {
+ colon: {toState: 'objVal'}
+ }
+ },
+ postTransform: {
+ tokenTypes: {
+ openParen: {toState: 'argVal'},
+ binaryOp: {toState: 'expectOperand'},
+ dot: {toState: 'traverse'},
+ openBracket: {toState: 'filter'},
+ pipe: {toState: 'expectTransform'}
+ },
+ completable: true
+ },
+ postTransformArgs: {
+ tokenTypes: {
+ binaryOp: {toState: 'expectOperand'},
+ dot: {toState: 'traverse'},
+ openBracket: {toState: 'filter'},
+ pipe: {toState: 'expectTransform'}
+ },
+ completable: true
+ },
+ identifier: {
+ tokenTypes: {
+ binaryOp: {toState: 'expectOperand'},
+ dot: {toState: 'traverse'},
+ openBracket: {toState: 'filter'},
+ pipe: {toState: 'expectTransform'},
+ question: {toState: 'ternaryMid', handler: h.ternaryStart}
+ },
+ completable: true
+ },
+ traverse: {
+ tokenTypes: {
+ 'identifier': {toState: 'identifier'}
+ }
+ },
+ filter: {
+ subHandler: h.filter,
+ endStates: {
+ closeBracket: 'identifier'
+ }
+ },
+ subExpression: {
+ subHandler: h.subExpression,
+ endStates: {
+ closeParen: 'expectBinOp'
+ }
+ },
+ argVal: {
+ subHandler: h.argVal,
+ endStates: {
+ comma: 'argVal',
+ closeParen: 'postTransformArgs'
+ }
+ },
+ objVal: {
+ subHandler: h.objVal,
+ endStates: {
+ comma: 'expectObjKey',
+ closeCurl: 'expectBinOp'
+ }
+ },
+ arrayVal: {
+ subHandler: h.arrayVal,
+ endStates: {
+ comma: 'arrayVal',
+ closeBracket: 'expectBinOp'
+ }
+ },
+ ternaryMid: {
+ subHandler: h.ternaryMid,
+ endStates: {
+ colon: 'ternaryEnd'
+ }
+ },
+ ternaryEnd: {
+ subHandler: h.ternaryEnd,
+ completable: true
+ }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/.eslintrc.js
@@ -0,0 +1,16 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ Assert: false,
+ BrowserTestUtils: false,
+ add_task: false,
+ is: false,
+ isnot: false,
+ ok: false,
+ },
+ rules: {
+ "spaced-comment": 2,
+ "space-before-function-paren": 2,
+ }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/TestUtils.jsm
@@ -0,0 +1,21 @@
+/* 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";
+
+/* eslint-disable no-console */
+this.EXPORTED_SYMBOLS = ["TestUtils"];
+
+this.TestUtils = {
+ promiseTest(test) {
+ return function(assert, done) {
+ test(assert)
+ .catch(err => {
+ console.error(err);
+ assert.ok(false, err);
+ })
+ .then(() => done());
+ };
+ },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser.ini
@@ -0,0 +1,5 @@
+[browser_driver_uuids.js]
+[browser_env_expressions.js]
+[browser_EventEmitter.js]
+[browser_Storage.js]
+[browser_Heartbeat.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_EventEmitter.js
@@ -0,0 +1,92 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Log.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+
+const sandboxManager = new SandboxManager();
+sandboxManager.addHold("test running");
+const driver = new NormandyDriver(sandboxManager);
+const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+
+
+const evidence = {
+ a: 0,
+ b: 0,
+ c: 0,
+ log: "",
+};
+
+function listenerA(x = 1) {
+ evidence.a += x;
+ evidence.log += "a";
+}
+
+function listenerB(x = 1) {
+ evidence.b += x;
+ evidence.log += "b";
+}
+
+function listenerC(x = 1) {
+ evidence.c += x;
+ evidence.log += "c";
+}
+
+add_task(function* () {
+ // Fire an unrelated event, to make sure nothing goes wrong
+ eventEmitter.on("nothing");
+
+ // bind listeners
+ eventEmitter.on("event", listenerA);
+ eventEmitter.on("event", listenerB);
+ eventEmitter.once("event", listenerC);
+
+ // one event for all listeners
+ eventEmitter.emit("event");
+ // another event for a and b, since c should have turned off already
+ eventEmitter.emit("event", 10);
+
+ // make sure events haven't actually fired yet, just queued
+ Assert.deepEqual(evidence, {
+ a: 0,
+ b: 0,
+ c: 0,
+ log: "",
+ }, "events are fired async");
+
+ // Spin the event loop to run events, so we can safely "off"
+ yield Promise.resolve();
+
+ // Check intermediate event results
+ Assert.deepEqual(evidence, {
+ a: 11,
+ b: 11,
+ c: 1,
+ log: "abcab",
+ }, "intermediate events are fired");
+
+ // one more event for a
+ eventEmitter.off("event", listenerB);
+ eventEmitter.emit("event", 100);
+
+ // And another unrelated event
+ eventEmitter.on("nothing");
+
+ // Spin the event loop to run events
+ yield Promise.resolve();
+
+ Assert.deepEqual(evidence, {
+ a: 111,
+ b: 11,
+ c: 1,
+ log: "abcaba", // events are in order
+ }, "events fired as expected");
+
+ sandboxManager.removeHold("test running");
+
+ yield sandboxManager.isNuked()
+ .then(() => ok(true, "sandbox is nuked"))
+ .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_Heartbeat.js
@@ -0,0 +1,188 @@
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+
+/**
+ * Assert an array is in non-descending order, and that every element is a number
+ */
+function assertOrdered(arr) {
+ for (let i = 0; i < arr.length; i++) {
+ Assert.equal(typeof arr[i], "number", `element ${i} is type "number"`);
+ }
+ for (let i = 0; i < arr.length - 1; i++) {
+ Assert.lessOrEqual(arr[i], arr[i + 1],
+ `element ${i} is less than or equal to element ${i + 1}`);
+ }
+}
+
+/* Close every notification in a target window and notification box */
+function closeAllNotifications(targetWindow, notificationBox) {
+ if (notificationBox.allNotifications.length === 0) {
+ return Promise.resolve();
+ }
+
+
+ return new Promise(resolve => {
+ const notificationSet = new Set(notificationBox.allNotifications);
+
+ const observer = new targetWindow.MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ for (let i = 0; i < mutation.removedNodes.length; i++) {
+ const node = mutation.removedNodes.item(i);
+ if (notificationSet.has(node)) {
+ notificationSet.delete(node);
+ }
+ }
+ }
+ if (notificationSet.size === 0) {
+ Assert.equal(notificationBox.allNotifications.length, 0, "No notifications left");
+ observer.disconnect();
+ resolve();
+ }
+ });
+
+ observer.observe(notificationBox, {childList: true});
+
+ for (const notification of notificationBox.allNotifications) {
+ notification.close();
+ }
+ });
+}
+
+/* Check that the correct telmetry was sent */
+function assertTelemetrySent(hb, eventNames) {
+ return new Promise(resolve => {
+ hb.eventEmitter.once("TelemetrySent", payload => {
+ const events = [0];
+ for (const name of eventNames) {
+ Assert.equal(typeof payload[name], "number", `payload field ${name} is a number`);
+ events.push(payload[name]);
+ }
+ events.push(Date.now());
+
+ assertOrdered(events);
+ resolve();
+ });
+ });
+}
+
+
+const sandboxManager = new SandboxManager();
+const driver = new NormandyDriver(sandboxManager);
+sandboxManager.addHold("test running");
+const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+
+
+// Several of the behaviors of heartbeat prompt are mutually exclusive, so checks are broken up
+// into three batches.
+
+/* Batch #1 - General UI, Stars, and telemetry data */
+add_task(function* () {
+ const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+ const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+
+ const preCount = notificationBox.childElementCount;
+ const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+ testing: true,
+ flowId: "test",
+ message: "test",
+ engagementButtonLabel: undefined,
+ learnMoreMessage: "Learn More",
+ learnMoreUrl: "https://example.org/learnmore",
+ });
+
+ // Check UI
+ const learnMoreEl = hb.notice.querySelector(".text-link");
+ const messageEl = targetWindow.document.getAnonymousElementByAttribute(hb.notice, "anonid", "messageText");
+ Assert.equal(notificationBox.childElementCount, preCount + 1, "Correct number of notifications open");
+ Assert.equal(hb.notice.querySelectorAll(".star-x").length, 5, "Correct number of stars");
+ Assert.equal(hb.notice.querySelectorAll(".notification-button").length, 0, "Engagement button not shown");
+ Assert.equal(learnMoreEl.href, "https://example.org/learnmore", "Learn more url correct");
+ Assert.equal(learnMoreEl.value, "Learn More", "Learn more label correct");
+ Assert.equal(messageEl.textContent, "test", "Message is correct");
+
+ // Check that when clicking the learn more link, a tab opens with the right URL
+ const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+ learnMoreEl.click();
+ const tab = yield tabOpenPromise;
+ const tabUrl = yield BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser, true, url => url && url !== "about:blank");
+
+ Assert.equal(tabUrl, "https://example.org/learnmore", "Learn more link opened the right url");
+
+ const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "learnMoreTS", "closedTS"]);
+ // Close notification to trigger telemetry to be sent
+ yield closeAllNotifications(targetWindow, notificationBox);
+ yield telemetrySentPromise;
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+
+// Batch #2 - Engagement buttons
+add_task(function* () {
+ const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+ const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+ const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+ testing: true,
+ flowId: "test",
+ message: "test",
+ engagementButtonLabel: "Click me!",
+ postAnswerUrl: "https://example.org/postAnswer",
+ learnMoreMessage: "Learn More",
+ learnMoreUrl: "https://example.org/learnMore",
+ });
+ const engagementButton = hb.notice.querySelector(".notification-button");
+
+ Assert.equal(hb.notice.querySelectorAll(".star-x").length, 0, "Stars not shown");
+ Assert.ok(engagementButton, "Engagement button added");
+ Assert.equal(engagementButton.label, "Click me!", "Engagement button has correct label");
+
+ const engagementEl = hb.notice.querySelector(".notification-button");
+ const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+ engagementEl.click();
+ const tab = yield tabOpenPromise;
+ const tabUrl = yield BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser, true, url => url && url !== "about:blank");
+ // the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal
+ Assert.ok(tabUrl.startsWith("https://example.org/postAnswer"), "Engagement button opened the right url");
+
+ const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "engagedTS", "closedTS"]);
+ // Close notification to trigger telemetry to be sent
+ yield closeAllNotifications(targetWindow, notificationBox);
+ yield telemetrySentPromise;
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+// Batch 3 - Closing the window while heartbeat is open
+add_task(function* () {
+ const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+ const targetWindow = yield BrowserTestUtils.openNewBrowserWindow();
+
+ const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+ testing: true,
+ flowId: "test",
+ message: "test",
+ });
+
+ const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
+ // triggers sending ping to normandy
+ yield BrowserTestUtils.closeWindow(targetWindow);
+ yield telemetrySentPromise;
+});
+
+
+// Cleanup
+add_task(function* () {
+ // Make sure the sandbox is clean.
+ sandboxManager.removeHold("test running");
+ yield sandboxManager.isNuked()
+ .then(() => ok(true, "sandbox is nuked"))
+ .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_Storage.js
@@ -0,0 +1,37 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm", this);
+
+const fakeSandbox = {Promise};
+const store1 = Storage.makeStorage("prefix1", fakeSandbox);
+const store2 = Storage.makeStorage("prefix2", fakeSandbox);
+
+add_task(function* () {
+ // Make sure values return null before being set
+ Assert.equal(yield store1.getItem("key"), null);
+ Assert.equal(yield store2.getItem("key"), null);
+
+ // Set values to check
+ yield store1.setItem("key", "value1");
+ yield store2.setItem("key", "value2");
+
+ // Check that they are available
+ Assert.equal(yield store1.getItem("key"), "value1");
+ Assert.equal(yield store2.getItem("key"), "value2");
+
+ // Remove them, and check they are gone
+ yield store1.removeItem("key");
+ yield store2.removeItem("key");
+ Assert.equal(yield store1.getItem("key"), null);
+ Assert.equal(yield store2.getItem("key"), null);
+
+ // Check that numbers are stored as numbers (not strings)
+ yield store1.setItem("number", 42);
+ Assert.equal(yield store1.getItem("number"), 42);
+
+ // Check complex types work
+ const complex = {a: 1, b: [2, 3], c: {d: 4}};
+ yield store1.setItem("complex", complex);
+ Assert.deepEqual(yield store1.getItem("complex"), complex);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_driver_uuids.js
@@ -0,0 +1,26 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+
+add_task(function* () {
+ const sandboxManager = new SandboxManager();
+ sandboxManager.addHold("test running");
+ let driver = new NormandyDriver(sandboxManager);
+
+ // Test that UUID look about right
+ const uuid1 = driver.uuid();
+ ok(/^[a-f0-9-]{36}$/.test(uuid1), "valid uuid format");
+
+ // Test that UUIDs are different each time
+ const uuid2 = driver.uuid();
+ isnot(uuid1, uuid2, "uuids are unique");
+
+ driver = null;
+ sandboxManager.removeHold("test running");
+
+ yield sandboxManager.isNuked()
+ .then(() => ok(true, "sandbox is nuked"))
+ .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_env_expressions.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/TelemetryController.jsm", this);
+Cu.import("resource://gre/modules/Task.jsm", this);
+
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm", this);
+Cu.import("resource://gre/modules/Log.jsm", this);
+
+add_task(function* () {
+ // setup
+ yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
+ yield TelemetryController.submitExternalPing("testbar", {bar: 2});
+
+ let val;
+ // Test that basic expressions work
+ val = yield EnvExpressions.eval("2+2");
+ is(val, 4, "basic expression works");
+
+ // Test that multiline expressions work
+ val = yield EnvExpressions.eval(`
+ 2
+ +
+ 2
+ `);
+ is(val, 4, "multiline expression works");
+
+ // Test it can access telemetry
+ val = yield EnvExpressions.eval("telemetry");
+ is(typeof val, "object", "Telemetry is accesible");
+
+ // Test it reads different types of telemetry
+ val = yield EnvExpressions.eval("telemetry");
+ is(val.testfoo.payload.foo, 1, "value 'foo' is in mock telemetry");
+ is(val.testbar.payload.bar, 2, "value 'bar' is in mock telemetry");
+
+ // Test has a date transform
+ val = yield EnvExpressions.eval('"2016-04-22"|date');
+ const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
+ is(val.toString(), d.toString(), "Date transform works");
+
+ // Test dates are comparable
+ const context = {someTime: Date.UTC(2016, 0, 1)};
+ val = yield EnvExpressions.eval('"2015-01-01"|date < someTime', context);
+ ok(val, "dates are comparable with less-than");
+ val = yield EnvExpressions.eval('"2017-01-01"|date > someTime', context);
+ ok(val, "dates are comparable with greater-than");
+
+ // Test stable sample returns true for matching samples
+ val = yield EnvExpressions.eval('["test"]|stableSample(1)');
+ is(val, true, "Stable sample returns true for 100% sample");
+
+ // Test stable sample returns true for matching samples
+ val = yield EnvExpressions.eval('["test"]|stableSample(0)');
+ is(val, false, "Stable sample returns false for 0% sample");
+});
--- a/devtools/client/debugger/test/mochitest/head.js
+++ b/devtools/client/debugger/test/mochitest/head.js
@@ -1,9 +1,9 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+ /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// shared-head.js handles imports, constants, and utility functions
Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this);
@@ -1343,9 +1343,8 @@ function* initWorkerDebugger(TAB_URL, WO
"jsdebugger",
Toolbox.HostType.WINDOW);
let debuggerPanel = toolbox.getCurrentPanel();
let gDebugger = debuggerPanel.panelWin;
return {client, tab, tabClient, workerClient, toolbox, gDebugger};
}
-
--- a/layout/tools/reftest/reftest-preferences.js
+++ b/layout/tools/reftest/reftest-preferences.js
@@ -56,16 +56,17 @@ user_pref("layout.interruptible-reflow.e
// Tell the search service we are running in the US. This also has the
// desired side-effect of preventing our geoip lookup.
user_pref("browser.search.isUS", true);
user_pref("browser.search.countryCode", "US");
user_pref("browser.search.geoSpecificDefaults", false);
// Make sure SelfSupport doesn't hit the network.
user_pref("browser.selfsupport.url", "https://localhost/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client.api_url", "https://localhost/selfsupport-dummy/");
// use about:blank, not browser.startup.homepage
user_pref("browser.startup.page", 0);
// Allow XUL and XBL files to be opened from file:// URIs
user_pref("dom.allow_XUL_XBL_for_file", true);
// Allow view-source URIs to be opened from URIs that share
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -296,18 +296,19 @@ user_pref("browser.uitour.url", "http://
// Tell the search service we are running in the US. This also has the desired
// side-effect of preventing our geoip lookup.
user_pref("browser.search.isUS", true);
user_pref("browser.search.countryCode", "US");
// This will prevent HTTP requests for region defaults.
user_pref("browser.search.geoSpecificDefaults", false);
-// Make sure the self support tab doesn't hit the network.
+// Make sure self support doesn't hit the network.
user_pref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client.api_url", "https://%(server)s/selfsupport-dummy/");
user_pref("media.eme.enabled", true);
user_pref("media.autoplay.enabled", true);
// Don't use auto-enabled e10s
user_pref("browser.tabs.remote.autostart.1", false);
user_pref("browser.tabs.remote.autostart.2", false);
--- a/testing/talos/talos/config.py
+++ b/testing/talos/talos/config.py
@@ -138,16 +138,18 @@ DEFAULTS = dict(
'http://127.0.0.1/extensions-dummy/repositoryBrowseURL',
'extensions.getAddons.search.url':
'http://127.0.0.1/extensions-dummy/repositorySearchURL',
'media.gmp-manager.url':
'http://127.0.0.1/gmpmanager-dummy/update.xml',
'media.gmp-manager.updateEnabled': False,
'extensions.systemAddon.update.url':
'http://127.0.0.1/dummy-system-addons.xml',
+ 'extensions.shield-recipe-client.api_url':
+ 'https://127.0.0.1/selfsupport-dummy/',
'media.navigator.enabled': True,
'media.peerconnection.enabled': True,
'media.navigator.permission.disabled': True,
'media.capturestream_hints.enabled': True,
'browser.contentHandlers.types.0.uri': 'http://127.0.0.1/rss?url=%s',
'browser.contentHandlers.types.1.uri': 'http://127.0.0.1/rss?url=%s',
'browser.contentHandlers.types.2.uri': 'http://127.0.0.1/rss?url=%s',
'browser.contentHandlers.types.3.uri': 'http://127.0.0.1/rss?url=%s',
--- a/testing/talos/talos/xtalos/xperf_whitelist.json
+++ b/testing/talos/talos/xtalos/xperf_whitelist.json
@@ -9,16 +9,17 @@
"{firefox}\\browser\\features\\aushelper@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\e10srollout@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\flyweb@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\formautofill@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\loop@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\firefox@getpocket.com.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\presentation@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{firefox}\\browser\\features\\webcompat@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
+ "{firefox}\\browser\\features\\shield-recipe-client@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
"{talos}\\tests\\tp5n\\tp5n.manifest": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
"{talos}\\talos\\tests\\tp5n\\tp5n.manifest": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
"{talos}\\tests\\tp5n\\tp5n.manifest.develop": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
"{talos}\\talos\\tests\\tp5n\\tp5n.manifest.develop": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
"{profile}\\localstore.rdf": {"mincount": 2, "maxcount": 2, "minbytes": 8192, "maxbytes": 8192},
"{firefox}\\dependentlibs.list": {"mincount": 4, "maxcount": 4, "minbytes": 16384, "maxbytes": 16384},
"{profile}\\content-prefs.sqlite": {"mincount": 6, "maxcount": 6, "minbytes": 65768, "maxbytes": 65768},
"{profile}\\extensions.ini": {"mincount": 2, "maxcount": 2, "minbytes": 8192, "maxbytes": 8192},
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -1603,27 +1603,28 @@ try {
// Always use network provider for geolocation tests
// so we bypass the OSX dialog raised by the corelocation provider
let prefs = Components.classes["@mozilla.org/preferences-service;1"]
.getService(Components.interfaces.nsIPrefBranch);
prefs.setBoolPref("geo.provider.testing", true);
}
} catch (e) { }
-
// We need to avoid hitting the network with certain components.
try {
if (runningInParent) {
let prefs = Components.classes["@mozilla.org/preferences-service;1"]
.getService(Components.interfaces.nsIPrefBranch);
prefs.setCharPref("media.gmp-manager.url.override", "http://%(server)s/dummy-gmp-manager.xml");
prefs.setCharPref("media.gmp-manager.updateEnabled", false);
prefs.setCharPref("extensions.systemAddon.update.url", "http://%(server)s/dummy-system-addons.xml");
prefs.setCharPref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+ prefs.setCharPref("extensions.shield-recipe-client.api_url",
+ "https://%(server)s/selfsupport-dummy/");
prefs.setCharPref("toolkit.telemetry.server", "https://%(server)s/telemetry-dummy");
prefs.setCharPref("browser.search.geoip.url", "https://%(server)s/geoip-dummy");
}
} catch (e) { }
// Make tests run consistently on DevEdition (which has a lightweight theme
// selected by default).
try {